diff --git a/crates/cc-config/src/runtime_settings.rs b/crates/cc-config/src/runtime_settings.rs index 0a828f14..f49190a4 100644 --- a/crates/cc-config/src/runtime_settings.rs +++ b/crates/cc-config/src/runtime_settings.rs @@ -49,6 +49,11 @@ pub struct SettingsJson { pub effort_level: Option, pub fast_mode: Option, pub fast_mode_per_session_opt_in: Option, + /// Optional advisor model id (issue #33). Persisted under + /// `settings.json::advisorModel`. When set and the active provider + /// supports advisors, this model is attached to the Messages request + /// via `MessagesRequest::advisor_model`. + pub advisor_model: Option, // -- Modes / integrations ------------------------------------------ pub teammate_mode: Option, diff --git a/crates/cc-config/src/settings.rs b/crates/cc-config/src/settings.rs index 8a3ad777..aba9936a 100644 --- a/crates/cc-config/src/settings.rs +++ b/crates/cc-config/src/settings.rs @@ -424,6 +424,10 @@ pub struct RawSettings { pub effort_level: Option, pub fast_mode: Option, pub fast_mode_per_session_opt_in: Option, + /// Stronger secondary model used as an advisor (issue #33). + /// Persisted under `advisorModel`. Only honored by providers that + /// advertise advisor support; others log a warning and ignore it. + pub advisor_model: Option, // -- Modes / integrations ------------------------------------------ pub teammate_mode: Option, @@ -516,6 +520,7 @@ impl RawSettings { "claudeInChromeDefaultEnabled" ); merge_opt!(auto_memory_enabled, "autoMemoryEnabled"); + merge_opt!(advisor_model, "advisorModel"); merge_opt!(system_prompt, "systemPrompt"); merge_opt!(api_key, "apiKey"); @@ -709,6 +714,8 @@ pub struct EffectiveSettings { pub teammate_mode: Option, /// Auto-memory toggle (issue #45). `None` means "inherit default" (off). pub auto_memory_enabled: Option, + /// Advisor model id (issue #33). + pub advisor_model: Option, } impl EffectiveSettings { @@ -751,6 +758,7 @@ impl EffectiveSettings { fast_mode_per_session_opt_in: raw.fast_mode_per_session_opt_in, teammate_mode: raw.teammate_mode, auto_memory_enabled: raw.auto_memory_enabled, + advisor_model: raw.advisor_model, } } } @@ -1292,6 +1300,7 @@ pub fn settings_schema() -> Value { "teammateMode": { "type": "boolean" }, "claudeInChromeDefaultEnabled": { "type": "boolean" }, "autoMemoryEnabled": { "type": "boolean" }, + "advisorModel": { "type": "string" }, "systemPrompt": { "type": "string" }, "apiKey": { "type": "string" } } diff --git a/crates/claude-code-rs/src/api/bedrock.rs b/crates/claude-code-rs/src/api/bedrock.rs index bd52065b..837a5a6f 100644 --- a/crates/claude-code-rs/src/api/bedrock.rs +++ b/crates/claude-code-rs/src/api/bedrock.rs @@ -390,6 +390,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let raw = to_bedrock_body(&req).unwrap(); let v: Value = serde_json::from_slice(&raw).unwrap(); diff --git a/crates/claude-code-rs/src/api/client/mod.rs b/crates/claude-code-rs/src/api/client/mod.rs index 73f0eff0..368a5e18 100644 --- a/crates/claude-code-rs/src/api/client/mod.rs +++ b/crates/claude-code-rs/src/api/client/mod.rs @@ -105,6 +105,29 @@ pub struct MessagesRequest { pub thinking: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + /// Optional advisor model id (issue #33). Carried through the request + /// pipeline only for providers that advertise advisor support + /// (see [`provider_supports_advisor`]). Serialized as `advisor_model`; + /// omitted when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub advisor_model: Option, +} + +/// Return `true` when the given provider supports the advisor-model field. +/// +/// Only the Anthropic Messages API currently recognizes `advisor_model`. +/// For Bedrock/Vertex (which ultimately reach the same Anthropic shape) we +/// also pass it through; OpenAI-compatible and Google providers don't have +/// the field in their native schema, so we drop it there and the `/advisor` +/// command surfaces an "inactive" message. +pub fn provider_supports_advisor(provider: &ApiProvider) -> bool { + matches!( + provider, + ApiProvider::Anthropic { .. } + | ApiProvider::Azure { .. } + | ApiProvider::Bedrock { .. } + | ApiProvider::Vertex { .. } + ) } /// API client configuration diff --git a/crates/claude-code-rs/src/api/client/tests.rs b/crates/claude-code-rs/src/api/client/tests.rs index ccdc53a9..fb5fb8d3 100644 --- a/crates/claude-code-rs/src/api/client/tests.rs +++ b/crates/claude-code-rs/src/api/client/tests.rs @@ -722,15 +722,17 @@ fn test_messages_request_serialization() { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["model"], "claude-sonnet-4-20250514"); assert_eq!(json["max_tokens"], 1024); assert_eq!(json["stream"], true); - // thinking and tool_choice should be omitted when None + // thinking, tool_choice and advisor_model should be omitted when None assert!(json.get("thinking").is_none()); assert!(json.get("tool_choice").is_none()); + assert!(json.get("advisor_model").is_none()); } #[test] @@ -746,6 +748,7 @@ fn test_messages_request_with_thinking() { stream: true, thinking: Some(serde_json::json!({"type": "enabled", "budget_tokens": 2048})), tool_choice: None, + advisor_model: None, }; let json = serde_json::to_value(&req).unwrap(); @@ -753,3 +756,44 @@ fn test_messages_request_with_thinking() { assert_eq!(json["thinking"]["type"], "enabled"); assert!(json.get("system").is_some()); } + +#[test] +fn test_messages_request_advisor_model_serializes_when_set() { + let req = MessagesRequest { + model: "claude-sonnet-4-20250514".to_string(), + messages: vec![], + system: None, + max_tokens: 1024, + tools: None, + stream: true, + thinking: None, + tool_choice: None, + advisor_model: Some("claude-opus-4-20250514".to_string()), + }; + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["advisor_model"], "claude-opus-4-20250514"); +} + +#[test] +fn test_provider_supports_advisor_matrix() { + use crate::api::client::{provider_supports_advisor, ApiProvider}; + assert!(provider_supports_advisor(&ApiProvider::Anthropic { + api_key: "k".into(), + base_url: None, + })); + assert!(provider_supports_advisor(&ApiProvider::Azure { + endpoint: "e".into(), + api_key: "k".into(), + })); + assert!(!provider_supports_advisor(&ApiProvider::OpenAiCompat { + name: "openai".into(), + api_key: "k".into(), + base_url: "u".into(), + default_model: "m".into(), + })); + assert!(!provider_supports_advisor(&ApiProvider::Google { + api_key: "k".into(), + base_url: "u".into(), + })); +} diff --git a/crates/claude-code-rs/src/api/google_provider.rs b/crates/claude-code-rs/src/api/google_provider.rs index 5d1dda2b..b5c7d9cc 100644 --- a/crates/claude-code-rs/src/api/google_provider.rs +++ b/crates/claude-code-rs/src/api/google_provider.rs @@ -340,6 +340,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_gemini_request(&req); assert_eq!(body["generationConfig"]["maxOutputTokens"], 1024); @@ -361,6 +362,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_gemini_request(&req); assert_eq!( @@ -384,6 +386,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_gemini_request(&req); let contents = body["contents"].as_array().unwrap(); @@ -408,6 +411,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_gemini_request(&req); let contents = body["contents"].as_array().unwrap(); @@ -437,6 +441,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_gemini_request(&req); let contents = body["contents"].as_array().unwrap(); diff --git a/crates/claude-code-rs/src/api/openai_compat.rs b/crates/claude-code-rs/src/api/openai_compat.rs index 333d7ec1..7b730792 100644 --- a/crates/claude-code-rs/src/api/openai_compat.rs +++ b/crates/claude-code-rs/src/api/openai_compat.rs @@ -633,6 +633,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_openai_request(&req, "openai"); assert_eq!(body["model"], "gpt-4o"); @@ -660,6 +661,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_openai_request(&req, OPENAI_CODEX_PROVIDER_NAME); assert_eq!(body["model"], "gpt-5.4"); @@ -680,6 +682,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_openai_request(&req, "deepseek"); let messages = body["messages"].as_array().unwrap(); @@ -706,6 +709,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let body = build_openai_request(&req, "openai"); let messages = body["messages"].as_array().unwrap(); diff --git a/crates/claude-code-rs/src/api/vertex.rs b/crates/claude-code-rs/src/api/vertex.rs index 88bda8ae..85ae4c43 100644 --- a/crates/claude-code-rs/src/api/vertex.rs +++ b/crates/claude-code-rs/src/api/vertex.rs @@ -286,6 +286,7 @@ mod tests { stream: true, thinking: None, tool_choice: None, + advisor_model: None, }; let raw = to_vertex_body(&req).unwrap(); let v: Value = serde_json::from_slice(&raw).unwrap(); diff --git a/crates/claude-code-rs/src/commands/advisor.rs b/crates/claude-code-rs/src/commands/advisor.rs new file mode 100644 index 00000000..2ba7dcbf --- /dev/null +++ b/crates/claude-code-rs/src/commands/advisor.rs @@ -0,0 +1,340 @@ +//! `/advisor` — advisor-model command (issue #33). +//! +//! Lets the user configure a stronger secondary model that the API request +//! pipeline passes through as [`crate::api::client::MessagesRequest::advisor_model`]. +//! Only providers that advertise advisor support (see +//! [`crate::api::client::provider_supports_advisor`]) actually receive the +//! field; for others the command still persists the setting but surfaces a +//! clear "inactive" message so the user knows their choice won't reach the +//! provider. +//! +//! Subcommands: +//! /advisor — show the current advisor + support status +//! /advisor — set the advisor model +//! /advisor unset|none|off — clear the advisor model +//! +//! Persistence: the user-level `settings.json` is updated under the +//! `advisorModel` key. AppState is mirrored in-place so the next API call +//! picks up the change without a restart. + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::commands::model::resolve_model_alias; + +pub struct AdvisorHandler; + +#[async_trait] +impl CommandHandler for AdvisorHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let arg = args.trim(); + + match arg { + "" => Ok(CommandResult::Output(render_status(ctx))), + "unset" | "none" | "off" | "clear" => clear_advisor(ctx), + other => set_advisor(ctx, other), + } + } +} + +/// Human-friendly status text showing current advisor + support state. +fn render_status(ctx: &CommandContext) -> String { + let mut lines = Vec::new(); + lines.push("Advisor model".to_string()); + lines.push(String::new()); + + match ctx.app_state.advisor_model.as_deref() { + Some(m) => lines.push(format!(" Current: {}", m)), + None => lines.push(" Current: (unset)".to_string()), + } + + if let Some(settings_model) = ctx.app_state.settings.advisor_model.as_deref() { + lines.push(format!(" Persisted (settings.json::advisorModel): {}", settings_model)); + } else { + lines.push(" Persisted: (not set)".to_string()); + } + + lines.push(String::new()); + lines.push(format!(" Main model: {}", ctx.app_state.main_loop_model)); + + if ctx.app_state.advisor_model.is_some() { + lines.push(String::new()); + lines.push( + " Status: active — advisor_model will be attached to outbound API requests \ + when the provider supports it (Anthropic, Azure, Bedrock, Vertex). Providers \ + that don't support it ignore the field and log a debug-level notice." + .into(), + ); + } + + lines.push(String::new()); + lines.push("Usage:".into()); + lines.push(" /advisor — show current state".into()); + lines.push(" /advisor — set advisor model (alias or full id)".into()); + lines.push(" /advisor unset — clear advisor".into()); + + lines.join("\n") +} + +fn set_advisor(ctx: &mut CommandContext, raw: &str) -> Result { + set_advisor_with_persist(ctx, raw, persist_advisor) +} + +fn clear_advisor(ctx: &mut CommandContext) -> Result { + clear_advisor_with_persist(ctx, persist_advisor) +} + +/// Validate + mutate + persist. Factored out so tests can exercise the +/// validation and AppState mutation with a no-op persist closure (avoiding +/// any touch of the real user settings file). +fn set_advisor_with_persist( + ctx: &mut CommandContext, + raw: &str, + persist: F, +) -> Result +where + F: FnOnce(&std::path::Path, Option<&str>) -> Result, +{ + let resolved = resolve_model_alias(raw); + let trimmed = resolved.trim(); + if trimmed.is_empty() { + return Ok(CommandResult::Output( + "Rejected: advisor model id cannot be empty.".to_string(), + )); + } + + let previous = ctx.app_state.advisor_model.clone(); + ctx.app_state.advisor_model = Some(trimmed.to_string()); + ctx.app_state.settings.advisor_model = Some(trimmed.to_string()); + + let persist_result = persist(&ctx.cwd, Some(trimmed)); + + let mut out = Vec::new(); + out.push(match previous { + Some(p) => format!("Advisor model updated: {} -> {}", p, trimmed), + None => format!("Advisor model set: {}", trimmed), + }); + match persist_result { + Ok(path) => out.push(format!("Persisted to: {}", path.display())), + Err(e) => out.push(format!( + "Warning: failed to persist advisor_model to settings.json: {}", + e + )), + } + + out.push(String::new()); + out.push( + "Note: `advisor_model` is only attached to requests for providers that support \ + it (Anthropic, Azure, Bedrock, Vertex). For other providers the setting is \ + preserved but inactive." + .to_string(), + ); + + Ok(CommandResult::Output(out.join("\n"))) +} + +fn clear_advisor_with_persist( + ctx: &mut CommandContext, + persist: F, +) -> Result +where + F: FnOnce(&std::path::Path, Option<&str>) -> Result, +{ + let previous = ctx.app_state.advisor_model.clone(); + ctx.app_state.advisor_model = None; + ctx.app_state.settings.advisor_model = None; + + let persist_result = persist(&ctx.cwd, None); + + let mut out = Vec::new(); + out.push(match previous { + Some(p) => format!("Advisor model cleared (was: {}).", p), + None => "Advisor model cleared (was already unset).".to_string(), + }); + if let Err(e) = persist_result { + out.push(format!( + "Warning: failed to persist advisor_model change to settings.json: {}", + e + )); + } + + Ok(CommandResult::Output(out.join("\n"))) +} + +/// Write the advisor model to the user-level `settings.json`. +/// +/// Uses the atomic-write helper in `cc-config::settings`. Returns the path +/// that was written so the caller can show it to the user. +fn persist_advisor( + _cwd: &std::path::Path, + new_value: Option<&str>, +) -> Result { + use cc_config::settings::{load_global_config, write_user_settings}; + let mut raw = load_global_config()?; + raw.advisor_model = new_value.map(|s| s.to_string()); + let path = write_user_settings(&raw)?; + Ok(path) +} + +/// Test-only: persist to an explicit path instead of the user-level file. +/// +/// Keeps the on-disk round-trip covered without racing on the shared +/// `CC_RUST_HOME` env var (which other modules' tests also mutate). +#[cfg(test)] +fn persist_advisor_to_path( + path: &std::path::Path, + new_value: Option<&str>, +) -> Result { + use cc_config::settings::{write_settings_file, RawSettings}; + let mut raw: RawSettings = if path.exists() { + let s = std::fs::read_to_string(path)?; + serde_json::from_str(&s).unwrap_or_default() + } else { + RawSettings::default() + }; + raw.advisor_model = new_value.map(|s| s.to_string()); + write_settings_file(path, &raw)?; + Ok(path.to_path_buf()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::SessionId; + use crate::types::app_state::AppState; + use std::path::PathBuf; + + fn test_ctx() -> CommandContext { + CommandContext { + messages: Vec::new(), + cwd: PathBuf::from("."), + app_state: AppState::default(), + session_id: SessionId::from_string("advisor-test-session"), + } + } + + /// No-op persist closure for tests. Reports a fake path so the + /// formatting code path exercises the Ok-branch without writing to + /// disk. This keeps the command tests hermetic — no env vars, no + /// files — so they can't race with other modules' settings tests. + fn noop_persist( + _cwd: &std::path::Path, + _v: Option<&str>, + ) -> Result { + Ok(std::path::PathBuf::from("/dev/null/fake-settings.json")) + } + + #[tokio::test] + async fn show_when_unset() { + let handler = AdvisorHandler; + let mut ctx = test_ctx(); + let result = handler.execute("", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + assert!(text.contains("Advisor model")); + assert!(text.contains("Current: (unset)")); + assert!(text.contains("Usage:")); + } + _ => panic!("expected Output"), + } + } + + #[tokio::test] + async fn set_mutates_app_state_and_settings() { + let mut ctx = test_ctx(); + let result = set_advisor_with_persist(&mut ctx, "opus", noop_persist).unwrap(); + match result { + CommandResult::Output(text) => assert!(text.contains("claude-opus-4-20250514")), + _ => panic!("expected Output"), + } + assert_eq!( + ctx.app_state.advisor_model.as_deref(), + Some("claude-opus-4-20250514") + ); + assert_eq!( + ctx.app_state.settings.advisor_model.as_deref(), + Some("claude-opus-4-20250514") + ); + } + + #[tokio::test] + async fn show_after_set_reports_active_status() { + let mut ctx = test_ctx(); + set_advisor_with_persist(&mut ctx, "my-advisor-model", noop_persist).unwrap(); + let rendered = render_status(&ctx); + assert!(rendered.contains("my-advisor-model")); + assert!(rendered.contains("Status: active")); + } + + #[test] + fn clear_resets_both_fields() { + let mut ctx = test_ctx(); + ctx.app_state.advisor_model = Some("foo".into()); + ctx.app_state.settings.advisor_model = Some("foo".into()); + let result = clear_advisor_with_persist(&mut ctx, noop_persist).unwrap(); + match result { + CommandResult::Output(text) => { + assert!(text.to_lowercase().contains("clear")); + } + _ => panic!("expected Output"), + } + assert!(ctx.app_state.advisor_model.is_none()); + assert!(ctx.app_state.settings.advisor_model.is_none()); + } + + /// Dispatch routing: all four unset aliases reach `clear_advisor`. + /// We call the private helpers so this test stays hermetic — a handler + /// round-trip would pull in real persistence. + #[test] + fn unset_variants_route_to_clear() { + for variant in ["unset", "none", "off", "clear"] { + // Simulate the dispatch branch the handler uses. + let matched = matches!(variant, "unset" | "none" | "off" | "clear"); + assert!(matched, "variant {variant:?} not routed to clear"); + + let mut ctx = test_ctx(); + ctx.app_state.advisor_model = Some("x".into()); + let _ = clear_advisor_with_persist(&mut ctx, noop_persist).unwrap(); + assert!( + ctx.app_state.advisor_model.is_none(), + "variant '{}' did not clear advisor_model", + variant + ); + } + } + + #[tokio::test] + async fn empty_set_rejected() { + // No persist path — whitespace-only input returns Rejected before + // touching disk. + let mut ctx = test_ctx(); + let result = set_advisor(&mut ctx, " ").unwrap(); + match result { + CommandResult::Output(text) => assert!(text.contains("Rejected")), + _ => panic!("expected Output"), + } + } + + #[test] + fn persist_to_path_round_trips_via_disk() { + // Uses an explicit tempdir path — no env-var mutation, no race. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + persist_advisor_to_path(&path, Some("claude-opus-4-20250514")).unwrap(); + + let raw_json = std::fs::read_to_string(&path).unwrap(); + let raw: cc_config::settings::RawSettings = serde_json::from_str(&raw_json).unwrap(); + assert_eq!(raw.advisor_model.as_deref(), Some("claude-opus-4-20250514")); + + // Clear via None. + persist_advisor_to_path(&path, None).unwrap(); + let raw_json = std::fs::read_to_string(&path).unwrap(); + let raw: cc_config::settings::RawSettings = serde_json::from_str(&raw_json).unwrap(); + assert!(raw.advisor_model.is_none()); + } +} diff --git a/crates/claude-code-rs/src/commands/btw.rs b/crates/claude-code-rs/src/commands/btw.rs new file mode 100644 index 00000000..3bbfb7d4 --- /dev/null +++ b/crates/claude-code-rs/src/commands/btw.rs @@ -0,0 +1,154 @@ +//! `/btw` — side-question command (issue #37). +//! +//! Ask a quick side question without interrupting the main task. The question +//! is routed to a forked, tool-free, single-turn child engine via +//! [`crate::engine::agent::fork::run_fork`]. The main conversation history +//! is NOT modified — the answer is returned as `CommandResult::Output` so +//! it appears in the UI as a one-off system message. +//! +//! Usage: +//! /btw +//! +//! Examples: +//! /btw What does the `fold` pattern do in Rust? +//! /btw Is serde_json::Value Send + Sync? +//! +//! The forked agent sees the parent conversation as cache-safe context +//! (no mutation); its own reply is discarded after it is delivered here. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::engine::agent::fork::{run_fork, ForkParams}; + +/// Shared system-prompt fragment that pins the forked agent to its side- +/// question role. Kept short so cache reuse with the parent prompt is high. +const BTW_SYSTEM_APPEND: &str = concat!( + "You are answering a one-off side question alongside a main task. ", + "Reply with a focused, self-contained answer — no tools, no file reads, ", + "no follow-ups. Prefer 1-3 sentences unless a longer answer is truly ", + "required. Do NOT continue the main task; assume the user will return ", + "to it after reading your reply." +); + +pub struct BtwHandler; + +#[async_trait] +impl CommandHandler for BtwHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let question = args.trim(); + if question.is_empty() { + return Ok(CommandResult::Output( + "Usage: /btw \n\nAsk a side question without \ + interrupting the current task. The question runs as a \ + tool-free, single-turn fork." + .to_string(), + )); + } + + let cwd = ctx.cwd.to_string_lossy().to_string(); + let model = ctx.app_state.main_loop_model.clone(); + let parent_messages = if ctx.messages.is_empty() { + None + } else { + Some(ctx.messages.clone()) + }; + + let params = ForkParams { + prompt: question.to_string(), + cwd, + model: model.clone(), + fallback_model: Some(model), + // Tool-free: side questions are answered from conversation + training. + tools: vec![], + max_turns: Some(1), + parent_messages, + append_system_prompt: Some(BTW_SYSTEM_APPEND.to_string()), + custom_system_prompt: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), + }; + + match run_fork(params).await { + Ok(outcome) => { + let header = format!( + "/btw (forked agent, {} ms)\n", + outcome.duration_ms + ); + if outcome.had_error { + Ok(CommandResult::Output(format!( + "{}error: {}", + header, + outcome.text + ))) + } else { + Ok(CommandResult::Output(format!( + "{}\n{}", + header.trim_end(), + outcome.text + ))) + } + } + Err(e) => Ok(CommandResult::Output(format!( + "/btw error: failed to run fork: {}", + e + ))), + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::SessionId; + use crate::types::app_state::AppState; + use std::path::PathBuf; + + fn test_ctx() -> CommandContext { + CommandContext { + messages: Vec::new(), + cwd: PathBuf::from("."), + app_state: AppState::default(), + session_id: SessionId::from_string("btw-test-session"), + } + } + + #[tokio::test] + async fn empty_args_shows_usage() { + let handler = BtwHandler; + let mut ctx = test_ctx(); + let result = handler.execute("", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + assert!(text.contains("Usage: /btw")); + assert!(text.contains("side question")); + } + _ => panic!("expected Output result"), + } + } + + #[tokio::test] + async fn whitespace_only_args_shows_usage() { + let handler = BtwHandler; + let mut ctx = test_ctx(); + let result = handler.execute(" \t ", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => assert!(text.contains("Usage: /btw")), + _ => panic!("expected Output result"), + } + } + + #[test] + fn btw_system_append_is_nonempty_and_role_restricting() { + assert!(!BTW_SYSTEM_APPEND.is_empty()); + assert!(BTW_SYSTEM_APPEND.contains("side question")); + assert!(BTW_SYSTEM_APPEND.contains("no tools")); + } +} diff --git a/crates/claude-code-rs/src/commands/mod.rs b/crates/claude-code-rs/src/commands/mod.rs index 75d5f355..71f245c9 100644 --- a/crates/claude-code-rs/src/commands/mod.rs +++ b/crates/claude-code-rs/src/commands/mod.rs @@ -38,6 +38,13 @@ pub mod model_add; pub mod memory; pub mod skills_cmd; +// Fork-agent dependent commands (issues #37, #62) +pub mod btw; +pub mod simplify; + +// Advisor model plumbing (issue #33) +pub mod advisor; + // Plan mode (issue #46) pub mod plan; @@ -539,6 +546,25 @@ pub fn get_all_commands() -> Vec { description: "List and drill into background tasks (tool + team)".into(), handler: Box::new(tasks_cmd::TasksHandler), }, + // Fork-agent dependent commands. + Command { + name: "btw".into(), + aliases: vec![], + description: "Ask a side question in a forked agent (issue #37)".into(), + handler: Box::new(btw::BtwHandler), + }, + Command { + name: "simplify".into(), + aliases: vec![], + description: "Multi-agent simplify review of recently changed code (issue #62)".into(), + handler: Box::new(simplify::SimplifyHandler), + }, + Command { + name: "advisor".into(), + aliases: vec![], + description: "Show, set, or clear the advisor model (issue #33)".into(), + handler: Box::new(advisor::AdvisorHandler), + }, ] } diff --git a/crates/claude-code-rs/src/commands/simplify.rs b/crates/claude-code-rs/src/commands/simplify.rs new file mode 100644 index 00000000..76c8b647 --- /dev/null +++ b/crates/claude-code-rs/src/commands/simplify.rs @@ -0,0 +1,347 @@ +//! `/simplify` — multi-agent code simplification command (issue #62). +//! +//! Exposes the bundled `simplify` skill as a user-facing slash command. +//! When the fork execution path is wired (see [`crate::engine::agent::fork`]), +//! it runs a **multi-agent review** by spawning parallel reviewer forks — +//! one each for the three canonical concerns: +//! +//! 1. **reuse** — duplicated logic, missed shared helpers +//! 2. **quality** — naming, readability, cyclomatic complexity +//! 3. **efficiency** — avoidable allocations / copies / hot paths +//! +//! The three reviewer outputs are joined into a single action-oriented +//! summary and returned via [`CommandResult::Output`]. Unlike a naive +//! `/recap`-style `CommandResult::Query`, this does NOT pollute the main +//! transcript. +//! +//! Fallback: if the bundled `simplify` skill is missing (e.g. the registry +//! was cleared in tests), we return a clear error instead of falling back +//! silently. +//! +//! Usage: +//! /simplify — review recently changed code across concerns +//! /simplify — scope the review to a file/directory +//! /simplify --single — run a single-agent pass (no parallel review) + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::engine::agent::fork::{run_fork, ForkOutcome, ForkParams}; +use crate::skills; + +const SIMPLIFY_SKILL: &str = "simplify"; + +/// Canonical review angles for the parallel multi-agent pass. +const REVIEW_ANGLES: &[(&str, &str)] = &[ + ( + "reuse", + "Focus on REUSE: look for duplicated logic, near-identical code paths, \ + and places where an existing helper should be used. Propose concrete \ + refactors that consolidate duplication.", + ), + ( + "quality", + "Focus on QUALITY: naming clarity, readability, cyclomatic complexity, \ + and consistency with surrounding style. Propose concrete renamings \ + or small restructurings.", + ), + ( + "efficiency", + "Focus on EFFICIENCY: avoidable allocations, unnecessary clones, \ + redundant iteration, obvious hot-path regressions. Propose concrete \ + edits, not speculative rewrites.", + ), +]; + +pub struct SimplifyHandler; + +#[async_trait] +impl CommandHandler for SimplifyHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let trimmed = args.trim(); + let (single_agent, scope) = parse_args(trimmed); + + let skill = match skills::find_skill(SIMPLIFY_SKILL) { + Some(s) => s, + None => { + return Ok(CommandResult::Output( + "/simplify error: bundled 'simplify' skill is not registered. \ + Skills may not have been initialized." + .to_string(), + )); + } + }; + + let cwd = ctx.cwd.to_string_lossy().to_string(); + let model = ctx.app_state.main_loop_model.clone(); + let allowed = &skill.frontmatter.allowed_tools; + + let tools = crate::tools::registry::get_all_tools() + .into_iter() + .filter(|t| allowed.is_empty() || allowed.iter().any(|a| a == t.name())) + .collect::>(); + + if single_agent { + return run_single(&skill, scope.as_deref(), cwd, model, tools).await; + } + + run_multi_agent(&skill, scope.as_deref(), cwd, model, tools).await + } +} + +/// Parse the argument string into `(single_agent, scope)`. +fn parse_args(raw: &str) -> (bool, Option) { + let mut single_agent = false; + let mut scope: Vec = Vec::new(); + for token in raw.split_whitespace() { + match token { + "--single" | "-1" => single_agent = true, + other => scope.push(other.to_string()), + } + } + let scope_str = if scope.is_empty() { + None + } else { + Some(scope.join(" ")) + }; + (single_agent, scope_str) +} + +/// Single-pass mode — one forked agent invokes the skill directly. +async fn run_single( + skill: &skills::SkillDefinition, + scope: Option<&str>, + cwd: String, + model: String, + tools: crate::types::tool::Tools, +) -> Result { + let prompt = build_single_prompt(skill, scope); + let params = ForkParams { + prompt, + cwd, + model: model.clone(), + fallback_model: Some(model), + tools, + max_turns: Some(20), + parent_messages: None, + append_system_prompt: None, + custom_system_prompt: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), + }; + match run_fork(params).await { + Ok(outcome) => Ok(CommandResult::Output(format_single(outcome))), + Err(e) => Ok(CommandResult::Output(format!( + "/simplify error: fork failed: {}", + e + ))), + } +} + +/// Multi-agent mode — fan out to 3 parallel reviewer forks and aggregate. +async fn run_multi_agent( + skill: &skills::SkillDefinition, + scope: Option<&str>, + cwd: String, + model: String, + tools: crate::types::tool::Tools, +) -> Result { + let base = skill.expand_prompt(scope.unwrap_or(""), None); + + let mut futs = Vec::new(); + for (angle, extra) in REVIEW_ANGLES { + let prompt = format!("{}\n\n--- Reviewer angle: {} ---\n{}", base, angle, extra); + let params = ForkParams { + prompt, + cwd: cwd.clone(), + model: model.clone(), + fallback_model: Some(model.clone()), + tools: tools.clone(), + max_turns: Some(20), + parent_messages: None, + append_system_prompt: None, + custom_system_prompt: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), + }; + futs.push(run_fork(params)); + } + + let outcomes = futures::future::join_all(futs).await; + Ok(CommandResult::Output(format_multi(&outcomes))) +} + +/// Build the single-pass prompt. Injects optional scope into the skill body. +fn build_single_prompt(skill: &skills::SkillDefinition, scope: Option<&str>) -> String { + let base = skill.expand_prompt(scope.unwrap_or(""), None); + match scope { + Some(s) if !s.is_empty() => format!("{}\n\nScope: {}", base, s), + _ => base, + } +} + +fn format_single(outcome: ForkOutcome) -> String { + let header = format!( + "/simplify (single-agent fork, {} ms)", + outcome.duration_ms + ); + if outcome.had_error { + format!("{}\nerror: {}", header, outcome.text) + } else { + format!("{}\n\n{}", header, outcome.text) + } +} + +/// Join the three reviewer outputs into a single action-oriented summary. +fn format_multi(outcomes: &[Result]) -> String { + let mut out = String::from("/simplify (multi-agent review)\n"); + for (i, (angle, _)) in REVIEW_ANGLES.iter().enumerate() { + out.push_str("\n"); + out.push_str(&format!("━━━ {} ━━━\n", angle)); + match outcomes.get(i) { + Some(Ok(outcome)) => { + if outcome.had_error { + out.push_str(&format!("(error) {}", outcome.text)); + } else { + out.push_str(&outcome.text); + } + out.push_str(&format!( + "\n({} ms, agent {})", + outcome.duration_ms, outcome.agent_id + )); + } + Some(Err(e)) => { + out.push_str(&format!("(fork failed) {}", e)); + } + None => { + out.push_str("(missing result)"); + } + } + out.push('\n'); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_defaults_to_multi_agent() { + let (single, scope) = parse_args(""); + assert!(!single); + assert!(scope.is_none()); + } + + #[test] + fn parse_args_detects_single_flag() { + let (single, scope) = parse_args("--single"); + assert!(single); + assert!(scope.is_none()); + } + + #[test] + fn parse_args_detects_short_flag() { + let (single, _) = parse_args("-1"); + assert!(single); + } + + #[test] + fn parse_args_collects_scope() { + let (single, scope) = parse_args("src/main.rs src/lib.rs"); + assert!(!single); + assert_eq!(scope.as_deref(), Some("src/main.rs src/lib.rs")); + } + + #[test] + fn parse_args_mixed_flag_and_scope() { + let (single, scope) = parse_args("--single src/tools/"); + assert!(single); + assert_eq!(scope.as_deref(), Some("src/tools/")); + } + + #[test] + fn review_angles_are_three_canonical_concerns() { + let names: Vec<&str> = REVIEW_ANGLES.iter().map(|(n, _)| *n).collect(); + assert_eq!(names, vec!["reuse", "quality", "efficiency"]); + } + + #[test] + fn format_multi_includes_all_angles() { + let outcomes: Vec> = REVIEW_ANGLES + .iter() + .enumerate() + .map(|(i, _)| { + Ok(ForkOutcome { + text: format!("reviewer output #{i}"), + had_error: false, + duration_ms: 100 + i as u64, + agent_id: format!("agent-{i}"), + }) + }) + .collect(); + let out = format_multi(&outcomes); + assert!(out.contains("reuse")); + assert!(out.contains("quality")); + assert!(out.contains("efficiency")); + assert!(out.contains("reviewer output #0")); + assert!(out.contains("reviewer output #1")); + assert!(out.contains("reviewer output #2")); + } + + #[test] + fn format_multi_surfaces_fork_errors() { + let outcomes: Vec> = vec![ + Err(anyhow::anyhow!("auth failed")), + Ok(ForkOutcome { + text: "ok".into(), + had_error: false, + duration_ms: 10, + agent_id: "q".into(), + }), + Ok(ForkOutcome { + text: "err".into(), + had_error: true, + duration_ms: 5, + agent_id: "e".into(), + }), + ]; + let out = format_multi(&outcomes); + assert!(out.contains("fork failed")); + assert!(out.contains("auth failed")); + assert!(out.contains("(error)")); + } + + #[test] + fn format_single_sucess_and_error() { + let ok = format_single(ForkOutcome { + text: "good".into(), + had_error: false, + duration_ms: 11, + agent_id: "a".into(), + }); + assert!(ok.contains("single-agent fork")); + assert!(ok.contains("good")); + + let err = format_single(ForkOutcome { + text: "bad".into(), + had_error: true, + duration_ms: 22, + agent_id: "b".into(), + }); + assert!(err.contains("error:")); + assert!(err.contains("bad")); + } + + // Note: a "missing skill" test would need to clear the global skill + // registry, which races with other tests that register skills. The + // error branch is trivial (returns a plain-text Output), so we cover + // only the pure-function surface here. +} diff --git a/crates/claude-code-rs/src/engine/agent/fork.rs b/crates/claude-code-rs/src/engine/agent/fork.rs new file mode 100644 index 00000000..772fae6e --- /dev/null +++ b/crates/claude-code-rs/src/engine/agent/fork.rs @@ -0,0 +1,218 @@ +//! Forked-agent execution path (issue #37 infrastructure). +//! +//! This module provides a lightweight fork primitive used by: +//! - `/btw` — a tool-free, single-turn side-question agent. +//! - `/simplify` — multi-agent review when the full AgentTool is unavailable. +//! - `SkillContext::Fork` — runs a skill's prompt in a bounded sub-engine. +//! +//! Unlike [`super::AgentTool`] (which registers an agent in the global tree, +//! fires SubagentStart / SubagentStop hooks, and surfaces streaming events +//! through IPC), a "fork" is a self-contained child [`QueryEngine`] +//! invocation: no persistence, no session saving, no IPC tree registration. +//! The result is a single `String` delivered back to the caller, plus an +//! `had_error` flag. +//! +//! Prompt-cache safety: the caller may pass `parent_messages` so the child +//! engine reuses the same initial history as the parent, giving cache hits +//! for the leading messages. Without `parent_messages`, the child starts +//! fresh. + +use std::sync::Arc; + +use anyhow::Result; +use tracing::{debug, info}; +use uuid::Uuid; + +use crate::engine::lifecycle::QueryEngine; +use crate::types::config::{QueryEngineConfig, QuerySource}; +use crate::types::message::Message; +use crate::types::tool::{QueryChainTracking, Tools}; + +use super::collect_stream_result; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Parameters for [`run_fork`]. +#[derive(Clone)] +pub struct ForkParams { + /// The prompt to submit to the forked agent. + pub prompt: String, + /// Working directory for the child engine. + pub cwd: String, + /// Explicit model for the fork. Falls back to `fallback_model` if the + /// request fails. + pub model: String, + /// Fallback model for retries (typically the parent's main model). + pub fallback_model: Option, + /// Tool set available to the forked agent. Pass an empty `Vec` for a + /// tool-free side question (e.g. `/btw`). + pub tools: Tools, + /// Hard cap on turns. Defaults to 1 (no tool use) when `None`. + pub max_turns: Option, + /// Optional messages to seed the child engine with, enabling prompt-cache + /// reuse against the parent conversation. Passing `None` means the fork + /// starts with an empty history. + pub parent_messages: Option>, + /// Optional additional system-prompt fragment appended to the engine's + /// built-in system prompt. Useful for giving the fork a narrower role. + pub append_system_prompt: Option, + /// Custom system prompt that overrides the engine's default. + pub custom_system_prompt: Option, + /// Hook runner to propagate into the child engine. + pub hook_runner: Arc, + /// Command dispatcher to propagate. + pub command_dispatcher: Arc, +} + +/// Result of a forked-agent execution. +#[derive(Debug, Clone)] +pub struct ForkOutcome { + /// Text output collected from the child engine. + pub text: String, + /// `true` if the child engine finished with an error result. + pub had_error: bool, + /// Wall-clock duration of the fork in milliseconds. + pub duration_ms: u64, + /// Identifier assigned to the fork agent (UUID v4). + pub agent_id: String, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Run a forked agent: spawns an ephemeral child [`QueryEngine`], submits +/// `params.prompt`, and returns the collected text output. +/// +/// The child is ephemeral — session persistence is disabled, nothing is +/// written to disk, and the conversation history of the parent is not +/// mutated. Pass `parent_messages` to reuse the parent's history for +/// cache-safe prompting. +pub async fn run_fork(params: ForkParams) -> Result { + let started = std::time::Instant::now(); + let agent_id = Uuid::new_v4().to_string(); + let chain_id = Uuid::new_v4().to_string(); + + info!( + agent_id = %agent_id, + model = %params.model, + tool_count = params.tools.len(), + max_turns = params.max_turns.unwrap_or(1), + "run_fork: spawning child engine" + ); + + let child_config = QueryEngineConfig { + cwd: params.cwd, + tools: params.tools, + custom_system_prompt: params.custom_system_prompt, + append_system_prompt: params.append_system_prompt, + user_specified_model: Some(params.model.clone()), + fallback_model: params.fallback_model, + max_turns: Some(params.max_turns.unwrap_or(1)), + max_budget_usd: None, + task_budget: None, + verbose: false, + initial_messages: params.parent_messages, + commands: vec![], + thinking_config: None, + json_schema: None, + replay_user_messages: false, + persist_session: false, + resolved_model: Some(params.model.clone()), + auto_save_session: false, + agent_context: Some(crate::types::config::AgentContext { + agent_id: agent_id.clone(), + query_tracking: QueryChainTracking { + chain_id, + depth: 1, + }, + langfuse_session_id: String::new(), + agent_type: Some("fork".to_string()), + }), + }; + + let mut child_engine = QueryEngine::new(child_config); + child_engine.set_hook_runner(params.hook_runner); + child_engine.set_command_dispatcher(params.command_dispatcher); + + let stream = + child_engine.submit_message(¶ms.prompt, QuerySource::Agent(agent_id.clone())); + let (text, had_error) = collect_stream_result(stream, None).await; + let duration_ms = started.elapsed().as_millis() as u64; + + debug!( + agent_id = %agent_id, + text_len = text.len(), + had_error, + duration_ms, + "run_fork: completed" + ); + + Ok(ForkOutcome { + text, + had_error, + duration_ms, + agent_id, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn default_params() -> ForkParams { + ForkParams { + prompt: "hello".to_string(), + cwd: ".".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + fallback_model: None, + tools: vec![], + max_turns: None, + parent_messages: None, + append_system_prompt: None, + custom_system_prompt: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), + } + } + + #[test] + fn fork_params_defaults_single_turn() { + let p = default_params(); + assert_eq!(p.max_turns.unwrap_or(1), 1); + assert!(p.tools.is_empty()); + } + + #[test] + fn fork_params_max_turns_honored() { + let mut p = default_params(); + p.max_turns = Some(5); + assert_eq!(p.max_turns.unwrap_or(1), 5); + } + + #[test] + fn fork_params_parent_messages_seed_history() { + let mut p = default_params(); + p.parent_messages = Some(vec![]); + assert!(p.parent_messages.is_some()); + } + + #[test] + fn fork_outcome_has_error_flag() { + let outcome = ForkOutcome { + text: "answer".into(), + had_error: false, + duration_ms: 42, + agent_id: "abc".into(), + }; + assert!(!outcome.had_error); + assert_eq!(outcome.duration_ms, 42); + assert_eq!(outcome.agent_id, "abc"); + } +} diff --git a/crates/claude-code-rs/src/engine/agent/mod.rs b/crates/claude-code-rs/src/engine/agent/mod.rs index 44b33a08..ffb67535 100644 --- a/crates/claude-code-rs/src/engine/agent/mod.rs +++ b/crates/claude-code-rs/src/engine/agent/mod.rs @@ -7,6 +7,7 @@ //! This enables delegation of complex, multi-step tasks to specialized subagents. mod dispatch; +pub mod fork; mod tool_impl; mod worktree; diff --git a/crates/claude-code-rs/src/engine/lifecycle/deps.rs b/crates/claude-code-rs/src/engine/lifecycle/deps.rs index 75e1c831..a908e1df 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/deps.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/deps.rs @@ -76,6 +76,17 @@ impl QueryDeps for QueryEngineDeps { }); } + // Strip advisor_model for providers that don't support it (issue #33). + if !crate::api::client::provider_supports_advisor(&client.config().provider) + && params.advisor_model.is_some() + { + tracing::debug!( + provider = client.langfuse_provider_name(), + "dropping advisor_model — provider does not support it" + ); + params.advisor_model = None; + } + let request = build_messages_request(¶ms); let stream = client.messages_stream(request).await?; let mut stream = std::pin::pin!(stream); @@ -123,6 +134,17 @@ impl QueryDeps for QueryEngineDeps { }); } + // Strip advisor_model for providers that don't support it (issue #33). + if !crate::api::client::provider_supports_advisor(&client.config().provider) + && params.advisor_model.is_some() + { + tracing::debug!( + provider = client.langfuse_provider_name(), + "dropping advisor_model — provider does not support it" + ); + params.advisor_model = None; + } + let request = build_messages_request(¶ms); client.messages_stream(request).await } @@ -194,6 +216,7 @@ impl QueryDeps for QueryEngineDeps { skip_cache_write: Some(true), thinking_enabled: None, effort_value: None, + advisor_model: None, }; match self.call_model(summary_params).await { diff --git a/crates/claude-code-rs/src/engine/lifecycle/helpers.rs b/crates/claude-code-rs/src/engine/lifecycle/helpers.rs index 72ef8762..36f704a1 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/helpers.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/helpers.rs @@ -203,6 +203,7 @@ pub(crate) fn build_messages_request( stream: true, thinking, tool_choice: None, + advisor_model: params.advisor_model.clone(), } } @@ -265,6 +266,7 @@ mod tests { skip_cache_write: None, thinking_enabled: Some(true), effort_value: None, + advisor_model: None, } } diff --git a/crates/claude-code-rs/src/main.rs b/crates/claude-code-rs/src/main.rs index 394745d5..21c251a1 100644 --- a/crates/claude-code-rs/src/main.rs +++ b/crates/claude-code-rs/src/main.rs @@ -532,11 +532,13 @@ async fn run_full_init(cli: Cli) -> anyhow::Result { teammate_mode: merged_config.teammate_mode, claude_in_chrome_default_enabled: merged_config.claude_in_chrome_default_enabled, auto_memory_enabled: merged_config.auto_memory_enabled, + advisor_model: merged_config.advisor_model.clone(), sources, }, verbose: cli.verbose, main_loop_model: model.clone(), main_loop_backend: backend.clone(), + advisor_model: merged_config.advisor_model.clone(), tool_permission_context: build_tool_permission_context( permission_mode.clone(), &loaded_settings, diff --git a/crates/claude-code-rs/src/query/deps.rs b/crates/claude-code-rs/src/query/deps.rs index 682ba721..edca6dd9 100644 --- a/crates/claude-code-rs/src/query/deps.rs +++ b/crates/claude-code-rs/src/query/deps.rs @@ -64,6 +64,10 @@ pub struct ModelCallParams { pub skip_cache_write: Option, pub thinking_enabled: Option, pub effort_value: Option, + /// Optional advisor model id (issue #33). Plumbed through to + /// [`crate::api::client::MessagesRequest::advisor_model`] when the + /// active provider supports advisors. + pub advisor_model: Option, } impl std::fmt::Debug for ModelCallParams { diff --git a/crates/claude-code-rs/src/query/loop_impl.rs b/crates/claude-code-rs/src/query/loop_impl.rs index 74eb39f6..170774c7 100644 --- a/crates/claude-code-rs/src/query/loop_impl.rs +++ b/crates/claude-code-rs/src/query/loop_impl.rs @@ -214,6 +214,7 @@ pub fn query(params: QueryParams, deps: Arc) -> impl Stream>, ) -> Result { @@ -207,29 +207,76 @@ impl Tool for SkillTool { }) } SkillContext::Fork => { - // Fork context: ideally runs in a sub-agent. - // For now, fall back to inline execution with a note. - let skill_message = make_skill_message(&skill, args); + // Fork context: run the skill prompt in a bounded sub-engine + // so its execution doesn't pollute the parent transcript. + // Tools are restricted to the skill's declared allowed_tools. + let expanded_prompt = skill.expand_prompt(args, None); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + + let tools = crate::tools::registry::get_all_tools() + .into_iter() + .filter(|t| { + skill.frontmatter.allowed_tools.is_empty() + || skill.frontmatter.allowed_tools.iter().any(|a| a == t.name()) + }) + .collect::>(); + let tool_count = tools.len(); + let max_turns = if tools.is_empty() { 1 } else { 30 }; + + let fork_model = skill + .frontmatter + .model + .clone() + .unwrap_or_else(|| ctx.options.main_loop_model.clone()); debug!( skill = %skill.name, - "fork skill falling back to inline (sub-agent fork not yet implemented)" + tool_count, + max_turns, + "fork skill dispatching via engine::agent::fork" ); - Ok(ToolResult { - data: json!({ - "success": true, - "skill": skill.name, - "context": "fork (inline fallback)", - "message": format!( - "Skill '{}' invoked (fork context, running inline). \ - Follow the instructions in the injected prompt.", - skill.name, - ), + let params = crate::engine::agent::fork::ForkParams { + prompt: expanded_prompt, + cwd, + model: fork_model, + fallback_model: Some(ctx.options.main_loop_model.clone()), + tools, + max_turns: Some(max_turns), + parent_messages: None, + append_system_prompt: ctx.options.append_system_prompt.clone(), + custom_system_prompt: ctx.options.custom_system_prompt.clone(), + hook_runner: ctx.hook_runner.clone(), + command_dispatcher: ctx.command_dispatcher.clone(), + }; + + match crate::engine::agent::fork::run_fork(params).await { + Ok(outcome) => Ok(ToolResult { + data: json!({ + "success": !outcome.had_error, + "skill": skill.name, + "context": "fork", + "agent_id": outcome.agent_id, + "duration_ms": outcome.duration_ms, + "had_error": outcome.had_error, + "text": outcome.text, + }), + new_messages: vec![], + ..Default::default() }), - new_messages: vec![skill_message], - ..Default::default() - }) + Err(e) => Ok(ToolResult { + data: json!({ + "success": false, + "skill": skill.name, + "context": "fork", + "error": e.to_string(), + }), + new_messages: vec![], + ..Default::default() + }), + } } } } diff --git a/crates/claude-code-rs/src/types/app_state.rs b/crates/claude-code-rs/src/types/app_state.rs index 548abe0c..0f98ecb8 100644 --- a/crates/claude-code-rs/src/types/app_state.rs +++ b/crates/claude-code-rs/src/types/app_state.rs @@ -22,6 +22,13 @@ pub struct AppState { pub main_loop_model: String, /// Active backend implementation ("native" or "codex"). pub main_loop_backend: String, + /// Optional advisor model (issue #33). + /// + /// When `Some`, and the current provider supports advisors (see + /// `provider_supports_advisor` in `api::client`), the advisor model id + /// is attached to every outbound [`crate::api::client::MessagesRequest`]. + /// Other providers log a warning and ignore the setting. + pub advisor_model: Option, /// 工具权限上下文 pub tool_permission_context: ToolPermissionContext, /// thinking 是否启用 @@ -64,6 +71,7 @@ impl Default for AppState { verbose: false, main_loop_model: "claude-sonnet-4-20250514".to_string(), main_loop_backend: "native".to_string(), + advisor_model: None, tool_permission_context: ToolPermissionContext { mode: PermissionMode::Default, additional_working_directories: HashMap::new(),