feat: v0.3.5 — smart context offloading, full MCP protocol, Duo mode, project_map tool
- Intelligent context offloading: large tool results (>15k chars) auto-moved to RLM memory - Persistent history context: compacted messages offloaded to RLM history variable - Full MCP protocol: SSE transport, Resources (resources/list, resources/read), Prompts (prompts/list, prompts/get) - mcp_read_resource and mcp_get_prompt virtual tools exposed to the model - Dialectical Duo mode with Player/Coach TUI rendering - Dynamic system prompt refresh at each turn for up-to-date RLM/Duo/working-set context - project_map tool for automatic codebase structure discovery - delegate_to_agent alias for streamlined sub-agent delegation - Default theme changed to Whale with updated color palette - Fix MCP test compilation for updated McpServerConfig struct shape - Fix clippy warnings (strip_prefix, inspect_err, flatten, is_some_and)
This commit is contained in:
@@ -157,6 +157,8 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let (role, body) = match cell {
|
||||
HistoryCell::User { content } => ("**You:**", content.clone()),
|
||||
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
|
||||
HistoryCell::Player { content, .. } => ("**Player:**", content.clone()),
|
||||
HistoryCell::Coach { content, .. } => ("**Coach:**", content.clone()),
|
||||
HistoryCell::System { content } => ("*System:*", content.clone()),
|
||||
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
|
||||
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
|
||||
|
||||
+8
-4
@@ -528,6 +528,8 @@ pub struct CompactionResult {
|
||||
pub messages: Vec<Message>,
|
||||
/// Summary system prompt
|
||||
pub summary_prompt: Option<SystemPrompt>,
|
||||
/// Messages that were removed from the active window
|
||||
pub removed_messages: Vec<Message>,
|
||||
/// Number of retries used before success
|
||||
pub retries_used: u32,
|
||||
}
|
||||
@@ -587,10 +589,11 @@ pub async fn compact_messages_safe(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((msgs, prompt)) => {
|
||||
Ok((msgs, prompt, removed)) => {
|
||||
return Ok(CompactionResult {
|
||||
messages: msgs,
|
||||
summary_prompt: prompt,
|
||||
removed_messages: removed,
|
||||
retries_used: attempt,
|
||||
});
|
||||
}
|
||||
@@ -615,9 +618,9 @@ pub async fn compact_messages(
|
||||
workspace: Option<&Path>,
|
||||
external_pins: Option<&[usize]>,
|
||||
external_working_set_paths: Option<&[String]>,
|
||||
) -> Result<(Vec<Message>, Option<SystemPrompt>)> {
|
||||
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
|
||||
if messages.is_empty() {
|
||||
return Ok((Vec::new(), None));
|
||||
return Ok((Vec::new(), None, Vec::new()));
|
||||
}
|
||||
|
||||
let plan = plan_compaction(
|
||||
@@ -628,7 +631,7 @@ pub async fn compact_messages(
|
||||
external_working_set_paths,
|
||||
);
|
||||
if plan.summarize_indices.is_empty() {
|
||||
return Ok((messages.to_vec(), None));
|
||||
return Ok((messages.to_vec(), None, Vec::new()));
|
||||
}
|
||||
|
||||
let to_summarize: Vec<Message> = plan
|
||||
@@ -664,6 +667,7 @@ pub async fn compact_messages(
|
||||
Ok((
|
||||
pinned_messages,
|
||||
Some(SystemPrompt::Blocks(vec![summary_block])),
|
||||
to_summarize,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+129
-7
@@ -7,6 +7,7 @@
|
||||
//! - Proper cancellation support
|
||||
//! - Tool execution orchestration
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -713,13 +714,19 @@ impl Engine {
|
||||
.with_todo_tool(todo_list.clone())
|
||||
.with_plan_tool(plan_state.clone())
|
||||
} else {
|
||||
let rlm_opt = if self.config.features.enabled(Feature::Rlm) {
|
||||
Some(self.config.rlm_session.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_test_runner_tool()
|
||||
.with_agent_tools(
|
||||
self.session.allow_shell,
|
||||
rlm_opt,
|
||||
self.deepseek_client.clone(),
|
||||
self.session.model.clone(),
|
||||
)
|
||||
.with_todo_tool(todo_list.clone())
|
||||
.with_plan_tool(plan_state.clone())
|
||||
};
|
||||
@@ -828,6 +835,42 @@ impl Engine {
|
||||
)
|
||||
}
|
||||
|
||||
/// Automatically offload large tool results to RLM memory if enabled.
|
||||
/// Returns either the original content or a pointer to the RLM context.
|
||||
fn offload_to_rlm_if_needed(&self, tool_name: &str, content: String) -> String {
|
||||
const OFFLOAD_THRESHOLD: usize = 15_000;
|
||||
|
||||
if !self.config.features.enabled(Feature::Rlm) || content.len() < OFFLOAD_THRESHOLD {
|
||||
return content;
|
||||
}
|
||||
|
||||
let mut session = match self.config.rlm_session.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return content,
|
||||
};
|
||||
|
||||
let context_id = format!(
|
||||
"auto_{}_{}",
|
||||
tool_name,
|
||||
&uuid::Uuid::new_v4().to_string()[..8]
|
||||
);
|
||||
let char_count = content.len();
|
||||
let line_count = content.lines().count();
|
||||
|
||||
session.load_context(&context_id, content, None);
|
||||
|
||||
format!(
|
||||
"[AUTOMATIC RLM OFFLOAD]\n\
|
||||
The output of '{tool_name}' was too large ({char_count} chars, {line_count} lines) \
|
||||
and has been moved to RLM memory to preserve your context window.\n\n\
|
||||
Context ID: {context_id}\n\n\
|
||||
You can explore this data using RLM tools:\n\
|
||||
- `rlm_exec(code=\"lines(1, 100)\", context_id=\"{context_id}\")` to see the start\n\
|
||||
- `rlm_exec(code=\"search(\\\"pattern\\\")\", context_id=\"{context_id}\")` to search\n\
|
||||
- `rlm_query(query=\"...\", context_id=\"{context_id}\")` for deep analysis"
|
||||
)
|
||||
}
|
||||
|
||||
async fn ensure_mcp_pool(&mut self) -> Result<Arc<AsyncMutex<McpPool>>, ToolError> {
|
||||
if let Some(pool) = self.mcp_pool.as_ref() {
|
||||
return Ok(Arc::clone(pool));
|
||||
@@ -985,6 +1028,9 @@ impl Engine {
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure system prompt is up to date with latest session states
|
||||
self.refresh_system_prompt(_mode);
|
||||
|
||||
if turn.at_max_steps() {
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -1025,6 +1071,49 @@ impl Engine {
|
||||
Ok(result) => {
|
||||
// Only update if we got valid messages (never corrupt state)
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
// Offload removed messages to RLM history if enabled
|
||||
if self.config.features.enabled(Feature::Rlm)
|
||||
&& !result.removed_messages.is_empty()
|
||||
{
|
||||
if let Ok(mut rlm) = self.config.rlm_session.lock() {
|
||||
let mut history_text = String::new();
|
||||
for msg in &result.removed_messages {
|
||||
let role = if msg.role == "user" {
|
||||
"User"
|
||||
} else {
|
||||
"Assistant"
|
||||
};
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
let _ =
|
||||
writeln!(history_text, "{role}: {text}\n");
|
||||
}
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
let _ = writeln!(
|
||||
history_text,
|
||||
"{role}: [Used tool: {name}] Input: {input}\n"
|
||||
);
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
let _ = writeln!(
|
||||
history_text,
|
||||
"Tool result: {content}\n"
|
||||
);
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
let _ = writeln!(
|
||||
history_text,
|
||||
"{role} (thinking): {thinking}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rlm.append_var("history", history_text);
|
||||
}
|
||||
}
|
||||
|
||||
self.session.messages = result.messages;
|
||||
self.session.system_prompt = merge_system_prompts(
|
||||
self.session.system_prompt.as_ref(),
|
||||
@@ -1567,7 +1656,10 @@ impl Engine {
|
||||
|
||||
match outcome.result {
|
||||
Ok(output) => {
|
||||
let output_content = output.content;
|
||||
let original_content = output.content;
|
||||
let output_content =
|
||||
self.offload_to_rlm_if_needed(&outcome.name, original_content);
|
||||
|
||||
tool_call.set_result(output_content.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
&tool_name_for_ws,
|
||||
@@ -1635,6 +1727,36 @@ impl Engine {
|
||||
pub fn session_mut(&mut self) -> &mut Session {
|
||||
&mut self.session
|
||||
}
|
||||
|
||||
/// Refresh the system prompt based on current mode and context.
|
||||
fn refresh_system_prompt(&mut self, mode: AppMode) {
|
||||
let rlm_summary = self
|
||||
.config
|
||||
.rlm_session
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|session| rlm_session_summary(&session));
|
||||
|
||||
let duo_summary = self
|
||||
.config
|
||||
.duo_session
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| duo_session_summary(&s));
|
||||
|
||||
let working_set_summary = self
|
||||
.session
|
||||
.working_set
|
||||
.summary_block(&self.config.workspace);
|
||||
|
||||
self.session.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
|
||||
mode,
|
||||
&self.config.workspace,
|
||||
working_set_summary.as_deref(),
|
||||
rlm_summary.as_deref(),
|
||||
duo_summary.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the engine in a background task
|
||||
|
||||
+10
-2
@@ -638,9 +638,10 @@ fn mcp_template_json() -> Result<String> {
|
||||
cfg.servers.insert(
|
||||
"example".to_string(),
|
||||
McpServerConfig {
|
||||
command: "node".to_string(),
|
||||
command: Some("node".to_string()),
|
||||
args: vec!["./path/to/your-mcp-server.js".to_string()],
|
||||
env: std::collections::HashMap::new(),
|
||||
url: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
@@ -1485,7 +1486,14 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
} else {
|
||||
format!(" {}", server.args.join(" "))
|
||||
};
|
||||
println!(" - {name} [{status}] {}{}", server.command, args);
|
||||
let cmd_str = if let Some(cmd) = server.command {
|
||||
format!("{cmd}{args}")
|
||||
} else if let Some(url) = server.url {
|
||||
url
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
println!(" - {name} [{status}] {cmd_str}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+517
-73
@@ -68,11 +68,12 @@ impl Default for McpTimeouts {
|
||||
/// Configuration for a single MCP server
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub command: String,
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub connect_timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
@@ -109,6 +110,37 @@ pub struct McpTool {
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Resource discovered from an MCP server
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpResource {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "mimeType", default)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Prompt discovered from an MCP server
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpPrompt {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub arguments: Vec<McpPromptArgument>,
|
||||
}
|
||||
|
||||
/// Argument for an MCP prompt
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpPromptArgument {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
// === Connection State ===
|
||||
|
||||
/// State of an MCP connection
|
||||
@@ -121,13 +153,176 @@ pub enum ConnectionState {
|
||||
|
||||
// === McpConnection - Async Connection Management ===
|
||||
|
||||
/// Manages a single async connection to an MCP server
|
||||
pub struct McpConnection {
|
||||
name: String,
|
||||
// === Transport Trait ===
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait McpTransport: Send + Sync {
|
||||
async fn send(&mut self, msg: serde_json::Value) -> Result<()>;
|
||||
async fn recv(&mut self) -> Result<serde_json::Value>;
|
||||
}
|
||||
|
||||
pub struct StdioTransport {
|
||||
_child: Child,
|
||||
stdin: ChildStdin,
|
||||
reader: tokio::io::BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for StdioTransport {
|
||||
async fn send(&mut self, msg: serde_json::Value) -> Result<()> {
|
||||
let line = serde_json::to_string(&msg)? + "\n";
|
||||
self.stdin.write_all(line.as_bytes()).await?;
|
||||
self.stdin.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<serde_json::Value> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = self.reader.read_line(&mut line).await?;
|
||||
if bytes == 0 {
|
||||
anyhow::bail!("Stdio transport closed");
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SseTransport {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
endpoint_url: Option<String>,
|
||||
receiver: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SseTransport {
|
||||
pub async fn connect(client: reqwest::Client, url: String) -> Result<Self> {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let client_clone = client.clone();
|
||||
let url_clone = url.clone();
|
||||
|
||||
// Start SSE background task
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run_sse_loop(client_clone, url_clone, tx).await {
|
||||
tracing::error!("SSE loop error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// The endpoint URL will be discovered from the first "endpoint" event
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: url,
|
||||
endpoint_url: None,
|
||||
receiver: rx,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_sse_loop(
|
||||
client: reqwest::Client,
|
||||
url: String,
|
||||
tx: tokio::sync::mpsc::UnboundedSender<serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let response = client.get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Failed to connect to SSE: {}", response.status());
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item?;
|
||||
let s = String::from_utf8_lossy(&chunk);
|
||||
buffer.push_str(&s);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_block = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
let mut event_type = "message";
|
||||
let mut data = String::new();
|
||||
|
||||
for line in event_block.lines() {
|
||||
if let Some(stripped) = line.strip_prefix("event: ") {
|
||||
event_type = stripped;
|
||||
} else if let Some(stripped) = line.strip_prefix("data: ") {
|
||||
data.push_str(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
match event_type {
|
||||
"endpoint" => {
|
||||
// Special internal message to set endpoint
|
||||
let _ = tx.send(serde_json::json!({
|
||||
"__internal_sse_endpoint__": data
|
||||
}));
|
||||
}
|
||||
"message" => {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||
let _ = tx.send(val);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for SseTransport {
|
||||
async fn send(&mut self, msg: serde_json::Value) -> Result<()> {
|
||||
let endpoint = self
|
||||
.endpoint_url
|
||||
.as_ref()
|
||||
.context("SSE endpoint not yet discovered")?;
|
||||
let response = self.client.post(endpoint).json(&msg).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Failed to send message via SSE POST: {}", response.status());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<serde_json::Value> {
|
||||
loop {
|
||||
let msg = self.receiver.recv().await.context("SSE transport closed")?;
|
||||
if let Some(endpoint) = msg.get("__internal_sse_endpoint__") {
|
||||
let url_str = endpoint.as_str().context("Invalid endpoint format")?;
|
||||
// Handle relative vs absolute URLs
|
||||
if url_str.starts_with("http") {
|
||||
self.endpoint_url = Some(url_str.to_string());
|
||||
} else {
|
||||
let base = reqwest::Url::parse(&self.base_url)?;
|
||||
let joined = base.join(url_str)?;
|
||||
self.endpoint_url = Some(joined.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return Ok(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === McpConnection - Async Connection Management ===
|
||||
|
||||
/// Manages a single async connection to an MCP server
|
||||
pub struct McpConnection {
|
||||
name: String,
|
||||
transport: Box<dyn McpTransport>,
|
||||
tools: Vec<McpTool>,
|
||||
resources: Vec<McpResource>,
|
||||
prompts: Vec<McpPrompt>,
|
||||
request_id: AtomicU64,
|
||||
state: ConnectionState,
|
||||
config: McpServerConfig,
|
||||
@@ -142,30 +337,48 @@ impl McpConnection {
|
||||
) -> Result<Self> {
|
||||
let connect_timeout_secs = config.effective_connect_timeout(global_timeouts);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&config.command);
|
||||
cmd.args(&config.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let transport: Box<dyn McpTransport> = if let Some(url) = &config.url {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(connect_timeout_secs))
|
||||
.build()?;
|
||||
Box::new(SseTransport::connect(client, url.clone()).await?)
|
||||
} else if let Some(command) = &config.command {
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
cmd.args(&config.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
for (key, value) in &config.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
for (key, value) in &config.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn MCP server '{name}'"))?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn MCP server '{name}'"))?;
|
||||
|
||||
let stdin = child.stdin.take().context("Failed to get MCP stdin")?;
|
||||
let stdout = child.stdout.take().context("Failed to get MCP stdout")?;
|
||||
let stdin = child.stdin.take().context("Failed to get MCP stdin")?;
|
||||
let stdout = child.stdout.take().context("Failed to get MCP stdout")?;
|
||||
|
||||
Box::new(StdioTransport {
|
||||
_child: child,
|
||||
stdin,
|
||||
reader: tokio::io::BufReader::new(stdout),
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"MCP server '{}' config must have either 'command' or 'url'",
|
||||
name
|
||||
);
|
||||
};
|
||||
|
||||
let mut conn = Self {
|
||||
name: name.clone(),
|
||||
_child: child,
|
||||
stdin,
|
||||
reader: tokio::io::BufReader::new(stdout),
|
||||
transport,
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
request_id: AtomicU64::new(1),
|
||||
state: ConnectionState::Connecting,
|
||||
config,
|
||||
@@ -176,13 +389,13 @@ impl McpConnection {
|
||||
.await
|
||||
.with_context(|| format!("MCP server '{name}' initialization timed out"))??;
|
||||
|
||||
// Discover tools with timeout
|
||||
// Discover tools, resources, and prompts with timeout
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(connect_timeout_secs),
|
||||
conn.discover_tools(),
|
||||
conn.discover_all(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("MCP server '{name}' tool discovery timed out"))??;
|
||||
.with_context(|| format!("MCP server '{name}' discovery timed out"))??;
|
||||
|
||||
conn.state = ConnectionState::Ready;
|
||||
Ok(conn)
|
||||
@@ -201,7 +414,11 @@ impl McpConnection {
|
||||
"name": "deepseek-cli",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"capabilities": { "tools": {} }
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
"prompts": {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
@@ -218,6 +435,16 @@ impl McpConnection {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover tools, resources, and prompts
|
||||
async fn discover_all(&mut self) -> Result<()> {
|
||||
// We use join! to discover everything concurrently if possible,
|
||||
// but for now let's keep it sequential for simplicity in error handling
|
||||
self.discover_tools().await?;
|
||||
self.discover_resources().await?;
|
||||
self.discover_prompts().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available tools from the MCP server
|
||||
async fn discover_tools(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
@@ -240,16 +467,113 @@ impl McpConnection {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available resources from the MCP server
|
||||
async fn discover_resources(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "resources/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
|
||||
if let Some(result) = response.get("result")
|
||||
&& let Some(resources) = result.get("resources")
|
||||
{
|
||||
self.resources = serde_json::from_value(resources.clone()).unwrap_or_default();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available prompts from the MCP server
|
||||
async fn discover_prompts(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "prompts/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
|
||||
if let Some(result) = response.get("result")
|
||||
&& let Some(prompts) = result.get("prompts")
|
||||
{
|
||||
self.prompts = serde_json::from_value(prompts.clone()).unwrap_or_default();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call a tool on this MCP server
|
||||
pub async fn call_tool(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
timeout_secs: u64,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.call_method(
|
||||
"tools/call",
|
||||
serde_json::json!({
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
}),
|
||||
timeout_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Read a resource from this MCP server
|
||||
pub async fn read_resource(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.call_method(
|
||||
"resources/read",
|
||||
serde_json::json!({
|
||||
"uri": uri
|
||||
}),
|
||||
timeout_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a prompt from this MCP server
|
||||
pub async fn get_prompt(
|
||||
&mut self,
|
||||
prompt_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
timeout_secs: u64,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.call_method(
|
||||
"prompts/get",
|
||||
serde_json::json!({
|
||||
"name": prompt_name,
|
||||
"arguments": arguments
|
||||
}),
|
||||
timeout_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Generic method to call an MCP method
|
||||
async fn call_method(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
timeout_secs: u64,
|
||||
) -> Result<serde_json::Value> {
|
||||
if self.state != ConnectionState::Ready {
|
||||
anyhow::bail!(
|
||||
"Failed to call MCP tool: connection '{}' is not ready",
|
||||
"Failed to call MCP method '{}': connection '{}' is not ready",
|
||||
method,
|
||||
self.name
|
||||
);
|
||||
}
|
||||
@@ -258,11 +582,8 @@ impl McpConnection {
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": call_id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
}
|
||||
"method": method,
|
||||
"params": params
|
||||
}))
|
||||
.await?;
|
||||
|
||||
@@ -270,14 +591,15 @@ impl McpConnection {
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"MCP tool '{}' on server '{}' timed out after {}s",
|
||||
tool_name, self.name, timeout_secs
|
||||
"MCP method '{}' on server '{}' timed out after {}s",
|
||||
method, self.name, timeout_secs
|
||||
)
|
||||
})??;
|
||||
|
||||
if let Some(error) = response.get("error") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"MCP error: {}",
|
||||
"MCP error in '{}': {}",
|
||||
method,
|
||||
serde_json::to_string_pretty(error)?
|
||||
));
|
||||
}
|
||||
@@ -293,6 +615,16 @@ impl McpConnection {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Get discovered resources
|
||||
pub fn resources(&self) -> &[McpResource] {
|
||||
&self.resources
|
||||
}
|
||||
|
||||
/// Get discovered prompts
|
||||
pub fn prompts(&self) -> &[McpPrompt] {
|
||||
&self.prompts
|
||||
}
|
||||
|
||||
/// Get server name
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
@@ -318,50 +650,32 @@ impl McpConnection {
|
||||
}
|
||||
|
||||
async fn send(&mut self, msg: serde_json::Value) -> Result<()> {
|
||||
let line = serde_json::to_string(&msg)? + "\n";
|
||||
self.stdin.write_all(line.as_bytes()).await?;
|
||||
self.stdin.flush().await?;
|
||||
Ok(())
|
||||
self.transport.send(msg).await
|
||||
}
|
||||
|
||||
async fn recv(&mut self, expected_id: u64) -> Result<serde_json::Value> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = self.reader.read_line(&mut line).await?;
|
||||
if bytes == 0 {
|
||||
let value = self.transport.recv().await.inspect_err(|_e| {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
anyhow::bail!(
|
||||
"Failed to read MCP response: server '{}' closed connection",
|
||||
self.name
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
// Check if this is a response with the expected id
|
||||
if value.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id) {
|
||||
return Ok(value);
|
||||
}
|
||||
// Skip notifications (no id) and responses with different ids
|
||||
// Check if this is a response with the expected id
|
||||
if value.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id) {
|
||||
return Ok(value);
|
||||
}
|
||||
// Skip notifications (no id) and responses with different ids
|
||||
}
|
||||
}
|
||||
|
||||
/// Gracefully close the connection
|
||||
pub fn close(&mut self) {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
// Child process will be killed on drop due to kill_on_drop(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpConnection {
|
||||
fn drop(&mut self) {
|
||||
// Child is automatically killed due to kill_on_drop(true)
|
||||
// StdioTransport will be dropped and child killed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,17 +781,125 @@ impl McpPool {
|
||||
tools
|
||||
}
|
||||
|
||||
/// Get all discovered resources with server-prefixed names
|
||||
pub fn all_resources(&self) -> Vec<(String, &McpResource)> {
|
||||
let mut resources = Vec::new();
|
||||
for (server, conn) in &self.connections {
|
||||
for resource in conn.resources() {
|
||||
// Format: mcp_{server}_{resource_name}
|
||||
// Note: resource names might contain spaces, we should probably slugify them
|
||||
let safe_name = resource.name.replace(' ', "_").to_lowercase();
|
||||
resources.push((format!("mcp_{}_{}", server, safe_name), resource));
|
||||
}
|
||||
}
|
||||
resources
|
||||
}
|
||||
|
||||
/// Get all discovered prompts with server-prefixed names
|
||||
pub fn all_prompts(&self) -> Vec<(String, &McpPrompt)> {
|
||||
let mut prompts = Vec::new();
|
||||
for (server, conn) in &self.connections {
|
||||
for prompt in conn.prompts() {
|
||||
// Format: mcp_{server}_{prompt}
|
||||
prompts.push((format!("mcp_{}_{}", server, prompt.name), prompt));
|
||||
}
|
||||
}
|
||||
prompts
|
||||
}
|
||||
|
||||
/// Read a resource from a specific server
|
||||
pub async fn read_resource(
|
||||
&mut self,
|
||||
server_name: &str,
|
||||
uri: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let global_timeouts = self.config.timeouts;
|
||||
let conn = self.get_or_connect(server_name).await?;
|
||||
let timeout = conn.config().effective_read_timeout(&global_timeouts);
|
||||
conn.read_resource(uri, timeout).await
|
||||
}
|
||||
|
||||
/// Get a prompt from a specific server
|
||||
pub async fn get_prompt(
|
||||
&mut self,
|
||||
server_name: &str,
|
||||
prompt_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let global_timeouts = self.config.timeouts;
|
||||
let conn = self.get_or_connect(server_name).await?;
|
||||
let timeout = conn.config().effective_execute_timeout(&global_timeouts);
|
||||
conn.get_prompt(prompt_name, arguments, timeout).await
|
||||
}
|
||||
|
||||
/// Parse a prefixed name into (server_name, tool_name)
|
||||
fn parse_prefixed_name<'a>(&self, prefixed_name: &'a str) -> Result<(&'a str, &'a str)> {
|
||||
if !prefixed_name.starts_with("mcp_") {
|
||||
anyhow::bail!("Invalid MCP tool name: {}", prefixed_name);
|
||||
}
|
||||
let rest = &prefixed_name[4..];
|
||||
let Some((server, tool)) = rest.split_once('_') else {
|
||||
anyhow::bail!("Invalid MCP tool name format: {}", prefixed_name);
|
||||
};
|
||||
Ok((server, tool))
|
||||
}
|
||||
|
||||
/// Convert discovered tools to API Tool format
|
||||
pub fn to_api_tools(&self) -> Vec<crate::models::Tool> {
|
||||
self.all_tools()
|
||||
.into_iter()
|
||||
.map(|(name, tool)| crate::models::Tool {
|
||||
let mut api_tools = Vec::new();
|
||||
|
||||
// Add regular tools
|
||||
for (name, tool) in self.all_tools() {
|
||||
api_tools.push(crate::models::Tool {
|
||||
name,
|
||||
description: tool.description.clone().unwrap_or_default(),
|
||||
input_schema: tool.input_schema.clone(),
|
||||
cache_control: None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
|
||||
// Add resource reading tools if resources exist
|
||||
let resources = self.all_resources();
|
||||
if !resources.is_empty() {
|
||||
api_tools.push(crate::models::Tool {
|
||||
name: "mcp_read_resource".to_string(),
|
||||
description: "Read a resource from an MCP server using its URI".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string", "description": "The name of the MCP server" },
|
||||
"uri": { "type": "string", "description": "The URI of the resource to read" }
|
||||
},
|
||||
"required": ["server", "uri"]
|
||||
}),
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Add prompt getting tools if prompts exist
|
||||
let prompts = self.all_prompts();
|
||||
if !prompts.is_empty() {
|
||||
api_tools.push(crate::models::Tool {
|
||||
name: "mcp_get_prompt".to_string(),
|
||||
description: "Get a prompt from an MCP server".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string", "description": "The name of the MCP server" },
|
||||
"name": { "type": "string", "description": "The name of the prompt" },
|
||||
"arguments": {
|
||||
"type": "object",
|
||||
"description": "Optional arguments for the prompt",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["server", "name"]
|
||||
}),
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
|
||||
api_tools
|
||||
}
|
||||
|
||||
/// Call a tool by its prefixed name (mcp_{server}_{tool})
|
||||
@@ -486,14 +908,35 @@ impl McpPool {
|
||||
prefixed_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let parts: Vec<&str> = prefixed_name.splitn(3, '_').collect();
|
||||
if parts.len() != 3 || parts[0] != "mcp" {
|
||||
anyhow::bail!(
|
||||
"Failed to parse MCP tool name '{prefixed_name}': expected format mcp_{{server}}_{{tool}}"
|
||||
);
|
||||
if prefixed_name == "mcp_read_resource" {
|
||||
let server_name = arguments
|
||||
.get("server")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing 'server' argument")?;
|
||||
let uri = arguments
|
||||
.get("uri")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing 'uri' argument")?;
|
||||
return self.read_resource(server_name, uri).await;
|
||||
}
|
||||
|
||||
let (server_name, tool_name) = (parts[1], parts[2]);
|
||||
if prefixed_name == "mcp_get_prompt" {
|
||||
let server_name = arguments
|
||||
.get("server")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing 'server' argument")?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing 'name' argument")?;
|
||||
let args = arguments
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
return self.get_prompt(server_name, name, args).await;
|
||||
}
|
||||
|
||||
let (server_name, tool_name) = self.parse_prefixed_name(prefixed_name)?;
|
||||
// Copy the global timeouts to avoid borrow conflict
|
||||
let global_timeouts = self.config.timeouts;
|
||||
let conn = self.get_or_connect(server_name).await?;
|
||||
@@ -899,7 +1342,7 @@ mod tests {
|
||||
assert!(config.servers.contains_key("test"));
|
||||
|
||||
let server = config.servers.get("test").unwrap();
|
||||
assert_eq!(server.command, "node");
|
||||
assert_eq!(server.command, Some("node".to_string()));
|
||||
assert_eq!(server.args, vec!["server.js"]);
|
||||
assert_eq!(server.env.get("FOO"), Some(&"bar".to_string()));
|
||||
}
|
||||
@@ -909,9 +1352,10 @@ mod tests {
|
||||
let global = McpTimeouts::default();
|
||||
|
||||
let server_with_override = McpServerConfig {
|
||||
command: "test".to_string(),
|
||||
command: Some("test".to_string()),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
url: None,
|
||||
connect_timeout: Some(20),
|
||||
execute_timeout: None,
|
||||
read_timeout: Some(180),
|
||||
|
||||
@@ -84,6 +84,12 @@ pub fn ui_theme(name: &str) -> UiTheme {
|
||||
selection_bg: Color::Rgb(38, 64, 112),
|
||||
header_bg: DEEPSEEK_SLATE,
|
||||
},
|
||||
"whale" => UiTheme {
|
||||
name: "whale",
|
||||
composer_bg: DEEPSEEK_SLATE,
|
||||
selection_bg: DEEPSEEK_NAVY,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
},
|
||||
_ => UiTheme {
|
||||
name: "default",
|
||||
composer_bg: COMPOSER_BG,
|
||||
|
||||
+9
-1
@@ -51,7 +51,15 @@ pub fn system_prompt_for_mode_with_context(
|
||||
let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
|
||||
format!("{}\n\n{}", base_prompt.trim(), project_block)
|
||||
} else {
|
||||
base_prompt.trim().to_string()
|
||||
// Fallback: Generate an automatic project map summary
|
||||
let summary = crate::utils::summarize_project(workspace);
|
||||
let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt
|
||||
format!(
|
||||
"{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```",
|
||||
base_prompt.trim(),
|
||||
summary,
|
||||
tree
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(summary) = working_set_summary
|
||||
|
||||
@@ -36,3 +36,9 @@ Approval etiquette:
|
||||
- In autonomous modes, warn before risky or irreversible actions.
|
||||
|
||||
Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe."
|
||||
|
||||
Context Management & RLM:
|
||||
- You have a finite context window. Large tool results may be automatically moved to RLM memory.
|
||||
- If you see "[AUTOMATIC RLM OFFLOAD]", the content was stored in an RLM context ID. Use `rlm_exec` or `rlm_query` to access it.
|
||||
- Proactively use `rlm_load` for files larger than 100KB to keep your main window lean.
|
||||
- If the conversation is long, earlier messages may be summarized. You can search the full history using `rlm_exec(code="get(\"history\")")` if available.
|
||||
|
||||
+14
@@ -631,6 +631,20 @@ impl RlmSession {
|
||||
pub fn record_query_usage(&mut self, usage: &Usage, chars_sent: usize, chars_received: usize) {
|
||||
self.usage.record(usage, chars_sent, chars_received);
|
||||
}
|
||||
|
||||
pub fn append_var(&mut self, name: &str, value: String) {
|
||||
let active = self.active_context.clone();
|
||||
if let Some(ctx) = self.contexts.get_mut(&active) {
|
||||
ctx.append_var(name, value);
|
||||
} else {
|
||||
// Fallback to default context if active doesn't exist
|
||||
let ctx = self
|
||||
.contexts
|
||||
.entry("default".to_string())
|
||||
.or_insert_with(|| RlmContext::new("default", String::new(), None));
|
||||
ctx.append_var(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn context_id_from_path(path: &Path) -> String {
|
||||
|
||||
+4
-4
@@ -34,9 +34,9 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "default".to_string(),
|
||||
theme: "whale".to_string(),
|
||||
auto_compact: true,
|
||||
auto_rlm: false,
|
||||
auto_rlm: true,
|
||||
show_thinking: true,
|
||||
show_tool_details: true,
|
||||
default_mode: "agent".to_string(),
|
||||
@@ -92,9 +92,9 @@ impl Settings {
|
||||
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"theme" => {
|
||||
if !["default", "dark", "light"].contains(&value) {
|
||||
if !["default", "dark", "light", "whale"].contains(&value) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light."
|
||||
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light, whale."
|
||||
);
|
||||
}
|
||||
self.theme = value.to_string();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod git;
|
||||
pub mod plan;
|
||||
pub mod project;
|
||||
pub mod registry;
|
||||
pub mod review;
|
||||
pub mod rlm;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Project mapping tool for understanding codebase structure.
|
||||
|
||||
use crate::utils::{is_key_file, project_tree, summarize_project};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
|
||||
};
|
||||
|
||||
pub struct ProjectMapTool;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProjectMap {
|
||||
tree: String,
|
||||
summary: String,
|
||||
key_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ProjectMapTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"project_map"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get a high-level map of the project structure, including key files and a tree view."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum depth for the tree view (default: 3)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let max_depth = optional_u64(&input, "max_depth", 3) as usize;
|
||||
let map = generate_project_map(&context.workspace, max_depth)?;
|
||||
ToolResult::json(&map).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_project_map(root: &std::path::Path, max_depth: usize) -> Result<ProjectMap, ToolError> {
|
||||
let tree = project_tree(root, max_depth);
|
||||
let summary = summarize_project(root);
|
||||
|
||||
// For key_files, we can just do a quick scan since summarize_project doesn't return them directly anymore
|
||||
let mut key_files = Vec::new();
|
||||
let mut builder = ignore::WalkBuilder::new(root);
|
||||
builder.hidden(false).follow_links(true).max_depth(Some(2));
|
||||
let walker = builder.build();
|
||||
|
||||
for entry in walker.flatten() {
|
||||
if is_key_file(entry.path()) {
|
||||
if let Ok(rel) = entry.path().strip_prefix(root) {
|
||||
key_files.push(rel.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProjectMap {
|
||||
tree,
|
||||
summary,
|
||||
key_files,
|
||||
})
|
||||
}
|
||||
+31
-4
@@ -308,6 +308,13 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(DiagnosticsTool))
|
||||
}
|
||||
|
||||
/// Include project mapping tools.
|
||||
#[must_use]
|
||||
pub fn with_project_tools(self) -> Self {
|
||||
use super::project::ProjectMapTool;
|
||||
self.with_tool(Arc::new(ProjectMapTool))
|
||||
}
|
||||
|
||||
/// Include cargo test runner tool.
|
||||
#[must_use]
|
||||
pub fn with_test_runner_tool(self) -> Self {
|
||||
@@ -345,8 +352,14 @@ impl ToolRegistryBuilder {
|
||||
|
||||
/// Include all agent tools (file tools + shell + note + search + patch).
|
||||
#[must_use]
|
||||
pub fn with_agent_tools(self, allow_shell: bool) -> Self {
|
||||
let builder = self
|
||||
pub fn with_agent_tools(
|
||||
self,
|
||||
allow_shell: bool,
|
||||
rlm_session: Option<SharedRlmSession>,
|
||||
client: Option<DeepSeekClient>,
|
||||
model: String,
|
||||
) -> Self {
|
||||
let mut builder = self
|
||||
.with_file_tools()
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
@@ -354,8 +367,13 @@ impl ToolRegistryBuilder {
|
||||
.with_patch_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_project_tools()
|
||||
.with_test_runner_tool();
|
||||
|
||||
if let Some(session) = rlm_session {
|
||||
builder = builder.with_rlm_tools(session, client, model);
|
||||
}
|
||||
|
||||
if allow_shell {
|
||||
builder.with_shell_tools()
|
||||
} else {
|
||||
@@ -387,8 +405,11 @@ impl ToolRegistryBuilder {
|
||||
allow_shell: bool,
|
||||
todo_list: super::todo::SharedTodoList,
|
||||
plan_state: super::plan::SharedPlanState,
|
||||
rlm_session: Option<SharedRlmSession>,
|
||||
client: Option<DeepSeekClient>,
|
||||
model: String,
|
||||
) -> Self {
|
||||
self.with_agent_tools(allow_shell)
|
||||
self.with_agent_tools(allow_shell, rlm_session, client, model)
|
||||
.with_todo_tool(todo_list)
|
||||
.with_plan_tool(plan_state)
|
||||
}
|
||||
@@ -427,13 +448,19 @@ impl ToolRegistryBuilder {
|
||||
manager: super::subagent::SharedSubAgentManager,
|
||||
runtime: super::subagent::SubAgentRuntime,
|
||||
) -> Self {
|
||||
use super::subagent::{AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool};
|
||||
use super::subagent::{
|
||||
AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool, DelegateToAgentTool,
|
||||
};
|
||||
use super::swarm::AgentSwarmTool;
|
||||
|
||||
self.with_tool(Arc::new(AgentSpawnTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(DelegateToAgentTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime)))
|
||||
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
|
||||
|
||||
@@ -659,6 +659,75 @@ impl ToolSpec for AgentListTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool to delegate a task to a specialized agent (alias for agent_spawn).
|
||||
pub struct DelegateToAgentTool {
|
||||
manager: SharedSubAgentManager,
|
||||
runtime: SubAgentRuntime,
|
||||
}
|
||||
|
||||
impl DelegateToAgentTool {
|
||||
/// Create a new delegation tool.
|
||||
#[must_use]
|
||||
pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
|
||||
Self { manager, runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DelegateToAgentTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"delegate_to_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delegate a task to a specialized sub-agent. This is an alias for agent_spawn."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name or type of the agent: general, explore, plan, review"
|
||||
},
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The goal or task description for the agent"
|
||||
}
|
||||
},
|
||||
"required": ["objective"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![
|
||||
ToolCapability::ExecutesCode,
|
||||
ToolCapability::RequiresApproval,
|
||||
]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Required
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let objective = required_str(&input, "objective")?.to_string();
|
||||
let agent_type_str = input
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("general");
|
||||
|
||||
let spawn_input = json!({
|
||||
"prompt": objective,
|
||||
"type": agent_type_str
|
||||
});
|
||||
|
||||
let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone());
|
||||
spawn_tool.execute(spawn_input, context).await
|
||||
}
|
||||
}
|
||||
|
||||
// === Sub-agent Execution ===
|
||||
|
||||
struct SubAgentTask {
|
||||
|
||||
+64
-4
@@ -27,6 +27,8 @@ const TOOL_TEXT_LIMIT: usize = 240;
|
||||
pub enum HistoryCell {
|
||||
User { content: String },
|
||||
Assistant { content: String, streaming: bool },
|
||||
Player { content: String, streaming: bool },
|
||||
Coach { content: String, streaming: bool },
|
||||
System { content: String },
|
||||
Thinking { content: String, streaming: bool },
|
||||
Tool(ToolCell),
|
||||
@@ -65,6 +67,30 @@ impl HistoryCell {
|
||||
}
|
||||
lines
|
||||
}
|
||||
HistoryCell::Player { content, streaming } => {
|
||||
let mut lines = render_message("Player", content, player_style(), width);
|
||||
if *streaming {
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(Span::styled(
|
||||
"▋",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
HistoryCell::Coach { content, streaming } => {
|
||||
let mut lines = render_message("Coach", content, coach_style(), width);
|
||||
if *streaming {
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(Span::styled(
|
||||
"▋",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
HistoryCell::System { content } => {
|
||||
render_message("System", content, system_style(), width)
|
||||
}
|
||||
@@ -122,6 +148,15 @@ impl HistoryCell {
|
||||
/// Convert a message into history cells for rendering.
|
||||
#[must_use]
|
||||
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
history_cells_from_message_with_mode(msg, None)
|
||||
}
|
||||
|
||||
/// Convert a message into history cells with optional Duo phase context.
|
||||
#[must_use]
|
||||
pub fn history_cells_from_message_with_mode(
|
||||
msg: &Message,
|
||||
duo_phase: Option<crate::duo::DuoPhase>,
|
||||
) -> Vec<HistoryCell> {
|
||||
let mut cells = Vec::new();
|
||||
let mut text_blocks = Vec::new();
|
||||
let mut thinking_blocks = Vec::new();
|
||||
@@ -138,10 +173,25 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
let content = text_blocks.join("\n");
|
||||
match msg.role.as_str() {
|
||||
"user" => cells.push(HistoryCell::User { content }),
|
||||
"assistant" => cells.push(HistoryCell::Assistant {
|
||||
content,
|
||||
streaming: false,
|
||||
}),
|
||||
"assistant" => {
|
||||
let cell = match duo_phase {
|
||||
Some(crate::duo::DuoPhase::Player) | Some(crate::duo::DuoPhase::Init) => {
|
||||
HistoryCell::Player {
|
||||
content,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach {
|
||||
content,
|
||||
streaming: false,
|
||||
},
|
||||
_ => HistoryCell::Assistant {
|
||||
content,
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
cells.push(cell);
|
||||
}
|
||||
"system" => cells.push(HistoryCell::System { content }),
|
||||
_ => {}
|
||||
}
|
||||
@@ -1271,6 +1321,16 @@ fn assistant_style() -> Style {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
}
|
||||
|
||||
fn player_style() -> Style {
|
||||
Style::default().fg(palette::MODE_DUO)
|
||||
}
|
||||
|
||||
fn coach_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_BLUE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn system_style() -> Style {
|
||||
Style::default().fg(palette::TEXT_MUTED).italic()
|
||||
}
|
||||
|
||||
+65
-14
@@ -60,8 +60,8 @@ use super::approval::{
|
||||
use super::history::{
|
||||
DiffPreviewCell, ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
|
||||
HistoryCell, McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell,
|
||||
ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output,
|
||||
summarize_tool_args, summarize_tool_output,
|
||||
ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message_with_mode,
|
||||
summarize_mcp_output, summarize_tool_args, summarize_tool_output,
|
||||
};
|
||||
use super::views::{HelpView, ModalKind, ViewEvent};
|
||||
use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable};
|
||||
@@ -154,8 +154,19 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
&saved.metadata.id[..8]
|
||||
),
|
||||
});
|
||||
|
||||
let duo_phase = if app.mode == AppMode::Duo {
|
||||
app.duo_session
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.active_state.as_ref().map(|st| st.phase))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for msg in &saved.messages {
|
||||
app.history.extend(history_cells_from_message(msg));
|
||||
app.history
|
||||
.extend(history_cells_from_message_with_mode(msg, duo_phase));
|
||||
}
|
||||
app.mark_history_updated();
|
||||
app.status_message = Some(format!("Resumed session: {}", &saved.metadata.id[..8]));
|
||||
@@ -268,19 +279,46 @@ async fn run_event_loop(
|
||||
let index = if let Some(index) = app.streaming_message_index {
|
||||
index
|
||||
} else {
|
||||
app.add_message(HistoryCell::Assistant {
|
||||
content: String::new(),
|
||||
streaming: true,
|
||||
});
|
||||
let duo_phase = if app.mode == AppMode::Duo {
|
||||
app.duo_session
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.active_state.as_ref().map(|st| st.phase))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cell = match duo_phase {
|
||||
Some(crate::duo::DuoPhase::Player)
|
||||
| Some(crate::duo::DuoPhase::Init) => HistoryCell::Player {
|
||||
content: String::new(),
|
||||
streaming: true,
|
||||
},
|
||||
Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach {
|
||||
content: String::new(),
|
||||
streaming: true,
|
||||
},
|
||||
_ => HistoryCell::Assistant {
|
||||
content: String::new(),
|
||||
streaming: true,
|
||||
},
|
||||
};
|
||||
|
||||
app.add_message(cell);
|
||||
let index = app.history.len().saturating_sub(1);
|
||||
app.streaming_message_index = Some(index);
|
||||
index
|
||||
};
|
||||
|
||||
if let Some(HistoryCell::Assistant { content, .. }) =
|
||||
app.history.get_mut(index)
|
||||
{
|
||||
content.clone_from(¤t_streaming_text);
|
||||
if let Some(cell) = app.history.get_mut(index) {
|
||||
match cell {
|
||||
HistoryCell::Assistant { content, .. }
|
||||
| HistoryCell::Player { content, .. }
|
||||
| HistoryCell::Coach { content, .. } => {
|
||||
content.clone_from(¤t_streaming_text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.mark_history_updated();
|
||||
}
|
||||
}
|
||||
@@ -1961,8 +1999,19 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events:
|
||||
fn apply_loaded_session(app: &mut App, session: &SavedSession) {
|
||||
app.api_messages.clone_from(&session.messages);
|
||||
app.history.clear();
|
||||
|
||||
let duo_phase = if app.mode == AppMode::Duo {
|
||||
app.duo_session
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.active_state.as_ref().map(|st| st.phase))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for msg in &app.api_messages {
|
||||
app.history.extend(history_cells_from_message(msg));
|
||||
app.history
|
||||
.extend(history_cells_from_message_with_mode(msg, duo_phase));
|
||||
}
|
||||
app.mark_history_updated();
|
||||
app.transcript_selection.clear();
|
||||
@@ -2337,9 +2386,11 @@ fn format_elapsed(start: Instant) -> String {
|
||||
}
|
||||
|
||||
fn deepseek_squiggle(start: Option<Instant>) -> &'static str {
|
||||
const FRAMES: [&str; 8] = ["🐳", "🐳·", "🐳··", "🐳···", "🐳··", "🐳·", "🐳", "🐳~"];
|
||||
const FRAMES: [&str; 12] = [
|
||||
"🐳", "🐳.", "🐳..", "🐳...", "🐳..", "🐳.", "🐋", "🐋.", "🐋..", "🐋...", "🐋..", "🐋.",
|
||||
];
|
||||
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
||||
let idx = ((elapsed_ms / 220) as usize) % FRAMES.len();
|
||||
let idx = ((elapsed_ms / 180) as usize) % FRAMES.len();
|
||||
FRAMES[idx]
|
||||
}
|
||||
|
||||
|
||||
+126
@@ -6,8 +6,134 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::models::{ContentBlock, Message};
|
||||
use anyhow::{Context, Result};
|
||||
use ignore::WalkBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
// === Project Mapping Helpers ===
|
||||
|
||||
/// Identify if a file is a "key" file for project identification.
|
||||
#[must_use]
|
||||
pub fn is_key_file(path: &Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
file_name.to_lowercase().as_str(),
|
||||
"cargo.toml"
|
||||
| "package.json"
|
||||
| "requirements.txt"
|
||||
| "build.gradle"
|
||||
| "pom.xml"
|
||||
| "readme.md"
|
||||
| "agents.md"
|
||||
| "claude.md"
|
||||
| "makefile"
|
||||
| "dockerfile"
|
||||
| "main.rs"
|
||||
| "lib.rs"
|
||||
| "index.js"
|
||||
| "index.ts"
|
||||
| "app.py"
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate a high-level summary of the project based on key files.
|
||||
#[must_use]
|
||||
pub fn summarize_project(root: &Path) -> String {
|
||||
let mut key_files = Vec::new();
|
||||
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder.hidden(false).follow_links(true).max_depth(Some(2));
|
||||
let walker = builder.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if is_key_file(entry.path()) {
|
||||
if let Ok(rel) = entry.path().strip_prefix(root) {
|
||||
key_files.push(rel.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_files.is_empty() {
|
||||
return "Unknown project type".to_string();
|
||||
}
|
||||
|
||||
let mut types = Vec::new();
|
||||
if key_files
|
||||
.iter()
|
||||
.any(|f| f.to_lowercase().contains("cargo.toml"))
|
||||
{
|
||||
types.push("Rust");
|
||||
}
|
||||
if key_files
|
||||
.iter()
|
||||
.any(|f| f.to_lowercase().contains("package.json"))
|
||||
{
|
||||
types.push("JavaScript/Node.js");
|
||||
}
|
||||
if key_files
|
||||
.iter()
|
||||
.any(|f| f.to_lowercase().contains("requirements.txt"))
|
||||
{
|
||||
types.push("Python");
|
||||
}
|
||||
|
||||
if types.is_empty() {
|
||||
format!("Project with key files: {}", key_files.join(", "))
|
||||
} else {
|
||||
format!("A {} project", types.join(" and "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a tree-like view of the project structure.
|
||||
#[must_use]
|
||||
pub fn project_tree(root: &Path, max_depth: usize) -> String {
|
||||
let mut tree_lines = Vec::new();
|
||||
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder
|
||||
.hidden(false)
|
||||
.follow_links(true)
|
||||
.max_depth(Some(max_depth + 1));
|
||||
let walker = builder.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
let depth = entry.depth();
|
||||
|
||||
if depth == 0 || depth > max_depth {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(path);
|
||||
let indent = " ".repeat(depth - 1);
|
||||
let prefix = if entry.file_type().is_some_and(|ft| ft.is_dir()) {
|
||||
"DIR: "
|
||||
} else {
|
||||
"FILE: "
|
||||
};
|
||||
|
||||
tree_lines.push(format!(
|
||||
"{}{}{}",
|
||||
indent,
|
||||
prefix,
|
||||
rel_path.file_name().unwrap_or_default().to_string_lossy()
|
||||
));
|
||||
}
|
||||
|
||||
tree_lines.join("\n")
|
||||
}
|
||||
|
||||
// === Filesystem Helpers ===
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
Reference in New Issue
Block a user