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:
+364
-2
@@ -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(¤t_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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user