fix: auto-compaction not triggering and doctor API key check

- Fix doctor command to properly validate DEEPSEEK_API_KEY (check non-empty)
- Lower auto-compaction threshold from 95% to 85% to trigger earlier
- Fix should_compact() to always trigger when over token threshold
  (even with few unpinned messages)

This fixes the issue where auto-compaction wasn't working and users
would hit context length errors.
This commit is contained in:
Hunter Bown
2026-02-16 10:50:25 -06:00
parent ab87765c26
commit fa3905041d
3 changed files with 2145 additions and 319 deletions
+364 -2
View File
@@ -11,6 +11,7 @@ use std::sync::OnceLock;
use std::time::Duration;
use crate::client::DeepSeekClient;
use crate::config::DEFAULT_TEXT_MODEL;
use crate::llm_client::LlmClient;
use crate::logging;
use crate::models::{
@@ -18,7 +19,7 @@ use crate::models::{
};
/// Configuration for conversation compaction behavior.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfig {
pub enabled: bool,
pub token_threshold: usize,
@@ -33,7 +34,7 @@ impl Default for CompactionConfig {
enabled: false,
token_threshold: 50000,
message_threshold: 50,
model: "deepseek-v3.2".to_string(),
model: DEFAULT_TEXT_MODEL.to_string(),
cache_summary: true,
}
}
@@ -501,6 +502,11 @@ pub fn should_compact(
let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
let effective_message_threshold = config.message_threshold.saturating_sub(pinned_count);
// Always compact if we exceed the token threshold, even with few unpinned messages.
if token_estimate > effective_token_threshold && effective_token_threshold > 0 {
return true;
}
let enough_unpinned = message_count >= MIN_SUMMARIZE_MESSAGES
|| effective_token_threshold == 0
|| effective_message_threshold == 0;
@@ -1283,4 +1289,360 @@ mod tests {
// All pairs should remain intact (no orphans)
assert_eq!(pinned.len(), messages.len());
}
// ========================================================================
// Additional Compaction Trigger Tests
// ========================================================================
#[test]
fn test_should_compact_token_threshold_triggers() {
let config = CompactionConfig {
enabled: true,
token_threshold: 100, // Low threshold for testing
message_threshold: 1000, // High message threshold
..Default::default()
};
// Create messages that exceed token threshold
let messages: Vec<Message> = (0..10)
.map(|_| msg("user", &"x".repeat(50))) // 50 chars = ~12 tokens each
.collect();
// Total tokens: ~120, which exceeds 100
assert!(should_compact(&messages, &config, None, None, None));
}
#[test]
fn test_should_compact_below_token_threshold() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1000,
message_threshold: 1000,
..Default::default()
};
// Create short messages
let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
assert!(!should_compact(&messages, &config, None, None, None));
}
#[test]
fn test_plan_compaction_pins_error_messages() {
let messages = vec![
msg("user", "normal message"),
msg("assistant", "error: compilation failed"),
msg("user", "another message"),
msg("assistant", "panic at src/main.rs:42"),
msg("user", "more chat"),
msg("assistant", "Traceback (most recent call last):"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
// Error messages should be pinned
assert!(plan.pinned_indices.contains(&1)); // error:
assert!(plan.pinned_indices.contains(&3)); // panic
assert!(plan.pinned_indices.contains(&5)); // traceback
}
#[test]
fn test_plan_compaction_pins_patch_messages() {
let messages = vec![
msg("user", "normal chat"),
msg("assistant", "diff --git a/src/main.rs b/src/main.rs"),
msg("user", "more chat"),
msg("assistant", "+++ b/src/core.rs"),
msg("user", "chat"),
msg("assistant", "```diff\n-some code\n+new code\n```"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
// Patch/diff messages should be pinned
assert!(plan.pinned_indices.contains(&1)); // diff --git
assert!(plan.pinned_indices.contains(&3)); // +++ b/
assert!(plan.pinned_indices.contains(&5)); // ```diff
}
#[test]
fn test_plan_compaction_pins_apply_patch_tool_calls() {
let messages = vec![
msg("user", "normal chat"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "patch-1".to_string(),
name: "apply_patch".to_string(),
input: json!({"patch": "diff content"}),
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "patch-1".to_string(),
content: "Patch applied successfully".to_string(),
}],
},
msg("assistant", "more chat"),
msg("user", "even more"),
msg("assistant", "recent 1"),
msg("user", "recent 2"),
msg("assistant", "recent 3"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
// Message 1 contains apply_patch tool call with matching result (message 2)
// Both should be pinned due to tool call pairing
// Messages 5, 6, 7, 8 are recent (last 4 messages)
eprintln!("Pinned indices: {:?}", plan.pinned_indices);
// apply_patch tool call and its result should be pinned
assert!(
plan.pinned_indices.contains(&1),
"apply_patch tool call should be pinned"
);
assert!(
plan.pinned_indices.contains(&2),
"apply_patch tool result should be pinned"
);
}
#[test]
fn test_extract_paths_from_text_finds_various_formats() {
let text = r#"
I'm working on src/main.rs
Also check Cargo.toml
The error is in src/core/engine.rs:42
See docs/API.md for details
Config at config.example.toml
"#;
let paths = extract_paths_from_text(text, None);
assert!(paths.iter().any(|p| p == "src/main.rs"));
assert!(paths.iter().any(|p| p == "Cargo.toml"));
assert!(paths.iter().any(|p| p == "src/core/engine.rs"));
assert!(paths.iter().any(|p| p == "docs/API.md"));
assert!(paths.iter().any(|p| p == "config.example.toml"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_path_field() {
let input = json!({
"path": "src/main.rs",
"content": "test"
});
let paths = extract_paths_from_tool_input(&input, None);
assert!(paths.iter().any(|p| p == "src/main.rs"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_paths_array() {
let input = json!({
"paths": ["src/main.rs", "src/core.rs", "tests/test.rs"]
});
let paths = extract_paths_from_tool_input(&input, None);
assert_eq!(paths.len(), 3);
assert!(paths.iter().any(|p| p == "src/main.rs"));
assert!(paths.iter().any(|p| p == "src/core.rs"));
assert!(paths.iter().any(|p| p == "tests/test.rs"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_cwd() {
let input = json!({
"cwd": "src/core",
"command": "cargo build"
});
let paths = extract_paths_from_tool_input(&input, None);
assert!(paths.iter().any(|p| p == "src/core"));
}
#[test]
fn test_normalize_path_candidate_handles_absolute_paths() {
use std::env;
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
// Create an absolute path
let absolute_path = current_dir.join("src/main.rs");
let absolute_path_str = absolute_path.to_string_lossy();
let normalized = normalize_path_candidate(&absolute_path_str, Some(&current_dir));
assert_eq!(normalized, Some("src/main.rs".to_string()));
}
#[test]
fn test_normalize_path_candidate_rejects_parent_refs() {
let normalized = normalize_path_candidate("../outside/file.rs", Some(&PathBuf::from(".")));
assert_eq!(normalized, None);
}
#[test]
fn test_normalize_path_candidate_cleans_backslashes() {
let normalized = normalize_path_candidate("src\\main.rs", Some(&PathBuf::from(".")));
assert_eq!(normalized, Some("src/main.rs".to_string()));
}
#[test]
fn test_merge_system_prompts_none_none() {
let result = merge_system_prompts(None, None);
assert!(result.is_none());
}
#[test]
fn test_merge_system_prompts_some_text_none() {
let original = Some(SystemPrompt::Text("original".to_string()));
let result = merge_system_prompts(original.as_ref(), None);
assert!(matches!(result, Some(SystemPrompt::Text(s)) if s == "original"));
}
#[test]
fn test_merge_system_prompts_none_some_blocks() {
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(None, summary);
assert!(matches!(result, Some(SystemPrompt::Blocks(b)) if b.len() == 1));
}
#[test]
fn test_merge_system_prompts_text_plus_blocks() {
let original = Some(SystemPrompt::Text("original".to_string()));
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_merge_system_prompts_blocks_plus_blocks() {
let original = Some(SystemPrompt::Blocks(vec![
SystemBlock {
block_type: "text".to_string(),
text: "orig1".to_string(),
cache_control: None,
},
SystemBlock {
block_type: "text".to_string(),
text: "orig2".to_string(),
cache_control: None,
},
]));
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "orig1"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "orig2"));
assert!(matches!(&blocks[2], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_merge_system_prompts_blocks_plus_text() {
let original = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "original".to_string(),
cache_control: None,
}]));
let summary = Some(SystemPrompt::Text("summary".to_string()));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_compaction_result_retries_used() {
// This test verifies the CompactionResult structure
let result = CompactionResult {
messages: vec![],
summary_prompt: None,
removed_messages: vec![],
retries_used: 2,
};
assert_eq!(result.retries_used, 2);
assert!(result.messages.is_empty());
assert!(result.removed_messages.is_empty());
}
#[test]
fn test_should_compact_with_workspace_path_detection() {
use std::env;
let workspace = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let _config = CompactionConfig {
enabled: true,
token_threshold: 1000,
message_threshold: 5,
..Default::default()
};
// Create messages mentioning workspace paths
let messages = vec![
msg("user", "working on src/main.rs"),
msg("assistant", "noise 1"),
msg("user", "noise 2"),
msg("assistant", "noise 3"),
msg("user", "noise 4"),
msg("assistant", "noise 5"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
// Should compact because:
// - More than message_threshold (5) unpinned messages
// - src/main.rs mention pins message 0
let plan = plan_compaction(
&messages,
Some(&workspace),
KEEP_RECENT_MESSAGES,
None,
None,
);
assert!(plan.pinned_indices.contains(&0)); // src/main.rs mention
}
}
+365 -31
View File
@@ -26,12 +26,14 @@ use dotenvy::dotenv;
use tempfile::NamedTempFile;
use wait_timeout::ChildExt;
mod audit;
mod client;
mod command_safety;
mod commands;
mod compaction;
mod config;
mod core;
mod error_taxonomy;
mod eval;
mod execpolicy;
mod features;
@@ -47,17 +49,20 @@ mod project_context;
mod project_doc;
mod prompts;
mod responses_api_proxy;
mod runtime_api;
mod runtime_threads;
mod sandbox;
mod session_manager;
mod settings;
mod skills;
mod task_manager;
mod tools;
mod tui;
mod ui;
mod utils;
mod working_set;
use crate::config::{Config, MAX_SUBAGENTS};
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
use crate::features::Feature;
use crate::llm_client::LlmClient;
@@ -159,6 +164,8 @@ enum Commands {
},
/// Remove the saved API key
Logout,
/// List available models from the configured API endpoint
Models(ModelsArgs),
/// Run a non-interactive prompt
Exec(ExecArgs),
/// Run a code review over a git diff
@@ -213,6 +220,9 @@ struct ExecArgs {
/// Enable agentic mode with tool access and auto-approvals
#[arg(long, default_value_t = false)]
auto: bool,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone, Default)]
@@ -253,6 +263,13 @@ struct EvalArgs {
json: bool,
}
#[derive(Args, Debug, Clone, Default)]
struct ModelsArgs {
/// Print models as pretty JSON
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Default, Clone)]
struct FeatureToggles {
/// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
@@ -293,6 +310,9 @@ struct ReviewArgs {
/// Maximum diff characters to include
#[arg(long, default_value_t = 200_000)]
max_chars: usize,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
@@ -307,6 +327,18 @@ struct ServeArgs {
/// Start MCP server over stdio
#[arg(long)]
mcp: bool,
/// Start runtime HTTP/SSE API server
#[arg(long)]
http: bool,
/// Bind host for HTTP server (default localhost)
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Bind port for HTTP server
#[arg(long, default_value_t = 7878)]
port: u16,
/// Background task worker count (1-8)
#[arg(long, default_value_t = 2)]
workers: usize,
}
#[derive(Subcommand, Debug, Clone)]
@@ -331,6 +363,37 @@ enum McpCommand {
#[arg(value_name = "SERVER")]
server: Option<String>,
},
/// Add an MCP server entry
Add {
/// Server name
name: String,
/// Command to launch stdio server
#[arg(long, conflicts_with = "url")]
command: Option<String>,
/// URL for streamable HTTP/SSE server
#[arg(long, conflicts_with = "command")]
url: Option<String>,
/// Arguments for command-based servers
#[arg(long = "arg")]
args: Vec<String>,
},
/// Remove an MCP server entry
Remove {
/// Server name
name: String,
},
/// Enable an MCP server
Enable {
/// Server name
name: String,
},
/// Disable an MCP server
Disable {
/// Server name
name: String,
},
/// Validate MCP config and required servers
Validate,
}
#[derive(Args, Debug, Clone)]
@@ -422,12 +485,16 @@ async fn main() -> Result<()> {
Commands::Init => init_project(),
Commands::Login { api_key } => run_login(api_key),
Commands::Logout => run_logout(),
Commands::Models(args) => {
let config = load_config_from_cli(&cli)?;
run_models(&config, args).await
}
Commands::Exec(args) => {
let config = load_config_from_cli(&cli)?;
let model = args
.model
.or_else(|| config.default_text_model.clone())
.unwrap_or_else(|| "deepseek-v3.2".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
if args.auto || cli.yolo {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
@@ -445,8 +512,11 @@ async fn main() -> Result<()> {
max_subagents,
true,
auto_mode,
args.json,
)
.await
} else if args.json {
run_one_shot_json(&config, &model, &args.prompt).await
} else {
run_one_shot(&config, &model, &args.prompt).await
}
@@ -471,10 +541,25 @@ async fn main() -> Result<()> {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
if args.mcp && args.http {
bail!("Choose exactly one server mode: --mcp or --http");
}
if args.mcp {
mcp_server::run_mcp_server(workspace)
} else if args.http {
let config = load_config_from_cli(&cli)?;
runtime_api::run_http_server(
config,
workspace,
runtime_api::RuntimeApiOptions {
host: args.host,
port: args.port,
workers: args.workers.clamp(1, 8),
},
)
.await
} else {
bail!("No server mode specified. Use --mcp.")
bail!("No server mode specified. Use --mcp or --http.")
}
}
Commands::Resume { session_id, last } => {
@@ -500,7 +585,7 @@ async fn main() -> Result<()> {
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| "deepseek-v3.2".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
return run_one_shot(&config, &model, &prompt).await;
}
@@ -644,6 +729,10 @@ fn mcp_template_json() -> Result<String> {
execute_timeout: None,
read_timeout: None,
disabled: true,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
},
);
serde_json::to_string_pretty(&cfg)
@@ -819,7 +908,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
// Check API keys
println!();
println!("{}", "API Keys:".bold());
let has_api_key = if std::env::var("DEEPSEEK_API_KEY").is_ok() {
let has_api_key = if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
println!(
" {} DEEPSEEK_API_KEY is set",
"".truecolor(aqua_r, aqua_g, aqua_b)
@@ -1072,6 +1165,41 @@ fn run_features_list(config: &Config) -> Result<()> {
Ok(())
}
async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
use crate::client::DeepSeekClient;
let client = DeepSeekClient::new(config)?;
let mut models = client.list_models().await?;
models.sort_by(|a, b| a.id.cmp(&b.id));
if args.json {
println!("{}", serde_json::to_string_pretty(&models)?);
return Ok(());
}
if models.is_empty() {
println!("No models returned by the API.");
return Ok(());
}
let default_model = config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
println!("Available models (default: {default_model})");
for model in models {
let marker = if model.id == default_model { "*" } else { " " };
if let Some(owner) = model.owned_by {
println!("{marker} {} ({owner})", model.id);
} else {
println!("{marker} {}", model.id);
}
}
Ok(())
}
/// Test API connectivity by making a minimal request
async fn test_api_connectivity() -> Result<String> {
use crate::client::DeepSeekClient;
@@ -1355,7 +1483,7 @@ async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
let model = args
.model
.or_else(|| config.default_text_model.clone())
.unwrap_or_else(|| "deepseek-v3.2".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
let system = SystemPrompt::Text(
"You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
@@ -1367,7 +1495,7 @@ Provide findings ordered by severity with file references, then open questions,
let client = DeepSeekClient::new(config)?;
let request = MessageRequest {
model,
model: model.clone(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
@@ -1387,11 +1515,25 @@ Provide findings ordered by severity with file references, then open questions,
};
let response = client.create_message(request).await?;
let mut output = String::new();
for block in response.content {
if let ContentBlock::Text { text, .. } = block {
println!("{text}");
output.push_str(&text);
}
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"mode": "review",
"model": model,
"success": true,
"content": output
}))?
);
} else {
println!("{output}");
}
Ok(())
}
@@ -1492,10 +1634,10 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
}
println!("MCP servers ({}):", cfg.servers.len());
for (name, server) in cfg.servers {
let status = if server.disabled {
"disabled"
} else {
let status = if server.enabled && !server.disabled {
"enabled"
} else {
"disabled"
};
let args = if server.args.is_empty() {
"".to_string()
@@ -1509,7 +1651,8 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
} else {
"unknown".to_string()
};
println!(" - {name} [{status}] {cmd_str}");
let required = if server.required { " required" } else { "" };
println!(" - {name} [{status}{required}] {cmd_str}");
}
Ok(())
}
@@ -1568,6 +1711,83 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
}
Ok(())
}
McpCommand::Add {
name,
command,
url,
args,
} => {
if command.is_none() && url.is_none() {
bail!("Provide either --command or --url for `mcp add`.");
}
let mut cfg = load_mcp_config(&config_path)?;
cfg.servers.insert(
name.clone(),
McpServerConfig {
command,
args,
env: std::collections::HashMap::new(),
url,
connect_timeout: None,
execute_timeout: None,
read_timeout: None,
disabled: false,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
},
);
save_mcp_config(&config_path, &cfg)?;
println!("Added MCP server '{name}' in {}", config_path.display());
Ok(())
}
McpCommand::Remove { name } => {
let mut cfg = load_mcp_config(&config_path)?;
if cfg.servers.remove(&name).is_none() {
bail!("MCP server '{name}' not found");
}
save_mcp_config(&config_path, &cfg)?;
println!("Removed MCP server '{name}'");
Ok(())
}
McpCommand::Enable { name } => {
let mut cfg = load_mcp_config(&config_path)?;
let server = cfg
.servers
.get_mut(&name)
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
server.enabled = true;
server.disabled = false;
save_mcp_config(&config_path, &cfg)?;
println!("Enabled MCP server '{name}'");
Ok(())
}
McpCommand::Disable { name } => {
let mut cfg = load_mcp_config(&config_path)?;
let server = cfg
.servers
.get_mut(&name)
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
server.enabled = false;
server.disabled = true;
save_mcp_config(&config_path, &cfg)?;
println!("Disabled MCP server '{name}'");
Ok(())
}
McpCommand::Validate => {
let mut pool = McpPool::from_config_path(&config_path)?;
let errors = pool.connect_all().await;
if errors.is_empty() {
println!("MCP config is valid. All enabled servers connected.");
return Ok(());
}
eprintln!("MCP validation failed:");
for (name, err) in errors {
eprintln!(" - {name}: {err}");
}
bail!("one or more MCP servers failed validation");
}
}
}
@@ -1582,6 +1802,19 @@ fn load_mcp_config(path: &Path) -> Result<McpConfig> {
Ok(cfg)
}
fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create MCP config directory {}", parent.display())
})?;
}
let rendered = serde_json::to_string_pretty(cfg)
.map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
std::fs::write(path, rendered)
.map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
Ok(())
}
fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
use crate::sandbox::{CommandSpec, SandboxManager};
@@ -1746,7 +1979,7 @@ async fn run_interactive(
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| "deepseek-v3.2".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
let max_subagents = cli.max_subagents.map_or_else(
|| config.max_subagents(),
|value| value.clamp(1, MAX_SUBAGENTS),
@@ -1812,6 +2045,53 @@ async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()>
Ok(())
}
async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result<()> {
use crate::client::DeepSeekClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
let client = DeepSeekClient::new(config)?;
let request = MessageRequest {
model: model.to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt.to_string(),
cache_control: None,
}],
}],
max_tokens: 4096,
system: Some(SystemPrompt::Text(
"You are a coding assistant. Give concise, actionable responses.".to_string(),
)),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: Some(false),
temperature: Some(0.2),
top_p: Some(0.9),
};
let response = client.create_message(request).await?;
let mut output = String::new();
for block in response.content {
if let ContentBlock::Text { text, .. } = block {
output.push_str(&text);
}
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"mode": "one-shot",
"model": model,
"success": true,
"output": output
}))?
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_exec_agent(
config: &Config,
model: &str,
@@ -1820,6 +2100,7 @@ async fn run_exec_agent(
max_subagents: usize,
auto_approve: bool,
trust_mode: bool,
json_output: bool,
) -> Result<()> {
use crate::compaction::CompactionConfig;
use crate::core::engine::{EngineConfig, spawn_engine};
@@ -1861,6 +2142,29 @@ async fn run_exec_agent(
))
.await?;
#[derive(serde::Serialize)]
struct ExecToolEntry {
name: String,
success: bool,
output: String,
}
#[derive(serde::Serialize, Default)]
struct ExecSummary {
mode: String,
model: String,
prompt: String,
output: String,
tools: Vec<ExecToolEntry>,
status: Option<String>,
error: Option<String>,
}
let mut summary = ExecSummary {
mode: "agent".to_string(),
model: model.to_string(),
prompt: prompt.to_string(),
..ExecSummary::default()
};
let mut stdout = io::stdout();
let mut ends_with_newline = false;
loop {
@@ -1875,35 +2179,49 @@ async fn run_exec_agent(
match event {
Event::MessageDelta { content, .. } => {
print!("{content}");
stdout.flush()?;
summary.output.push_str(&content);
if !json_output {
print!("{content}");
stdout.flush()?;
}
ends_with_newline = content.ends_with('\n');
}
Event::MessageComplete { .. } => {
if !ends_with_newline {
if !json_output && !ends_with_newline {
println!();
}
}
Event::ToolCallStarted { name, input, .. } => {
let summary = summarize_tool_args(&input);
if let Some(summary) = summary {
eprintln!("tool: {name} ({summary})");
} else {
eprintln!("tool: {name}");
if !json_output {
let summary = summarize_tool_args(&input);
if let Some(summary) = summary {
eprintln!("tool: {name} ({summary})");
} else {
eprintln!("tool: {name}");
}
}
}
Event::ToolCallProgress { id, output } => {
eprintln!("tool {id}: {}", summarize_tool_output(&output));
if !json_output {
eprintln!("tool {id}: {}", summarize_tool_output(&output));
}
}
Event::ToolCallComplete { name, result, .. } => match result {
Ok(output) => {
summary.tools.push(ExecToolEntry {
name: name.clone(),
success: output.success,
output: output.content.clone(),
});
if name == "exec_shell" && !output.content.trim().is_empty() {
eprintln!("tool {name} completed");
eprintln!(
"--- stdout/stderr ---\n{}\n---------------------",
output.content
);
} else {
if !json_output {
eprintln!("tool {name} completed");
eprintln!(
"--- stdout/stderr ---\n{}\n---------------------",
output.content
);
}
} else if !json_output {
eprintln!(
"tool {name} completed: {}",
summarize_tool_output(&output.content)
@@ -1911,7 +2229,14 @@ async fn run_exec_agent(
}
}
Err(err) => {
eprintln!("tool {name} failed: {err}");
summary.tools.push(ExecToolEntry {
name: name.clone(),
success: false,
output: err.to_string(),
});
if !json_output {
eprintln!("tool {name} failed: {err}");
}
}
},
Event::AgentSpawned { id, prompt } => {
@@ -1952,9 +2277,14 @@ async fn run_exec_agent(
message,
recoverable: _,
} => {
eprintln!("error: {message}");
summary.error = Some(message.clone());
if !json_output {
eprintln!("error: {message}");
}
}
Event::TurnComplete { .. } => {
Event::TurnComplete { status, error, .. } => {
summary.status = Some(format!("{status:?}").to_lowercase());
summary.error = error;
let _ = engine_handle.send(Op::Shutdown).await;
break;
}
@@ -1962,5 +2292,9 @@ async fn run_exec_agent(
}
}
if json_output {
println!("{}", serde_json::to_string_pretty(&summary)?);
}
Ok(())
}
+1416 -286
View File
File diff suppressed because it is too large Load Diff