From b17b8aeaed761210093afb6ba45f6e12622b661d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 19:10:13 -0400 Subject: [PATCH 01/20] fix(agent): refuse to truncate history on a degenerate compaction summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the summarization LLM call returned an empty completion, compaction truncated the conversation anyway. `history.compact_from` discarded the summarized turns irreversibly, `CompactionCompleted` was emitted as if nothing had gone wrong, and the replacement system turn contained only the handoff preamble: "A different assistant began this task and produced the following summary" followed by nothing. The agent then continued with zero context while having been explicitly told a handoff summary existed. It presents to a user as the agent suddenly forgetting everything, and the only trace was a `debug!` line that is off by default, so there was nothing in production logs to correlate against. This is provider-independent. Any completion that comes back empty triggers it: a truncated stream, a reasoning model that spends its whole token budget on reasoning, or a rate-limit edge. Validate the summary before mutating history. A summary that is empty, whitespace-only, or shorter than 32 bytes after trimming is refused: the history is left fully intact and an error is returned instead. The threshold is deliberately far below any genuine summary — 32 bytes is shorter than a single source file path — because this guards against degenerate responses, not summary quality, and a false refusal would let the context keep growing. Structure is not validated, since a model may legitimately vary the requested section format. Returning `Err` is sufficient to surface the failure. `compact_if_needed` already converts it into an `AgentEvent::Error`, which lands in the run event stream and logs at ERROR via `AgentEvent::trace`, and the session continues rather than dying — behavior already covered by `compaction_failure_is_non_fatal`. The canned summary in `compaction_includes_structured_prompt_and_file_tracking` was 26 bytes, which the new guard rejects. That test verifies the summarization request prompt and file tracking, not minimum summary length, so its fixture is now a realistic summary. Co-Authored-By: Claude Opus 5 (1M context) --- lib/components/fabro-agent/src/compaction.rs | 156 ++++++++++++++++++- lib/components/fabro-agent/src/session.rs | 4 +- 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index c4395a2e3..0a9e5ef86 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -13,6 +13,17 @@ use crate::types::{AgentEvent, Message}; const APPROX_CHARS_PER_TOKEN: usize = 4; +/// Minimum length, in bytes, of a usable compaction summary after trimming. +/// +/// A summary is traded for many turns of conversation, so anything shorter +/// than a single source file path +/// (`lib/components/fabro-agent/src/compaction.rs` is 43 bytes) cannot be +/// carrying that context forward. The bar is set far below any genuine summary +/// on purpose: this exists to catch degenerate responses, not to judge summary +/// quality. A false refusal leaves the context to keep growing, so the check +/// must never fire on a real summary. +const MIN_SUMMARY_LEN: usize = 32; + #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub(crate) enum ContextEstimateMethod { @@ -149,7 +160,25 @@ function names, error messages, and exact values. Omit pleasantries and conversa .await .map_err(Error::Llm)?; - let summary_text = response.text(); + let response_text = response.text(); + let summary_text = response_text.trim(); + + // Refuse to compact on a degenerate summary. `compact_from` discards the + // summarized turns irreversibly, so an empty or near-empty summary must not + // be traded for them: the preamble below would tell the model a handoff + // summary exists while it actually runs with no history at all. Empty + // completions are provider-independent — a truncated stream, a reasoning + // model that spent its whole budget on reasoning, or a rate-limit edge all + // produce one. Returning here leaves the history intact; the caller turns + // this into an `AgentEvent::Error` and continues the session. + if summary_text.len() < MIN_SUMMARY_LEN { + return Err(Error::InvalidState(format!( + "compaction summary was empty or too short to replace {preserve_start} turns \ + ({} bytes, minimum {MIN_SUMMARY_LEN}); history left intact", + summary_text.len() + ))); + } + debug!( summary_len = summary_text.len(), "Compaction summary generated" @@ -298,6 +327,7 @@ pub fn render_turns_for_summary(turns: &[Message]) -> String { #[cfg(test)] mod tests { + use std::sync::Arc; use std::time::SystemTime; use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; @@ -305,7 +335,7 @@ mod tests { use super::*; use crate::event::Emitter; use crate::history::History; - use crate::test_support::TestProfile; + use crate::test_support::{MockLlmProvider, TestProfile, make_client, text_response}; use crate::tool_registry::ToolRegistry; use crate::types::Message; @@ -570,4 +600,126 @@ mod tests { assert!(matches!(event.event, AgentEvent::Warning { details, .. } if details["estimate_method"] == "local_estimate")); } + + /// Run `compact_context` over a fixed four-turn history against a mock + /// provider that returns `summary` from the summarization call. + async fn compact_with_summary(summary: &str) -> (Result<(), Error>, History, Vec) { + let mut history = History::default(); + for index in 0..4 { + history.push(Message::User { + content: format!("message {index}"), + timestamp: SystemTime::now(), + }); + } + + let provider = Arc::new(MockLlmProvider::new(vec![text_response(summary)])); + let client = make_client(provider).await; + let profile = TestProfile::new(); + let file_tracker = FileTracker::default(); + let emitter = Emitter::new(); + let mut rx = emitter.subscribe(); + + let result = compact_context( + &mut history, + &client, + &profile, + &file_tracker, + 1, + ContextEstimate { + tokens: 1_000, + method: ContextEstimateMethod::LocalEstimate, + }, + &emitter, + "sess", + ) + .await; + + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event.event); + } + + (result, history, events) + } + + fn assert_history_untouched(history: &History) { + assert_eq!( + history.turns().len(), + 4, + "history must not be truncated when the summary is rejected" + ); + assert!( + history + .turns() + .iter() + .all(|turn| matches!(turn, Message::User { .. })), + "no summary turn should be inserted when the summary is rejected" + ); + } + + #[tokio::test] + async fn compaction_refuses_to_truncate_on_empty_summary() { + let (result, history, events) = compact_with_summary("").await; + + let err = result.expect_err("empty summary must not report success"); + assert!( + matches!(&err, Error::InvalidState(message) if message.contains("empty or too short")), + "unexpected error: {err}" + ); + + assert_history_untouched(&history); + assert!( + !events + .iter() + .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), + "CompactionCompleted must not be emitted for a rejected summary" + ); + } + + #[tokio::test] + async fn compaction_refuses_to_truncate_on_whitespace_only_summary() { + let (result, history, events) = compact_with_summary(" \n\t \n ").await; + + let err = result.expect_err("whitespace-only summary must not report success"); + assert!( + matches!(&err, Error::InvalidState(message) if message.contains("empty or too short")), + "unexpected error: {err}" + ); + + assert_history_untouched(&history); + assert!( + !events + .iter() + .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), + "CompactionCompleted must not be emitted for a rejected summary" + ); + } + + #[tokio::test] + async fn compaction_replaces_history_on_normal_summary() { + let (result, history, events) = compact_with_summary( + "## Goal\nAdd a compaction guard.\n\n## Next Steps\nRun the test suite.", + ) + .await; + + result.expect("a normal summary should compact"); + + let summary_turn = history + .turns() + .iter() + .find_map(|turn| match turn { + Message::System { content, .. } => Some(content), + _ => None, + }) + .expect("compacted history should contain a summary turn"); + assert!(summary_turn.contains("A different assistant began this task")); + assert!(summary_turn.contains("Add a compaction guard.")); + + assert!( + events + .iter() + .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), + "CompactionCompleted should be emitted on success" + ); + } } diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index afb8a0c63..faf3bb2d7 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -4690,7 +4690,9 @@ mod tests { async fn complete(&self, request: &Request) -> Result { *self.captured_complete.lock().unwrap() = Some(request.clone()); - Ok(text_response("## Goal\nSummary goes here.")) + Ok(text_response( + "## Goal\nSummary goes here.\n\n## Progress\nRead /src/main.rs.", + )) } async fn stream(&self, _request: &Request) -> Result { From f94955ede5e7852df35aa31ec44a9d3c9f2e688b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 19:11:25 -0400 Subject: [PATCH 02/20] fix(agent): budget compaction summaries for reasoning models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compaction summarizes the conversation with the session's own model, but hard-coded `max_tokens: Some(4096)` and sent no `reasoning_effort`. On a reasoning model that ceiling covers thinking *and* visible output, so a long conversation can exhaust it on reasoning alone and return a successful response with empty content — silently replacing the compacted history with an empty summary. The Anthropic codec's existing clamp does not cover this path: it only runs when the request carries a `reasoning_effort` and the model has no native effort parameter. Compaction sends `reasoning_effort: None`, so encoding falls through to the branch that injects `{"type": "adaptive"}` for `levels` models with no clamp at all, and the openai_compatible and openai_responses codecs pass `max_tokens` straight through. Resolve the budget from the catalog instead. Models whose endpoint reasons without being asked (`always_adaptive` natively, `levels` via default adaptive thinking or the provider's default effort) get 16K of reasoning headroom above the 4096-token summary allowance, capped at the model's own `max_output`. Models with no reasoning-effort feature never reason on this path and keep the existing 4096. Co-Authored-By: Claude Opus 5 (1M context) --- lib/components/fabro-agent/src/compaction.rs | 137 ++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index c4395a2e3..9031ec2ca 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use fabro_llm::client::Client; use fabro_llm::types::{Message as LlmMessage, Request}; +use fabro_model::Model; use tracing::debug; use crate::agent_profile::AgentProfile; @@ -13,6 +14,15 @@ use crate::types::{AgentEvent, Message}; const APPROX_CHARS_PER_TOKEN: usize = 4; +/// Output budget for the visible summary text itself. +const SUMMARY_MAX_TOKENS: i64 = 4096; + +/// Extra output budget for models that reason on every request. `max_tokens` +/// bounds reasoning *plus* visible output, so a reasoning model handed only +/// `SUMMARY_MAX_TOKENS` can spend the whole budget thinking and return a +/// successful response with empty content — a silently empty summary. +const REASONING_HEADROOM_TOKENS: i64 = 16_384; + #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub(crate) enum ContextEstimateMethod { @@ -122,6 +132,8 @@ function names, error messages, and exact values. Omit pleasantries and conversa {file_ops_section}" ); + let max_tokens = summary_max_tokens(provider_profile.catalog_model()); + let summary_request = Request { model: provider_profile.model().to_string(), messages: vec![ @@ -136,7 +148,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa response_format: None, temperature: None, top_p: None, - max_tokens: Some(4096), + max_tokens: Some(max_tokens), stop_sequences: None, reasoning_effort: None, speed: None, @@ -152,7 +164,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa let summary_text = response.text(); debug!( summary_len = summary_text.len(), - "Compaction summary generated" + max_tokens, "Compaction summary generated" ); let summary_content = format!( "A different assistant began this task and produced the following summary. \ @@ -172,6 +184,28 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" Ok(()) } +/// Output budget for the summarization request. +/// +/// Compaction runs against the session's own model, so a reasoning session +/// summarizes with reasoning enabled and the budget has to cover the thinking +/// as well as the summary. Models that reason unconditionally get headroom on +/// top of the summary allowance, capped at the model's own `max_output`. +/// +/// A model only reasons here when its endpoint reasons without being asked: +/// `always_adaptive` models think natively, and `levels` models get thinking +/// enabled by default (adaptive thinking injected by the Anthropic codec, the +/// provider's default effort on OpenAI-style routes). Compaction never sends a +/// `reasoning_effort`, so models without an effort feature stay non-reasoning +/// on this path and keep the plain summary budget. +fn summary_max_tokens(model: Option<&Model>) -> i64 { + let Some(model) = model.filter(|m| m.supports_reasoning_effort()) else { + return SUMMARY_MAX_TOKENS; + }; + + let budget = SUMMARY_MAX_TOKENS.saturating_add(REASONING_HEADROOM_TOKENS); + model.max_output().map_or(budget, |limit| budget.min(limit)) +} + pub(crate) fn estimate_active_context_usage( system_prompt: &str, history: &History, @@ -301,6 +335,10 @@ mod tests { use std::time::SystemTime; use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; + use fabro_model::{ + Catalog, ModelControls, ModelCosts, ModelFeatures, ModelId, ModelLimits, ProviderId, + ReasoningEffortFeature, + }; use super::*; use crate::event::Emitter; @@ -309,6 +347,101 @@ mod tests { use crate::tool_registry::ToolRegistry; use crate::types::Message; + fn anthropic_model(id: &str) -> &'static Model { + Catalog::builtin() + .get_on_provider(&ProviderId::anthropic(), id) + .unwrap_or_else(|| panic!("{id} missing from builtin catalog")) + } + + /// A model that reasons unconditionally but caps output below the budget + /// compaction would otherwise ask for. + fn small_output_reasoning_model(max_output: i64) -> Model { + Model { + id: ModelId::new("small-output-reasoner"), + provider: ProviderId::anthropic(), + family: "test".into(), + display_name: "Small Output Reasoner".into(), + limits: ModelLimits { + context_window: 200_000, + max_output: Some(max_output), + }, + training: None, + knowledge_cutoff: None, + features: ModelFeatures { + tools: true, + vision: false, + reasoning: true, + reasoning_effort: ReasoningEffortFeature::AlwaysAdaptive, + prompt_cache: false, + cache_control_breakpoints: false, + sampling_params: false, + }, + controls: ModelControls::default(), + costs: ModelCosts { + input_cost_per_mtok: None, + output_cost_per_mtok: None, + cache_input_cost_per_mtok: None, + }, + estimated_output_tps: None, + aliases: vec![], + default: false, + small_default: false, + configured: false, + } + } + + #[test] + fn summary_budget_without_catalog_model_is_summary_allowance() { + assert_eq!(summary_max_tokens(None), 4096); + // The default agent test profile has no catalog behind it. + assert_eq!(summary_max_tokens(TestProfile::new().catalog_model()), 4096); + } + + #[test] + fn summary_budget_for_non_reasoning_model_is_summary_allowance() { + // claude-haiku-4-5: reasoning = false. + assert_eq!( + summary_max_tokens(Some(anthropic_model("claude-haiku-4-5"))), + 4096 + ); + } + + #[test] + fn summary_budget_for_model_without_effort_feature_is_summary_allowance() { + // claude-sonnet-4-5 reasons only when a request asks for it, and + // compaction never sends a reasoning effort. + let model = anthropic_model("claude-sonnet-4-5"); + assert!(model.supports_reasoning()); + assert!(!model.supports_reasoning_effort()); + assert_eq!(summary_max_tokens(Some(model)), 4096); + } + + #[test] + fn summary_budget_for_always_adaptive_model_adds_reasoning_headroom() { + let model = anthropic_model("claude-fable-5"); + assert_eq!( + model.features.reasoning_effort, + ReasoningEffortFeature::AlwaysAdaptive + ); + assert_eq!(summary_max_tokens(Some(model)), 4096 + 16_384); + } + + #[test] + fn summary_budget_for_effort_levels_model_adds_reasoning_headroom() { + let model = anthropic_model("claude-opus-5"); + assert_eq!( + model.features.reasoning_effort, + ReasoningEffortFeature::Levels + ); + assert_eq!(summary_max_tokens(Some(model)), 4096 + 16_384); + } + + #[test] + fn summary_budget_never_exceeds_model_max_output() { + let model = small_output_reasoning_model(8_192); + assert_eq!(summary_max_tokens(Some(&model)), 8_192); + } + #[test] fn render_turns_produces_labeled_text() { let turns = vec![ From c08e5c54909e71932336c9f319c934072ff34247 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 19:50:58 -0400 Subject: [PATCH 03/20] feat(agent): add a Kimi agent profile for Moonshot and gateway routes Kimi models ran on the OpenAI profile, which exists to look like Codex. Give them their own profile derived from Kimi Code's system prompt. Routing is per model, not per provider, because Kimi models are served both directly by Moonshot and through gateways. `kimi` sets agent_profile at the provider level; the Kimi model rows on `openrouter` set it individually, so a gateway route behaves like the direct one while other OpenRouter models keep the provider's OpenAI profile. The profile targets a measured failure. Across two observed K3 implementation stages, 32 of 35 tool failures were the same thing: writes to files the model had not read, rejected by the workspace read-before-write guard, or `old_string` values reconstructed from memory rather than taken from a read. Kimi Code drills this rule in its own tool descriptions, so the profile does too -- `edit_file` and `write_file` carry Kimi-specific descriptions naming the guard and the failure text the model will see, alongside a "Reading Before Writing" section in the system prompt. Profiles own their tool registries, so this re-describes the tools for Kimi only; every other profile is untouched and the executors and JSON schemas are shared unchanged. Tool names stay fabro's existing snake_case. Whether Kimi Code's PascalCase vocabulary measurably helps is untested, and renaming would also mean updating the name-keyed categories in tool_permissions.rs, where an unknown tool falls back to Shell. That is a separate change to make on evidence. The prompt is a subtractive port: capabilities fabro does not have -- plan mode, background tasks, cron, subagent swarms, the cwd tree listing -- are dropped rather than promised. The shell timeout default matches Kimi Code's 60s and memory discovery reads AGENTS.md, which is the only instruction file Kimi Code looks for. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/server/handler/sessions.rs | 5 +- lib/components/fabro-agent/src/config.rs | 4 + lib/components/fabro-agent/src/memory.rs | 3 + .../fabro-agent/src/profiles/kimi.rs | 251 ++++++++++++++++++ .../fabro-agent/src/profiles/mod.rs | 7 + .../src/profiles/prompts/kimi.md.j2 | 77 ++++++ .../fabro-agent/src/question_tools.rs | 6 +- .../fabro-llm/src/adapter_registry.rs | 4 +- lib/foundation/fabro-model/src/adapter.rs | 5 + .../src/catalog/providers/kimi.toml | 1 + .../src/catalog/providers/openrouter.toml | 4 + 11 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 lib/components/fabro-agent/src/profiles/kimi.rs create mode 100644 lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 4ee47aa55..6df445cb0 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -928,7 +928,10 @@ fn summarizer_model_id( .map_or_else( || match profile_kind { AgentProfileKind::Anthropic => "claude-haiku-4-5", - AgentProfileKind::OpenAi => selected_model, + // Kimi is reached through several providers, so there is + // no fixed summarizer model to name; reuse the selected + // one, as the OpenAI profile does. + AgentProfileKind::OpenAi | AgentProfileKind::Kimi => selected_model, AgentProfileKind::Gemini => "gemini-2.0-flash", }, |model| model.id.as_str(), diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index 30b168050..f83a721fe 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -131,6 +131,10 @@ impl NativeToolOptions { // rather than silently inheriting the default timeout. let default_command_timeout_ms = match profile_kind { AgentProfileKind::Anthropic => 120_000, + // Matches the 60s foreground default Kimi Code's Bash tool + // documents, which is what these models are used to budgeting + // against. + AgentProfileKind::Kimi => 60_000, AgentProfileKind::OpenAi | AgentProfileKind::Gemini => { defaults.default_command_timeout_ms } diff --git a/lib/components/fabro-agent/src/memory.rs b/lib/components/fabro-agent/src/memory.rs index 263702c4b..bc4d40b5e 100644 --- a/lib/components/fabro-agent/src/memory.rs +++ b/lib/components/fabro-agent/src/memory.rs @@ -34,6 +34,9 @@ pub async fn discover_memory( AgentProfileKind::Anthropic => vec!["AGENTS.md", "CLAUDE.md"], AgentProfileKind::OpenAi => vec!["AGENTS.md", ".codex/instructions.md"], AgentProfileKind::Gemini => vec!["AGENTS.md", "GEMINI.md"], + // Kimi Code reads only AGENTS.md (and a lowercase variant); it has no + // vendor-specific instruction filename of its own. + AgentProfileKind::Kimi => vec!["AGENTS.md"], }; let mut results: Vec = Vec::new(); diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs new file mode 100644 index 000000000..5546d5063 --- /dev/null +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -0,0 +1,251 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; + +use super::EnvContext; +use crate::agent_profile::AgentProfile; +use crate::config::NativeToolOptions; +use crate::profiles::{self, BaseProfile, EmbeddedPrompt}; +use crate::sandbox::Sandbox; +use crate::skills::Skill; +use crate::todo_runtime::TodoRuntime; +use crate::todo_tools::{ + make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, +}; +use crate::tool_registry::{RegisteredTool, ToolRegistry}; +use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; + +const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2"); + +/// Kimi models repeatedly fail the workspace's read-before-write guard: across +/// two observed K3 implementation stages, 32 of 35 tool failures were writes to +/// files the model had not read, or `old_string` values reconstructed from +/// memory. Kimi Code's own tool descriptions drill this rule directly, so the +/// Kimi profile restates it where the model is most likely to act on it — in +/// the description of the tool being called — rather than relying only on the +/// system prompt. +const EDIT_FILE_DESCRIPTION: &str = "Edit a file by replacing an exact string. \ +Read the file with read_file before EVERY edit — this workspace refuses writes to files that \ +have not been read, and the call will fail. Take old_string verbatim from the read_file output; \ +never reconstruct it from memory or from an earlier version of the file. old_string must be an \ +exact match and unique unless replace_all is true. If the edit fails with 'old_string not found', \ +re-read the file and take the exact text from the fresh output rather than guessing again. \ +Preserve existing indentation."; + +const WRITE_FILE_DESCRIPTION: &str = "Create a new file, or completely replace an existing one. \ +Read an existing file with read_file before writing to it — this workspace refuses writes to \ +files that have not been read, and the call will fail. Prefer edit_file for any incremental \ +change: write_file replaces the entire file, so using it to make a small edit discards \ +everything you did not restate."; + +/// Replace a registered tool's description, keeping its executor and schema. +/// +/// Profiles own their registries, so tailoring wording per model is a local +/// change and does not affect what other profiles expose. +fn redescribe(registry: &mut ToolRegistry, name: &str, description: &str) { + let Some(tool) = registry.unregister(name) else { + return; + }; + registry.register(RegisteredTool { + definition: ToolDefinition { + description: description.to_string(), + ..tool.definition + }, + ..tool + }); +} + +pub struct KimiProfile { + base: BaseProfile, +} + +impl KimiProfile { + #[must_use] + pub fn new(model: impl Into) -> Self { + let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi); + Self::with_native_tools(model, &options, None) + } + + pub(crate) fn with_native_tools( + model: impl Into, + options: &NativeToolOptions, + summarizer: Option, + ) -> Self { + let mut registry = ToolRegistry::new(); + + register_core_tools(&mut registry, options, summarizer); + registry.register(make_edit_file_tool()); + redescribe(&mut registry, "edit_file", EDIT_FILE_DESCRIPTION); + redescribe(&mut registry, "write_file", WRITE_FILE_DESCRIPTION); + + // Kimi Code exposes a single TodoList tool; fabro's four task tools + // cover the same ground over one runtime, so reuse them rather than + // introducing a fifth shape of todo state. + let todo_runtime = Arc::new(TodoRuntime::new()); + registry.register(make_task_create_tool(todo_runtime.clone())); + registry.register(make_task_update_tool(todo_runtime.clone())); + registry.register(make_task_get_tool(todo_runtime.clone())); + registry.register(make_task_list_tool(todo_runtime)); + + Self { + base: BaseProfile { + profile_kind: AgentProfileKind::Kimi, + provider_id: ProviderId::new("kimi"), + model: model.into(), + catalog: None, + registry, + }, + } + } + + /// Override the provider ID while retaining the adapter/profile behavior. + /// + /// Kimi models are served both directly by Moonshot and through gateways + /// such as OpenRouter, so the provider is not fixed by the profile. + #[must_use] + pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self { + self.base.provider_id = provider_id; + self + } + + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> Self { + self.base.catalog = Some(catalog); + self + } +} + +impl AgentProfile for KimiProfile { + fn profile_kind(&self) -> AgentProfileKind { + self.base.profile_kind + } + + fn provider_id(&self) -> ProviderId { + self.base.provider_id.clone() + } + + fn model(&self) -> &str { + &self.base.model + } + + fn catalog(&self) -> Option<&Catalog> { + self.base.catalog.as_deref() + } + + fn tool_registry(&self) -> &ToolRegistry { + &self.base.registry + } + + fn tool_registry_mut(&mut self) -> &mut ToolRegistry { + &mut self.base.registry + } + + fn build_system_prompt( + &self, + env: &dyn Sandbox, + env_context: &EnvContext, + memory: &[String], + user_instructions: Option<&str>, + skills: &[Skill], + ) -> String { + let template = EmbeddedPrompt::new("kimi.md.j2", CORE_PROMPT); + + profiles::assemble_system_prompt( + template, + env, + env_context, + memory, + user_instructions, + skills, + ) + } +} + +#[cfg(test)] +mod tests { + use fabro_model::catalog::LlmCatalogSettings; + + use super::*; + use crate::test_support::MockSandbox; + + fn catalog() -> Arc { + Arc::new(Catalog::from_builtin().unwrap()) + } + + /// OpenRouter ships disabled, so an operator opts in before its models are + /// selectable. Enable it the way they would, to observe gateway routing. + fn catalog_with_openrouter() -> Arc { + let overrides: LlmCatalogSettings = + toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap(); + Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()) + } + + /// Kimi models must resolve to the Kimi profile whether they are reached + /// directly at Moonshot or through a gateway such as OpenRouter. + #[test] + fn kimi_models_select_the_kimi_profile_on_every_provider() { + for (catalog, provider, model) in [ + (catalog(), "kimi", "kimi-k3"), + (catalog(), "kimi", "kimi-k2.5"), + (catalog_with_openrouter(), "openrouter", "kimi-k3"), + (catalog_with_openrouter(), "openrouter", "kimi-k2.6"), + ] { + assert_eq!( + catalog.effective_agent_profile(&ProviderId::new(provider), Some(model)), + Some(AgentProfileKind::Kimi), + "{provider}/{model} should use the Kimi profile" + ); + } + } + + /// Non-Kimi models on a shared gateway must keep the provider's own + /// profile — the override is per model, not per provider. + #[test] + fn openrouter_non_kimi_models_keep_the_provider_profile() { + let catalog = catalog_with_openrouter(); + let profile = + catalog.effective_agent_profile(&ProviderId::new("openrouter"), Some("gpt-5.6-sol")); + assert_eq!(profile, Some(AgentProfileKind::OpenAi)); + } + + #[test] + fn edit_and_write_descriptions_drill_read_before_write() { + let profile = KimiProfile::new("kimi-k3"); + let describe = |name: &str| { + profile + .tool_registry() + .get(name) + .unwrap_or_else(|| panic!("{name} should be registered")) + .definition + .description + .clone() + }; + + for name in ["edit_file", "write_file"] { + let text = describe(name); + assert!( + text.contains("have not been read") || text.contains("has not been read"), + "{name} should warn about the read-before-write guard" + ); + } + assert!(describe("edit_file").contains("never reconstruct it from memory")); + // The shared description is untouched for other profiles. + assert!(!describe("read_file").contains("refuses writes")); + } + + #[test] + fn kimi_profile_identity_and_prompt() { + let profile = KimiProfile::new("kimi-k3") + .with_provider_id(ProviderId::new("openrouter")) + .with_catalog(catalog()); + assert_eq!(profile.profile_kind(), AgentProfileKind::Kimi); + assert_eq!(profile.provider_id(), ProviderId::new("openrouter")); + + let env = MockSandbox::linux(); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); + assert!(prompt.contains("You are Kimi")); + assert!(prompt.contains("# Reading Before Writing")); + assert!(prompt.contains("")); + } +} diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index dcc44be7e..adc9d1a96 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -5,10 +5,12 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId}; pub mod anthropic; pub mod gemini; +pub mod kimi; pub mod openai; pub use anthropic::AnthropicProfile; pub use gemini::GeminiProfile; +pub use kimi::KimiProfile; pub use openai::OpenAiProfile; use crate::agent_profile::AgentProfile; @@ -85,6 +87,11 @@ impl AgentProfileBuilder { .with_provider_id(self.provider_id.clone()) .with_catalog(Arc::clone(&self.catalog)), ), + AgentProfileKind::Kimi => Box::new( + KimiProfile::with_native_tools(model, options, summarizer) + .with_provider_id(self.provider_id.clone()) + .with_catalog(Arc::clone(&self.catalog)), + ), } } } diff --git a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 new file mode 100644 index 000000000..a33a0ec33 --- /dev/null +++ b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 @@ -0,0 +1,77 @@ +You are Kimi, an interactive general AI agent running in a terminal-based agentic coding assistant. + +Your primary goal is to help users with software engineering tasks by taking action — use the tools available to you to make real changes on the user's system. You should also answer questions when asked. Always adhere strictly to the following system instructions and the user's requirements. + +# Language + +Write in the user's language unless they explicitly ask for a different one. Determine it from their most recent messages — if they switch languages mid-session, switch with them. This applies to everything user-visible: your replies, progress notes before and between tool calls, and questions you ask. Long stretches of English tool output do not change this — when you return to address the user, use their language. + +Keep code, commands, identifiers, file paths, and technical terms in their original form. Artifacts that go into the repository — code comments, commit messages, PR descriptions, documentation — follow the project's existing conventions, not the conversation language. + +{{ inputs.env_block }} + +# Prompt and Tool Use + +For simple questions or greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task. For instance, "change `methodName` to snake_case" is a task, not a question — locate the method in the code and edit it; do not just reply with `method_name`. + +When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. For simple requests, call tools directly. For non-trivial or multi-step tasks, first emit one short user-visible sentence describing what you will do next, then call the tool(s). Keep that sentence to roughly 8–10 words, plain and concrete. On a long, multi-phase task, add a brief one-line note when you move to a distinctly new phase, but keep these sparse — do not narrate every tool call. + +When a dedicated tool fits the job, reach for it before raw shell: `read_file` for a known path, `glob` to find files by name, and `grep` to search file contents. These cap their output, so they keep large raw dumps out of the conversation. + +You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. This applies especially to read-only investigation — issue independent `read_file`, `grep`, and `glob` calls in parallel rather than one after another. + +Tool calls run behind the user's permission settings. A rejected or denied call means the user or their policy declined that specific action — adjust your approach, or ask what they would prefer instead. Do not retry the same call unchanged, and do not route around the denial by doing the same thing through a different tool or shell command. + +When a tool call fails, diagnose why before acting again: read the error, check your assumptions, and make a focused adjustment. Do not retry the identical call blindly, but do not abandon a viable approach after a single failure either — if you are still stuck after investigating, ask the user. + +# Reading Before Writing + +This workspace refuses writes to files you have not read. `edit_file` and `write_file` both fail with "file exists but has not been read" when you target an existing file without reading it first. That failure costs a full turn and teaches you nothing you could not have known. + +- Call `read_file` on the target before every `edit_file` or `write_file` against a file that already exists. No exceptions, including small or "obvious" edits. +- Take `old_string` and `new_string` from what `read_file` actually returned. Never construct `old_string` from memory, from an earlier version of the file, or from what you expect the file to contain. +- If an `edit_file` fails with "old_string not found", do not guess a different string. Re-read the file and take the exact text from the fresh output. +- After you edit a file, its contents have changed. Re-read before your next edit to the same file rather than assuming your own edit landed as written. +- `write_file` fully replaces a file. Use it only for new files or a deliberate complete rewrite; for every incremental change use `edit_file`. + +# General Guidelines for Coding + +When building something from scratch, understand the requirements, plan the architecture, and write modular, maintainable code. + +When working on an existing codebase, you should: + +- Understand the codebase by reading it with tools (`read_file`, `glob`, `grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve it. +- For a bug fix, check error logs or failing tests, scan the codebase to find the root cause, and figure out a fix. If the user mentioned failing tests, make sure they pass after the changes. +- For a feature, design the architecture and write the code in a modular, maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests. +- For a refactor, update all the places that call the code you are refactoring if the interface changes. DO NOT change existing logic, especially in tests; focus only on fixing errors caused by the interface change. +- Make MINIMAL changes to achieve the goal. This is very important to your performance. A bug fix does not need the surrounding code cleaned up, a simple feature does not need extra configurability, and three similar lines are better than a premature abstraction — no speculative generality, but no half-finished work either. +- Keep edits scoped to the files and modules the request actually implies. Leave unrelated refactors, reformatting, renames, and metadata churn alone unless they are truly needed to finish the task safely — a tidy, reviewable diff beats an opportunistic cleanup. +- Make new code read like the code around it: match the surrounding file's comment density, naming conventions, and structural idioms rather than importing your own defaults. +- Do not assume a library, framework, or utility is available just because it is common. Before writing code that uses one, confirm the project already depends on it — check the imports in neighboring files, the manifest or lockfile, or existing usage — and match the version and idiom already in use. If the capability is genuinely missing, surface that rather than silently adding a dependency. + +DO NOT run `git commit`, `git push`, `git reset`, `git rebase`, or any other git mutation unless explicitly asked to do so. Ask for confirmation each time you need a git mutation, even if the user has confirmed in earlier conversations. + +Apply the same care beyond git: weigh the reversibility and blast radius of any action before you take it. Local, reversible work your role permits — editing files, running tests, reading code — you may do freely. But actions that are hard to undo or that reach beyond your local environment warrant a confirmation first: destructive ones (`rm -rf`, dropping database tables, killing processes, force-pushing, overwriting uncommitted changes) and outward-facing ones that touch shared state (pushing, opening or commenting on PRs and issues, sending messages, uploading to third-party services). A one-time approval covers that one action in that one context, not a standing license. Never reach for a destructive shortcut to clear an obstacle — investigate unfamiliar files, branches, or locks as possible in-progress work before deleting or overwriting them. + +# Validating Your Work + +If the codebase has tests or the ability to build or run, use them to verify your work. Start as specific as possible to the code you changed to catch issues efficiently, then widen to broader tests as you build confidence. + +Long-running commands need a raised timeout rather than a retry. `shell` takes a `timeout_ms` argument; use it for builds, test suites, and installs instead of letting the default elapse and trying again. + +# Ultimate Reminders + +At any time, you should be HELPFUL, CONCISE, ACCURATE, and CANDID. Be thorough in your actions — test what you build, verify what you change — not in your explanations. When you could not actually run, reproduce, or verify something, say so plainly; never dress an unverified change up as done. + +- Never diverge from the requirements and the goals of the task you work on. Stay on track. +- Never give the user more than what they asked for. +- Try your best to avoid any hallucination. Do fact checking before providing any factual information. +- Think about the best approach, then take action decisively. +- Do not give up too early. +- ALWAYS keep it stupidly simple. Do not overcomplicate things. +- Talk like a seasoned engineer, not a cheerleader. Skip flattery, motivational filler, and hollow reassurance — the user wants the work done, not to be impressed. +- When you have evidence the user is wrong, say so and show the evidence — agreeing to be agreeable wastes their time and can break their code. Defer once they've decided; until then, an honest objection is the helpful answer. +- When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system. +- Deliver the complete change. Never stub out code with placeholders like `// ... rest unchanged` or leave the user to fill in the gaps; write out every line you mean to change. +- After a change, sweep for comments and docstrings that now describe the old behavior, and bring them in line with what the code actually does. +- Before calling a task done, verify it: run the checks that cover your change and look at the result instead of assuming. Don't mark work complete while tests are red or the implementation is still partial. diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 102eee803..60767399b 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -160,7 +160,11 @@ pub fn is_question_tool(name: &str) -> bool { pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut ToolRegistry) { match profile_kind { AgentProfileKind::OpenAi => registry.register(make_openai_question_tool()), - AgentProfileKind::Anthropic => registry.register(make_anthropic_question_tool()), + // Kimi Code names this tool `AskUserQuestion` with the same + // question/option shape, so the Anthropic-style tool is a match. + AgentProfileKind::Anthropic | AgentProfileKind::Kimi => { + registry.register(make_anthropic_question_tool()); + } AgentProfileKind::Gemini => {} } } diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs index ed623f446..eeb870515 100644 --- a/lib/components/fabro-llm/src/adapter_registry.rs +++ b/lib/components/fabro-llm/src/adapter_registry.rs @@ -317,8 +317,8 @@ mod tests { ("gpt-5.6-luna", "gpt-5.6-luna", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), ("gpt-5.6-sol", "gpt-5.6-sol", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), ("gpt-5.6-terra", "gpt-5.6-terra", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), - ("kimi-k2.5", "kimi-k2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), - ("kimi-k3", "kimi-k3", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("kimi-k2.5", "kimi-k2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::Kimi), + ("kimi-k3", "kimi-k3", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::Kimi), ("laguna-s-2.1", "poolside/laguna-s-2.1", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), ("laguna-xs-2.1", "poolside/laguna-xs-2.1", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), ("mercury-2", "mercury-2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), diff --git a/lib/foundation/fabro-model/src/adapter.rs b/lib/foundation/fabro-model/src/adapter.rs index db67fe132..091fcf896 100644 --- a/lib/foundation/fabro-model/src/adapter.rs +++ b/lib/foundation/fabro-model/src/adapter.rs @@ -71,6 +71,11 @@ pub enum AgentProfileKind { #[strum(to_string = "openai")] OpenAi, Gemini, + /// Kimi (Moonshot) models, wherever they are served from. Selected per + /// model rather than per provider, so a Kimi model reached through a + /// gateway such as OpenRouter gets the same profile as one reached + /// directly at `api.moonshot.ai`. + Kimi, } #[cfg(test)] diff --git a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml index daa4b20c2..66758337e 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml @@ -1,6 +1,7 @@ [providers.kimi] display_name = "Kimi" adapter = "openai_compatible" +agent_profile = "kimi" api_key_url = "https://platform.kimi.ai/console/api-keys" base_url = "https://api.moonshot.ai/v1" priority = 70 diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index 175d76cad..eea168785 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -392,6 +392,9 @@ output_cost_per_mtok = 0.20 api_id = "moonshotai/kimi-k2.6" display_name = "Kimi K2.6" family = "kimi-k2" +# Kimi models get the Kimi agent profile wherever they are served from, so a +# gateway route behaves like the direct Moonshot one. +agent_profile = "kimi" [providers.openrouter.models."kimi-k2.6".limits] context_window = 262144 @@ -410,6 +413,7 @@ output_cost_per_mtok = 3.49 api_id = "moonshotai/kimi-k3" display_name = "Kimi K3 (via OpenRouter)" family = "kimi-k3" +agent_profile = "kimi" [providers.openrouter.models."kimi-k3".limits] context_window = 1048576 From fbff6f5774e04c1c5716ffb753189c0562476a83 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 20:04:11 -0400 Subject: [PATCH 04/20] refactor(agent): model built-in tools as an enum with per-profile vocabularies Tool names were string literals matched in several places, which made renaming a tool for one profile unsafe: `tool_category` falls back to `Shell` for an unrecognized name, so exposing `Read` instead of `read_file` would have silently demanded shell-level approval for every file read. Introduce `NativeTool`, the closed set of tools fabro implements, with strum string conversions per the repo convention. A tool is an identity; a name is one rendering of it. `ToolVocabulary` names the renderings -- fabro's own, and Kimi Code's -- and `NativeTool::from_any_name` resolves a name in any vocabulary back to the identity. Permissions, categories, and telemetry go through that resolution, so behavior no longer depends on which profile is running. `known_tool_category` is now an exhaustive match on the enum rather than a string match, so a new built-in tool has to state its category instead of silently inheriting the unknown-tool default. Tools that are uncategorized today stay uncategorized: giving them a category would change the CLI permission gate, which is a behavior change rather than a cleanup. MCP, skill, and run-scoped tools keep arbitrary string names, so `ToolDefinition.name` and the registry keys stay `String`. The enum covers the closed set only. With that in place, the Kimi profile exposes its tools under Kimi Code's vocabulary -- Read, Write, Edit, Bash, Grep, Glob, WebSearch, FetchURL -- and its prompt and tool descriptions use those names. Tools with no Kimi Code counterpart of the same shape keep fabro's names. Ask Fabro's tool policy resolves through the canonical name so a Kimi-model run is not denied its whole tool set. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/server/handler/sessions.rs | 5 +- lib/components/fabro-agent/src/lib.rs | 3 + lib/components/fabro-agent/src/native_tool.rs | 239 ++++++++++++++++++ .../fabro-agent/src/profiles/kimi.rs | 101 +++++++- .../src/profiles/prompts/kimi.md.j2 | 18 +- .../fabro-agent/src/tool_permissions.rs | 34 ++- 6 files changed, 366 insertions(+), 34 deletions(-) create mode 100644 lib/components/fabro-agent/src/native_tool.rs diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 6df445cb0..5445d2780 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -944,7 +944,10 @@ struct AskFabroToolAccessPolicy; impl ToolAccessPolicy for AskFabroToolAccessPolicy { fn access_for_tool(&self, tool_name: &str) -> ToolAccess { - match tool_name { + // Resolve through the canonical name so a profile that exposes its own + // vocabulary (the Kimi profile uses `Read`/`Grep`/`Glob`) is not denied + // its whole tool set. + match fabro_agent::canonical_tool_name(tool_name) { "read_file" | "grep" | "glob" => ToolAccess::Allowed, name if ASK_FABRO_RUN_TOOL_NAMES.contains(&name) => ToolAccess::Allowed, _ => ToolAccess::Denied, diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index ebc732dc2..dfbeb8a5b 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -15,6 +15,7 @@ pub mod local_sandbox; pub mod loop_detection; pub mod mcp_integration; pub mod memory; +pub mod native_tool; pub mod profiles; pub mod question_tools; pub mod read_before_write_sandbox; @@ -47,6 +48,7 @@ pub use history::History; pub use local_sandbox::LocalSandbox; pub use loop_detection::detect_loop; pub use memory::{MemoryDocument, discover_memory}; +pub use native_tool::{NativeTool, ToolVocabulary}; pub use profiles::{ AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile, }; @@ -72,6 +74,7 @@ pub use todo_tools::{ make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, make_update_plan_tool, }; +pub use tool_permissions::canonical_tool_name; pub use tool_registry::{AgentEventEmitter, ToolRegistry}; pub use tools::{ WebFetchSummarizer, make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, diff --git a/lib/components/fabro-agent/src/native_tool.rs b/lib/components/fabro-agent/src/native_tool.rs new file mode 100644 index 000000000..0a7ebd8a0 --- /dev/null +++ b/lib/components/fabro-agent/src/native_tool.rs @@ -0,0 +1,239 @@ +//! The built-in tools fabro implements, and the names they can be expressed +//! under. +//! +//! Tool names reach this crate from two very different places. The tools fabro +//! implements are a fixed set known at compile time; MCP, skill, and +//! run-scoped tools are open-ended and named by whatever registered them. This +//! module covers the first group, so anything reasoning about a built-in tool +//! is checked by the compiler instead of matched on string literals. +//! +//! A [`NativeTool`] is an identity, not a name. The same tool is expressed +//! under different names depending on the [`ToolVocabulary`] a profile speaks: +//! fabro's own names by default, Kimi Code's names for the Kimi profile. +//! Permissions, categories, and telemetry resolve any name back to the +//! identity, so behavior never depends on which vocabulary is in play. +//! +//! `ToolDefinition.name` and [`crate::tool_registry::ToolRegistry`] keys stay +//! `String`, because they carry both groups. + +use fabro_types::AgentToolCategory; +use strum::{Display, EnumString, IntoStaticStr, VariantArray}; + +/// A naming scheme for built-in tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, VariantArray)] +pub enum ToolVocabulary { + /// Fabro's own names, and the canonical identity used internally. + #[default] + Fabro, + /// The names Kimi Code exposes, for models trained against that harness. + KimiCode, +} + +/// A tool fabro implements itself. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, IntoStaticStr, VariantArray, +)] +pub enum NativeTool { + #[strum(to_string = "read_file")] + ReadFile, + #[strum(to_string = "read_many_files")] + ReadManyFiles, + #[strum(to_string = "write_file")] + WriteFile, + #[strum(to_string = "edit_file")] + EditFile, + #[strum(to_string = "apply_patch")] + ApplyPatch, + #[strum(to_string = "list_dir")] + ListDir, + #[strum(to_string = "grep")] + Grep, + #[strum(to_string = "glob")] + Glob, + #[strum(to_string = "shell")] + Shell, + #[strum(to_string = "web_search")] + WebSearch, + #[strum(to_string = "web_fetch")] + WebFetch, + #[strum(to_string = "spawn_agent")] + SpawnAgent, + #[strum(to_string = "send_input")] + SendInput, + #[strum(to_string = "wait")] + Wait, + #[strum(to_string = "close_agent")] + CloseAgent, + #[strum(to_string = "use_skill")] + UseSkill, + #[strum(to_string = "update_plan")] + UpdatePlan, + // Task and question tools are already PascalCase on the wire; they came + // from the Claude Code vocabulary rather than fabro's own. + #[strum(to_string = "TaskCreate")] + TaskCreate, + #[strum(to_string = "TaskUpdate")] + TaskUpdate, + #[strum(to_string = "TaskGet")] + TaskGet, + #[strum(to_string = "TaskList")] + TaskList, + #[strum(to_string = "AskUserQuestion")] + AskUserQuestion, + #[strum(to_string = "request_user_input")] + RequestUserInput, +} + +impl NativeTool { + /// The canonical name: how fabro refers to this tool internally. + #[must_use] + pub fn canonical_name(self) -> &'static str { + self.into() + } + + /// The name this tool is exposed under in `vocabulary`. + /// + /// A tool with no counterpart in the vocabulary keeps its canonical name. + #[must_use] + pub fn name(self, vocabulary: ToolVocabulary) -> &'static str { + match vocabulary { + ToolVocabulary::Fabro => self.canonical_name(), + ToolVocabulary::KimiCode => match self { + Self::ReadFile => "Read", + Self::WriteFile => "Write", + Self::EditFile => "Edit", + Self::Shell => "Bash", + Self::Grep => "Grep", + Self::Glob => "Glob", + Self::WebSearch => "WebSearch", + Self::WebFetch => "FetchURL", + // Kimi Code has no counterpart with these semantics: its + // TodoList replaces a whole list rather than mutating tasks, + // and it has no equivalent of the remaining tools. + other => other.canonical_name(), + }, + } + } + + /// Resolve a name in any known vocabulary back to the tool it identifies. + /// + /// Returns `None` for MCP, skill, and run-scoped tools, whose names are + /// not drawn from this set. + #[must_use] + pub fn from_any_name(name: &str) -> Option { + Self::VARIANTS.iter().copied().find(|tool| { + ToolVocabulary::VARIANTS + .iter() + .any(|vocabulary| tool.name(*vocabulary) == name) + }) + } + + /// Coarse access category, or `None` when the tool is not part of the + /// permission taxonomy. + /// + /// Matched exhaustively so a new built-in tool has to state its answer. + /// `None` is a real answer, and callers disagree about what it means: the + /// CLI gate treats an uncategorized tool as `Shell` (requiring approval), + /// while projection metadata reports `Other`. + #[must_use] + pub fn category(self) -> Option { + match self { + Self::ReadFile | Self::ReadManyFiles | Self::Grep | Self::Glob | Self::ListDir => { + Some(AgentToolCategory::Read) + } + Self::WriteFile | Self::EditFile | Self::ApplyPatch => Some(AgentToolCategory::Write), + Self::Shell => Some(AgentToolCategory::Shell), + Self::SpawnAgent | Self::SendInput | Self::Wait | Self::CloseAgent => { + Some(AgentToolCategory::Subagent) + } + // Uncategorized today. Giving these a category would change the CLI + // permission gate, which is a behavior change rather than a + // classification cleanup, so they keep their existing answer. + Self::WebSearch + | Self::WebFetch + | Self::UseSkill + | Self::UpdatePlan + | Self::TaskCreate + | Self::TaskUpdate + | Self::TaskGet + | Self::TaskList + | Self::AskUserQuestion + | Self::RequestUserInput => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn canonical_names_round_trip() { + for tool in NativeTool::VARIANTS { + assert_eq!(NativeTool::from_str(tool.canonical_name()).unwrap(), *tool); + } + } + + #[test] + fn every_name_in_every_vocabulary_resolves_back_to_its_tool() { + for tool in NativeTool::VARIANTS { + for vocabulary in ToolVocabulary::VARIANTS { + let name = tool.name(*vocabulary); + assert_eq!( + NativeTool::from_any_name(name), + Some(*tool), + "{name} ({vocabulary:?}) should resolve back to {tool}" + ); + } + } + } + + /// Two tools resolving to the same name would make `from_any_name` + /// ambiguous and silently mis-categorize one of them. + #[test] + fn vocabularies_do_not_collide() { + let mut seen: Vec<(&str, NativeTool)> = Vec::new(); + for tool in NativeTool::VARIANTS { + for vocabulary in ToolVocabulary::VARIANTS { + let name = tool.name(*vocabulary); + if let Some((_, other)) = seen.iter().find(|(seen, _)| *seen == name) { + assert_eq!(*other, *tool, "name '{name}' is claimed by two tools"); + } else { + seen.push((name, *tool)); + } + } + } + } + + #[test] + fn kimi_vocabulary_renames_only_where_kimi_code_differs() { + assert_eq!(NativeTool::ReadFile.name(ToolVocabulary::KimiCode), "Read"); + assert_eq!(NativeTool::Shell.name(ToolVocabulary::KimiCode), "Bash"); + assert_eq!( + NativeTool::WebFetch.name(ToolVocabulary::KimiCode), + "FetchURL" + ); + // No Kimi Code counterpart: keeps fabro's name. + assert_eq!( + NativeTool::TaskCreate.name(ToolVocabulary::KimiCode), + "TaskCreate" + ); + assert_eq!( + NativeTool::SpawnAgent.name(ToolVocabulary::KimiCode), + "spawn_agent" + ); + } + + #[test] + fn categories_are_vocabulary_independent() { + for tool in NativeTool::VARIANTS { + for vocabulary in ToolVocabulary::VARIANTS { + let resolved = NativeTool::from_any_name(tool.name(*vocabulary)) + .expect("known name should resolve"); + assert_eq!(resolved.category(), tool.category()); + } + } + } +} diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index 5546d5063..aedd2b5b7 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -2,10 +2,12 @@ use std::sync::Arc; use fabro_llm::types::ToolDefinition; use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use strum::VariantArray; use super::EnvContext; use crate::agent_profile::AgentProfile; use crate::config::NativeToolOptions; +use crate::native_tool::{NativeTool, ToolVocabulary}; use crate::profiles::{self, BaseProfile, EmbeddedPrompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; @@ -26,25 +28,50 @@ const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2"); /// the description of the tool being called — rather than relying only on the /// system prompt. const EDIT_FILE_DESCRIPTION: &str = "Edit a file by replacing an exact string. \ -Read the file with read_file before EVERY edit — this workspace refuses writes to files that \ -have not been read, and the call will fail. Take old_string verbatim from the read_file output; \ +Read the file with Read before EVERY edit — this workspace refuses writes to files that \ +have not been read, and the call will fail. Take old_string verbatim from the Read output; \ never reconstruct it from memory or from an earlier version of the file. old_string must be an \ exact match and unique unless replace_all is true. If the edit fails with 'old_string not found', \ re-read the file and take the exact text from the fresh output rather than guessing again. \ Preserve existing indentation."; const WRITE_FILE_DESCRIPTION: &str = "Create a new file, or completely replace an existing one. \ -Read an existing file with read_file before writing to it — this workspace refuses writes to \ -files that have not been read, and the call will fail. Prefer edit_file for any incremental \ -change: write_file replaces the entire file, so using it to make a small edit discards \ +Read an existing file with Read before writing to it — this workspace refuses writes to \ +files that have not been read, and the call will fail. Prefer Edit for any incremental \ +change: Write replaces the entire file, so using it to make a small edit discards \ everything you did not restate."; +/// Expose every registered built-in tool under Kimi Code's names. +/// +/// Only the exposed name changes: executors, schemas, and the identity that +/// permissions and telemetry resolve through are untouched, because +/// `canonical_tool_name` maps every vocabulary back to the same +/// [`NativeTool`]. +fn apply_vocabulary(registry: &mut ToolRegistry, vocabulary: ToolVocabulary) { + for tool in NativeTool::VARIANTS { + let exposed = tool.name(vocabulary); + if exposed == tool.canonical_name() { + continue; + } + let Some(registered) = registry.unregister(tool.canonical_name()) else { + continue; + }; + registry.register(RegisteredTool { + definition: ToolDefinition { + name: exposed.to_string(), + ..registered.definition + }, + ..registered + }); + } +} + /// Replace a registered tool's description, keeping its executor and schema. /// /// Profiles own their registries, so tailoring wording per model is a local /// change and does not affect what other profiles expose. -fn redescribe(registry: &mut ToolRegistry, name: &str, description: &str) { - let Some(tool) = registry.unregister(name) else { +fn redescribe(registry: &mut ToolRegistry, tool: NativeTool, description: &str) { + let Some(tool) = registry.unregister(tool.canonical_name()) else { return; }; registry.register(RegisteredTool { @@ -76,8 +103,8 @@ impl KimiProfile { register_core_tools(&mut registry, options, summarizer); registry.register(make_edit_file_tool()); - redescribe(&mut registry, "edit_file", EDIT_FILE_DESCRIPTION); - redescribe(&mut registry, "write_file", WRITE_FILE_DESCRIPTION); + redescribe(&mut registry, NativeTool::EditFile, EDIT_FILE_DESCRIPTION); + redescribe(&mut registry, NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); // Kimi Code exposes a single TodoList tool; fabro's four task tools // cover the same ground over one runtime, so reuse them rather than @@ -88,6 +115,9 @@ impl KimiProfile { registry.register(make_task_get_tool(todo_runtime.clone())); registry.register(make_task_list_tool(todo_runtime)); + // Applied last so every built-in registered above is renamed. + apply_vocabulary(&mut registry, ToolVocabulary::KimiCode); + Self { base: BaseProfile { profile_kind: AgentProfileKind::Kimi, @@ -165,9 +195,11 @@ impl AgentProfile for KimiProfile { #[cfg(test)] mod tests { use fabro_model::catalog::LlmCatalogSettings; + use fabro_types::AgentToolCategory; use super::*; use crate::test_support::MockSandbox; + use crate::tool_permissions::{known_tool_category, tool_category}; fn catalog() -> Arc { Arc::new(Catalog::from_builtin().unwrap()) @@ -209,6 +241,51 @@ mod tests { assert_eq!(profile, Some(AgentProfileKind::OpenAi)); } + /// The rename must not change what a tool is allowed to do. An exposed + /// name that fails to resolve would fall back to `Shell` in the CLI gate, + /// silently demanding approval for reads. + #[test] + fn renamed_tools_keep_their_permission_category() { + let profile = KimiProfile::new("kimi-k3"); + for name in profile.tool_registry().names() { + let Some(tool) = NativeTool::from_any_name(&name) else { + continue; + }; + assert_eq!( + known_tool_category(&name), + tool.category(), + "exposed name '{name}' must categorize as its canonical identity" + ); + } + // The specific regression: reads stay reads, not Shell. + assert_eq!(tool_category("Read"), AgentToolCategory::Read); + assert_eq!(tool_category("Bash"), AgentToolCategory::Shell); + } + + #[test] + fn tools_are_exposed_under_kimi_code_names() { + let profile = KimiProfile::new("kimi-k3"); + let names = profile.tool_registry().names(); + for expected in ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "FetchURL"] { + assert!(names.contains(&expected.to_string()), "missing {expected}"); + } + for canonical in [ + "read_file", + "write_file", + "edit_file", + "shell", + "grep", + "glob", + ] { + assert!( + !names.contains(&canonical.to_string()), + "{canonical} should have been renamed" + ); + } + // No Kimi Code counterpart, so these keep fabro's names. + assert!(names.contains(&"TaskCreate".to_string())); + } + #[test] fn edit_and_write_descriptions_drill_read_before_write() { let profile = KimiProfile::new("kimi-k3"); @@ -222,16 +299,16 @@ mod tests { .clone() }; - for name in ["edit_file", "write_file"] { + for name in ["Edit", "Write"] { let text = describe(name); assert!( text.contains("have not been read") || text.contains("has not been read"), "{name} should warn about the read-before-write guard" ); } - assert!(describe("edit_file").contains("never reconstruct it from memory")); + assert!(describe("Edit").contains("never reconstruct it from memory")); // The shared description is untouched for other profiles. - assert!(!describe("read_file").contains("refuses writes")); + assert!(!describe("Read").contains("refuses writes")); } #[test] diff --git a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 index a33a0ec33..7f83ab58b 100644 --- a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 +++ b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 @@ -16,9 +16,9 @@ For simple questions or greetings that do not involve any information in the wor When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. For simple requests, call tools directly. For non-trivial or multi-step tasks, first emit one short user-visible sentence describing what you will do next, then call the tool(s). Keep that sentence to roughly 8–10 words, plain and concrete. On a long, multi-phase task, add a brief one-line note when you move to a distinctly new phase, but keep these sparse — do not narrate every tool call. -When a dedicated tool fits the job, reach for it before raw shell: `read_file` for a known path, `glob` to find files by name, and `grep` to search file contents. These cap their output, so they keep large raw dumps out of the conversation. +When a dedicated tool fits the job, reach for it before raw shell: `Read` for a known path, `Glob` to find files by name, and `Grep` to search file contents. These cap their output, so they keep large raw dumps out of the conversation. -You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. This applies especially to read-only investigation — issue independent `read_file`, `grep`, and `glob` calls in parallel rather than one after another. +You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. This applies especially to read-only investigation — issue independent `Read`, `Grep`, and `Glob` calls in parallel rather than one after another. Tool calls run behind the user's permission settings. A rejected or denied call means the user or their policy declined that specific action — adjust your approach, or ask what they would prefer instead. Do not retry the same call unchanged, and do not route around the denial by doing the same thing through a different tool or shell command. @@ -26,13 +26,13 @@ When a tool call fails, diagnose why before acting again: read the error, check # Reading Before Writing -This workspace refuses writes to files you have not read. `edit_file` and `write_file` both fail with "file exists but has not been read" when you target an existing file without reading it first. That failure costs a full turn and teaches you nothing you could not have known. +This workspace refuses writes to files you have not read. `Edit` and `Write` both fail with "file exists but has not been read" when you target an existing file without reading it first. That failure costs a full turn and teaches you nothing you could not have known. -- Call `read_file` on the target before every `edit_file` or `write_file` against a file that already exists. No exceptions, including small or "obvious" edits. -- Take `old_string` and `new_string` from what `read_file` actually returned. Never construct `old_string` from memory, from an earlier version of the file, or from what you expect the file to contain. -- If an `edit_file` fails with "old_string not found", do not guess a different string. Re-read the file and take the exact text from the fresh output. +- Call `Read` on the target before every `Edit` or `Write` against a file that already exists. No exceptions, including small or "obvious" edits. +- Take `old_string` and `new_string` from what `Read` actually returned. Never construct `old_string` from memory, from an earlier version of the file, or from what you expect the file to contain. +- If an `Edit` fails with "old_string not found", do not guess a different string. Re-read the file and take the exact text from the fresh output. - After you edit a file, its contents have changed. Re-read before your next edit to the same file rather than assuming your own edit landed as written. -- `write_file` fully replaces a file. Use it only for new files or a deliberate complete rewrite; for every incremental change use `edit_file`. +- `Write` fully replaces a file. Use it only for new files or a deliberate complete rewrite; for every incremental change use `Edit`. # General Guidelines for Coding @@ -40,7 +40,7 @@ When building something from scratch, understand the requirements, plan the arch When working on an existing codebase, you should: -- Understand the codebase by reading it with tools (`read_file`, `glob`, `grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve it. +- Understand the codebase by reading it with tools (`Read`, `Glob`, `Grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve it. - For a bug fix, check error logs or failing tests, scan the codebase to find the root cause, and figure out a fix. If the user mentioned failing tests, make sure they pass after the changes. - For a feature, design the architecture and write the code in a modular, maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests. - For a refactor, update all the places that call the code you are refactoring if the interface changes. DO NOT change existing logic, especially in tests; focus only on fixing errors caused by the interface change. @@ -57,7 +57,7 @@ Apply the same care beyond git: weigh the reversibility and blast radius of any If the codebase has tests or the ability to build or run, use them to verify your work. Start as specific as possible to the code you changed to catch issues efficiently, then widen to broader tests as you build confidence. -Long-running commands need a raised timeout rather than a retry. `shell` takes a `timeout_ms` argument; use it for builds, test suites, and installs instead of letting the default elapse and trying again. +Long-running commands need a raised timeout rather than a retry. `Bash` takes a `timeout_ms` argument; use it for builds, test suites, and installs instead of letting the default elapse and trying again. # Ultimate Reminders diff --git a/lib/components/fabro-agent/src/tool_permissions.rs b/lib/components/fabro-agent/src/tool_permissions.rs index a56d17e08..c79acfa1f 100644 --- a/lib/components/fabro-agent/src/tool_permissions.rs +++ b/lib/components/fabro-agent/src/tool_permissions.rs @@ -1,20 +1,30 @@ use fabro_types::{AgentToolCategory, PermissionLevel}; -/// Coarse access category for an exposed tool. Returns `None` for unknown -/// names so callers can decide whether to default (legacy CLI permission -/// gate) or surface a distinct "other" category (projection metadata). -pub fn known_tool_category(name: &str) -> Option { - match name { - "read_file" | "read_many_files" | "grep" | "glob" | "list_dir" => { - Some(AgentToolCategory::Read) - } - "write_file" | "edit_file" | "apply_patch" => Some(AgentToolCategory::Write), - "shell" => Some(AgentToolCategory::Shell), - "spawn_agent" | "send_input" | "wait" | "close_agent" => Some(AgentToolCategory::Subagent), - _ => None, +use crate::native_tool::NativeTool; + +/// Resolve a tool name in any profile's vocabulary to the canonical name the +/// rest of the system reasons about. +/// +/// A profile may expose a built-in tool under the vocabulary its model was +/// trained against — the Kimi profile uses Kimi Code's `Read`/`Edit`/`Bash` +/// names — but permissions, categories, and telemetry must not depend on which +/// profile is running. Names that are not built-in (MCP, skill, run-scoped) +/// pass through unchanged. +#[must_use] +pub fn canonical_tool_name(name: &str) -> &str { + match NativeTool::from_any_name(name) { + Some(tool) => tool.canonical_name(), + None => name, } } +/// Coarse access category for an exposed tool. Returns `None` for names +/// outside the permission taxonomy so callers can decide what that means: the +/// CLI gate defaults them to `Shell`, projection metadata reports `Other`. +pub fn known_tool_category(name: &str) -> Option { + NativeTool::from_any_name(name).and_then(NativeTool::category) +} + /// CLI permission gate category. Unknown tools fall back to `Shell` so they /// require explicit user approval at any permission level below `Full`. pub fn tool_category(name: &str) -> AgentToolCategory { From ddddc5bb33d10c07a8aa220b1129c69e6177c0b1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 20:18:29 -0400 Subject: [PATCH 05/20] feat(agent): give the Kimi profile Kimi Code's TodoList tool The Kimi profile was registering the Anthropic task tools. Both persist through the same TodoRuntime, but they model opposite interactions: TaskCreate and TaskUpdate mutate individual tasks against tracked ids, while Kimi Code's TodoList replaces the whole list in one call. Of the two surfaces fabro already had, Kimi was given the one furthest from what its models are trained on. Add TodoListKind::KimiTodos and a TodoList tool matching Kimi Code's contract exactly: TodoList({ todos?: [{ title, status: pending | in_progress | done }] }) Omitting `todos` reads the list, an empty array clears it, and a list replaces it. Reconciliation mirrors update_plan -- items are identified by their text, so re-submitting a list preserves identity for unchanged entries -- and the runtime, projections, and events are unchanged. Two differences from the existing surfaces were behavioral rather than cosmetic. Items carry only `title`, where TaskCreate requires both `subject` and `description`, so a model with nothing to say for a description had to invent one. And the terminal status is spelled `done`; `completed` is the Anthropic and Codex spelling, and a model emitting `done` against the old schema got a validation error rather than a todo. The internal representation stays TodoStatus::Completed; only the wire vocabulary differs. Kimi todo lists are session-scoped like OpenAI plans, so the root-agent projection excludes subagent lists the same way. Co-Authored-By: Claude Opus 5 (1M context) --- lib/components/fabro-agent/src/lib.rs | 2 +- lib/components/fabro-agent/src/native_tool.rs | 3 + .../fabro-agent/src/profiles/kimi.rs | 18 +- .../src/profiles/prompts/kimi.md.j2 | 10 + lib/components/fabro-agent/src/todo_tools.rs | 293 +++++++++++++++++- lib/components/fabro-store/src/run_state.rs | 9 +- lib/foundation/fabro-types/src/todo.rs | 6 + 7 files changed, 324 insertions(+), 17 deletions(-) diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index dfbeb8a5b..19fee7d40 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -72,7 +72,7 @@ pub use subagent::{SubAgentEventCallback, SubAgentResult, SubAgentStatus, SubAge pub use todo_runtime::TodoRuntime; pub use todo_tools::{ make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, - make_update_plan_tool, + make_todo_list_tool, make_update_plan_tool, }; pub use tool_permissions::canonical_tool_name; pub use tool_registry::{AgentEventEmitter, ToolRegistry}; diff --git a/lib/components/fabro-agent/src/native_tool.rs b/lib/components/fabro-agent/src/native_tool.rs index 0a7ebd8a0..64d4dc387 100644 --- a/lib/components/fabro-agent/src/native_tool.rs +++ b/lib/components/fabro-agent/src/native_tool.rs @@ -78,6 +78,8 @@ pub enum NativeTool { TaskGet, #[strum(to_string = "TaskList")] TaskList, + #[strum(to_string = "TodoList")] + TodoList, #[strum(to_string = "AskUserQuestion")] AskUserQuestion, #[strum(to_string = "request_user_input")] @@ -157,6 +159,7 @@ impl NativeTool { | Self::TaskUpdate | Self::TaskGet | Self::TaskList + | Self::TodoList | Self::AskUserQuestion | Self::RequestUserInput => None, } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index aedd2b5b7..5b18bb127 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -12,9 +12,7 @@ use crate::profiles::{self, BaseProfile, EmbeddedPrompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::todo_runtime::TodoRuntime; -use crate::todo_tools::{ - make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, -}; +use crate::todo_tools::make_todo_list_tool; use crate::tool_registry::{RegisteredTool, ToolRegistry}; use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; @@ -106,14 +104,12 @@ impl KimiProfile { redescribe(&mut registry, NativeTool::EditFile, EDIT_FILE_DESCRIPTION); redescribe(&mut registry, NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); - // Kimi Code exposes a single TodoList tool; fabro's four task tools - // cover the same ground over one runtime, so reuse them rather than - // introducing a fifth shape of todo state. + // Kimi Code drives todos with one replace-whole-list call. The + // Anthropic task tools model the opposite interaction -- incremental + // mutation against tracked ids -- so they are the wrong surface here + // even though both persist through the same runtime. let todo_runtime = Arc::new(TodoRuntime::new()); - registry.register(make_task_create_tool(todo_runtime.clone())); - registry.register(make_task_update_tool(todo_runtime.clone())); - registry.register(make_task_get_tool(todo_runtime.clone())); - registry.register(make_task_list_tool(todo_runtime)); + registry.register(make_todo_list_tool(todo_runtime)); // Applied last so every built-in registered above is renamed. apply_vocabulary(&mut registry, ToolVocabulary::KimiCode); @@ -283,7 +279,7 @@ mod tests { ); } // No Kimi Code counterpart, so these keep fabro's names. - assert!(names.contains(&"TaskCreate".to_string())); + assert!(names.contains(&"TodoList".to_string())); } #[test] diff --git a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 index 7f83ab58b..6f8c8306d 100644 --- a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 +++ b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 @@ -34,6 +34,16 @@ This workspace refuses writes to files you have not read. `Edit` and `Write` bot - After you edit a file, its contents have changed. Re-read before your next edit to the same file rather than assuming your own edit landed as written. - `Write` fully replaces a file. Use it only for new files or a deliberate complete rewrite; for every incremental change use `Edit`. +# Tracking Multi-Step Work + +Use `TodoList` for work that spans several steps, and keep it current as you go. + +- Pass the whole list every time; it replaces what is there. Omit `todos` to read the list back without changing it, and pass an empty array to clear it. +- Keep exactly one item `in_progress` while you are working. +- Mark an item `done` the moment it is finished — do not batch completions until the end. +- Do not re-send an unchanged list. Update it when something actually moved. +- Skip it for single-step work where tracking adds nothing. + # General Guidelines for Coding When building something from scratch, understand the requirements, plan the architecture, and write modular, maintainable code. diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index b6bc87421..fc6b28e89 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -211,6 +211,183 @@ pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { } } +/// Compute the Kimi todo scope (`kimi_todos:`). +fn kimi_todo_scope(ctx: &ToolContext) -> Result { + ctx.session_id + .as_ref() + .map(|sid| TodoListKind::KimiTodos.list_id(sid)) + .ok_or_else(|| "TodoList requires an active session".to_string()) +} + +/// Kimi Code spells the terminal status `done`; internally it is +/// [`TodoStatus::Completed`]. +fn parse_kimi_status(value: &str) -> Result { + match value { + "pending" => Ok(TodoStatus::Pending), + "in_progress" => Ok(TodoStatus::InProgress), + "done" => Ok(TodoStatus::Completed), + other => Err(format!( + "Invalid status `{other}` (expected pending|in_progress|done)" + )), + } +} + +fn kimi_status_name(status: TodoStatus) -> &'static str { + match status { + TodoStatus::Pending => "pending", + TodoStatus::InProgress => "in_progress", + TodoStatus::Completed | TodoStatus::Deleted => "done", + } +} + +fn render_kimi_todos(items: &[TodoProjection]) -> String { + if items.is_empty() { + return "The todo list is empty.".to_string(); + } + let mut out = String::new(); + for todo in items { + let _ = writeln!(out, "[{}] {}", kimi_status_name(todo.status), todo.subject); + } + out.truncate(out.trim_end().len()); + out +} + +/// Kimi Code-compatible `TodoList`. +/// +/// A single tool serves reads and writes, matching the surface Kimi models are +/// trained against: omit `todos` to read, pass `[]` to clear, pass a list to +/// replace the whole thing. Items carry only `title` and `status`, and the +/// terminal status is spelled `done`. +/// +/// Reconciliation mirrors `update_plan` — items are identified by their text, +/// so a re-submitted list preserves identity for unchanged entries — and the +/// same [`TodoRuntime`] backs it, so projections and events are unchanged. +pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "TodoList".into(), + description: "Maintain a structured TODO list for the current task. Use it \ + proactively for multi-step work. Pass `todos` to replace the entire \ + list, omit `todos` to read the current list without changing it, and \ + pass an empty array to clear it. Keep exactly one item `in_progress` \ + while work is underway, and mark an item `done` as soon as it is \ + finished rather than batching completions at the end." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The updated todo list. Omit to read the current list \ + without making changes. Pass an empty array to clear the list.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Short, actionable title for the todo." + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "done"], + "description": "Current status of the todo." + } + }, + "required": ["title", "status"] + } + } + } + }), + }, + executor: Arc::new(move |args, ctx| { + let runtime = runtime.clone(); + Box::pin(async move { + let list_id = kimi_todo_scope(&ctx)?; + + // Read mode: `todos` omitted entirely. + let Some(todos) = args.get("todos") else { + let items = runtime + .snapshot(&list_id) + .map(|l| l.items) + .unwrap_or_default(); + return Ok(render_kimi_todos(&items)); + }; + let todos = todos + .as_array() + .ok_or_else(|| "`todos` must be an array".to_string())?; + + let mut incoming: Vec<(String, String, TodoStatus)> = + Vec::with_capacity(todos.len()); + let mut seen: HashSet<&str> = HashSet::with_capacity(todos.len()); + for (index, entry) in todos.iter().enumerate() { + let title = entry + .get("title") + .and_then(Value::as_str) + .ok_or_else(|| format!("todos[{index}] is missing `title`"))?; + let status = entry + .get("status") + .and_then(Value::as_str) + .ok_or_else(|| format!("todos[{index}] is missing `status`"))?; + let status = parse_kimi_status(status)?; + if !seen.insert(title) { + return Err(format!("Duplicate todo `{title}` — titles must be unique")); + } + incoming.push((openai_step_id(&list_id, title), title.to_string(), status)); + } + + let previous: HashMap = runtime + .snapshot(&list_id) + .map(|list| list.items.into_iter().map(|t| (t.id.clone(), t)).collect()) + .unwrap_or_default(); + let incoming_ids: HashSet<&str> = + incoming.iter().map(|(id, _, _)| id.as_str()).collect(); + + for id in previous.keys() { + if !incoming_ids.contains(id.as_str()) { + runtime.delete(&ctx, TodoListKind::KimiTodos, list_id.clone(), id.clone()); + } + } + + for (index, (todo_id, title, status)) in incoming.iter().enumerate() { + let order = u32::try_from(index).unwrap_or(u32::MAX); + match previous.get(todo_id) { + Some(prev) + if prev.status == *status + && prev.order == order + && prev.subject == *title => {} + Some(_) => { + runtime.update(&ctx, TodoUpdatedProps { + status: Some(*status), + order: Some(order), + subject: Some(title.clone()), + ..TodoUpdatedProps::new(&list_id, TodoListKind::KimiTodos, todo_id) + }); + } + None => { + let mut projection = + TodoProjection::new(todo_id.clone(), order, title.clone()); + projection.status = *status; + runtime.create( + &ctx, + TodoListKind::KimiTodos, + list_id.clone(), + projection, + ); + } + } + } + + let items = runtime + .snapshot(&list_id) + .map(|l| l.items) + .unwrap_or_default(); + Ok(render_kimi_todos(&items)) + }) + }), + source: ToolSource::Native, + } +} + /// Per-list monotonically-increasing task counter for Anthropic /// `TaskCreate`. Shared state lives inside the tool closure so two parallel /// `TaskCreate` calls inside one session can never receive the same ID. @@ -497,6 +674,120 @@ pub fn make_task_list_tool(runtime: Arc) -> RegisteredTool { } } +#[cfg(test)] +mod kimi_todo_tests { + use std::sync::Arc; + + use serde_json::json; + use tokio_util::sync::CancellationToken; + + use super::tests::SilentEmitter; + use super::*; + use crate::sandbox::Sandbox; + use crate::test_support::MockSandbox; + + fn ctx() -> ToolContext { + let env: Arc = Arc::new(MockSandbox::default()); + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: Some("ses_kimi".to_string()), + root_session_id: Some("ses_kimi".to_string()), + tool_call_id: None, + agent_event_emitter: Some(Arc::new(SilentEmitter)), + } + } + + async fn call(tool: &RegisteredTool, args: serde_json::Value) -> Result { + (tool.executor)(args, ctx()).await + } + + #[tokio::test] + async fn replaces_the_whole_list_and_reads_it_back() { + let runtime = Arc::new(TodoRuntime::new()); + let tool = make_todo_list_tool(runtime); + + call( + &tool, + json!({"todos": [ + {"title": "read the config", "status": "done"}, + {"title": "patch the parser", "status": "in_progress"}, + {"title": "add a test", "status": "pending"} + ]}), + ) + .await + .unwrap(); + + // Read mode: `todos` omitted entirely. + let listed = call(&tool, json!({})).await.unwrap(); + assert!(listed.contains("[done] read the config"), "{listed}"); + assert!( + listed.contains("[in_progress] patch the parser"), + "{listed}" + ); + + // Re-submitting a shorter list drops the missing entries. + call( + &tool, + json!({"todos": [{"title": "add a test", "status": "done"}]}), + ) + .await + .unwrap(); + let listed = call(&tool, json!({})).await.unwrap(); + assert!(listed.contains("[done] add a test"), "{listed}"); + assert!(!listed.contains("patch the parser"), "{listed}"); + } + + #[tokio::test] + async fn empty_array_clears_the_list() { + let runtime = Arc::new(TodoRuntime::new()); + let tool = make_todo_list_tool(runtime); + call( + &tool, + json!({"todos": [{"title": "x", "status": "pending"}]}), + ) + .await + .unwrap(); + call(&tool, json!({"todos": []})).await.unwrap(); + assert_eq!( + call(&tool, json!({})).await.unwrap(), + "The todo list is empty." + ); + } + + /// Kimi Code spells the terminal status `done`; `completed` is the + /// Anthropic/Codex spelling and must not be silently accepted. + #[tokio::test] + async fn status_vocabulary_is_kimi_codes() { + let runtime = Arc::new(TodoRuntime::new()); + let tool = make_todo_list_tool(runtime); + let err = call( + &tool, + json!({"todos": [{"title": "x", "status": "completed"}]}), + ) + .await + .unwrap_err(); + assert!(err.contains("expected pending|in_progress|done"), "{err}"); + } + + #[tokio::test] + async fn duplicate_titles_are_rejected() { + let runtime = Arc::new(TodoRuntime::new()); + let tool = make_todo_list_tool(runtime); + let err = call( + &tool, + json!({"todos": [ + {"title": "same", "status": "pending"}, + {"title": "same", "status": "done"} + ]}), + ) + .await + .unwrap_err(); + assert!(err.contains("must be unique"), "{err}"); + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -510,7 +801,7 @@ mod tests { use crate::types::AgentEvent; #[derive(Default)] - struct SilentEmitter; + pub(super) struct SilentEmitter; impl AgentEventEmitter for SilentEmitter { fn emit(&self, _event: AgentEvent) {} } diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index 5d1f83861..979d54406 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -746,12 +746,13 @@ impl RunProjectionReducer for RunProjection { /// OpenAI plan lists are scoped per agent session (`openai_plan:`), /// so a child/subagent session emits its own list events on the same stage. /// The root-agent projection excludes those child plans, while the underlying -/// events remain in the run event log. Anthropic task lists are root-scoped -/// (`anthropic_tasks:`) and intentionally shared with -/// subagents, so they always project. +/// events remain in the run event log. Kimi todo lists +/// (`kimi_todos:`) are scoped the same way. Anthropic task lists +/// are root-scoped (`anthropic_tasks:`) and intentionally +/// shared with subagents, so they always project. fn should_project_root_agent_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { match list_kind { - TodoListKind::OpenAiPlan => stored.parent_session_id.is_none(), + TodoListKind::OpenAiPlan | TodoListKind::KimiTodos => stored.parent_session_id.is_none(), TodoListKind::AnthropicTasks => true, } } diff --git a/lib/foundation/fabro-types/src/todo.rs b/lib/foundation/fabro-types/src/todo.rs index b37d355fa..e1dc38202 100644 --- a/lib/foundation/fabro-types/src/todo.rs +++ b/lib/foundation/fabro-types/src/todo.rs @@ -71,6 +71,12 @@ pub enum TodoListKind { #[serde(rename = "anthropic_tasks")] #[strum(to_string = "anthropic_tasks")] AnthropicTasks, + /// `TodoList` (Kimi Code). Like [`Self::OpenAiPlan`] it replaces the whole + /// list in one call and reconciles by item text, but it uses Kimi Code's + /// field names and exposes read and clear modes. Session-scoped. + #[serde(rename = "kimi_todos")] + #[strum(to_string = "kimi_todos")] + KimiTodos, } impl TodoListKind { From 9604d2ac9dd2f69f0e7691910c8bacabab78c495 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 20:27:53 -0400 Subject: [PATCH 06/20] fix(agent): apply the Kimi vocabulary to every tool, not just the early ones Renaming ran as a pass at the end of profile construction, so it only covered tools registered by that point. Subagent tools arrive later via `register_subagent_tools`, and the skill tool is registered when a session discovers skills, so a Kimi profile actually exposed a mixed set: Read Write Edit Bash Grep Glob FetchURL TodoList renamed spawn_agent send_input close_agent wait use_skill missed Move the vocabulary into ToolRegistry instead of applying it as a pass. `register` renames built-ins on the way in, so registration order stops mattering and a late registration cannot slip through. `ToolRegistry::new` keeps the fabro vocabulary, so no other profile changes. `use_skill` now exposes as `Skill`, matching Kimi Code, which has the same semantics. The subagent tools stay under fabro's names on purpose: Kimi Code's `Agent` launches a subagent and returns its result, while fabro's spawn_agent returns a handle that send_input, wait, and close_agent drive. Borrowing the name without the semantics would promise a result the tool does not return -- the same mistake as exposing incremental task tools under a whole-list name. The skills prompt section hardcoded `use_skill`, which under this vocabulary names a tool the model was not given. It takes the exposed name now, threaded through EmbeddedPrompt so a profile's prompt and its registry cannot disagree. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabro-agent/src/context_window.rs | 3 +- lib/components/fabro-agent/src/native_tool.rs | 13 ++- .../fabro-agent/src/profiles/kimi.rs | 86 +++++++------------ .../fabro-agent/src/profiles/mod.rs | 20 ++++- lib/components/fabro-agent/src/skills.rs | 18 ++-- .../fabro-agent/src/test_support.rs | 4 +- .../fabro-agent/src/tool_registry.rs | 38 +++++++- 7 files changed, 112 insertions(+), 70 deletions(-) diff --git a/lib/components/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs index 902056764..0a5d1e362 100644 --- a/lib/components/fabro-agent/src/context_window.rs +++ b/lib/components/fabro-agent/src/context_window.rs @@ -12,6 +12,7 @@ use fabro_types::{ }; use crate::memory::MemoryDocument; +use crate::native_tool::NativeTool; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::{ToolDefinitionWithSource, ToolSource}; @@ -221,7 +222,7 @@ fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String { } fn skills_prompt_suffix(skills: &[Skill]) -> String { - let section = format_skills_prompt_section(skills); + let section = format_skills_prompt_section(skills, NativeTool::UseSkill.canonical_name()); if section.is_empty() { String::new() } else { diff --git a/lib/components/fabro-agent/src/native_tool.rs b/lib/components/fabro-agent/src/native_tool.rs index 64d4dc387..c2f9c8ff1 100644 --- a/lib/components/fabro-agent/src/native_tool.rs +++ b/lib/components/fabro-agent/src/native_tool.rs @@ -109,9 +109,16 @@ impl NativeTool { Self::Glob => "Glob", Self::WebSearch => "WebSearch", Self::WebFetch => "FetchURL", - // Kimi Code has no counterpart with these semantics: its - // TodoList replaces a whole list rather than mutating tasks, - // and it has no equivalent of the remaining tools. + Self::UseSkill => "Skill", + // Deliberately unmapped. Kimi Code's `Agent` launches a + // subagent and returns its result; fabro's spawn_agent returns + // a handle that send_input, wait, and close_agent then drive. + // Borrowing the name without the semantics would promise a + // result the tool does not return -- the same mistake as + // exposing incremental task tools under a whole-list name. + Self::SpawnAgent | Self::SendInput | Self::Wait | Self::CloseAgent => { + self.canonical_name() + } other => other.canonical_name(), }, } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index 5b18bb127..a02d28ef4 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -1,8 +1,6 @@ use std::sync::Arc; -use fabro_llm::types::ToolDefinition; use fabro_model::{AgentProfileKind, Catalog, ProviderId}; -use strum::VariantArray; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -13,7 +11,7 @@ use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::todo_runtime::TodoRuntime; use crate::todo_tools::make_todo_list_tool; -use crate::tool_registry::{RegisteredTool, ToolRegistry}; +use crate::tool_registry::ToolRegistry; use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2"); @@ -39,48 +37,6 @@ files that have not been read, and the call will fail. Prefer Edit for any incre change: Write replaces the entire file, so using it to make a small edit discards \ everything you did not restate."; -/// Expose every registered built-in tool under Kimi Code's names. -/// -/// Only the exposed name changes: executors, schemas, and the identity that -/// permissions and telemetry resolve through are untouched, because -/// `canonical_tool_name` maps every vocabulary back to the same -/// [`NativeTool`]. -fn apply_vocabulary(registry: &mut ToolRegistry, vocabulary: ToolVocabulary) { - for tool in NativeTool::VARIANTS { - let exposed = tool.name(vocabulary); - if exposed == tool.canonical_name() { - continue; - } - let Some(registered) = registry.unregister(tool.canonical_name()) else { - continue; - }; - registry.register(RegisteredTool { - definition: ToolDefinition { - name: exposed.to_string(), - ..registered.definition - }, - ..registered - }); - } -} - -/// Replace a registered tool's description, keeping its executor and schema. -/// -/// Profiles own their registries, so tailoring wording per model is a local -/// change and does not affect what other profiles expose. -fn redescribe(registry: &mut ToolRegistry, tool: NativeTool, description: &str) { - let Some(tool) = registry.unregister(tool.canonical_name()) else { - return; - }; - registry.register(RegisteredTool { - definition: ToolDefinition { - description: description.to_string(), - ..tool.definition - }, - ..tool - }); -} - pub struct KimiProfile { base: BaseProfile, } @@ -97,12 +53,14 @@ impl KimiProfile { options: &NativeToolOptions, summarizer: Option, ) -> Self { - let mut registry = ToolRegistry::new(); + // The registry carries the vocabulary, so tools registered later + // (subagent tools, skills) are renamed too. + let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode); register_core_tools(&mut registry, options, summarizer); registry.register(make_edit_file_tool()); - redescribe(&mut registry, NativeTool::EditFile, EDIT_FILE_DESCRIPTION); - redescribe(&mut registry, NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); + registry.redescribe(NativeTool::EditFile, EDIT_FILE_DESCRIPTION); + registry.redescribe(NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); // Kimi Code drives todos with one replace-whole-list call. The // Anthropic task tools model the opposite interaction -- incremental @@ -111,9 +69,6 @@ impl KimiProfile { let todo_runtime = Arc::new(TodoRuntime::new()); registry.register(make_todo_list_tool(todo_runtime)); - // Applied last so every built-in registered above is renamed. - apply_vocabulary(&mut registry, ToolVocabulary::KimiCode); - Self { base: BaseProfile { profile_kind: AgentProfileKind::Kimi, @@ -175,7 +130,8 @@ impl AgentProfile for KimiProfile { user_instructions: Option<&str>, skills: &[Skill], ) -> String { - let template = EmbeddedPrompt::new("kimi.md.j2", CORE_PROMPT); + let template = EmbeddedPrompt::new("kimi.md.j2", CORE_PROMPT) + .with_vocabulary(self.base.registry.vocabulary()); profiles::assemble_system_prompt( template, @@ -194,6 +150,8 @@ mod tests { use fabro_types::AgentToolCategory; use super::*; + use crate::skills::make_use_skill_tool; + use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; use crate::tool_permissions::{known_tool_category, tool_category}; @@ -278,10 +236,32 @@ mod tests { "{canonical} should have been renamed" ); } - // No Kimi Code counterpart, so these keep fabro's names. assert!(names.contains(&"TodoList".to_string())); } + /// Tools registered after the profile is constructed must also land in the + /// Kimi vocabulary, or the model sees a mixed-case tool set. + #[test] + fn post_construction_tools_also_use_kimi_names() { + let mut profile = KimiProfile::new("kimi-k3"); + let factory: SessionFactory = Arc::new(|| panic!("unused")); + profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0); + profile + .tool_registry_mut() + .register(make_use_skill_tool(Arc::new(vec![Skill { + name: "demo".into(), + description: "d".into(), + template: "t".into(), + }]))); + + let names = profile.tool_registry().names(); + assert!(names.contains(&"Skill".to_string()), "got {names:?}"); + assert!(!names.contains(&"use_skill".to_string()), "got {names:?}"); + // Deliberately not renamed to Kimi Code's `Agent`: fabro's subagent + // tools are a supervisor model, not a call-and-return one. + assert!(names.contains(&"spawn_agent".to_string()), "got {names:?}"); + } + #[test] fn edit_and_write_descriptions_drill_read_before_write() { let profile = KimiProfile::new("kimi-k3"); diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index adc9d1a96..617e008c7 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -15,6 +15,7 @@ pub use openai::OpenAiProfile; use crate::agent_profile::AgentProfile; use crate::config::{NativeToolOptions, ToolSecrets}; +use crate::native_tool::{NativeTool, ToolVocabulary}; use crate::sandbox::Sandbox; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::ToolRegistry; @@ -125,9 +126,11 @@ pub struct EnvContext { /// The environment block is supplied by [`assemble_system_prompt`] and cannot /// be overridden by callers. pub struct EmbeddedPrompt { - name: &'static str, - source: &'static str, - inputs: HashMap, + name: &'static str, + source: &'static str, + inputs: HashMap, + /// Vocabulary the surrounding prompt sections should name tools in. + vocabulary: ToolVocabulary, } impl EmbeddedPrompt { @@ -137,9 +140,17 @@ impl EmbeddedPrompt { name, source, inputs: HashMap::new(), + vocabulary: ToolVocabulary::Fabro, } } + /// Name tools in `vocabulary` in the generated sections. + #[must_use] + pub fn with_vocabulary(mut self, vocabulary: ToolVocabulary) -> Self { + self.vocabulary = vocabulary; + self + } + #[must_use] pub fn with_string(mut self, name: &'static str, value: impl Into) -> Self { self.inputs @@ -184,6 +195,7 @@ pub fn assemble_system_prompt( skills: &[Skill], ) -> String { let env_block = build_env_context_block_with(env, env_context); + let skill_tool = NativeTool::UseSkill.name(template.vocabulary); let prompt = template.render(env_block); let docs_section = if memory.is_empty() { @@ -192,7 +204,7 @@ pub fn assemble_system_prompt( format!("\n\n{}", memory.join("\n\n")) }; let skills_section = { - let s = format_skills_prompt_section(skills); + let s = format_skills_prompt_section(skills, skill_tool); if s.is_empty() { String::new() } else { diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index fe380bb28..026464d75 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -196,16 +196,22 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { } } -pub fn format_skills_prompt_section(skills: &[Skill]) -> String { +/// Render the skills section of a system prompt. +/// +/// `skill_tool` is the name the skill tool is exposed under, which depends on +/// the profile's vocabulary — telling a model to call a tool it was not given +/// is worse than omitting the guidance. +pub fn format_skills_prompt_section(skills: &[Skill], skill_tool: &str) -> String { if skills.is_empty() { return String::new(); } let mut lines = vec![ "# Available Skills".to_string(), - "When the user's request matches a skill below, call the `use_skill` tool \ - to load its instructions, then follow them." - .to_string(), + format!( + "When the user's request matches a skill below, call the `{skill_tool}` tool \ + to load its instructions, then follow them." + ), ]; for skill in skills { if skill.description.is_empty() { @@ -452,13 +458,13 @@ name: trimmed #[test] fn format_empty() { - assert_eq!(format_skills_prompt_section(&[]), ""); + assert_eq!(format_skills_prompt_section(&[], "use_skill"), ""); } #[test] fn format_lists_skills() { let skills = test_skills(); - let section = format_skills_prompt_section(&skills); + let section = format_skills_prompt_section(&skills, "use_skill"); assert!(section.contains("# Available Skills")); assert!(section.contains("call the `use_skill` tool")); assert!(section.contains("- `commit`: Create a commit")); diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index fd827ec0c..e9c2d8112 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -15,6 +15,7 @@ use futures::stream; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; +use crate::native_tool::NativeTool; use crate::profiles::EnvContext; use crate::sandbox::*; use crate::session::Session; @@ -80,7 +81,8 @@ impl AgentProfile for TestProfile { user_instructions: Option<&str>, skills: &[Skill], ) -> String { - let skills_section = format_skills_prompt_section(skills); + let skills_section = + format_skills_prompt_section(skills, NativeTool::UseSkill.canonical_name()); let skills_part = if skills_section.is_empty() { String::new() } else { diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index 74af24354..55f06222c 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -8,6 +8,7 @@ use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary}; use tokio_util::sync::CancellationToken; use crate::config::{ToolAccessPolicy, ToolExposureMode}; +use crate::native_tool::{NativeTool, ToolVocabulary}; use crate::sandbox::Sandbox; use crate::session::ToolEnvProvider; use crate::tool_permissions; @@ -125,21 +126,54 @@ fn agent_tool_source(source: &ToolSource) -> AgentToolSource { } pub struct ToolRegistry { - tools: HashMap, + tools: HashMap, + /// Naming scheme applied to built-in tools as they are registered. + /// + /// Held by the registry rather than applied as a pass after construction, + /// so tools registered later — subagent tools, skills — cannot miss it and + /// leave the model with a mixed-vocabulary tool set. + vocabulary: ToolVocabulary, } impl ToolRegistry { #[must_use] pub fn new() -> Self { + Self::with_vocabulary(ToolVocabulary::Fabro) + } + + /// A registry that exposes built-in tools under `vocabulary`. + #[must_use] + pub fn with_vocabulary(vocabulary: ToolVocabulary) -> Self { Self { tools: HashMap::new(), + vocabulary, } } - pub fn register(&mut self, tool: RegisteredTool) { + #[must_use] + pub fn vocabulary(&self) -> ToolVocabulary { + self.vocabulary + } + + pub fn register(&mut self, mut tool: RegisteredTool) { + if let Some(native) = NativeTool::from_any_name(&tool.definition.name) { + tool.definition.name = native.name(self.vocabulary).to_string(); + } self.tools.insert(tool.definition.name.clone(), tool); } + /// Replace a built-in tool's description, keeping its executor and schema. + /// + /// Resolves through the registry's vocabulary, so callers name the tool by + /// identity rather than by whatever string it is currently exposed under. + pub fn redescribe(&mut self, tool: NativeTool, description: impl Into) { + let exposed = tool.name(self.vocabulary); + if let Some(mut registered) = self.tools.remove(exposed) { + registered.definition.description = description.into(); + self.tools.insert(exposed.to_string(), registered); + } + } + pub fn unregister(&mut self, name: &str) -> Option { self.tools.remove(name) } From 7eef7652d374bf34b7ad53d04803dbd8cc657e87 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 24 Jul 2026 20:57:57 -0400 Subject: [PATCH 07/20] fix(agent): harden compaction failure handling Accept every nonblank summary instead of applying an arbitrary length heuristic. Preserve typed compaction failures and their source chains, suppress repeat attempts within one input, and clear the CLI compaction indicator when the existing agent error event arrives. --- .../src/commands/run/run_progress/event.rs | 15 ++ .../src/commands/run/run_progress/mod.rs | 33 ++++ .../run/run_progress/stage_display.rs | 27 ++++ lib/components/fabro-agent/src/compaction.rs | 150 ++++++++---------- lib/components/fabro-agent/src/error.rs | 75 +++++++++ lib/components/fabro-agent/src/lib.rs | 2 +- lib/components/fabro-agent/src/session.rs | 78 ++++++--- .../fabro-workflow/src/handler/llm/api.rs | 1 + 8 files changed, 274 insertions(+), 107 deletions(-) diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 91e8edaaf..31aa14b1c 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -164,6 +164,10 @@ pub(super) enum ProgressEvent { preserved_turn_count: u64, tracked_file_count: u64, }, + CompactionFailed { + stage_node_id: String, + error: String, + }, LlmRetry { stage_node_id: String, model: String, @@ -360,6 +364,12 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { preserved_turn_count: props.preserved_turn_count as u64, tracked_file_count: props.tracked_file_count as u64, }), + EventBody::AgentError(props) => { + display_compaction_error(&props.error).map(|error| ProgressEvent::CompactionFailed { + stage_node_id: node_id, + error, + }) + } EventBody::AgentLlmRetry(props) => { #[allow( clippy::cast_possible_truncation, @@ -422,6 +432,11 @@ pub(super) fn from_json_line(line: &str) -> Option { from_run_event(&stored) } +fn display_compaction_error(value: &Value) -> Option { + let error = serde_json::from_value::(value.clone()).ok()?; + matches!(&error, fabro_agent::Error::Compaction(_)).then(|| error.to_string()) +} + fn display_value(value: &Value) -> Option { match value { Value::Null => None, diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index 97799bfc1..a1dd513ee 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -316,6 +316,13 @@ impl ProgressUI { tracked_file_count, ); } + ProgressEvent::CompactionFailed { + stage_node_id, + error, + } => { + self.stage + .on_compaction_failed(renderer, &stage_node_id, &error); + } ProgressEvent::LlmRetry { stage_node_id, model, @@ -679,6 +686,32 @@ mod tests { assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); } + #[test] + fn compaction_failure_clears_bar() { + let mut ui = ProgressUI::new(true, false); + + emit(&mut ui, stage_started("s1", "Build")); + emit( + &mut ui, + agent_event("s1", AgentEvent::CompactionStarted { + estimated_tokens: 5000, + context_window_size: 8000, + }), + ); + assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); + + emit( + &mut ui, + agent_event("s1", AgentEvent::Error { + error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary { + summarized_turn_count: 14, + }), + }), + ); + + assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); + } + #[test] fn handle_json_line_ignores_invalid_json() { let (mut ui, buffer) = capture_ui(false); diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs index b58568b48..c7c8f8ef6 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs @@ -474,6 +474,33 @@ impl StageDisplay { } } + pub(super) fn on_compaction_failed( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + error: &str, + ) { + let message = format!( + "{} compaction failed: {error}", + styles::red_cross(renderer.styles()) + ); + + if renderer.is_tty() { + if let Some(bar) = self + .active_stages + .get_mut(stage_node_id) + .and_then(|stage| stage.compaction_bar.take()) + { + bar.set_style(styles::style_tool_done()); + bar.finish_with_message(message); + } else { + self.insert_info_line_for_stage(renderer, stage_node_id, &message); + } + } else { + renderer.print_line(6, &message); + } + } + pub(super) fn on_llm_retry( &mut self, renderer: &ProgressRenderer, diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index 0a9e5ef86..e2331144b 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -5,7 +5,7 @@ use fabro_llm::types::{Message as LlmMessage, Request}; use tracing::debug; use crate::agent_profile::AgentProfile; -use crate::error::Error; +use crate::error::{CompactionError, Error}; use crate::event::Emitter; use crate::file_tracker::FileTracker; use crate::history::History; @@ -13,17 +13,6 @@ use crate::types::{AgentEvent, Message}; const APPROX_CHARS_PER_TOKEN: usize = 4; -/// Minimum length, in bytes, of a usable compaction summary after trimming. -/// -/// A summary is traded for many turns of conversation, so anything shorter -/// than a single source file path -/// (`lib/components/fabro-agent/src/compaction.rs` is 43 bytes) cannot be -/// carrying that context forward. The bar is set far below any genuine summary -/// on purpose: this exists to catch degenerate responses, not to judge summary -/// quality. A false refusal leaves the context to keep growing, so the check -/// must never fire on a real summary. -const MIN_SUMMARY_LEN: usize = 32; - #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub(crate) enum ContextEstimateMethod { @@ -158,25 +147,19 @@ function names, error messages, and exact values. Omit pleasantries and conversa let response = llm_client .complete(&summary_request) .await - .map_err(Error::Llm)?; + .map_err(CompactionError::Llm)?; let response_text = response.text(); let summary_text = response_text.trim(); - // Refuse to compact on a degenerate summary. `compact_from` discards the - // summarized turns irreversibly, so an empty or near-empty summary must not - // be traded for them: the preamble below would tell the model a handoff - // summary exists while it actually runs with no history at all. Empty - // completions are provider-independent — a truncated stream, a reasoning - // model that spent its whole budget on reasoning, or a rate-limit edge all - // produce one. Returning here leaves the history intact; the caller turns - // this into an `AgentEvent::Error` and continues the session. - if summary_text.len() < MIN_SUMMARY_LEN { - return Err(Error::InvalidState(format!( - "compaction summary was empty or too short to replace {preserve_start} turns \ - ({} bytes, minimum {MIN_SUMMARY_LEN}); history left intact", - summary_text.len() - ))); + // `compact_from` discards summarized turns irreversibly. Refuse an empty + // response before mutating history; trimming also prevents a + // whitespace-only response from masquerading as a summary. + if summary_text.is_empty() { + return Err(CompactionError::EmptySummary { + summarized_turn_count: preserve_start, + } + .into()); } debug!( @@ -601,9 +584,16 @@ mod tests { if details["estimate_method"] == "local_estimate")); } + struct CompactionTestResult { + result: Result<(), Error>, + history: History, + original_turns: Vec, + events: Vec, + } + /// Run `compact_context` over a fixed four-turn history against a mock /// provider that returns `summary` from the summarization call. - async fn compact_with_summary(summary: &str) -> (Result<(), Error>, History, Vec) { + async fn compact_with_summary(summary: &str) -> CompactionTestResult { let mut history = History::default(); for index in 0..4 { history.push(Message::User { @@ -611,6 +601,7 @@ mod tests { timestamp: SystemTime::now(), }); } + let original_turns = history.to_session_messages(); let provider = Arc::new(MockLlmProvider::new(vec![text_response(summary)])); let client = make_client(provider).await; @@ -639,70 +630,69 @@ mod tests { events.push(event.event); } - (result, history, events) + CompactionTestResult { + result, + history, + original_turns, + events, + } } - fn assert_history_untouched(history: &History) { + fn assert_history_untouched(history: &History, original_turns: &[fabro_types::SessionMessage]) { assert_eq!( - history.turns().len(), - 4, - "history must not be truncated when the summary is rejected" - ); - assert!( - history - .turns() - .iter() - .all(|turn| matches!(turn, Message::User { .. })), - "no summary turn should be inserted when the summary is rejected" + history.to_session_messages(), + original_turns, + "history must remain exactly unchanged when the summary is rejected" ); } #[tokio::test] - async fn compaction_refuses_to_truncate_on_empty_summary() { - let (result, history, events) = compact_with_summary("").await; + async fn compaction_refuses_to_truncate_on_blank_summary() { + for summary in ["", " \n\t \n "] { + let CompactionTestResult { + result, + history, + original_turns, + events, + } = compact_with_summary(summary).await; - let err = result.expect_err("empty summary must not report success"); - assert!( - matches!(&err, Error::InvalidState(message) if message.contains("empty or too short")), - "unexpected error: {err}" - ); + let err = result.expect_err("blank summary must not report success"); + assert!( + matches!( + &err, + Error::Compaction(CompactionError::EmptySummary { + summarized_turn_count: 3, + }) + ), + "unexpected error: {err}" + ); - assert_history_untouched(&history); - assert!( - !events - .iter() - .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), - "CompactionCompleted must not be emitted for a rejected summary" - ); + assert_history_untouched(&history, &original_turns); + assert!( + events + .iter() + .any(|event| matches!(event, AgentEvent::CompactionStarted { .. })), + "CompactionStarted should record the attempted summary request" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), + "CompactionCompleted must not be emitted for a rejected summary" + ); + } } #[tokio::test] - async fn compaction_refuses_to_truncate_on_whitespace_only_summary() { - let (result, history, events) = compact_with_summary(" \n\t \n ").await; + async fn compaction_accepts_concise_nonempty_summary() { + let CompactionTestResult { + result, + history, + events, + .. + } = compact_with_summary("Brief handoff.").await; - let err = result.expect_err("whitespace-only summary must not report success"); - assert!( - matches!(&err, Error::InvalidState(message) if message.contains("empty or too short")), - "unexpected error: {err}" - ); - - assert_history_untouched(&history); - assert!( - !events - .iter() - .any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })), - "CompactionCompleted must not be emitted for a rejected summary" - ); - } - - #[tokio::test] - async fn compaction_replaces_history_on_normal_summary() { - let (result, history, events) = compact_with_summary( - "## Goal\nAdd a compaction guard.\n\n## Next Steps\nRun the test suite.", - ) - .await; - - result.expect("a normal summary should compact"); + result.expect("a nonempty summary should compact"); let summary_turn = history .turns() @@ -713,7 +703,7 @@ mod tests { }) .expect("compacted history should contain a summary turn"); assert!(summary_turn.contains("A different assistant began this task")); - assert!(summary_turn.contains("Add a compaction guard.")); + assert!(summary_turn.contains("Brief handoff.")); assert!( events diff --git a/lib/components/fabro-agent/src/error.rs b/lib/components/fabro-agent/src/error.rs index be5bbf05e..b274bd4b7 100644 --- a/lib/components/fabro-agent/src/error.rs +++ b/lib/components/fabro-agent/src/error.rs @@ -17,12 +17,28 @@ impl std::fmt::Display for InterruptReason { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum CompactionError { + #[error("summary request failed: {0}")] + Llm(#[source] LlmError), + + #[error( + "generated summary was empty after trimming; refused to replace \ + {summarized_turn_count} turns and left history intact" + )] + EmptySummary { summarized_turn_count: usize }, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum Error { #[error("LLM error: {0}")] Llm(#[from] LlmError), + #[error("Context compaction failed: {0}")] + Compaction(#[from] CompactionError), + #[error("Session is closed")] SessionClosed, @@ -41,6 +57,7 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { use fabro_llm::{ProviderErrorDetail, ProviderErrorKind}; + use fabro_util::error; use super::*; @@ -55,6 +72,39 @@ mod tests { assert!(agent_err.to_string().contains("connection refused")); } + #[test] + fn compaction_error_preserves_llm_source_chain() { + let err = Error::Compaction(CompactionError::Llm(LlmError::Network { + message: "connection refused".into(), + source: None, + })); + + let chain = error::collect_chain(&err); + + assert!( + chain.len() >= 3, + "expected agent, compaction, and LLM errors in the source chain: {chain:?}" + ); + assert!( + chain + .last() + .is_some_and(|cause| cause.contains("connection refused")), + "underlying LLM failure missing from source chain: {chain:?}" + ); + } + + #[test] + fn empty_compaction_summary_display() { + let err = Error::Compaction(CompactionError::EmptySummary { + summarized_turn_count: 3, + }); + assert_eq!( + err.to_string(), + "Context compaction failed: generated summary was empty after trimming; \ + refused to replace 3 turns and left history intact" + ); + } + #[test] fn session_closed_display() { let err = Error::SessionClosed; @@ -116,6 +166,16 @@ mod tests { assert_eq!(err.to_string(), deserialized.to_string()); } + #[test] + fn serde_roundtrip_compaction() { + let err = Error::Compaction(CompactionError::EmptySummary { + summarized_turn_count: 3, + }); + let json = serde_json::to_string(&err).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); + assert_eq!(err.to_string(), deserialized.to_string()); + } + #[test] fn serde_roundtrip_session_closed() { let err = Error::SessionClosed; @@ -157,6 +217,9 @@ mod tests { message: "refused".into(), source: None, }), + Error::Compaction(CompactionError::EmptySummary { + summarized_turn_count: 3, + }), Error::SessionClosed, Error::InvalidState("reason".into()), Error::ToolExecution("reason".into()), @@ -180,6 +243,18 @@ mod tests { assert_eq!(v["type"], "llm"); } + #[test] + fn serde_tag_format_compaction() { + let err = Error::Compaction(CompactionError::EmptySummary { + summarized_turn_count: 3, + }); + let json = serde_json::to_string(&err).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["type"], "compaction"); + assert_eq!(v["data"]["type"], "empty_summary"); + assert_eq!(v["data"]["data"]["summarized_turn_count"], 3); + } + #[test] fn serde_tag_format_session_closed() { let err = Error::SessionClosed; diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index ebc732dc2..03a29a3a9 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -39,7 +39,7 @@ pub use config::{ }; #[cfg(feature = "docker")] pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions}; -pub use error::{Error, InterruptReason, Result}; +pub use error::{CompactionError, Error, InterruptReason, Result}; pub use event::Emitter; pub use fabro_mcp::config::McpServerSettings; pub use fabro_types::SteeringMessage; diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index faf3bb2d7..9daf79ad1 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -1408,6 +1408,12 @@ impl Session { text: expanded_input.clone(), }); + // A failed summarization is unlikely to improve within the same agent + // turn. Suppress further attempts until the next user/follow-up input + // so a provider returning empty responses cannot create a paid retry + // loop at both compaction checkpoints. + let mut compaction_failed = false; + loop { // Top-of-loop: if the previous round's interrupt token fired, // swap in a fresh one before draining and rebuilding state. @@ -1470,7 +1476,9 @@ impl Session { .clone(); // Pre-turn compaction: trim context before building the request - self.compact_if_needed().await; + if !compaction_failed { + compaction_failed = self.compact_if_needed().await; + } self.inject_task_reminder_if_needed(); @@ -1811,7 +1819,9 @@ impl Session { }); // Post-response compaction: trim context after appending assistant turn - self.compact_if_needed().await; + if !compaction_failed { + compaction_failed = self.compact_if_needed().await; + } // If no tool calls, natural completion. Consult the optional // completion coordinator: it can return `true` to force one more @@ -1913,7 +1923,12 @@ impl Session { } } - async fn compact_if_needed(&mut self) { + /// Attempt context compaction when the configured threshold is exceeded. + /// + /// Returns `true` when an attempted compaction failed so the current input + /// loop can suppress repeated paid summary calls. The next input starts + /// with a fresh retry opportunity. + async fn compact_if_needed(&mut self) -> bool { let Some(estimate) = check_context_usage( &self.system_prompt, &self.history, @@ -1922,12 +1937,12 @@ impl Session { &self.event_emitter, &self.id, ) else { - return; + return false; }; if !self.config.enable_context_compaction { - return; + return false; } - if let Err(e) = compact_context( + if let Err(error) = compact_context( &mut self.history, &self.llm_client, self.provider_profile.as_ref(), @@ -1939,10 +1954,11 @@ impl Session { ) .await { - self.event_emitter.emit(self.id.clone(), AgentEvent::Error { - error: Error::InvalidState(format!("Context compaction failed: {e}")), - }); + self.event_emitter + .emit(self.id.clone(), AgentEvent::Error { error }); + return true; } + false } fn drain_steering(&mut self) { @@ -2122,6 +2138,7 @@ mod tests { use super::*; use crate::config::{ToolAccess, ToolAccessPolicy, ToolApprovalAdapter, ToolExposureMode}; + use crate::error::CompactionError; use crate::skills::{Skill, make_use_skill_tool}; use crate::subagent::{SubAgentStatus, make_wait_tool}; use crate::test_support::*; @@ -4580,8 +4597,9 @@ mod tests { // provider that errors on complete() but succeeds on stream(). struct StreamOnlyProvider { - responses: Vec, - call_index: AtomicUsize, + responses: Vec, + stream_index: AtomicUsize, + complete_calls: AtomicUsize, } #[async_trait::async_trait] @@ -4591,6 +4609,7 @@ mod tests { } async fn complete(&self, _request: &Request) -> Result { + self.complete_calls.fetch_add(1, Ordering::SeqCst); Err(LlmError::Stream { message: "summarization failed".into(), source: None, @@ -4598,7 +4617,7 @@ mod tests { } async fn stream(&self, _request: &Request) -> Result { - let idx = self.call_index.fetch_add(1, Ordering::SeqCst); + let idx = self.stream_index.fetch_add(1, Ordering::SeqCst); let response = if idx < self.responses.len() { self.responses[idx].clone() } else { @@ -4627,16 +4646,20 @@ mod tests { } let large_input = "x".repeat(400); - let responses = vec![response_with_usage( + let responses = vec![ + response_with_input_tokens( + tool_call_response("nonexistent_tool", "call_1", serde_json::json!({})), + 90, + ), text_response("OK"), - TokenCounts::default(), - )]; + ]; let provider = Arc::new(StreamOnlyProvider { responses, - call_index: AtomicUsize::new(0), + stream_index: AtomicUsize::new(0), + complete_calls: AtomicUsize::new(0), }); - let client = make_client(provider as Arc).await; + let client = make_client(provider.clone() as Arc).await; let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); @@ -4654,15 +4677,20 @@ mod tests { result.is_ok(), "Session should continue despite compaction failure" ); + assert_eq!( + provider.complete_calls.load(Ordering::SeqCst), + 1, + "a failed compaction should suppress retries for the rest of the input" + ); - // Should emit an Error event for the failed compaction + // Should emit the structured compaction error without flattening the + // underlying LLM failure. let mut found_error = false; while let Ok(event) = rx.try_recv() { - if let AgentEvent::Error { error } = &event.event { - let msg = error.to_string(); - if msg.contains("compaction") || msg.contains("summarization") { - found_error = true; - } + if matches!(event.event, AgentEvent::Error { + error: Error::Compaction(CompactionError::Llm(_)), + }) { + found_error = true; } } assert!(found_error, "Should emit Error event for failed compaction"); @@ -4690,9 +4718,7 @@ mod tests { async fn complete(&self, request: &Request) -> Result { *self.captured_complete.lock().unwrap() = Some(request.clone()); - Ok(text_response( - "## Goal\nSummary goes here.\n\n## Progress\nRead /src/main.rs.", - )) + Ok(text_response("## Goal\nSummary goes here.")) } async fn stream(&self, _request: &Request) -> Result { diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index b9d7a2b84..3be40891e 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -137,6 +137,7 @@ fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentA } fabro_agent::Error::Llm(err) => AgentApiErrorDisposition::Terminal(Error::Llm(err)), other @ (fabro_agent::Error::SessionClosed + | fabro_agent::Error::Compaction(_) | fabro_agent::Error::InvalidState(_) | fabro_agent::Error::ToolExecution(_)) => AgentApiErrorDisposition::Terminal( Error::Precondition(format!("Agent session failed: {other}")), From 5349e9d99ce564eb4382cd2df63cc17aba6ba241 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:14:23 -0400 Subject: [PATCH 08/20] feat(agent): give the Kimi profile Kimi Code's tool descriptions Edit and Write already carried Kimi-specific descriptions; the other four built-ins were still fabro's one-liners, roughly 150-220 characters against Kimi Code's 1-5KB. Port Bash, Read, Grep, and Glob the same way. Bash is the largest and the most useful: most of its length is an explicit translation table steering shell usage to the dedicated tools -- cat to Read, sed to Edit, find to Glob, grep to Grep -- under the names this profile exposes. It also states that each call runs in a fresh bash process, so `cd` and environment variables do not persist, and that a command which timed out needs a raised `timeout_ms` rather than a retry. Two of the observed K3 tool failures were shell timeouts. The port stays subtractive. Kimi Code's Bash documents background execution, TaskOutput, TaskStop, and a `cwd` argument; fabro's shell has none of those, so none of it is claimed. Read drops Kimi Code's media and paging specifics that do not match fabro's offset/limit, and gains the fact that reading a file is what clears it for writing. Grep deliberately does not promise ripgrep syntax: fabro falls back to POSIX grep when rg is absent, so the description asks for portable patterns instead. Bash quotes the timeouts this profile actually enforces by interpolating them from NativeToolOptions, so the description cannot drift from behavior. Tests assert the interpolation rendered, that the translation table names the exposed tools, and that no background-execution guidance leaked in. Parameter names stay fabro's. Kimi Code's differ (`path` and `line_offset` where fabro has `file_path` and `offset`), but across roughly 1200 tool calls in two observed K3 runs there were no schema or missing-parameter errors, so the model reads the schema it is given. Renaming parameters would be churn against a hypothesis the data does not support. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabro-agent/src/profiles/kimi.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index a02d28ef4..ef6a86dbd 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -37,6 +37,76 @@ files that have not been read, and the call will fail. Prefer Edit for any incre change: Write replaces the entire file, so using it to make a small edit discards \ everything you did not restate."; +const BASH_DESCRIPTION: &str = "Execute a bash command. Use this for shell semantics — pipes, \ +env, processes, git, package managers, build and test runners. + +Translate these to a dedicated tool instead: +- `cat` / `head` / `tail` on a known path → Read +- `sed` / `awk` for an in-place edit → Edit +- `echo > file` / heredoc → Write +- `find` or recursive `ls` to locate files by name → Glob (plain `ls ` is fine) +- `grep` / `rg` to search file contents → Grep + +The dedicated tools cap their output, so they keep large raw dumps out of the conversation. That \ +is why they are worth reaching for whenever one fits. + +Output: stdout and stderr are combined and returned as a string, truncated if very long. + +Guidelines: +- Each call runs in a fresh bash process in the working directory. Environment variables and `cd` \ +do NOT persist between calls — use absolute paths, or `cd && ` within one call. +- Do not run interactive commands, or commands that never exit. +- A long-running command needs a raised `timeout_ms`, not a retry. The default is \ +{default_timeout_ms}ms and the maximum is {max_timeout_ms}ms. Retrying a command that timed out \ +once will simply time out again. +- Chain genuinely dependent steps with `&&`. Issue independent read-only commands as separate \ +parallel calls in one response rather than chaining them, so their output stays separate. +- Quote paths containing spaces. +- Avoid `..` to reach outside the working directory, and do not modify files outside it unless \ +explicitly asked. +- Never run commands requiring superuser privileges unless explicitly asked."; + +const READ_DESCRIPTION: &str = "Read a text file from the workspace. + +Reading a file is also what clears it for writing: Edit and Write refuse a file that has not been \ +read in this session. + +- If you have a concrete path, call Read directly. Do not Glob or `ls` first to check that it \ +exists — a missing path returns an error you can handle. +- When you need several files, emit multiple Read calls in one response rather than one per turn. +- Returns `\\t` per line. Drop the number and tab when you take text for an \ +Edit `old_string`. +- `limit` defaults to 2000 lines. Page a larger file with `offset` (1-based first line) and \ +`limit`. +- Use Bash or an MCP tool for binary formats; this tool reads text."; + +const GREP_DESCRIPTION: &str = "Search file contents with a regular expression. + +Use Grep when you are looking for unknown content or an unknown location. If you already know the \ +path, use Read instead. Prefer this over running `grep` or `rg` through Bash: it caps its output, \ +so it will not flood the conversation. + +- Backed by ripgrep when available and POSIX `grep` otherwise, so keep patterns portable across \ +both rather than relying on ripgrep-only syntax. +- `path` chooses the search root, `glob_filter` limits which files are searched, \ +`case_insensitive` folds case, and `max_results` caps the output."; + +const GLOB_DESCRIPTION: &str = "Find files by name using a glob pattern, most recently modified \ +first. + +Use this instead of `find` or recursive `ls` through Bash. Prefer patterns with a literal anchor \ +— an extension or a subdirectory — over bare wildcards. + +Good patterns: +- `*.rs` — an extension at any depth below the search root +- `src/*.rs` — directly inside `src/`, not recursive +- `src/**/*.rs` — recursive walk under a subdirectory +- `{src,tests}/**/*.rs` — brace expansion works + +Avoid recursing into dependency or build output (`node_modules/**`, `target/**`): those produce \ +thousands of matches and waste context. Narrow to a specific subpath instead. Results are files, \ +so to locate a directory, glob for something inside it."; + pub struct KimiProfile { base: BaseProfile, } @@ -61,6 +131,23 @@ impl KimiProfile { registry.register(make_edit_file_tool()); registry.redescribe(NativeTool::EditFile, EDIT_FILE_DESCRIPTION); registry.redescribe(NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); + registry.redescribe(NativeTool::ReadFile, READ_DESCRIPTION); + registry.redescribe(NativeTool::Grep, GREP_DESCRIPTION); + registry.redescribe(NativeTool::Glob, GLOB_DESCRIPTION); + // The Bash description quotes the timeouts this profile actually + // enforces, so the two cannot drift. + registry.redescribe( + NativeTool::Shell, + BASH_DESCRIPTION + .replace( + "{default_timeout_ms}", + &options.default_command_timeout_ms.to_string(), + ) + .replace( + "{max_timeout_ms}", + &options.max_command_timeout_ms.to_string(), + ), + ); // Kimi Code drives todos with one replace-whole-list call. The // Anthropic task tools model the opposite interaction -- incremental @@ -283,6 +370,34 @@ mod tests { ); } assert!(describe("Edit").contains("never reconstruct it from memory")); + + // Bash steers shell usage toward the dedicated tools, under the names + // this profile actually exposes. + let bash = describe("Bash"); + for expected in ["→ Read", "→ Edit", "→ Write", "→ Glob", "→ Grep"] { + assert!(bash.contains(expected), "Bash should map {expected}"); + } + // Timeouts are interpolated from the options this profile enforces, so + // the description cannot drift from behavior. + let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi); + assert!( + bash.contains(&options.default_command_timeout_ms.to_string()), + "Bash should quote the real default timeout" + ); + assert!( + !bash.contains("{default_timeout_ms}"), + "placeholder left unrendered" + ); + // Fabro has no background shell; promising one would be a lie. + assert!(!bash.contains("run_in_background"), "{bash}"); + assert!(!bash.to_lowercase().contains("background task"), "{bash}"); + + // Read explains that reading is what clears a file for writing. + assert!(describe("Read").contains("refuse a file that has not been read")); + // Grep must not promise ripgrep syntax: fabro falls back to POSIX grep. + let grep = describe("Grep"); + assert!(grep.contains("POSIX"), "{grep}"); + assert!(describe("Glob").contains("most recently modified")); // The shared description is untouched for other profiles. assert!(!describe("Read").contains("refuses writes")); } From 21b90bad00ccfd5db7ec8240cb4bbb72f5b02bf8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:19:28 -0400 Subject: [PATCH 09/20] test(agent): pin that Kimi tool descriptions stay scoped to the Kimi profile The Kimi profile rewrites several built-in tool descriptions. Every profile builds its registry from the same factories, so a change made in the wrong place would reword tools for models that were never meant to see it, and nothing would fail. Assert the isolation directly: for each shared built-in, Kimi's description differs from Anthropic's, OpenAI and Gemini match Anthropic's stock wording, and the read-before-write phrasing appears nowhere but Kimi. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabro-agent/src/profiles/mod.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 617e008c7..47e9ac21b 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -316,6 +316,66 @@ mod tests { .with_catalog(Arc::new(Catalog::from_builtin().unwrap())) } + /// Per-profile tool descriptions must stay per-profile. The Kimi profile + /// rewrites several built-in descriptions; every other profile shares the + /// registry factories, so a leak would silently reword tools for models + /// that were never meant to see the change. + #[test] + fn kimi_tool_descriptions_do_not_leak_into_other_profiles() { + use crate::agent_profile::AgentProfile; + use crate::native_tool::NativeTool; + + let describe = |profile: &dyn AgentProfile, tool: NativeTool| { + let vocabulary = profile.tool_registry().vocabulary(); + profile + .tool_registry() + .get(tool.name(vocabulary)) + .map(|t| t.definition.description.clone()) + }; + + let anthropic = AnthropicProfile::new("claude-sonnet-4-6"); + let openai = OpenAiProfile::new("gpt-5.5"); + let gemini = GeminiProfile::new("gemini-3-flash-preview"); + let kimi = KimiProfile::new("kimi-k3"); + + for tool in [ + NativeTool::ReadFile, + NativeTool::WriteFile, + NativeTool::EditFile, + NativeTool::Shell, + NativeTool::Grep, + NativeTool::Glob, + ] { + let (Some(kimi_text), Some(anthropic_text)) = + (describe(&kimi, tool), describe(&anthropic, tool)) + else { + continue; + }; + assert_ne!( + kimi_text, anthropic_text, + "{tool} should be reworded for Kimi only" + ); + + // The other three share the stock wording. + for (label, other) in [ + ("openai", describe(&openai, tool)), + ("gemini", describe(&gemini, tool)), + ] { + let Some(other) = other else { continue }; + assert_eq!( + other, anthropic_text, + "{label} should keep the stock {tool} description" + ); + } + + // The specific Kimi-only phrasing must not appear elsewhere. + assert!( + !anthropic_text.contains("has not been read"), + "read-before-write drilling leaked into {tool} for other profiles" + ); + } + } + #[test] fn env_context_block_contains_platform() { let env = MockSandbox::linux(); From 392b8dd27b94818f1a1af61f72b33df1801e4d5c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:24:16 -0400 Subject: [PATCH 10/20] fix(agent): report real shell process outcomes The shell executor rendered every returned ExecResult and returned Ok(output), so nonzero exits, timeouts, and cancellations reached execute_one_tool() as successes. That false ToolResult propagated consistently: agent.tool.completed recorded is_error: false, the success post-tool hook ran, Anthropic saw is_error: false, OpenAI Responses saw a completed function-call output, and CLI/web rendered a successful tool call. ExecResult::is_success() is now the authoritative predicate. The executor runs through exec_command_streaming() with a sink callback, so it keeps the production providers' stream provenance and partial-output capture, and drops the exec 2>&1 prefix that merged stderr into stdout before Fabro could report it. Model-facing text labels termination, exit code, duration, and either separate stdout/stderr sections or one combined section when the provider cannot separate streams. Session-bound dispatch also emits a typed agent.tool.process.completed event carrying the process metadata, streams_separated, and bounded redacted output tails. It is subordinate diagnostic data: the following agent.tool.completed remains the one tool-protocol completion and the authoritative owner of is_error, so consumers need no new row. Nonzero, timed-out, and cancelled commands intentionally change from successful to failed tool results, and PostToolUseFailure replaces PostToolUse for them. On Docker the agent shell tool now uses the streaming path's bash -lc supervisor, which terminates the process group on timeout instead of leaving container-side processes running. The public shell schema is unchanged and pinned by an exact assertion. Co-Authored-By: Claude Opus 5 (1M context) --- docs/internal/events.md | 35 ++ .../fabro-agent/src/tool_execution.rs | 176 ++++++- lib/components/fabro-agent/src/tools.rs | 471 +++++++++++++++--- lib/components/fabro-agent/src/types.rs | 40 +- .../fabro-agent/tests/it/docker_shell.rs | 97 ++++ lib/components/fabro-agent/tests/it/main.rs | 2 + .../fabro-sandbox/src/test_support.rs | 52 +- .../fabro-workflow/src/event/convert.rs | 62 +++ .../fabro-workflow/src/event/names.rs | 1 + .../fabro-workflow/src/event/redaction.rs | 35 ++ .../fabro-workflow/src/event/stored_fields.rs | 3 +- .../fabro-types/src/run_event/agent.rs | 25 +- .../fabro-types/src/run_event/mod.rs | 92 +++- 13 files changed, 1009 insertions(+), 82 deletions(-) create mode 100644 lib/components/fabro-agent/tests/it/docker_shell.rs diff --git a/docs/internal/events.md b/docs/internal/events.md index eea2d6878..57bb9c115 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1157,6 +1157,41 @@ Emitted when a tool call finishes. | `output` | any | Tool output (string or structured) | | `is_error` | boolean | Whether the tool returned an error | +### `agent.tool.process.completed` + +Subordinate diagnostic for a tool call that ran a process, emitted between +`agent.tool.started` and `agent.tool.completed`. It explains the underlying +process outcome; `agent.tool.completed.is_error` remains the protocol and UI +truth. Absent when the tool never produced a process result (setup, transport, +or launch failure) and when the tool ran without a session-bound emitter. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.tool.process.completed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "tool_call_id": "call_abc123", + "properties": { + "exit_code": 7, + "termination": "exited", + "duration_ms": 812, + "streams_separated": true, + "exec_output_tail": {"stdout": "...", "stderr": "..."}, + "visit": 1 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `exit_code` | integer | Process exit code; omitted for timeout and cancellation | +| `termination` | string | `exited`, `timed_out`, or `cancelled` | +| `duration_ms` | integer | Process duration | +| `streams_separated` | boolean | `false` when the provider could not separate stdout from stderr; the combined output is then in `exec_output_tail.stdout` | +| `exec_output_tail` | object | Bounded, redacted output tails; omitted when both streams were empty | +| `visit` | integer | Stage visit | + ### `agent.error` Emitted when the agent encounters an error. diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index 2c77b3a43..259d04530 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -558,6 +558,7 @@ mod tests { use async_trait::async_trait; use fabro_llm::types::{ToolCall, ToolDefinition}; use fabro_model::AgentProfileKind; + use tokio::sync::broadcast; use super::*; use crate::config::{ @@ -570,11 +571,13 @@ mod tests { AgentToolRuntime, register_question_tools, }; use crate::read_before_write_sandbox::ReadBeforeWriteSandbox; - use crate::test_support::MutableMockSandbox; + use crate::test_support::{MockSandbox, MutableMockSandbox}; use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource}; use crate::tools::{ - make_edit_file_tool, make_grep_tool, make_read_file_tool, make_write_file_tool, + make_edit_file_tool, make_grep_tool, make_read_file_tool, make_shell_tool, + make_write_file_tool, }; + use crate::types::SessionEvent; struct NamedPolicy { decisions: HashMap, @@ -1294,4 +1297,173 @@ mod tests { assert!(!result.is_error); } + + fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc { + Arc::new(MockSandbox { + exec_result: result, + ..Default::default() + }) + } + + fn exited(exit_code: i32) -> fabro_sandbox::ExecResult { + fabro_sandbox::ExecResult { + stdout: "out".into(), + stderr: "err".into(), + exit_code: Some(exit_code), + termination: fabro_types::CommandTermination::Exited, + duration_ms: 12, + } + } + + fn cancelled() -> fabro_sandbox::ExecResult { + fabro_sandbox::ExecResult { + stdout: "out".into(), + stderr: String::new(), + exit_code: None, + termination: fabro_types::CommandTermination::Cancelled, + duration_ms: 12, + } + } + + async fn run_shell_tool( + exec_result: fabro_sandbox::ExecResult, + hooks: Option<&Arc>, + emitter: &Emitter, + ) -> ToolResult { + let mut registry = ToolRegistry::new(); + registry.register(make_shell_tool()); + let tc = make_tool_call( + "shell", + "call_1", + serde_json::json!({"command": "make test"}), + ); + + execute_and_emit_one_tool( + &tc, + ®istry, + shell_sandbox(exec_result), + hooks, + CancellationToken::new(), + &SessionOptions::default(), + emitter, + "test-session", + "test-session", + None, + ) + .await + } + + fn drain(receiver: &mut broadcast::Receiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = receiver.try_recv() { + events.push(event); + } + events + } + + #[tokio::test] + async fn shell_nonzero_exit_becomes_an_error_tool_result() { + let emitter = Emitter::new(); + let result = run_shell_tool(exited(7), None, &emitter).await; + + assert!(result.is_error); + assert!( + result.content.as_str().unwrap().contains("Exit code: 7"), + "got: {}", + result.content + ); + } + + #[tokio::test] + async fn shell_exit_zero_remains_a_successful_tool_result() { + let emitter = Emitter::new(); + let result = run_shell_tool(exited(0), None, &emitter).await; + + assert!(!result.is_error); + } + + #[tokio::test] + async fn shell_failure_emits_started_then_process_then_completed() { + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); + run_shell_tool(exited(7), None, &emitter).await; + + let events = drain(&mut receiver); + let names: Vec<&str> = events + .iter() + .filter_map(|event| match &event.event { + AgentEvent::ToolCallStarted { .. } => Some("started"), + AgentEvent::ToolProcessCompleted { .. } => Some("process"), + AgentEvent::ToolCallCompleted { .. } => Some("completed"), + _ => None, + }) + .collect(); + assert_eq!(names, vec!["started", "process", "completed"]); + + for event in &events { + assert_eq!(event.session_id, "test-session"); + } + let process = events + .iter() + .find(|event| matches!(event.event, AgentEvent::ToolProcessCompleted { .. })) + .expect("process event"); + assert_eq!(process.tool_call_id.as_deref(), Some("call_1")); + match &process.event { + AgentEvent::ToolProcessCompleted { + exit_code, + termination, + .. + } => { + assert_eq!(*exit_code, Some(7)); + assert_eq!(*termination, fabro_types::CommandTermination::Exited); + } + other => panic!("expected a process event, got {other:?}"), + } + + let completed = events + .iter() + .find_map(|event| match &event.event { + AgentEvent::ToolCallCompleted { + tool_call_id, + is_error, + .. + } => Some((tool_call_id.clone(), *is_error)), + _ => None, + }) + .expect("tool completed event"); + assert_eq!(completed, ("call_1".to_string(), true)); + } + + #[tokio::test] + async fn shell_failure_runs_only_the_failure_hook() { + for exec_result in [exited(7), cancelled()] { + let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed)); + let hooks: Arc = mock.clone(); + run_shell_tool(exec_result, Some(&hooks), &Emitter::new()).await; + + assert_eq!(mock.post_failure_calls.lock().unwrap().len(), 1); + assert!(mock.post_calls.lock().unwrap().is_empty()); + } + } + + #[tokio::test] + async fn shell_success_runs_only_the_success_hook() { + let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed)); + let hooks: Arc = mock.clone(); + run_shell_tool(exited(0), Some(&hooks), &Emitter::new()).await; + + assert_eq!(mock.post_calls.lock().unwrap().len(), 1); + assert!(mock.post_failure_calls.lock().unwrap().is_empty()); + } + + #[test] + fn truncation_preserves_tool_call_id_and_error_state() { + let result = ToolResult::error("call_1", "x".repeat(60_000)); + + let truncated = truncate_tool_result(&result, "shell", &SessionOptions::default()); + + assert_eq!(truncated.tool_call_id, "call_1"); + assert!(truncated.is_error); + assert!(truncated.content.as_str().unwrap().len() < 60_000); + } } diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 5eca7b5be..4043006fa 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -10,8 +10,9 @@ use fabro_static::EnvVars; use futures::{StreamExt, stream}; use crate::config::NativeToolOptions; -use crate::sandbox::GrepOptions; +use crate::sandbox::{CommandOutputCallback, ExecStreamingResult, GrepOptions}; use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource}; +use crate::types::AgentEvent; const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; const MAX_READ_MANY_FILES_CONCURRENCY: usize = 8; @@ -239,51 +240,92 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; - let command = format!("exec 2>&1\n{command}"); let timeout_ms = args .get("timeout_ms") .and_then(serde_json::Value::as_u64) .unwrap_or(default_timeout) .min(max_timeout); - let tool_env = ctx.resolve_tool_env().await.map_err(|e| format!("{e:#}"))?; + let tool_env = ctx + .resolve_tool_env() + .await + .map_err(|e| format!("{SHELL_NO_PROCESS_RESULT}: {e:#}"))?; tracing::debug!( env_var_count = tool_env.as_ref().map_or(0, std::collections::HashMap::len), "Injecting sandbox env vars into tool execution" ); - let result = ctx + let streaming = ctx .env - .exec_command( - &command, - timeout_ms, + .exec_command_streaming( + command, + Some(timeout_ms), None, tool_env.as_ref(), - Some(ctx.cancel), + Some(ctx.cancel.clone()), + discard_output_callback(), ) .await - .map_err(|e| e.display_with_causes())?; + .map_err(|e| { + format!("{SHELL_NO_PROCESS_RESULT}: {}", e.display_with_causes()) + })?; - let mut output = String::new(); - if result.is_timed_out() { - output.push_str("Command timed out.\n"); - } else if result.is_cancelled() { - output.push_str("Command cancelled.\n"); + let text = render_shell_result(&streaming); + ctx.emit_agent_event(AgentEvent::ToolProcessCompleted { + exit_code: streaming.result.exit_code, + termination: streaming.result.termination, + duration_ms: streaming.result.duration_ms, + streams_separated: streaming.streams_separated, + exec_output_tail: streaming.result.default_redacted_output_tail(), + }); + + if streaming.result.is_success() { + Ok(text) + } else { + Err(text) } - let _ = write!( - output, - "Exit code: {}\noutput:\n{}", - result - .exit_code - .map_or_else(|| "none".to_string(), |code| code.to_string()), - result.stdout - ); - Ok(output) }) }), source: ToolSource::Native, } } +/// Prefix for shell failures that never produced an `ExecResult`, so the model +/// can tell "the process never ran" apart from "the process ran and failed". +const SHELL_NO_PROCESS_RESULT: &str = "Shell command produced no process result"; + +/// The agent shell tool consumes the streaming exec path for its stream +/// provenance and partial-output capture, but does not forward live output +/// deltas onto the agent protocol. +fn discard_output_callback() -> CommandOutputCallback { + Arc::new(|_stream, _bytes| Box::pin(async { Ok(()) })) +} + +/// Renders the model-facing shell result: termination, exit code, duration, +/// and provider-honest output sections. Metadata stays at the head and +/// `stderr` at the tail so head/tail truncation preserves both. +fn render_shell_result(streaming: &ExecStreamingResult) -> String { + let result = &streaming.result; + let mut output = format!( + "Termination: {}\nExit code: {}\nDuration: {}ms\n", + result.termination.as_str(), + result + .exit_code + .map_or_else(|| "none".to_string(), |code| code.to_string()), + result.duration_ms, + ); + if streaming.streams_separated { + if !result.stdout.is_empty() { + let _ = write!(output, "stdout:\n{}\n", result.stdout); + } + if !result.stderr.is_empty() { + let _ = write!(output, "stderr:\n{}\n", result.stderr); + } + } else if !result.stdout.is_empty() { + let _ = write!(output, "output (combined):\n{}\n", result.stdout); + } + output +} + #[must_use] pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { @@ -693,10 +735,12 @@ mod tests { use tokio_util::sync::CancellationToken; use super::*; - use crate::config::{NativeToolOptions, ToolSecrets}; + use crate::config::{NativeToolOptions, SessionOptions, ToolSecrets}; + use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{AgentEventEmitter, ToolContext}; + use crate::truncation; #[test] fn core_tool_descriptions_include_actionable_guidance() { @@ -981,20 +1025,35 @@ mod tests { assert_eq!(written[0].1, "1 | keep this literal\ngoodbye"); } - #[tokio::test] - async fn shell_basic_command() { - let tool = make_shell_tool(); - let env: Arc = Arc::new(MockSandbox { - exec_result: ExecResult { - stdout: "hello".into(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 10, - }, - ..Default::default() - }); - let result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { + /// Records the typed agent events a tool emits through its bound emitter. + #[derive(Default)] + struct RecordingAgentEmitter { + events: std::sync::Mutex>, + } + + impl RecordingAgentEmitter { + fn events(&self) -> Vec { + self.events.lock().expect("events lock poisoned").clone() + } + + fn only_process_event(&self) -> AgentEvent { + let events = self.events(); + assert_eq!(events.len(), 1, "expected one agent event, got {events:?}"); + events.into_iter().next().expect("one event") + } + } + + impl AgentEventEmitter for RecordingAgentEmitter { + fn emit(&self, event: AgentEvent) { + self.events + .lock() + .expect("events lock poisoned") + .push(event); + } + } + + fn shell_context(env: Arc) -> ToolContext { + ToolContext { env, cancel: CancellationToken::new(), tool_env_provider: None, @@ -1002,11 +1061,72 @@ mod tests { root_session_id: None, tool_call_id: None, agent_event_emitter: None, + } + } + + fn shell_context_with_emitter( + env: Arc, + emitter: Arc, + ) -> ToolContext { + ToolContext { + agent_event_emitter: Some(emitter), + ..shell_context(env) + } + } + + fn mock_sandbox_with(result: ExecResult) -> Arc { + Arc::new(MockSandbox { + exec_result: result, + ..Default::default() }) + } + + #[tokio::test] + async fn shell_success_returns_ok_with_metadata_and_separate_streams() { + let tool = make_shell_tool(); + let env: Arc = mock_sandbox_with(ExecResult { + stdout: "hello".into(), + stderr: "a warning".into(), + exit_code: Some(0), + termination: CommandTermination::Exited, + duration_ms: 10, + }); + let output = (tool.executor)( + serde_json::json!({"command": "echo hello"}), + shell_context(env), + ) + .await + .expect("exit 0 is a successful tool result"); + + assert_eq!( + output, + "Termination: exited\nExit code: 0\nDuration: 10ms\nstdout:\nhello\nstderr:\na \ + warning\n" + ); + } + + #[tokio::test] + async fn shell_forwards_command_without_stream_redirection_wrapper() { + let tool = make_shell_tool(); + let env = mock_sandbox_with(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + termination: CommandTermination::Exited, + duration_ms: 1, + }); + let _ = (tool.executor)( + serde_json::json!({"command": "make test"}), + shell_context(env.clone()), + ) .await; - let output = result.unwrap(); - assert!(output.contains("Exit code: 0")); - assert!(output.contains("hello")); + + let captured = env + .captured_command + .lock() + .expect("captured_command lock poisoned") + .clone(); + assert_eq!(captured.as_deref(), Some("make test")); } #[tokio::test] @@ -1043,46 +1163,253 @@ mod tests { }, ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"command": "false"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) - .await; - let output = result.unwrap(); - assert!(output.contains("Exit code: 1")); - assert!(output.contains("error")); + let output = (tool.executor)(serde_json::json!({"command": "false"}), shell_context(env)) + .await + .expect_err("a nonzero exit is a failed tool result"); + assert!(output.contains("Termination: exited"), "got: {output}"); + assert!(output.contains("Exit code: 1"), "got: {output}"); + assert!(output.contains("stdout:\nerror"), "got: {output}"); + assert!(!output.contains("stderr:"), "got: {output}"); } #[tokio::test] - async fn shell_timeout_output() { + async fn shell_timeout_returns_error_with_partial_output() { + let tool = make_shell_tool(); + let env: Arc = mock_sandbox_with(ExecResult { + stdout: "partial".into(), + stderr: String::new(), + exit_code: None, + termination: CommandTermination::TimedOut, + duration_ms: 10000, + }); + let output = (tool.executor)( + serde_json::json!({"command": "sleep 100"}), + shell_context(env), + ) + .await + .expect_err("a timeout is a failed tool result"); + + assert!(output.contains("Termination: timed_out"), "got: {output}"); + assert!(output.contains("Exit code: none"), "got: {output}"); + assert!(output.contains("stdout:\npartial"), "got: {output}"); + } + + #[tokio::test] + async fn shell_cancellation_returns_error_with_partial_output() { + let tool = make_shell_tool(); + let env: Arc = mock_sandbox_with(ExecResult { + stdout: "partial".into(), + stderr: String::new(), + exit_code: None, + termination: CommandTermination::Cancelled, + duration_ms: 42, + }); + let output = (tool.executor)( + serde_json::json!({"command": "sleep 100"}), + shell_context(env), + ) + .await + .expect_err("a cancellation is a failed tool result"); + + assert!(output.contains("Termination: cancelled"), "got: {output}"); + assert!(output.contains("Exit code: none"), "got: {output}"); + assert!(output.contains("stdout:\npartial"), "got: {output}"); + } + + #[tokio::test] + async fn shell_sandbox_failure_returns_error_without_a_process_outcome() { + let tool = make_shell_tool(); + let env: Arc = Arc::new(MockSandbox { + exec_error: Some("sandbox transport is down".into()), + ..Default::default() + }); + let emitter = Arc::new(RecordingAgentEmitter::default()); + + let output = (tool.executor)( + serde_json::json!({"command": "make test"}), + shell_context_with_emitter(env, emitter.clone()), + ) + .await + .expect_err("a sandbox transport failure is a failed tool result"); + + assert!( + output.contains("Shell command produced no process result"), + "got: {output}" + ); + assert!( + output.contains("sandbox transport is down"), + "got: {output}" + ); + assert!(!output.contains("Exit code"), "got: {output}"); + assert!(emitter.events().is_empty(), "got: {:?}", emitter.events()); + } + + #[tokio::test] + async fn shell_emits_process_event_with_typed_outcome_and_redacted_tails() { + let tool = make_shell_tool(); + let env: Arc = mock_sandbox_with(ExecResult { + stdout: "out".into(), + stderr: "boom key=AKIAYRWQG5EJLPZLBYNP".into(), + exit_code: Some(7), + termination: CommandTermination::Exited, + duration_ms: 12, + }); + let emitter = Arc::new(RecordingAgentEmitter::default()); + + let _ = (tool.executor)( + serde_json::json!({"command": "printf out; printf err >&2; exit 7"}), + shell_context_with_emitter(env, emitter.clone()), + ) + .await; + + match emitter.only_process_event() { + AgentEvent::ToolProcessCompleted { + exit_code, + termination, + duration_ms, + streams_separated, + exec_output_tail, + } => { + assert_eq!(exit_code, Some(7)); + assert_eq!(termination, CommandTermination::Exited); + assert_eq!(duration_ms, 12); + assert!(streams_separated); + let tail = exec_output_tail.expect("output tail"); + assert_eq!(tail.stdout.as_deref(), Some("out")); + let stderr = tail.stderr.expect("stderr tail"); + assert!(stderr.contains("boom"), "got: {stderr}"); + assert!(!stderr.contains("AKIAYRWQG5EJLPZLBYNP"), "got: {stderr}"); + } + other => panic!("expected a process event, got {other:?}"), + } + } + + #[tokio::test] + async fn shell_renders_combined_output_when_streams_are_not_separated() { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), + stdout: "interleaved".into(), stderr: String::new(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms: 10000, + exit_code: Some(0), + termination: CommandTermination::Exited, + duration_ms: 5, }, + streams_separated: false, ..Default::default() }); - let result = (tool.executor)(serde_json::json!({"command": "sleep 100"}), ToolContext { - env, - cancel: CancellationToken::new(), - tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: None, - }) - .await; - let output = result.unwrap(); - assert!(output.starts_with("Command timed out.\n")); + let emitter = Arc::new(RecordingAgentEmitter::default()); + + let output = (tool.executor)( + serde_json::json!({"command": "echo interleaved"}), + shell_context_with_emitter(env, emitter.clone()), + ) + .await + .expect("exit 0 is a successful tool result"); + + assert!( + output.contains("output (combined):\ninterleaved"), + "got: {output}" + ); + assert!(!output.contains("stderr:"), "got: {output}"); + match emitter.only_process_event() { + AgentEvent::ToolProcessCompleted { + streams_separated, .. + } => assert!(!streams_separated), + other => panic!("expected a process event, got {other:?}"), + } + } + + #[tokio::test] + async fn shell_truncation_preserves_exit_metadata_and_stderr_tail() { + let tool = make_shell_tool(); + let stdout = (0..400) + .map(|line| format!("{line}: {}", "x".repeat(100))) + .collect::>() + .join("\n"); + assert!(stdout.len() > 30_000); + let env: Arc = mock_sandbox_with(ExecResult { + stdout, + stderr: "the build failed".into(), + exit_code: Some(2), + termination: CommandTermination::Exited, + duration_ms: 900, + }); + + let output = (tool.executor)( + serde_json::json!({"command": "make build"}), + shell_context(env), + ) + .await + .expect_err("a nonzero exit is a failed tool result"); + let truncated = + truncation::truncate_tool_output(&output, "shell", &SessionOptions::default()); + + assert!(truncated.len() < output.len()); + assert!(truncated.starts_with("Termination: exited\nExit code: 2\n")); + assert!( + truncated.contains("stderr:\nthe build failed"), + "stderr tail did not survive truncation" + ); + } + + /// End-to-end against a real process: the local provider separates the + /// streams and reports the real exit code, and none of it is laundered + /// into a successful tool result. + #[tokio::test] + async fn shell_reports_real_local_process_outcome() { + let tool = make_shell_tool(); + let env: Arc = Arc::new(LocalSandbox::new( + std::env::current_dir().expect("current dir"), + )); + let emitter = Arc::new(RecordingAgentEmitter::default()); + + let output = (tool.executor)( + serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}), + shell_context_with_emitter(env, emitter.clone()), + ) + .await + .expect_err("exit 7 is a failed tool result"); + + assert!(output.contains("Termination: exited"), "got: {output}"); + assert!(output.contains("Exit code: 7"), "got: {output}"); + assert!(output.contains("stdout:\nout"), "got: {output}"); + assert!(output.contains("stderr:\nerr"), "got: {output}"); + + match emitter.only_process_event() { + AgentEvent::ToolProcessCompleted { + exit_code, + termination, + streams_separated, + exec_output_tail, + .. + } => { + assert_eq!(exit_code, Some(7)); + assert_eq!(termination, CommandTermination::Exited); + assert!(streams_separated); + let tail = exec_output_tail.expect("output tail"); + assert_eq!(tail.stdout.as_deref(), Some("out")); + assert_eq!(tail.stderr.as_deref(), Some("err")); + } + other => panic!("expected a process event, got {other:?}"), + } + } + + #[tokio::test] + async fn shell_public_schema_is_command_timeout_and_description() { + let tool = make_shell_tool(); + assert_eq!( + tool.definition.parameters, + serde_json::json!({ + "type": "object", + "properties": { + "command": {"type": "string", "description": "The shell command to execute"}, + "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds"}, + "description": {"type": "string", "description": "Description of what this command does"} + }, + "required": ["command"] + }) + ); } #[tokio::test] diff --git a/lib/components/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs index fc3581c57..129cfc8a0 100644 --- a/lib/components/fabro-agent/src/types.rs +++ b/lib/components/fabro-agent/src/types.rs @@ -4,7 +4,10 @@ use chrono::{DateTime, Utc}; use fabro_llm::Error as LlmError; use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult}; use fabro_model::{CostSource, ModelRef}; -use fabro_types::{ReasoningOutput, SessionMessage, StageContextWindowProjection}; +use fabro_types::{ + CommandTermination, ExecOutputTail, ReasoningOutput, SessionMessage, + StageContextWindowProjection, +}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -280,6 +283,19 @@ pub enum AgentEvent { output: serde_json::Value, is_error: bool, }, + /// Subordinate process outcome for a tool call that ran a command. + /// Emitted before the owning `ToolCallCompleted`, which stays the single + /// tool-protocol completion and the authoritative owner of `is_error`. + /// Session and tool-call identity come from the emitting envelope. + ToolProcessCompleted { + #[serde(default, skip_serializing_if = "Option::is_none")] + exit_code: Option, + termination: CommandTermination, + duration_ms: u64, + streams_separated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, Error { error: Error, }, @@ -457,6 +473,28 @@ impl AgentEvent { "Tool call completed" ); } + Self::ToolProcessCompleted { + exit_code, + termination, + duration_ms, + streams_separated, + exec_output_tail, + } => { + let tail = ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + debug!( + session_id, + exit_code = ?exit_code, + termination = termination.as_str(), + duration_ms, + streams_separated, + output_tail_present = tail.present, + stdout_bytes = tail.stdout_bytes, + stderr_bytes = tail.stderr_bytes, + stdout_truncated = tail.stdout_truncated, + stderr_truncated = tail.stderr_truncated, + "Tool process completed" + ); + } Self::Error { error } => { error!(session_id, error = %error, "Agent error"); } diff --git a/lib/components/fabro-agent/tests/it/docker_shell.rs b/lib/components/fabro-agent/tests/it/docker_shell.rs new file mode 100644 index 000000000..52d1dd081 --- /dev/null +++ b/lib/components/fabro-agent/tests/it/docker_shell.rs @@ -0,0 +1,97 @@ +//! Proves the agent shell tool reports real process outcomes through the +//! Docker provider's streaming path, which uses a `bash -lc` supervisor and +//! separate stdout/stderr channels. + +use std::sync::{Arc, Mutex}; + +use fabro_agent::sandbox::Sandbox; +use fabro_agent::tool_registry::{AgentEventEmitter, ToolContext}; +use fabro_agent::tools::make_shell_tool; +use fabro_agent::types::AgentEvent; +use fabro_agent::{DockerSandbox, DockerSandboxOptions}; +use fabro_types::CommandTermination; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct RecordingAgentEmitter { + events: Mutex>, +} + +impl AgentEventEmitter for RecordingAgentEmitter { + fn emit(&self, event: AgentEvent) { + self.events + .lock() + .expect("events lock poisoned") + .push(event); + } +} + +#[tokio::test] +#[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"] +async fn shell_reports_real_docker_process_outcome() { + let Ok(sandbox) = DockerSandbox::new( + DockerSandboxOptions { + image: "buildpack-deps:noble".to_string(), + auto_pull: false, + skip_clone: true, + ..DockerSandboxOptions::default() + }, + None, + None, + None, + None, + ) else { + return; + }; + // No Docker daemon or no local image: nothing to prove here. + if sandbox.initialize().await.is_err() { + return; + } + + let sandbox = Arc::new(sandbox); + let emitter = Arc::new(RecordingAgentEmitter::default()); + let tool = make_shell_tool(); + let result = (tool.executor)( + serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}), + ToolContext { + env: sandbox.clone() as Arc, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: Some(emitter.clone()), + }, + ) + .await; + sandbox + .cleanup() + .await + .expect("docker cleanup should succeed"); + + let output = result.expect_err("exit 7 is a failed tool result"); + assert!(output.contains("Termination: exited"), "got: {output}"); + assert!(output.contains("Exit code: 7"), "got: {output}"); + assert!(output.contains("stdout:\nout"), "got: {output}"); + assert!(output.contains("stderr:\nerr"), "got: {output}"); + + let events = emitter.events.lock().expect("events lock poisoned").clone(); + assert_eq!(events.len(), 1, "expected one agent event, got {events:?}"); + match &events[0] { + AgentEvent::ToolProcessCompleted { + exit_code, + termination, + streams_separated, + exec_output_tail, + .. + } => { + assert_eq!(*exit_code, Some(7)); + assert_eq!(*termination, CommandTermination::Exited); + assert!(streams_separated); + let tail = exec_output_tail.as_ref().expect("output tail"); + assert_eq!(tail.stdout.as_deref(), Some("out")); + assert_eq!(tail.stderr.as_deref(), Some("err")); + } + other => panic!("expected a process event, got {other:?}"), + } +} diff --git a/lib/components/fabro-agent/tests/it/main.rs b/lib/components/fabro-agent/tests/it/main.rs index efb7dbdce..485fe7037 100644 --- a/lib/components/fabro-agent/tests/it/main.rs +++ b/lib/components/fabro-agent/tests/it/main.rs @@ -1,3 +1,5 @@ mod compaction; +#[cfg(feature = "docker")] +mod docker_shell; mod guardrails; mod parity_matrix; diff --git a/lib/components/fabro-sandbox/src/test_support.rs b/lib/components/fabro-sandbox/src/test_support.rs index 1593c9032..d48f552e2 100644 --- a/lib/components/fabro-sandbox/src/test_support.rs +++ b/lib/components/fabro-sandbox/src/test_support.rs @@ -44,6 +44,12 @@ pub struct MockSandbox { pub event_callback: Option, pub stdio_process_error: Option, pub stdio_process: Mutex>, + /// Fails `exec_command` and `exec_command_streaming` before any process + /// runs, so callers see a transport error rather than an `ExecResult`. + pub exec_error: Option, + /// Reported by `exec_command_streaming`. Set to `false` to model a + /// provider that cannot separate stdout from stderr. + pub streams_separated: bool, } impl MockSandbox { @@ -116,6 +122,8 @@ impl Default for MockSandbox { event_callback: None, stdio_process_error: None, stdio_process: Mutex::new(None), + exec_error: None, + streams_separated: true, } } } @@ -233,7 +241,49 @@ impl Sandbox for MockSandbox { .captured_env_vars .lock() .expect("captured_env_vars lock poisoned") = env_vars.cloned(); - Ok(self.exec_result.clone()) + match &self.exec_error { + Some(error) => Err(crate::Error::message(error.clone())), + None => Ok(self.exec_result.clone()), + } + } + + async fn exec_command_streaming( + &self, + command: &str, + timeout_ms: Option, + working_dir: Option<&str>, + env_vars: Option<&std::collections::HashMap>, + cancel_token: Option, + output_callback: crate::CommandOutputCallback, + ) -> crate::Result { + let result = self + .exec_command( + command, + timeout_ms.unwrap_or(u64::MAX), + working_dir, + env_vars, + cancel_token, + ) + .await?; + if !result.stdout.is_empty() { + output_callback( + fabro_types::CommandOutputStream::Stdout, + result.stdout.as_bytes().to_vec(), + ) + .await?; + } + if !result.stderr.is_empty() { + output_callback( + fabro_types::CommandOutputStream::Stderr, + result.stderr.as_bytes().to_vec(), + ) + .await?; + } + Ok(crate::ExecStreamingResult { + result, + streams_separated: self.streams_separated, + live_streaming: false, + }) } async fn spawn_stdio_process( diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index d86c17424..1731cd265 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -655,6 +655,22 @@ fn event_body_from_event(event: &Event) -> EventBody { tool_result: None, turn_id: None, }), + AgentEvent::ToolProcessCompleted { + exit_code, + termination, + duration_ms, + streams_separated, + exec_output_tail, + } => EventBody::AgentToolProcessCompleted( + fabro_types::AgentToolProcessCompletedProps { + exit_code: *exit_code, + termination: *termination, + duration_ms: *duration_ms, + streams_separated: *streams_separated, + exec_output_tail: exec_output_tail.clone(), + visit: *visit, + }, + ), AgentEvent::Error { error } => EventBody::AgentError(fabro_types::AgentErrorProps { error: serde_json::to_value(error).expect("agent Error derives Serialize with no custom logic that can fail"), visit: *visit, @@ -1517,6 +1533,52 @@ mod tests { assert_eq!(properties["visit"], 2); } + #[test] + fn run_event_agent_tool_process_completed_carries_stage_session_and_actor() { + let stored = to_run_event(&fixtures::RUN_4, &Event::Agent { + stage: "code".to_string(), + visit: 2, + event: AgentEvent::ToolProcessCompleted { + exit_code: Some(7), + termination: ::fabro_types::CommandTermination::Exited, + duration_ms: 12, + streams_separated: true, + exec_output_tail: Some(::fabro_types::ExecOutputTail { + stdout: Some("out".to_string()), + stderr: Some("err".to_string()), + stdout_truncated: false, + stderr_truncated: false, + }), + }, + session_id: Some("ses_child".to_string()), + parent_session_id: Some("ses_parent".to_string()), + tool_call_id: Some("call_1".to_string()), + }); + + assert_eq!(stored.event_name(), "agent.tool.process.completed"); + assert_eq!(stored.node_id.as_deref(), Some("code")); + assert_eq!(stored.stage_id, Some(StageId::new("code", 2))); + assert_eq!(stored.session_id.as_deref(), Some("ses_child")); + assert_eq!(stored.parent_session_id.as_deref(), Some("ses_parent")); + assert_eq!(stored.tool_call_id.as_deref(), Some("call_1")); + assert_eq!( + stored.actor, + Some(::fabro_types::Principal::Agent { + session_id: Some("ses_child".to_string()), + parent_session_id: Some("ses_parent".to_string()), + model: None, + }) + ); + + let properties = stored.properties().unwrap(); + assert_eq!(properties["exit_code"], 7); + assert_eq!(properties["termination"], "exited"); + assert_eq!(properties["duration_ms"], 12); + assert_eq!(properties["streams_separated"], true); + assert_eq!(properties["exec_output_tail"]["stdout"], "out"); + assert_eq!(properties["visit"], 2); + } + #[test] fn run_event_agent_tools_available_moves_session_and_stage_metadata_to_header() { let stored = to_run_event(&fixtures::RUN_4, &Event::AgentToolsAvailable { diff --git a/lib/components/fabro-workflow/src/event/names.rs b/lib/components/fabro-workflow/src/event/names.rs index 67be1ab3a..cd04b08b1 100644 --- a/lib/components/fabro-workflow/src/event/names.rs +++ b/lib/components/fabro-workflow/src/event/names.rs @@ -75,6 +75,7 @@ pub fn event_name(event: &Event) -> &'static str { AgentEvent::ToolCallStarted { .. } => "agent.tool.started", AgentEvent::ToolCallOutputDelta { .. } => "agent.tool.output.delta", AgentEvent::ToolCallCompleted { .. } => "agent.tool.completed", + AgentEvent::ToolProcessCompleted { .. } => "agent.tool.process.completed", AgentEvent::Error { .. } => "agent.error", AgentEvent::Warning { .. } => "agent.warning", AgentEvent::LoopDetected => "agent.loop.detected", diff --git a/lib/components/fabro-workflow/src/event/redaction.rs b/lib/components/fabro-workflow/src/event/redaction.rs index 16aabccab..49e86a2ab 100644 --- a/lib/components/fabro-workflow/src/event/redaction.rs +++ b/lib/components/fabro-workflow/src/event/redaction.rs @@ -76,6 +76,41 @@ mod tests { ); } + #[test] + fn build_redacted_event_payload_redacts_tool_process_output_tails() { + let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + let stored = to_run_event(&fixtures::RUN_8, &Event::Agent { + stage: "code".to_string(), + visit: 1, + event: AgentEvent::ToolProcessCompleted { + exit_code: Some(7), + termination: ::fabro_types::CommandTermination::Exited, + duration_ms: 12, + streams_separated: true, + exec_output_tail: Some(fabro_types::ExecOutputTail { + stdout: Some(format!("stdout {secret}")), + stderr: Some("plain stderr".to_string()), + stdout_truncated: false, + stderr_truncated: false, + }), + }, + session_id: Some("ses_child".to_string()), + parent_session_id: None, + tool_call_id: Some("call_1".to_string()), + }); + + let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); + let payload_text = serde_json::to_string(payload.as_value()).unwrap(); + + assert!(!payload_text.contains(secret)); + assert!(payload_text.contains("REDACTED")); + assert_eq!(payload.as_value()["event"], "agent.tool.process.completed"); + assert_eq!( + payload.as_value()["properties"]["exec_output_tail"]["stderr"], + "plain stderr" + ); + } + /// Reasoning is model-authored text like any other, so it goes through /// the same canonical redaction pass as assistant output. #[test] diff --git a/lib/components/fabro-workflow/src/event/stored_fields.rs b/lib/components/fabro-workflow/src/event/stored_fields.rs index 285bd9c89..66129d757 100644 --- a/lib/components/fabro-workflow/src/event/stored_fields.rs +++ b/lib/components/fabro-workflow/src/event/stored_fields.rs @@ -328,7 +328,8 @@ fn agent_actor_for_event( }), AgentEvent::ToolCallStarted { .. } | AgentEvent::ToolCallOutputDelta { .. } - | AgentEvent::ToolCallCompleted { .. } => Some(Principal::Agent { + | AgentEvent::ToolCallCompleted { .. } + | AgentEvent::ToolProcessCompleted { .. } => Some(Principal::Agent { session_id: session_id.map(str::to_string), parent_session_id: parent_session_id.map(str::to_string), model: None, diff --git a/lib/foundation/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/run_event/agent.rs index 9cd852407..604843011 100644 --- a/lib/foundation/fabro-types/src/run_event/agent.rs +++ b/lib/foundation/fabro-types/src/run_event/agent.rs @@ -3,11 +3,11 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; -use super::BilledTokenCounts; +use super::{BilledTokenCounts, ExecOutputTail}; use crate::transcript::{ToolCall, ToolResult, TranscriptMessage}; use crate::{ - MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, PermissionLevel, - ReasoningOutput, StageContextWindowProjection, TurnId, + CommandTermination, MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, + PermissionLevel, ReasoningOutput, StageContextWindowProjection, TurnId, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -181,6 +181,25 @@ pub struct AgentToolCompletedProps { pub turn_id: Option, } +/// Subordinate diagnostic for a tool call that ran a process: the real +/// termination, exit code, duration, and bounded redacted output tails. +/// +/// This never replaces `agent.tool.completed`, which remains the single +/// tool-protocol completion and the authoritative owner of `is_error`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentToolProcessCompletedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + pub termination: CommandTermination, + pub duration_ms: u64, + /// `false` when the provider could not separate stdout from stderr. The + /// combined output is then carried in `exec_output_tail.stdout`. + pub streams_separated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_output_tail: Option, + pub visit: u32, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentErrorProps { pub error: Value, diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 7c8d1621a..4b937f682 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -208,6 +208,8 @@ pub enum EventBody { AgentToolStarted(AgentToolStartedProps), #[serde(rename = "agent.tool.completed")] AgentToolCompleted(AgentToolCompletedProps), + #[serde(rename = "agent.tool.process.completed")] + AgentToolProcessCompleted(AgentToolProcessCompletedProps), #[serde(rename = "agent.error")] AgentError(AgentErrorProps), #[serde(rename = "agent.warning")] @@ -487,6 +489,7 @@ impl EventBody { Self::AgentMessage(_) => "agent.message", Self::AgentToolStarted(_) => "agent.tool.started", Self::AgentToolCompleted(_) => "agent.tool.completed", + Self::AgentToolProcessCompleted(_) => "agent.tool.process.completed", Self::AgentError(_) => "agent.error", Self::AgentWarning(_) => "agent.warning", Self::AgentLoopDetected(_) => "agent.loop.detected", @@ -656,6 +659,7 @@ fn is_known_event_name(event: &str) -> bool { | "agent.message" | "agent.tool.started" | "agent.tool.completed" + | "agent.tool.process.completed" | "agent.error" | "agent.warning" | "agent.loop.detected" @@ -920,8 +924,8 @@ mod tests { use super::*; use crate::{ - AuthMethod, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId, WorkflowSettings, - fixtures, test_support, + AuthMethod, CommandTermination, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId, + WorkflowSettings, fixtures, test_support, }; fn user_principal(login: &str) -> Principal { @@ -2409,6 +2413,90 @@ mod tests { assert_eq!(parsed, body); } + #[test] + fn agent_tool_process_completed_round_trips_as_a_known_typed_event() { + let value = json!({ + "id": "evt_process", + "ts": "2026-04-08T16:21:11.106Z", + "run_id": fixtures::RUN_1, + "event": "agent.tool.process.completed", + "node_id": "code", + "session_id": "ses_child", + "tool_call_id": "call_1", + "properties": { + "exit_code": 7, + "termination": "exited", + "duration_ms": 12, + "streams_separated": true, + "exec_output_tail": {"stdout": "out", "stderr": "err"}, + "visit": 1 + } + }); + + let parsed = RunEvent::from_value(value.clone()).unwrap(); + assert_eq!(parsed.event_name(), "agent.tool.process.completed"); + assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1")); + let EventBody::AgentToolProcessCompleted(props) = &parsed.body else { + panic!("expected a typed process event, got {:?}", parsed.body); + }; + assert_eq!(props.exit_code, Some(7)); + assert_eq!(props.termination, CommandTermination::Exited); + assert_eq!(props.duration_ms, 12); + assert!(props.streams_separated); + assert_eq!( + props.exec_output_tail.as_ref().unwrap().stdout.as_deref(), + Some("out") + ); + + assert_eq!(parsed.to_value().unwrap(), value); + } + + #[test] + fn agent_tool_process_completed_omits_absent_exit_code_and_output_tail() { + let body = EventBody::AgentToolProcessCompleted(AgentToolProcessCompletedProps { + exit_code: None, + termination: CommandTermination::TimedOut, + duration_ms: 10_000, + streams_separated: false, + exec_output_tail: None, + visit: 1, + }); + + let value = serde_json::to_value(&body).unwrap(); + + assert_eq!(value["event"], "agent.tool.process.completed"); + assert_eq!(value["properties"]["termination"], "timed_out"); + assert_eq!(value["properties"]["streams_separated"], false); + let properties = value["properties"].as_object().unwrap(); + assert!(!properties.contains_key("exit_code")); + assert!(!properties.contains_key("exec_output_tail")); + + let parsed: EventBody = serde_json::from_value(value).unwrap(); + assert_eq!(parsed, body); + } + + /// The trace summary is what `AgentEvent::trace()` expands into tracing + /// fields, so it must carry sizes and truncation flags only. + #[test] + fn exec_output_tail_trace_summary_exposes_sizes_not_content() { + let tail = ExecOutputTail { + stdout: Some("secret stdout bytes".to_string()), + stderr: Some("secret stderr".to_string()), + stdout_truncated: true, + stderr_truncated: false, + }; + + let summary = ExecOutputTail::trace_summary(Some(&tail)); + + assert!(summary.present); + assert_eq!(summary.stdout_bytes, 19); + assert_eq!(summary.stderr_bytes, 13); + assert!(summary.stdout_truncated); + assert!(!summary.stderr_truncated); + let rendered = format!("{summary:?}"); + assert!(!rendered.contains("secret"), "got: {rendered}"); + } + #[test] fn agent_tool_source_and_category_use_public_json_shape() { assert_eq!( From af647aba4bf54b2d911018b4a0f0041b54bf7fdb Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 24 Jul 2026 21:24:31 -0400 Subject: [PATCH 11/20] fix(cli): avoid duplicate compaction error prefix --- .../src/commands/run/run_progress/event.rs | 8 ++++++-- .../src/commands/run/run_progress/mod.rs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 31aa14b1c..317bfa449 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -1,6 +1,7 @@ use std::convert::TryFrom; use chrono::{DateTime, Utc}; +use fabro_agent::Error as AgentError; use fabro_types::{BilledModelUsage, EventBody, RunEvent}; use fabro_util::error; use fabro_workflow::event::RunNoticeLevel; @@ -433,8 +434,11 @@ pub(super) fn from_json_line(line: &str) -> Option { } fn display_compaction_error(value: &Value) -> Option { - let error = serde_json::from_value::(value.clone()).ok()?; - matches!(&error, fabro_agent::Error::Compaction(_)).then(|| error.to_string()) + let error = serde_json::from_value::(value.clone()).ok()?; + match error { + AgentError::Compaction(error) => Some(error.to_string()), + _ => None, + } } fn display_value(value: &Value) -> Option { diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index a1dd513ee..28e62c2b2 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -712,6 +712,22 @@ mod tests { assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); } + #[test] + fn plain_compaction_failure_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit( + &mut ui, + agent_event("s1", AgentEvent::Error { + error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary { + summarized_turn_count: 14, + }), + }), + ); + + insta::assert_snapshot!(rendered(&buffer), @" ✗ compaction failed: generated summary was empty after trimming; refused to replace 14 turns and left history intact"); + } + #[test] fn handle_json_line_ignores_invalid_json() { let (mut ui, buffer) = capture_ui(false); From c464e1b91c96fce242cf3272f0d12b23d745c1ca Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:28:47 -0400 Subject: [PATCH 12/20] feat(agent): implement Read, Write, and Bash to Kimi Code's contract Three Kimi Code tools differ from fabro's built-ins in what their parameters mean, not just what they are called. Renaming fabro's parameters would have advertised behavior fabro does not have, so these are separate tools: - Bash takes `timeout` in SECONDS where fabro takes milliseconds, and accepts a `cwd`. A rename alone would have made every timeout 1000x wrong -- silently, since nothing validates the magnitude. - Read accepts a NEGATIVE `line_offset`, meaning "read the last N lines". Fabro's `offset` has no such meaning, so the tool counts the file's lines and converts to an absolute start. - Write takes a `mode`, so it can append. The Sandbox trait has no append, so append is read-modify-write, which keeps every provider working and stays inside path policy. Everything reaches the environment through the same Sandbox methods the built-ins use, so sandbox behavior, path policy, and the read-before-write guard are unchanged. Tools register under their canonical names and the registry's vocabulary renames them, so the Kimi profile does not special-case naming twice. Edit needed no new tool: `old_string`, `new_string`, and `replace_all` already match Kimi Code exactly, and `file_path` versus `path` is a pure rename. Grep and Glob are not converted. Their shared parameters already behave identically; the gap is optional capability fabro lacks -- Grep's `type`, `multiline`, and `include_ignored`, and Glob's `include_dirs` and `include_ignored` -- which needs new Sandbox trait methods implemented across the local, Docker, and Daytona providers. Omitting an optional parameter is honest; renaming one whose semantics differ is not. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabro-agent/src/profiles/kimi.rs | 94 +--- .../fabro-agent/src/profiles/kimi_tools.rs | 423 ++++++++++++++++++ .../fabro-agent/src/profiles/mod.rs | 1 + lib/components/fabro-agent/src/tools.rs | 5 +- 4 files changed, 449 insertions(+), 74 deletions(-) create mode 100644 lib/components/fabro-agent/src/profiles/kimi_tools.rs diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index ef6a86dbd..dab196841 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -6,7 +6,7 @@ use super::EnvContext; use crate::agent_profile::AgentProfile; use crate::config::NativeToolOptions; use crate::native_tool::{NativeTool, ToolVocabulary}; -use crate::profiles::{self, BaseProfile, EmbeddedPrompt}; +use crate::profiles::{self, BaseProfile, EmbeddedPrompt, kimi_tools}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::todo_runtime::TodoRuntime; @@ -31,55 +31,6 @@ exact match and unique unless replace_all is true. If the edit fails with 'old_s re-read the file and take the exact text from the fresh output rather than guessing again. \ Preserve existing indentation."; -const WRITE_FILE_DESCRIPTION: &str = "Create a new file, or completely replace an existing one. \ -Read an existing file with Read before writing to it — this workspace refuses writes to \ -files that have not been read, and the call will fail. Prefer Edit for any incremental \ -change: Write replaces the entire file, so using it to make a small edit discards \ -everything you did not restate."; - -const BASH_DESCRIPTION: &str = "Execute a bash command. Use this for shell semantics — pipes, \ -env, processes, git, package managers, build and test runners. - -Translate these to a dedicated tool instead: -- `cat` / `head` / `tail` on a known path → Read -- `sed` / `awk` for an in-place edit → Edit -- `echo > file` / heredoc → Write -- `find` or recursive `ls` to locate files by name → Glob (plain `ls ` is fine) -- `grep` / `rg` to search file contents → Grep - -The dedicated tools cap their output, so they keep large raw dumps out of the conversation. That \ -is why they are worth reaching for whenever one fits. - -Output: stdout and stderr are combined and returned as a string, truncated if very long. - -Guidelines: -- Each call runs in a fresh bash process in the working directory. Environment variables and `cd` \ -do NOT persist between calls — use absolute paths, or `cd && ` within one call. -- Do not run interactive commands, or commands that never exit. -- A long-running command needs a raised `timeout_ms`, not a retry. The default is \ -{default_timeout_ms}ms and the maximum is {max_timeout_ms}ms. Retrying a command that timed out \ -once will simply time out again. -- Chain genuinely dependent steps with `&&`. Issue independent read-only commands as separate \ -parallel calls in one response rather than chaining them, so their output stays separate. -- Quote paths containing spaces. -- Avoid `..` to reach outside the working directory, and do not modify files outside it unless \ -explicitly asked. -- Never run commands requiring superuser privileges unless explicitly asked."; - -const READ_DESCRIPTION: &str = "Read a text file from the workspace. - -Reading a file is also what clears it for writing: Edit and Write refuse a file that has not been \ -read in this session. - -- If you have a concrete path, call Read directly. Do not Glob or `ls` first to check that it \ -exists — a missing path returns an error you can handle. -- When you need several files, emit multiple Read calls in one response rather than one per turn. -- Returns `\\t` per line. Drop the number and tab when you take text for an \ -Edit `old_string`. -- `limit` defaults to 2000 lines. Page a larger file with `offset` (1-based first line) and \ -`limit`. -- Use Bash or an MCP tool for binary formats; this tool reads text."; - const GREP_DESCRIPTION: &str = "Search file contents with a regular expression. Use Grep when you are looking for unknown content or an unknown location. If you already know the \ @@ -129,25 +80,20 @@ impl KimiProfile { register_core_tools(&mut registry, options, summarizer); registry.register(make_edit_file_tool()); + + // Read, Write, and Bash differ from fabro's built-ins in what their + // parameters mean, not just what they are called, so they are separate + // tools rather than renames. Registered after the core set so they + // replace it. + registry.register(kimi_tools::make_kimi_read_tool()); + registry.register(kimi_tools::make_kimi_write_tool()); + registry.register(kimi_tools::make_kimi_bash_tool( + options.default_command_timeout_ms, + options.max_command_timeout_ms, + )); registry.redescribe(NativeTool::EditFile, EDIT_FILE_DESCRIPTION); - registry.redescribe(NativeTool::WriteFile, WRITE_FILE_DESCRIPTION); - registry.redescribe(NativeTool::ReadFile, READ_DESCRIPTION); registry.redescribe(NativeTool::Grep, GREP_DESCRIPTION); registry.redescribe(NativeTool::Glob, GLOB_DESCRIPTION); - // The Bash description quotes the timeouts this profile actually - // enforces, so the two cannot drift. - registry.redescribe( - NativeTool::Shell, - BASH_DESCRIPTION - .replace( - "{default_timeout_ms}", - &options.default_command_timeout_ms.to_string(), - ) - .replace( - "{max_timeout_ms}", - &options.max_command_timeout_ms.to_string(), - ), - ); // Kimi Code drives todos with one replace-whole-list call. The // Anthropic task tools model the opposite interaction -- incremental @@ -377,20 +323,22 @@ mod tests { for expected in ["→ Read", "→ Edit", "→ Write", "→ Glob", "→ Grep"] { assert!(bash.contains(expected), "Bash should map {expected}"); } - // Timeouts are interpolated from the options this profile enforces, so - // the description cannot drift from behavior. + // Bash takes SECONDS, unlike fabro's millisecond built-in. Assert the + // seconds value is quoted and the raw millisecond value is not, which + // is what a unit bug would look like. let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi); + let seconds = (options.default_command_timeout_ms / 1000).to_string(); assert!( - bash.contains(&options.default_command_timeout_ms.to_string()), - "Bash should quote the real default timeout" + bash.contains(&seconds), + "Bash should quote {seconds}s: {bash}" ); assert!( - !bash.contains("{default_timeout_ms}"), - "placeholder left unrendered" + !bash.contains(&options.default_command_timeout_ms.to_string()), + "Bash quotes milliseconds, so the unit conversion is wrong: {bash}" ); + assert!(bash.contains("SECONDS"), "{bash}"); // Fabro has no background shell; promising one would be a lie. assert!(!bash.contains("run_in_background"), "{bash}"); - assert!(!bash.to_lowercase().contains("background task"), "{bash}"); // Read explains that reading is what clears a file for writing. assert!(describe("Read").contains("refuse a file that has not been read")); diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs new file mode 100644 index 000000000..5916bb168 --- /dev/null +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -0,0 +1,423 @@ +//! Tools whose behavior differs from fabro's built-ins, implemented to Kimi +//! Code's contract. +//! +//! Where a Kimi Code tool behaves identically to an existing fabro tool, the +//! Kimi profile reuses that tool and only its exposed name changes (see +//! [`crate::native_tool::ToolVocabulary`]). These three differ in what their +//! parameters *mean*, not just what they are called, so renaming fabro's +//! parameters would advertise behavior fabro does not have: +//! +//! - `Bash` takes `timeout` in **seconds** where fabro takes milliseconds, and +//! accepts a `cwd`. A rename alone would make every timeout 1000x wrong. +//! - `Read` accepts a **negative** `line_offset`, meaning "read the last N +//! lines". Fabro's `offset` has no such meaning. +//! - `Write` takes a `mode`, so it can append. Fabro's write always replaces. +//! +//! Everything these tools do reaches the environment through the same +//! [`Sandbox`](crate::sandbox::Sandbox) methods the built-ins use, so sandbox +//! behavior, path policy, and the read-before-write guard are unchanged. + +use std::fmt::Write as _; +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; +use serde_json::Value; + +use crate::native_tool::NativeTool; +use crate::tool_registry::{RegisteredTool, ToolSource}; +use crate::tools::{optional_usize_arg, required_str}; + +/// Largest `n_lines` a single `Read` call returns, matching fabro's built-in +/// read default so the two tools cannot disagree about how much is "a page". +const DEFAULT_READ_LINES: usize = 2000; + +fn definition(tool: NativeTool, description: &str, parameters: Value) -> ToolDefinition { + ToolDefinition { + // Registered under the canonical name; the registry's vocabulary + // renames it on the way in. + name: tool.canonical_name().to_string(), + description: description.to_string(), + parameters, + } +} + +/// `Bash`, taking `timeout` in seconds and an optional `cwd`. +#[must_use] +pub fn make_kimi_bash_tool(default_timeout_ms: u64, max_timeout_ms: u64) -> RegisteredTool { + let default_timeout_s = default_timeout_ms / 1000; + let max_timeout_s = max_timeout_ms / 1000; + let description = format!( + "Execute a bash command. Use this for shell semantics — pipes, env, processes, git, \ +package managers, build and test runners. + +Translate these to a dedicated tool instead: +- `cat` / `head` / `tail` on a known path → Read +- `sed` / `awk` for an in-place edit → Edit +- `echo > file` / heredoc → Write +- `find` or recursive `ls` to locate files by name → Glob (plain `ls ` is fine) +- `grep` / `rg` to search file contents → Grep + +The dedicated tools cap their output, so they keep large raw dumps out of the conversation. + +Output: stdout and stderr are combined and returned as a string. A non-zero exit appends a \ +`Command failed with exit code: N` line. + +Guidelines: +- Each call runs in a fresh bash process. Environment variables and `cd` do NOT persist between \ +calls — pass `cwd`, or use absolute paths. +- `timeout` is in SECONDS. It defaults to {default_timeout_s} and is capped at {max_timeout_s}. +- A long-running command needs a raised `timeout`, not a retry: a command that timed out once \ +will time out again. +- Do not run interactive commands, or commands that never exit. +- Chain genuinely dependent steps with `&&`. Issue independent read-only commands as separate \ +parallel calls in one response so their output stays separate. +- Quote paths containing spaces. +- Avoid `..` to reach outside the working directory, and do not modify files outside it unless \ +explicitly asked. Never run commands requiring superuser privileges unless explicitly asked." + ); + + RegisteredTool { + definition: definition( + NativeTool::Shell, + &description, + serde_json::json!({ + "type": "object", + "properties": { + "command": {"type": "string", "description": "The command to execute."}, + "cwd": { + "type": "string", + "description": "Directory to run the command in. Defaults to the \ + working directory." + }, + "timeout": { + "type": "integer", + "description": format!( + "Timeout in seconds (default {default_timeout_s}, max {max_timeout_s})." + ) + }, + "description": { + "type": "string", + "description": "Short description of what this command does." + } + }, + "required": ["command"] + }), + ), + executor: Arc::new(move |args, ctx| { + Box::pin(async move { + let command = required_str(&args, "command")?; + let cwd = args.get("cwd").and_then(Value::as_str); + // Seconds on the wire, milliseconds in the sandbox. + let timeout_ms = match args.get("timeout").and_then(Value::as_u64) { + Some(seconds) => seconds.saturating_mul(1000).min(max_timeout_ms), + None => default_timeout_ms, + }; + + let result = ctx + .env + .exec_command(command, timeout_ms, cwd, None, Some(ctx.cancel.clone())) + .await + .map_err(|e| e.display_with_causes())?; + + let mut out = result.stdout; + if !result.stderr.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&result.stderr); + } + if let Some(code) = result.exit_code.filter(|c| *c != 0) { + if !out.is_empty() { + out.push('\n'); + } + let _ = write!(out, "Command failed with exit code: {code}"); + } + Ok(out) + }) + }), + source: ToolSource::Native, + } +} + +/// `Read`, where a negative `line_offset` reads from the end of the file. +#[must_use] +pub fn make_kimi_read_tool() -> RegisteredTool { + RegisteredTool { + definition: definition( + NativeTool::ReadFile, + "Read a text file from the workspace. + +Reading a file is also what clears it for writing: Edit and Write refuse a file that has not been \ +read in this session. + +- If you have a concrete path, call Read directly. Do not Glob or `ls` first to check that it \ +exists — a missing path returns an error you can handle. +- When you need several files, emit multiple Read calls in one response rather than one per turn. +- Returns `\\t` per line. Drop the number and tab when taking text for an \ +Edit `old_string`. +- `line_offset` is the 1-based first line to read. A NEGATIVE value reads from the end, so -100 \ +returns the last 100 lines. +- `n_lines` defaults to 2000 lines. +- Use Bash or an MCP tool for binary formats; this tool reads text.", + serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file to read."}, + "line_offset": { + "type": "integer", + "description": "1-based first line to read. Negative reads from the end \ + of the file (-100 reads the last 100 lines)." + }, + "n_lines": { + "type": "integer", + "description": "Number of lines to read (default 2000)." + } + }, + "required": ["path"] + }), + ), + executor: Arc::new(|args, ctx| { + Box::pin(async move { + let path = required_str(&args, "path")?; + let n_lines = optional_usize_arg(&args, "n_lines")?; + let line_offset = args.get("line_offset").and_then(Value::as_i64); + + let content = match line_offset { + // Negative offset: count the file's lines, then start that + // many from the end. Kimi Code's semantics. + Some(offset) if offset < 0 => { + let from_end = usize::try_from(offset.unsigned_abs()) + .map_err(|_| "line_offset is too large".to_string())?; + let total = ctx + .env + .read_file(path, None, None) + .await + .map_err(|e| e.display_with_causes())? + .lines() + .count(); + let start = total.saturating_sub(from_end).saturating_add(1); + ctx.env + .read_file(path, Some(start), n_lines.or(Some(from_end))) + .await + } + Some(offset) => { + let start = usize::try_from(offset) + .map_err(|_| "line_offset must fit in usize".to_string())?; + ctx.env.read_file(path, Some(start), n_lines).await + } + None => { + ctx.env + .read_file(path, None, n_lines.or(Some(DEFAULT_READ_LINES))) + .await + } + } + .map_err(|e| e.display_with_causes())?; + + ctx.env.mark_agent_read(path); + Ok(content) + }) + }), + source: ToolSource::Native, + } +} + +/// `Write`, with Kimi Code's `mode` so it can append. +#[must_use] +pub fn make_kimi_write_tool() -> RegisteredTool { + RegisteredTool { + definition: definition( + NativeTool::WriteFile, + "Create, append to, or replace a file. + +Read an existing file with Read before writing to it — this workspace refuses writes to files \ +that have not been read, and the call will fail. + +- `mode` defaults to `overwrite`, which replaces the whole file. `append` adds to the end without \ +inserting a newline. +- Write is NOT for incremental changes to an existing file, however small. Use Edit instead: \ +overwrite replaces everything you did not restate. +- Use Write when the file does not exist, or when you intend a complete replacement. +- Do not create documentation files that were not asked for.", + serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file to write."}, + "content": {"type": "string", "description": "Content to write."}, + "mode": { + "type": "string", + "enum": ["overwrite", "append"], + "description": "Whether to replace the file or append to it (default \ + overwrite)." + } + }, + "required": ["path", "content"] + }), + ), + executor: Arc::new(|args, ctx| { + Box::pin(async move { + let path = required_str(&args, "path")?; + let content = required_str(&args, "content")?; + let mode = args + .get("mode") + .and_then(Value::as_str) + .unwrap_or("overwrite"); + + let payload = match mode { + "overwrite" => content.to_string(), + // The sandbox trait has no append; read-modify-write keeps + // every provider working and stays inside path policy. + "append" => { + let existing = ctx.env.read_file_text(path).await.unwrap_or_default(); + format!("{existing}{content}") + } + other => { + return Err(format!( + "Invalid mode `{other}` (expected overwrite|append)" + )); + } + }; + + ctx.env + .write_file(path, &payload) + .await + .map_err(|e| e.display_with_causes())?; + Ok(format!("Wrote {path}")) + }) + }), + source: ToolSource::Native, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::sandbox::Sandbox; + use crate::test_support::MutableMockSandbox; + use crate::tool_registry::ToolContext; + + fn ctx(env: Arc) -> ToolContext { + ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: Some("ses".into()), + root_session_id: Some("ses".into()), + tool_call_id: None, + agent_event_emitter: None, + } + } + + fn sandbox_with(path: &str, content: &str) -> Arc { + let mut files = HashMap::new(); + files.insert(path.to_string(), content.to_string()); + Arc::new(MutableMockSandbox::new(files)) + } + + /// The reason Read is a separate tool: a negative `line_offset` means + /// "the last N lines", which fabro's `offset` has no notion of. + #[tokio::test] + async fn read_negative_line_offset_reads_from_the_end() { + let lines: Vec = (1..=20).map(|n| format!("line{n}")).collect(); + let env = sandbox_with("/f.txt", &lines.join("\n")); + let tool = make_kimi_read_tool(); + + let out = (tool.executor)( + json!({"path": "/f.txt", "line_offset": -3}), + ctx(env.clone()), + ) + .await + .unwrap(); + + assert!(out.contains("line18"), "{out}"); + assert!(out.contains("line20"), "{out}"); + assert!( + !out.contains("line1\n"), + "should not include the head: {out}" + ); + } + + #[tokio::test] + async fn read_positive_line_offset_starts_there() { + let lines: Vec = (1..=20).map(|n| format!("line{n}")).collect(); + let env = sandbox_with("/f.txt", &lines.join("\n")); + let tool = make_kimi_read_tool(); + + let out = (tool.executor)( + json!({"path": "/f.txt", "line_offset": 5, "n_lines": 2}), + ctx(env), + ) + .await + .unwrap(); + assert!(out.contains("line5"), "{out}"); + assert!(!out.contains("line8"), "{out}"); + } + + /// The reason Write is a separate tool: it has a mode, so it can append. + #[tokio::test] + async fn write_append_mode_preserves_existing_content() { + let env = sandbox_with("/f.txt", "first"); + let tool = make_kimi_write_tool(); + + (tool.executor)( + json!({"path": "/f.txt", "content": "-second", "mode": "append"}), + ctx(env.clone()), + ) + .await + .unwrap(); + + assert_eq!(env.read_file_text("/f.txt").await.unwrap(), "first-second"); + } + + #[tokio::test] + async fn write_defaults_to_overwrite() { + let env = sandbox_with("/f.txt", "first"); + let tool = make_kimi_write_tool(); + (tool.executor)( + json!({"path": "/f.txt", "content": "only"}), + ctx(env.clone()), + ) + .await + .unwrap(); + assert_eq!(env.read_file_text("/f.txt").await.unwrap(), "only"); + } + + #[tokio::test] + async fn write_rejects_an_unknown_mode() { + let env = sandbox_with("/f.txt", "x"); + let tool = make_kimi_write_tool(); + let err = (tool.executor)( + json!({"path": "/f.txt", "content": "y", "mode": "prepend"}), + ctx(env), + ) + .await + .unwrap_err(); + assert!(err.contains("expected overwrite|append"), "{err}"); + } + + /// The reason Bash is a separate tool: `timeout` is seconds, not + /// milliseconds. A rename would have made every timeout 1000x wrong. + #[test] + fn bash_schema_states_seconds_and_quotes_real_limits() { + let tool = make_kimi_bash_tool(60_000, 600_000); + let params = &tool.definition.parameters; + let timeout = params["properties"]["timeout"]["description"] + .as_str() + .unwrap(); + assert!(timeout.contains("seconds"), "{timeout}"); + assert!(timeout.contains("60"), "default should be 60s: {timeout}"); + assert!(timeout.contains("600"), "max should be 600s: {timeout}"); + assert!(params["properties"].get("cwd").is_some(), "cwd missing"); + assert!( + tool.definition + .description + .contains("timeout` is in SECONDS") + ); + // Fabro has no background shell, so none is promised. + assert!(!tool.definition.description.contains("run_in_background")); + } +} diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 47e9ac21b..7855617bf 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -6,6 +6,7 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId}; pub mod anthropic; pub mod gemini; pub mod kimi; +pub mod kimi_tools; pub mod openai; pub use anthropic::AnthropicProfile; diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 5eca7b5be..f787d3c8e 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -78,7 +78,10 @@ pub(crate) fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> Result .ok_or_else(|| format!("Missing required parameter: {key}")) } -fn optional_usize_arg(args: &serde_json::Value, key: &str) -> Result, String> { +pub(crate) fn optional_usize_arg( + args: &serde_json::Value, + key: &str, +) -> Result, String> { args.get(key) .and_then(serde_json::Value::as_u64) .map(|value| { From cbd257c016c55da3a04a1d0e6478e5edb603a22b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:35:02 -0400 Subject: [PATCH 13/20] feat(agent): give the Kimi profile Grep's output modes and paging Kimi Code's Grep returns matching lines, matching file names, or per-file counts, and pages results with `head_limit` and `offset`. All four are shapes of the result list the Sandbox trait already returns, so the Kimi profile gets them without any provider work. Scoped to the Kimi profile. The other profiles keep fabro's grep tool: these options exist because Kimi models are trained against them, not because every model should be handed more knobs. Two details worth knowing when reading it. Extracting a file path means parsing the `::` prefix, which the underlying search omits when scanning a single file, so the search root is the fallback; the parser also walks candidate separators so a colon inside matched content is not mistaken for the line-number field. And `head_limit` is only pushed down to the search as a result cap in `content` mode, where results and lines are the same thing -- capping lines early would undercount files for the other two modes. Kimi Code's `type`, `multiline`, and `include_ignored` are still absent. They would have to reach ripgrep flags through new Sandbox trait methods implemented across the local, Docker, and Daytona providers, and a parameter that is advertised but ignored is worse than one that is missing. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabro-agent/src/profiles/kimi.rs | 13 +- .../fabro-agent/src/profiles/kimi_tools.rs | 255 +++++++++++++++++- 2 files changed, 255 insertions(+), 13 deletions(-) diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index dab196841..327b4868a 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -31,17 +31,6 @@ exact match and unique unless replace_all is true. If the edit fails with 'old_s re-read the file and take the exact text from the fresh output rather than guessing again. \ Preserve existing indentation."; -const GREP_DESCRIPTION: &str = "Search file contents with a regular expression. - -Use Grep when you are looking for unknown content or an unknown location. If you already know the \ -path, use Read instead. Prefer this over running `grep` or `rg` through Bash: it caps its output, \ -so it will not flood the conversation. - -- Backed by ripgrep when available and POSIX `grep` otherwise, so keep patterns portable across \ -both rather than relying on ripgrep-only syntax. -- `path` chooses the search root, `glob_filter` limits which files are searched, \ -`case_insensitive` folds case, and `max_results` caps the output."; - const GLOB_DESCRIPTION: &str = "Find files by name using a glob pattern, most recently modified \ first. @@ -87,12 +76,12 @@ impl KimiProfile { // replace it. registry.register(kimi_tools::make_kimi_read_tool()); registry.register(kimi_tools::make_kimi_write_tool()); + registry.register(kimi_tools::make_kimi_grep_tool()); registry.register(kimi_tools::make_kimi_bash_tool( options.default_command_timeout_ms, options.max_command_timeout_ms, )); registry.redescribe(NativeTool::EditFile, EDIT_FILE_DESCRIPTION); - registry.redescribe(NativeTool::Grep, GREP_DESCRIPTION); registry.redescribe(NativeTool::Glob, GLOB_DESCRIPTION); // Kimi Code drives todos with one replace-whole-list call. The diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 5916bb168..36892cf41 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -24,6 +24,7 @@ use fabro_llm::types::ToolDefinition; use serde_json::Value; use crate::native_tool::NativeTool; +use crate::sandbox::GrepOptions; use crate::tool_registry::{RegisteredTool, ToolSource}; use crate::tools::{optional_usize_arg, required_str}; @@ -297,7 +298,7 @@ mod tests { use super::*; use crate::sandbox::Sandbox; - use crate::test_support::MutableMockSandbox; + use crate::test_support::{MockSandbox, MutableMockSandbox}; use crate::tool_registry::ToolContext; fn ctx(env: Arc) -> ToolContext { @@ -399,6 +400,96 @@ mod tests { assert!(err.contains("expected overwrite|append"), "{err}"); } + /// `files_with_matches` and `count` both need the file path, which the + /// underlying search only prefixes when scanning a directory. + #[test] + fn grep_result_path_handles_both_output_shapes() { + // Directory scan: `::`. + assert_eq!( + grep_result_path("src/main.rs:42:fn main() {", "src"), + "src/main.rs" + ); + // A colon in the content must not be mistaken for the line field. + assert_eq!( + grep_result_path("src/a.rs:7:let x: u8 = 1;", "src"), + "src/a.rs" + ); + // Single-file scan omits the path, so fall back to what was searched. + assert_eq!( + grep_result_path("42:fn main() {", "src/main.rs"), + "src/main.rs" + ); + } + + async fn grep_with(args: serde_json::Value, lines: Vec) -> Result { + let env: Arc = Arc::new(MockSandbox { + grep_results: lines, + ..MockSandbox::default() + }); + let tool = make_kimi_grep_tool(); + (tool.executor)(args, ctx(env)).await + } + + #[tokio::test] + async fn grep_content_mode_returns_matching_lines() { + let out = grep_with(json!({"pattern": "x"}), vec![ + "a.rs:1:x".into(), + "b.rs:2:x".into(), + ]) + .await + .unwrap(); + assert_eq!(out, "a.rs:1:x\nb.rs:2:x"); + } + + #[tokio::test] + async fn grep_files_with_matches_deduplicates_paths_in_order() { + let out = grep_with( + json!({"pattern": "x", "output_mode": "files_with_matches"}), + vec!["a.rs:1:x".into(), "a.rs:9:x".into(), "b.rs:2:x".into()], + ) + .await + .unwrap(); + assert_eq!(out, "a.rs\nb.rs"); + } + + #[tokio::test] + async fn grep_count_mode_counts_per_file() { + let out = grep_with(json!({"pattern": "x", "output_mode": "count"}), vec![ + "a.rs:1:x".into(), + "a.rs:9:x".into(), + "b.rs:2:x".into(), + ]) + .await + .unwrap(); + assert_eq!(out, "a.rs:2\nb.rs:1"); + } + + #[tokio::test] + async fn grep_offset_and_head_limit_page_results() { + let lines: Vec = (1..=6).map(|n| format!("f{n}.rs:1:x")).collect(); + let out = grep_with(json!({"pattern": "x", "offset": 2, "head_limit": 2}), lines) + .await + .unwrap(); + assert_eq!(out, "f3.rs:1:x\nf4.rs:1:x"); + } + + #[tokio::test] + async fn grep_rejects_an_unknown_output_mode() { + let err = grep_with(json!({"pattern": "x", "output_mode": "json"}), vec![]) + .await + .unwrap_err(); + assert!( + err.contains("expected content|files_with_matches|count"), + "{err}" + ); + } + + #[tokio::test] + async fn grep_reports_no_matches_plainly() { + let out = grep_with(json!({"pattern": "x"}), vec![]).await.unwrap(); + assert_eq!(out, "No matches found"); + } + /// The reason Bash is a separate tool: `timeout` is seconds, not /// milliseconds. A rename would have made every timeout 1000x wrong. #[test] @@ -421,3 +512,165 @@ mod tests { assert!(!tool.definition.description.contains("run_in_background")); } } + +/// Output shapes Kimi Code's `Grep` supports. +#[derive(Clone, Copy, PartialEq, Eq)] +enum GrepOutputMode { + Content, + FilesWithMatches, + Count, +} + +impl GrepOutputMode { + fn parse(value: Option<&str>) -> Result { + match value.unwrap_or("content") { + "content" => Ok(Self::Content), + "files_with_matches" => Ok(Self::FilesWithMatches), + "count" => Ok(Self::Count), + other => Err(format!( + "Invalid output_mode `{other}` (expected content|files_with_matches|count)" + )), + } + } +} + +/// Extract the file path from a grep result line. +/// +/// The underlying search emits `::` when scanning a +/// directory, but omits the path when scanning a single file, so fall back to +/// the path that was searched. +fn grep_result_path<'a>(line: &'a str, searched: &'a str) -> &'a str { + // Walk candidate separators so absolute Windows-style paths and paths + // containing colons still split at the line-number field. + let mut rest = line; + let mut consumed = 0usize; + while let Some(idx) = rest.find(':') { + let after = &rest[idx + 1..]; + let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); + if !digits.is_empty() && after[digits.len()..].starts_with(':') { + return &line[..consumed + idx]; + } + consumed += idx + 1; + rest = after; + } + searched +} + +/// `Grep` with Kimi Code's `output_mode`, `head_limit`, and `offset`. +/// +/// These are all shapes of the result list the sandbox already returns, so no +/// provider work is needed. Kimi Code's `type`, `multiline`, and +/// `include_ignored` are deliberately absent: they would have to reach ripgrep +/// flags through new `Sandbox` trait methods, and advertising a parameter that +/// is ignored is worse than omitting it. +#[must_use] +pub fn make_kimi_grep_tool() -> RegisteredTool { + RegisteredTool { + definition: definition( + NativeTool::Grep, + "Search file contents with a regular expression. + +Use Grep when looking for unknown content or an unknown location. If you already know the path, \ +use Read instead. Prefer this over running `grep` or `rg` through Bash: it caps its output, so it \ +will not flood the conversation. + +- Backed by ripgrep when available and POSIX `grep` otherwise, so keep patterns portable across \ +both rather than relying on ripgrep-only syntax. +- `output_mode` selects what comes back: `content` (matching lines, the default), \ +`files_with_matches` (just the paths), or `count` (matches per file). +- `head_limit` caps how many results are returned and `offset` skips that many first, so you can \ +page through a large result set. +- `glob` limits which files are searched; `case_insensitive` folds case.", + serde_json::json!({ + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "Regular expression to search for."}, + "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."}, + "glob": {"type": "string", "description": "Only search files matching this glob."}, + "output_mode": { + "type": "string", + "enum": ["content", "files_with_matches", "count"], + "description": "Shape of the results (default content)." + }, + "head_limit": {"type": "integer", "description": "Return at most this many results."}, + "offset": {"type": "integer", "description": "Skip this many results before returning."}, + "case_insensitive": {"type": "boolean", "description": "Fold case when matching."} + }, + "required": ["pattern"] + }), + ), + executor: Arc::new(|args, ctx| { + Box::pin(async move { + let pattern = required_str(&args, "pattern")?; + // The trait requires a search root; "." is the working directory. + let path = args.get("path").and_then(Value::as_str).unwrap_or("."); + let mode = GrepOutputMode::parse(args.get("output_mode").and_then(Value::as_str))?; + let head_limit = optional_usize_arg(&args, "head_limit")?; + let offset = optional_usize_arg(&args, "offset")?.unwrap_or(0); + + let options = GrepOptions { + glob_filter: args.get("glob").and_then(Value::as_str).map(str::to_string), + case_insensitive: args + .get("case_insensitive") + .and_then(Value::as_bool) + .unwrap_or(false), + // Only push the cap down for `content`, where results and + // lines are the same thing. Capping lines early would + // undercount files for the other modes. + max_results: match mode { + GrepOutputMode::Content => head_limit.map(|n| n.saturating_add(offset)), + _ => None, + }, + }; + + let lines = ctx + .env + .grep(pattern, path, &options) + .await + .map_err(|e| e.display_with_causes())?; + + let searched = path; + let mut results: Vec = match mode { + GrepOutputMode::Content => lines, + GrepOutputMode::FilesWithMatches => { + let mut seen: Vec = Vec::new(); + for line in &lines { + let file = grep_result_path(line, searched).to_string(); + if !seen.contains(&file) { + seen.push(file); + } + } + seen + } + GrepOutputMode::Count => { + let mut counts: Vec<(String, usize)> = Vec::new(); + for line in &lines { + let file = grep_result_path(line, searched).to_string(); + match counts.iter_mut().find(|(name, _)| *name == file) { + Some((_, count)) => *count += 1, + None => counts.push((file, 1)), + } + } + counts + .into_iter() + .map(|(file, count)| format!("{file}:{count}")) + .collect() + } + }; + + if offset > 0 { + results = results.into_iter().skip(offset).collect(); + } + if let Some(limit) = head_limit { + results.truncate(limit); + } + + if results.is_empty() { + return Ok("No matches found".to_string()); + } + Ok(results.join("\n")) + }) + }), + source: ToolSource::Native, + } +} From c803354309d657780c5a7255cf0c2cb2c0034308 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 21:50:07 -0400 Subject: [PATCH 14/20] refactor(agent): streamline shell outcome reporting --- docs/internal/events.md | 4 + lib/components/fabro-agent/src/tools.rs | 144 ++++++++++-------- .../fabro-agent/tests/it/docker_shell.rs | 69 ++++----- .../fabro-sandbox/src/daytona/mod.rs | 29 ++-- lib/components/fabro-sandbox/src/docker.rs | 14 +- lib/components/fabro-sandbox/src/local.rs | 8 +- lib/components/fabro-sandbox/src/sandbox.rs | 64 ++++---- .../fabro-sandbox/src/test_support.rs | 24 +-- .../tests/daytona_streaming_live.rs | 4 +- .../fabro-sandbox/tests/docker_streaming.rs | 2 +- .../fabro-workflow/src/event/convert.rs | 9 +- .../fabro-workflow/src/handler/command.rs | 2 +- .../fabro-types/src/run_event/infra.rs | 27 ++++ .../fabro-types/src/run_event/mod.rs | 22 --- 14 files changed, 222 insertions(+), 200 deletions(-) diff --git a/docs/internal/events.md b/docs/internal/events.md index 57bb9c115..0a51e4e13 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1190,6 +1190,10 @@ or launch failure) and when the tool ran without a session-bound emitter. | `duration_ms` | integer | Process duration | | `streams_separated` | boolean | `false` when the provider could not separate stdout from stderr; the combined output is then in `exec_output_tail.stdout` | | `exec_output_tail` | object | Bounded, redacted output tails; omitted when both streams were empty | +| `exec_output_tail.stdout` | string | Bounded stdout tail, or combined-output tail when `streams_separated` is `false`; omitted when empty | +| `exec_output_tail.stderr` | string | Bounded stderr tail; omitted when empty | +| `exec_output_tail.stdout_truncated` | boolean | `true` when earlier stdout bytes were omitted; omitted when `false` | +| `exec_output_tail.stderr_truncated` | boolean | `true` when earlier stderr bytes were omitted; omitted when `false` | | `visit` | integer | Stage visit | ### `agent.error` diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 4043006fa..3797350df 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -8,9 +8,10 @@ use fabro_model::ModelHandle; #[cfg(test)] use fabro_static::EnvVars; use futures::{StreamExt, stream}; +use tokio::task; use crate::config::NativeToolOptions; -use crate::sandbox::{CommandOutputCallback, ExecStreamingResult, GrepOptions}; +use crate::sandbox::{ExecStreamingResult, GrepOptions}; use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource}; use crate::types::AgentEvent; @@ -262,7 +263,7 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo None, tool_env.as_ref(), Some(ctx.cancel.clone()), - discard_output_callback(), + None, ) .await .map_err(|e| { @@ -270,15 +271,37 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo })?; let text = render_shell_result(&streaming); - ctx.emit_agent_event(AgentEvent::ToolProcessCompleted { - exit_code: streaming.result.exit_code, - termination: streaming.result.termination, - duration_ms: streaming.result.duration_ms, - streams_separated: streaming.streams_separated, - exec_output_tail: streaming.result.default_redacted_output_tail(), - }); + let is_success = streaming.result.is_success(); + if let Some(emitter) = ctx.agent_event_emitter { + let exit_code = streaming.result.exit_code; + let termination = streaming.result.termination; + let duration_ms = streaming.result.duration_ms; + let streams_separated = streaming.streams_separated; + let result = streaming.result; + let exec_output_tail = match task::spawn_blocking(move || { + result.default_redacted_output_tail() + }) + .await + { + Ok(exec_output_tail) => exec_output_tail, + Err(err) => { + tracing::warn!( + error = ?err, + "Failed to redact shell process output tail" + ); + None + } + }; + emitter.emit(AgentEvent::ToolProcessCompleted { + exit_code, + termination, + duration_ms, + streams_separated, + exec_output_tail, + }); + } - if streaming.result.is_success() { + if is_success { Ok(text) } else { Err(text) @@ -290,16 +313,9 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo } /// Prefix for shell failures that never produced an `ExecResult`, so the model -/// can tell "the process never ran" apart from "the process ran and failed". +/// can distinguish missing process diagnostics from a reported process failure. const SHELL_NO_PROCESS_RESULT: &str = "Shell command produced no process result"; -/// The agent shell tool consumes the streaming exec path for its stream -/// provenance and partial-output capture, but does not forward live output -/// deltas onto the agent protocol. -fn discard_output_callback() -> CommandOutputCallback { - Arc::new(|_stream, _bytes| Box::pin(async { Ok(()) })) -} - /// Renders the model-facing shell result: termination, exit code, duration, /// and provider-honest output sections. Metadata stays at the head and /// `stderr` at the tail so head/tail truncation preserves both. @@ -732,15 +748,18 @@ mod tests { use fabro_llm::provider::ProviderAdapter; use fabro_model::ProviderId; use fabro_types::CommandTermination; + use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use super::*; use crate::config::{NativeToolOptions, SessionOptions, ToolSecrets}; + use crate::event::{Emitter, SessionBoundEmitter}; use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; use crate::test_support::MockSandbox; - use crate::tool_registry::{AgentEventEmitter, ToolContext}; + use crate::tool_registry::ToolContext; use crate::truncation; + use crate::types::SessionEvent; #[test] fn core_tool_descriptions_include_actionable_guidance() { @@ -1025,33 +1044,6 @@ mod tests { assert_eq!(written[0].1, "1 | keep this literal\ngoodbye"); } - /// Records the typed agent events a tool emits through its bound emitter. - #[derive(Default)] - struct RecordingAgentEmitter { - events: std::sync::Mutex>, - } - - impl RecordingAgentEmitter { - fn events(&self) -> Vec { - self.events.lock().expect("events lock poisoned").clone() - } - - fn only_process_event(&self) -> AgentEvent { - let events = self.events(); - assert_eq!(events.len(), 1, "expected one agent event, got {events:?}"); - events.into_iter().next().expect("one event") - } - } - - impl AgentEventEmitter for RecordingAgentEmitter { - fn emit(&self, event: AgentEvent) { - self.events - .lock() - .expect("events lock poisoned") - .push(event); - } - } - fn shell_context(env: Arc) -> ToolContext { ToolContext { env, @@ -1064,16 +1056,31 @@ mod tests { } } - fn shell_context_with_emitter( - env: Arc, - emitter: Arc, - ) -> ToolContext { + fn shell_context_with_emitter(env: Arc, emitter: &Emitter) -> ToolContext { ToolContext { - agent_event_emitter: Some(emitter), + session_id: Some("test-session".to_string()), + root_session_id: Some("test-session".to_string()), + tool_call_id: Some("call_1".to_string()), + agent_event_emitter: Some(Arc::new(SessionBoundEmitter { + emitter: emitter.clone(), + session_id: "test-session".to_string(), + tool_call_id: Some("call_1".to_string()), + })), ..shell_context(env) } } + fn only_process_event(receiver: &mut broadcast::Receiver) -> AgentEvent { + let event = receiver.try_recv().expect("one process event"); + assert_eq!(event.session_id, "test-session"); + assert_eq!(event.tool_call_id.as_deref(), Some("call_1")); + assert!(matches!( + receiver.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + event.event + } + fn mock_sandbox_with(result: ExecResult) -> Arc { Arc::new(MockSandbox { exec_result: result, @@ -1223,11 +1230,12 @@ mod tests { exec_error: Some("sandbox transport is down".into()), ..Default::default() }); - let emitter = Arc::new(RecordingAgentEmitter::default()); + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); let output = (tool.executor)( serde_json::json!({"command": "make test"}), - shell_context_with_emitter(env, emitter.clone()), + shell_context_with_emitter(env, &emitter), ) .await .expect_err("a sandbox transport failure is a failed tool result"); @@ -1241,7 +1249,10 @@ mod tests { "got: {output}" ); assert!(!output.contains("Exit code"), "got: {output}"); - assert!(emitter.events().is_empty(), "got: {:?}", emitter.events()); + assert!(matches!( + receiver.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); } #[tokio::test] @@ -1254,15 +1265,16 @@ mod tests { termination: CommandTermination::Exited, duration_ms: 12, }); - let emitter = Arc::new(RecordingAgentEmitter::default()); + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); let _ = (tool.executor)( serde_json::json!({"command": "printf out; printf err >&2; exit 7"}), - shell_context_with_emitter(env, emitter.clone()), + shell_context_with_emitter(env, &emitter), ) .await; - match emitter.only_process_event() { + match only_process_event(&mut receiver) { AgentEvent::ToolProcessCompleted { exit_code, termination, @@ -1298,11 +1310,12 @@ mod tests { streams_separated: false, ..Default::default() }); - let emitter = Arc::new(RecordingAgentEmitter::default()); + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); let output = (tool.executor)( serde_json::json!({"command": "echo interleaved"}), - shell_context_with_emitter(env, emitter.clone()), + shell_context_with_emitter(env, &emitter), ) .await .expect("exit 0 is a successful tool result"); @@ -1312,7 +1325,7 @@ mod tests { "got: {output}" ); assert!(!output.contains("stderr:"), "got: {output}"); - match emitter.only_process_event() { + match only_process_event(&mut receiver) { AgentEvent::ToolProcessCompleted { streams_separated, .. } => assert!(!streams_separated), @@ -1362,11 +1375,12 @@ mod tests { let env: Arc = Arc::new(LocalSandbox::new( std::env::current_dir().expect("current dir"), )); - let emitter = Arc::new(RecordingAgentEmitter::default()); + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); let output = (tool.executor)( serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}), - shell_context_with_emitter(env, emitter.clone()), + shell_context_with_emitter(env, &emitter), ) .await .expect_err("exit 7 is a failed tool result"); @@ -1376,7 +1390,7 @@ mod tests { assert!(output.contains("stdout:\nout"), "got: {output}"); assert!(output.contains("stderr:\nerr"), "got: {output}"); - match emitter.only_process_event() { + match only_process_event(&mut receiver) { AgentEvent::ToolProcessCompleted { exit_code, termination, @@ -1395,8 +1409,8 @@ mod tests { } } - #[tokio::test] - async fn shell_public_schema_is_command_timeout_and_description() { + #[test] + fn shell_public_schema_is_command_timeout_and_description() { let tool = make_shell_tool(); assert_eq!( tool.definition.parameters, diff --git a/lib/components/fabro-agent/tests/it/docker_shell.rs b/lib/components/fabro-agent/tests/it/docker_shell.rs index 52d1dd081..ac0692fba 100644 --- a/lib/components/fabro-agent/tests/it/docker_shell.rs +++ b/lib/components/fabro-agent/tests/it/docker_shell.rs @@ -2,34 +2,22 @@ //! Docker provider's streaming path, which uses a `bash -lc` supervisor and //! separate stdout/stderr channels. -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use fabro_agent::event::SessionBoundEmitter; use fabro_agent::sandbox::Sandbox; -use fabro_agent::tool_registry::{AgentEventEmitter, ToolContext}; +use fabro_agent::tool_registry::ToolContext; use fabro_agent::tools::make_shell_tool; use fabro_agent::types::AgentEvent; -use fabro_agent::{DockerSandbox, DockerSandboxOptions}; +use fabro_agent::{DockerSandbox, DockerSandboxOptions, Emitter}; use fabro_types::CommandTermination; +use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; -#[derive(Default)] -struct RecordingAgentEmitter { - events: Mutex>, -} - -impl AgentEventEmitter for RecordingAgentEmitter { - fn emit(&self, event: AgentEvent) { - self.events - .lock() - .expect("events lock poisoned") - .push(event); - } -} - #[tokio::test] #[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"] async fn shell_reports_real_docker_process_outcome() { - let Ok(sandbox) = DockerSandbox::new( + let sandbox = DockerSandbox::new( DockerSandboxOptions { image: "buildpack-deps:noble".to_string(), auto_pull: false, @@ -40,16 +28,16 @@ async fn shell_reports_real_docker_process_outcome() { None, None, None, - ) else { - return; - }; - // No Docker daemon or no local image: nothing to prove here. - if sandbox.initialize().await.is_err() { - return; - } + ) + .expect("docker sandbox should construct"); + sandbox + .initialize() + .await + .expect("docker sandbox should initialize"); let sandbox = Arc::new(sandbox); - let emitter = Arc::new(RecordingAgentEmitter::default()); + let emitter = Emitter::new(); + let mut receiver = emitter.subscribe(); let tool = make_shell_tool(); let result = (tool.executor)( serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}), @@ -57,10 +45,14 @@ async fn shell_reports_real_docker_process_outcome() { env: sandbox.clone() as Arc, cancel: CancellationToken::new(), tool_env_provider: None, - session_id: None, - root_session_id: None, - tool_call_id: None, - agent_event_emitter: Some(emitter.clone()), + session_id: Some("test-session".to_string()), + root_session_id: Some("test-session".to_string()), + tool_call_id: Some("call_1".to_string()), + agent_event_emitter: Some(Arc::new(SessionBoundEmitter { + emitter, + session_id: "test-session".to_string(), + tool_call_id: Some("call_1".to_string()), + })), }, ) .await; @@ -75,9 +67,14 @@ async fn shell_reports_real_docker_process_outcome() { assert!(output.contains("stdout:\nout"), "got: {output}"); assert!(output.contains("stderr:\nerr"), "got: {output}"); - let events = emitter.events.lock().expect("events lock poisoned").clone(); - assert_eq!(events.len(), 1, "expected one agent event, got {events:?}"); - match &events[0] { + let event = receiver.try_recv().expect("one process event"); + assert_eq!(event.session_id, "test-session"); + assert_eq!(event.tool_call_id.as_deref(), Some("call_1")); + assert!(matches!( + receiver.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + match event.event { AgentEvent::ToolProcessCompleted { exit_code, termination, @@ -85,10 +82,10 @@ async fn shell_reports_real_docker_process_outcome() { exec_output_tail, .. } => { - assert_eq!(*exit_code, Some(7)); - assert_eq!(*termination, CommandTermination::Exited); + assert_eq!(exit_code, Some(7)); + assert_eq!(termination, CommandTermination::Exited); assert!(streams_separated); - let tail = exec_output_tail.as_ref().expect("output tail"); + let tail = exec_output_tail.expect("output tail"); assert_eq!(tail.stdout.as_deref(), Some("out")); assert_eq!(tail.stderr.as_deref(), Some("err")); } diff --git a/lib/components/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs index fa227d2c8..693a93307 100644 --- a/lib/components/fabro-sandbox/src/daytona/mod.rs +++ b/lib/components/fabro-sandbox/src/daytona/mod.rs @@ -1637,7 +1637,7 @@ impl Sandbox for DaytonaSandbox { working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let sandbox = self.sandbox()?; let start = Instant::now(); @@ -1694,9 +1694,11 @@ impl Sandbox for DaytonaSandbox { if !bytes.is_empty() { saw_live_chunk.store(true, Ordering::Relaxed); stdout_seen.lock().await.extend_from_slice(&bytes); - callback(CommandOutputStream::Stdout, bytes) - .await - .map_err(|err| daytona_callback_error(&err))?; + if let Some(callback) = callback { + callback(CommandOutputStream::Stdout, bytes) + .await + .map_err(|err| daytona_callback_error(&err))?; + } } Ok(()) } @@ -1710,9 +1712,11 @@ impl Sandbox for DaytonaSandbox { if !bytes.is_empty() { saw_live_chunk.store(true, Ordering::Relaxed); stderr_seen.lock().await.extend_from_slice(&bytes); - callback(CommandOutputStream::Stderr, bytes) - .await - .map_err(|err| daytona_callback_error(&err))?; + if let Some(callback) = callback { + callback(CommandOutputStream::Stderr, bytes) + .await + .map_err(|err| daytona_callback_error(&err))?; + } } Ok(()) } @@ -1769,14 +1773,14 @@ impl Sandbox for DaytonaSandbox { CommandOutputStream::Stdout, logs.stdout.as_bytes(), &stdout_seen, - &output_callback, + output_callback.as_ref(), ) .await?; append_missing_log_suffix( CommandOutputStream::Stderr, logs.stderr.as_bytes(), &stderr_seen, - &output_callback, + output_callback.as_ref(), ) .await?; } @@ -2181,7 +2185,7 @@ async fn append_missing_log_suffix( stream: CommandOutputStream, final_bytes: &[u8], seen: &Arc>>, - output_callback: &CommandOutputCallback, + output_callback: Option<&CommandOutputCallback>, ) -> crate::Result<()> { if final_bytes.is_empty() { return Ok(()); @@ -2196,7 +2200,10 @@ async fn append_missing_log_suffix( let missing = final_bytes[offset..].to_vec(); seen.extend_from_slice(&missing); drop(seen); - output_callback(stream, missing).await + match output_callback { + Some(output_callback) => output_callback(stream, missing).await, + None => Ok(()), + } } fn missing_log_suffix_offset(seen: &[u8], final_bytes: &[u8]) -> usize { diff --git a/lib/components/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs index 51f52a41c..07f39b10a 100644 --- a/lib/components/fabro-sandbox/src/docker.rs +++ b/lib/components/fabro-sandbox/src/docker.rs @@ -340,7 +340,7 @@ impl DockerSandbox { cmd: Vec, working_dir: Option, env: Option>, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result<(Vec, Vec, i32)> { let exec_opts = CreateExecOptions { cmd: Some(cmd), @@ -369,11 +369,15 @@ impl DockerSandbox { match chunk { Ok(LogOutput::StdOut { message }) => { stdout.extend_from_slice(&message); - output_callback(CommandOutputStream::Stdout, message.to_vec()).await?; + if let Some(output_callback) = output_callback.as_ref() { + output_callback(CommandOutputStream::Stdout, message.to_vec()).await?; + } } Ok(LogOutput::StdErr { message }) => { stderr.extend_from_slice(&message); - output_callback(CommandOutputStream::Stderr, message.to_vec()).await?; + if let Some(output_callback) = output_callback.as_ref() { + output_callback(CommandOutputStream::Stderr, message.to_vec()).await?; + } } Ok(_) => {} Err(e) => { @@ -460,7 +464,7 @@ impl DockerSandbox { working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let start = Instant::now(); let effective_dir = working_dir @@ -1548,7 +1552,7 @@ impl Sandbox for DockerSandbox { working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let dir = working_dir.map(|path| self.resolve_container_path(path)); self.docker_exec_shell_streaming( diff --git a/lib/components/fabro-sandbox/src/local.rs b/lib/components/fabro-sandbox/src/local.rs index ca21e5631..77fcd79f5 100644 --- a/lib/components/fabro-sandbox/src/local.rs +++ b/lib/components/fabro-sandbox/src/local.rs @@ -419,7 +419,7 @@ impl Sandbox for LocalSandbox { working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, cancel_token: Option, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let start = Instant::now(); @@ -831,7 +831,7 @@ async fn sigterm_then_kill(child: &mut Child) { async fn drain_command_pipe( mut reader: Option, stream: CommandOutputStream, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result> where R: AsyncRead + Unpin, @@ -851,7 +851,9 @@ where return Ok(output); } output.extend_from_slice(&buf[..read]); - output_callback(stream, buf[..read].to_vec()).await?; + if let Some(output_callback) = output_callback.as_ref() { + output_callback(stream, buf[..read].to_vec()).await?; + } } } diff --git a/lib/components/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs index fa2e9bacb..8bc9cb801 100644 --- a/lib/components/fabro-sandbox/src/sandbox.rs +++ b/lib/components/fabro-sandbox/src/sandbox.rs @@ -111,7 +111,7 @@ macro_rules! delegate_sandbox { working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, cancel_token: Option, - output_callback: $crate::CommandOutputCallback, + output_callback: Option<$crate::CommandOutputCallback>, ) -> $crate::Result<$crate::ExecStreamingResult> { self.$field .exec_command_streaming( @@ -680,6 +680,34 @@ pub type CommandOutputCallback = Arc< + Sync, >; +pub(crate) async fn replay_exec_result( + result: ExecResult, + streams_separated: bool, + output_callback: Option<&CommandOutputCallback>, +) -> crate::Result { + if let Some(output_callback) = output_callback { + if !result.stdout.is_empty() { + output_callback( + CommandOutputStream::Stdout, + result.stdout.as_bytes().to_vec(), + ) + .await?; + } + if !result.stderr.is_empty() { + output_callback( + CommandOutputStream::Stderr, + result.stderr.as_bytes().to_vec(), + ) + .await?; + } + } + Ok(ExecStreamingResult { + result, + streams_separated, + live_streaming: false, + }) +} + pub struct StdioProcess { pub stdin: Pin>, pub stdout: Pin>, @@ -856,11 +884,13 @@ pub trait Sandbox: Send + Sync { /// /// **Production sandboxes must override this.** The default falls back to /// the non-streaming [`exec_command`](Self::exec_command) and replays its - /// output through `output_callback` at the end, marking - /// `live_streaming: false`. That's the right behavior for test mocks but - /// silently drops live output for any real sandbox that wraps another — - /// decorators in particular must forward to the inner sandbox's streaming - /// implementation rather than relying on this default. + /// output through `output_callback` at the end when one is supplied, + /// marking `live_streaming: false`. Passing `None` captures the final + /// result without paying per-chunk callback costs. That's the right + /// behavior for test mocks but silently drops live output for any real + /// sandbox that wraps another — decorators in particular must forward to + /// the inner sandbox's streaming implementation rather than relying on + /// this default. async fn exec_command_streaming( &self, command: &str, @@ -868,7 +898,7 @@ pub trait Sandbox: Send + Sync { working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, cancel_token: Option, - output_callback: CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let fallback_timeout_ms = timeout_ms.unwrap_or(u64::MAX); let result = self @@ -880,25 +910,7 @@ pub trait Sandbox: Send + Sync { cancel_token, ) .await?; - if !result.stdout.is_empty() { - output_callback( - CommandOutputStream::Stdout, - result.stdout.as_bytes().to_vec(), - ) - .await?; - } - if !result.stderr.is_empty() { - output_callback( - CommandOutputStream::Stderr, - result.stderr.as_bytes().to_vec(), - ) - .await?; - } - Ok(ExecStreamingResult { - result, - streams_separated: true, - live_streaming: false, - }) + replay_exec_result(result, true, output_callback.as_ref()).await } async fn spawn_stdio_process( diff --git a/lib/components/fabro-sandbox/src/test_support.rs b/lib/components/fabro-sandbox/src/test_support.rs index d48f552e2..f976f5069 100644 --- a/lib/components/fabro-sandbox/src/test_support.rs +++ b/lib/components/fabro-sandbox/src/test_support.rs @@ -9,7 +9,7 @@ use tokio::io::{DuplexStream, duplex}; use tokio::time::sleep; use tokio_util::sync::CancellationToken; -use crate::sandbox::StdioProcessControl; +use crate::sandbox::{self, StdioProcessControl}; use crate::{ DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle, @@ -254,7 +254,7 @@ impl Sandbox for MockSandbox { working_dir: Option<&str>, env_vars: Option<&std::collections::HashMap>, cancel_token: Option, - output_callback: crate::CommandOutputCallback, + output_callback: Option, ) -> crate::Result { let result = self .exec_command( @@ -265,25 +265,7 @@ impl Sandbox for MockSandbox { cancel_token, ) .await?; - if !result.stdout.is_empty() { - output_callback( - fabro_types::CommandOutputStream::Stdout, - result.stdout.as_bytes().to_vec(), - ) - .await?; - } - if !result.stderr.is_empty() { - output_callback( - fabro_types::CommandOutputStream::Stderr, - result.stderr.as_bytes().to_vec(), - ) - .await?; - } - Ok(crate::ExecStreamingResult { - result, - streams_separated: self.streams_separated, - live_streaming: false, - }) + sandbox::replay_exec_result(result, self.streams_separated, output_callback.as_ref()).await } async fn spawn_stdio_process( diff --git a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs index 4b5dd6590..40f3a9b77 100644 --- a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs @@ -265,7 +265,7 @@ mod daytona_streaming_live { None, None, Some(cancel_for_exec), - callback, + Some(callback), ) .await }); @@ -390,7 +390,7 @@ mod daytona_streaming_live { None, None, cancel_token, - callback, + Some(callback), ) .await?; let chunks = chunks.lock().await.clone(); diff --git a/lib/components/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs index f77674a30..6f4cd7562 100644 --- a/lib/components/fabro-sandbox/tests/docker_streaming.rs +++ b/lib/components/fabro-sandbox/tests/docker_streaming.rs @@ -53,7 +53,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() { None, None, None, - callback, + Some(callback), ) .await .expect("streaming command should return a timeout result"); diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index 1731cd265..f6e2de0f1 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -1543,12 +1543,7 @@ mod tests { termination: ::fabro_types::CommandTermination::Exited, duration_ms: 12, streams_separated: true, - exec_output_tail: Some(::fabro_types::ExecOutputTail { - stdout: Some("out".to_string()), - stderr: Some("err".to_string()), - stdout_truncated: false, - stderr_truncated: false, - }), + exec_output_tail: Some(exec_tail()), }, session_id: Some("ses_child".to_string()), parent_session_id: Some("ses_parent".to_string()), @@ -1575,7 +1570,7 @@ mod tests { assert_eq!(properties["termination"], "exited"); assert_eq!(properties["duration_ms"], 12); assert_eq!(properties["streams_separated"], true); - assert_eq!(properties["exec_output_tail"]["stdout"], "out"); + assert_eq!(properties["exec_output_tail"]["stdout"], "last stdout line"); assert_eq!(properties["visit"], 2); } diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index 54fe0ff20..74a32f0ac 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -128,7 +128,7 @@ impl Handler for CommandHandler { None, env_vars, Some(cancel_token.clone()), - output_callback, + Some(output_callback), ) .await; cancel_token.cancel(); diff --git a/lib/foundation/fabro-types/src/run_event/infra.rs b/lib/foundation/fabro-types/src/run_event/infra.rs index 8af295ffa..5348b4931 100644 --- a/lib/foundation/fabro-types/src/run_event/infra.rs +++ b/lib/foundation/fabro-types/src/run_event/infra.rs @@ -401,3 +401,30 @@ pub struct CliEnsureFailedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub exec_output_tail: Option, } + +#[cfg(test)] +mod tests { + use super::ExecOutputTail; + + /// The trace summary is expanded into tracing fields, so it must carry + /// sizes and truncation flags only. + #[test] + fn exec_output_tail_trace_summary_exposes_sizes_not_content() { + let tail = ExecOutputTail { + stdout: Some("secret stdout bytes".to_string()), + stderr: Some("secret stderr".to_string()), + stdout_truncated: true, + stderr_truncated: false, + }; + + let summary = ExecOutputTail::trace_summary(Some(&tail)); + + assert!(summary.present); + assert_eq!(summary.stdout_bytes, 19); + assert_eq!(summary.stderr_bytes, 13); + assert!(summary.stdout_truncated); + assert!(!summary.stderr_truncated); + let rendered = format!("{summary:?}"); + assert!(!rendered.contains("secret"), "got: {rendered}"); + } +} diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 4b937f682..b79f7758b 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -2475,28 +2475,6 @@ mod tests { assert_eq!(parsed, body); } - /// The trace summary is what `AgentEvent::trace()` expands into tracing - /// fields, so it must carry sizes and truncation flags only. - #[test] - fn exec_output_tail_trace_summary_exposes_sizes_not_content() { - let tail = ExecOutputTail { - stdout: Some("secret stdout bytes".to_string()), - stderr: Some("secret stderr".to_string()), - stdout_truncated: true, - stderr_truncated: false, - }; - - let summary = ExecOutputTail::trace_summary(Some(&tail)); - - assert!(summary.present); - assert_eq!(summary.stdout_bytes, 19); - assert_eq!(summary.stderr_bytes, 13); - assert!(summary.stdout_truncated); - assert!(!summary.stderr_truncated); - let rendered = format!("{summary:?}"); - assert!(!rendered.contains("secret"), "got: {rendered}"); - } - #[test] fn agent_tool_source_and_category_use_public_json_shape() { assert_eq!( From 5d0617f5478120a8223a61efe963f5798e759244 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 24 Jul 2026 21:58:23 -0400 Subject: [PATCH 15/20] fix(agent): harden compaction reasoning budgets Model default reasoning explicitly at the provider-route level so always-reasoning endpoints without effort controls receive summary headroom. Cap all summary requests at model output limits and bound retained visible summaries to the original allowance. Reuse builtin catalog fixtures and named budget constants in tests, and document the new model setting. --- docs/public/reference/user-configuration.mdx | 1 + .../fabro-agent/src/agent_profile.rs | 9 + lib/components/fabro-agent/src/cli.rs | 2 + lib/components/fabro-agent/src/compaction.rs | 180 ++++++++++-------- lib/foundation/fabro-config/src/builders.rs | 1 + lib/foundation/fabro-config/src/layers/llm.rs | 4 + .../src/commands/docs_options_reference.rs | 1 + lib/foundation/fabro-model/src/catalog.rs | 94 ++++++++- .../src/catalog/providers/bedrock.toml | 1 + .../src/catalog/providers/kimi.toml | 1 + 10 files changed, 211 insertions(+), 83 deletions(-) diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index dd6afd83f..cfc3e09cb 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -279,6 +279,7 @@ cache_input_cost_per_mtok = 0.60 | `tools` | boolean | `false` | Whether the model supports tool calls. | | `vision` | boolean | `false` | Whether the model accepts image inputs. | | `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | +| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. | | `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. | | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). | diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index b46095ff1..15140a3e2 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -52,6 +52,15 @@ pub trait AgentProfile: Send + Sync { self.catalog_model().and_then(Model::max_output) } + fn reasons_by_default(&self) -> bool { + let Some(catalog) = self.catalog() else { + return false; + }; + catalog + .model_settings_on_provider(&self.provider_id(), self.model()) + .is_some_and(|settings| settings.reasoning_by_default) + } + fn register_subagent_tools( &mut self, supervisor: SubAgentSupervisor, diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index 12e033e03..61551af4b 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -993,6 +993,7 @@ mod tests { tools: Some(true), vision: Some(false), reasoning: Some(false), + reasoning_by_default: None, reasoning_effort: None, prompt_cache: None, cache_control_breakpoints: None, @@ -1079,6 +1080,7 @@ mod tests { tools: Some(true), vision: Some(false), reasoning: Some(false), + reasoning_by_default: None, reasoning_effort: None, prompt_cache: None, cache_control_breakpoints: None, diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index c2256d186..698d68275 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -2,7 +2,6 @@ use std::fmt::Write; use fabro_llm::client::Client; use fabro_llm::types::{Message as LlmMessage, Request}; -use fabro_model::Model; use tracing::debug; use crate::agent_profile::AgentProfile; @@ -127,12 +126,16 @@ Write a summary using EXACTLY these sections:\n\n\ ## Failed Approaches\nWhat was tried and didn't work, and why.\n\n\ ## Open Issues\nBugs, edge cases, or TODOs that remain.\n\n\ ## Next Steps\nWhat should happen next to make progress.\n\n\ +Keep the entire response under {SUMMARY_MAX_TOKENS} tokens.\n\n\ Be thorough and specific — the assistant taking over has no prior context. Include file paths, \ function names, error messages, and exact values. Omit pleasantries and conversational filler.\ {file_ops_section}" ); - let max_tokens = summary_max_tokens(provider_profile.catalog_model()); + let max_tokens = summary_max_tokens( + provider_profile.reasons_by_default(), + provider_profile.max_output_tokens(), + ); let summary_request = Request { model: provider_profile.model().to_string(), @@ -174,9 +177,10 @@ function names, error messages, and exact values. Omit pleasantries and conversa .into()); } + let (summary_text, summary_truncated) = truncate_summary_text(summary_text); debug!( summary_len = summary_text.len(), - max_tokens, "Compaction summary generated" + summary_truncated, max_tokens, "Compaction summary generated" ); let summary_content = format!( "A different assistant began this task and produced the following summary. \ @@ -196,26 +200,41 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" Ok(()) } -/// Output budget for the summarization request. +/// Combined reasoning and visible-output budget for the summarization request. /// /// Compaction runs against the session's own model, so a reasoning session /// summarizes with reasoning enabled and the budget has to cover the thinking -/// as well as the summary. Models that reason unconditionally get headroom on -/// top of the summary allowance, capped at the model's own `max_output`. -/// -/// A model only reasons here when its endpoint reasons without being asked: -/// `always_adaptive` models think natively, and `levels` models get thinking -/// enabled by default (adaptive thinking injected by the Anthropic codec, the -/// provider's default effort on OpenAI-style routes). Compaction never sends a -/// `reasoning_effort`, so models without an effort feature stay non-reasoning -/// on this path and keep the plain summary budget. -fn summary_max_tokens(model: Option<&Model>) -> i64 { - let Some(model) = model.filter(|m| m.supports_reasoning_effort()) else { - return SUMMARY_MAX_TOKENS; +/// as well as the summary. Provider routes that reason by default get headroom +/// on top of the summary allowance. Every known model budget is capped at its +/// declared `max_output`. +fn summary_max_tokens(reasoning_by_default: bool, max_output: Option) -> i64 { + let budget = if reasoning_by_default { + SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS + } else { + SUMMARY_MAX_TOKENS }; - let budget = SUMMARY_MAX_TOKENS.saturating_add(REASONING_HEADROOM_TOKENS); - model.max_output().map_or(budget, |limit| budget.min(limit)) + max_output.map_or(budget, |limit| budget.min(limit)) +} + +/// Bound retained summary text with the same local bytes-per-token heuristic +/// used for context estimates. Provider APIs expose only one combined ceiling +/// for reasoning and visible output, so the larger request budget cannot +/// enforce this limit itself. +fn truncate_summary_text(summary: &str) -> (&str, bool) { + let max_bytes = summary_max_approx_bytes(); + if summary.len() <= max_bytes { + return (summary, false); + } + + let end = summary.floor_char_boundary(max_bytes); + (&summary[..end], true) +} + +fn summary_max_approx_bytes() -> usize { + usize::try_from(SUMMARY_MAX_TOKENS) + .unwrap_or(usize::MAX) + .saturating_mul(APPROX_CHARS_PER_TOKEN) } pub(crate) fn estimate_active_context_usage( @@ -348,10 +367,7 @@ mod tests { use std::time::SystemTime; use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; - use fabro_model::{ - Catalog, ModelControls, ModelCosts, ModelFeatures, ModelId, ModelLimits, ProviderId, - ReasoningEffortFeature, - }; + use fabro_model::{Catalog, Model, ProviderId}; use super::*; use crate::event::Emitter; @@ -360,62 +376,38 @@ mod tests { use crate::tool_registry::ToolRegistry; use crate::types::Message; - fn anthropic_model(id: &str) -> &'static Model { + fn catalog_model(provider: &ProviderId, id: &str) -> &'static Model { Catalog::builtin() - .get_on_provider(&ProviderId::anthropic(), id) - .unwrap_or_else(|| panic!("{id} missing from builtin catalog")) + .get_on_provider(provider, id) + .unwrap_or_else(|| panic!("{provider}/{id} missing from builtin catalog")) } - /// A model that reasons unconditionally but caps output below the budget - /// compaction would otherwise ask for. - fn small_output_reasoning_model(max_output: i64) -> Model { - Model { - id: ModelId::new("small-output-reasoner"), - provider: ProviderId::anthropic(), - family: "test".into(), - display_name: "Small Output Reasoner".into(), - limits: ModelLimits { - context_window: 200_000, - max_output: Some(max_output), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::AlwaysAdaptive, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: None, - output_cost_per_mtok: None, - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: vec![], - default: false, - small_default: false, - configured: false, - } + fn builtin_summary_max_tokens(provider: &ProviderId, id: &str) -> i64 { + let catalog = Catalog::builtin(); + let model = catalog_model(provider, id); + let settings = catalog + .settings_for(model) + .unwrap_or_else(|| panic!("{provider}/{id} missing catalog settings")); + summary_max_tokens(settings.reasoning_by_default, model.max_output()) } #[test] fn summary_budget_without_catalog_model_is_summary_allowance() { - assert_eq!(summary_max_tokens(None), 4096); + assert_eq!(summary_max_tokens(false, None), SUMMARY_MAX_TOKENS); // The default agent test profile has no catalog behind it. - assert_eq!(summary_max_tokens(TestProfile::new().catalog_model()), 4096); + let profile = TestProfile::new(); + assert_eq!( + summary_max_tokens(profile.reasons_by_default(), profile.max_output_tokens()), + SUMMARY_MAX_TOKENS + ); } #[test] fn summary_budget_for_non_reasoning_model_is_summary_allowance() { // claude-haiku-4-5: reasoning = false. assert_eq!( - summary_max_tokens(Some(anthropic_model("claude-haiku-4-5"))), - 4096 + builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-haiku-4-5"), + SUMMARY_MAX_TOKENS ); } @@ -423,36 +415,47 @@ mod tests { fn summary_budget_for_model_without_effort_feature_is_summary_allowance() { // claude-sonnet-4-5 reasons only when a request asks for it, and // compaction never sends a reasoning effort. - let model = anthropic_model("claude-sonnet-4-5"); + let model = catalog_model(&ProviderId::anthropic(), "claude-sonnet-4-5"); assert!(model.supports_reasoning()); assert!(!model.supports_reasoning_effort()); - assert_eq!(summary_max_tokens(Some(model)), 4096); + assert_eq!( + builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-sonnet-4-5"), + SUMMARY_MAX_TOKENS + ); } #[test] fn summary_budget_for_always_adaptive_model_adds_reasoning_headroom() { - let model = anthropic_model("claude-fable-5"); assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::AlwaysAdaptive + builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-fable-5"), + SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); - assert_eq!(summary_max_tokens(Some(model)), 4096 + 16_384); } #[test] fn summary_budget_for_effort_levels_model_adds_reasoning_headroom() { - let model = anthropic_model("claude-opus-5"); assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::Levels + builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-opus-5"), + SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS + ); + } + + #[test] + fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() { + let kimi = ProviderId::new("kimi"); + let model = catalog_model(&kimi, "kimi-k2.5"); + assert!(model.supports_reasoning()); + assert!(!model.supports_reasoning_effort()); + assert_eq!( + builtin_summary_max_tokens(&kimi, "kimi-k2.5"), + SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); - assert_eq!(summary_max_tokens(Some(model)), 4096 + 16_384); } #[test] fn summary_budget_never_exceeds_model_max_output() { - let model = small_output_reasoning_model(8_192); - assert_eq!(summary_max_tokens(Some(&model)), 8_192); + assert_eq!(summary_max_tokens(true, Some(8_192)), 8_192); + assert_eq!(summary_max_tokens(false, Some(2_048)), 2_048); } #[test] @@ -845,4 +848,29 @@ mod tests { "CompactionCompleted should be emitted on success" ); } + + #[tokio::test] + async fn compaction_bounds_retained_summary_to_visible_budget() { + let max_bytes = summary_max_approx_bytes(); + let overlong = format!("{}END", "€".repeat(max_bytes / 3 + 1)); + let CompactionTestResult { + result, history, .. + } = compact_with_summary(&overlong).await; + + result.expect("an overlong summary should be compacted after truncation"); + + let summary_turn = history + .turns() + .iter() + .find_map(|turn| match turn { + Message::System { content, .. } => Some(content), + _ => None, + }) + .expect("compacted history should contain a summary turn"); + let (_, retained_summary) = summary_turn + .split_once("\n\n") + .expect("summary turn should separate its header from the generated text"); + assert!(retained_summary.len() <= max_bytes); + assert!(!retained_summary.contains("END")); + } } diff --git a/lib/foundation/fabro-config/src/builders.rs b/lib/foundation/fabro-config/src/builders.rs index a1e00907c..980faa3f3 100644 --- a/lib/foundation/fabro-config/src/builders.rs +++ b/lib/foundation/fabro-config/src/builders.rs @@ -420,6 +420,7 @@ fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::Sett tools: features.tools, vision: features.vision, reasoning: features.reasoning, + reasoning_by_default: features.reasoning_by_default, reasoning_effort: features.reasoning_effort, prompt_cache: features.prompt_cache, cache_control_breakpoints: features.cache_control_breakpoints, diff --git a/lib/foundation/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs index 4f89c55fc..d1faa7e8d 100644 --- a/lib/foundation/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -244,6 +244,8 @@ pub struct ModelFeatures { #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_by_default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_cache: Option, @@ -647,6 +649,7 @@ provider = "bedrock" tools = true vision = true reasoning = true +reasoning_by_default = false reasoning_effort = "levels" prompt_cache = false "#; @@ -663,6 +666,7 @@ prompt_cache = false features.reasoning_effort, Some(fabro_model::ReasoningEffortFeature::Levels) ); + assert_eq!(features.reasoning_by_default, Some(false)); assert_eq!(features.prompt_cache, Some(false)); } diff --git a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs index 9a2414e0a..e547e8e4c 100644 --- a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs @@ -326,6 +326,7 @@ cache_input_cost_per_mtok = 0.60 | `tools` | boolean | `false` | Whether the model supports tool calls. | | `vision` | boolean | `false` | Whether the model accepts image inputs. | | `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | +| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. | | `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. | | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). | diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 7ed634241..7fa9eb161 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -146,6 +146,11 @@ pub struct SettingsModelFeatures { pub vision: Option, #[serde(default)] pub reasoning: Option, + /// Whether requests reason when no effort control is supplied. When + /// omitted, effort-capable models default to `true` and other models to + /// `false`. + #[serde(default)] + pub reasoning_by_default: Option, #[serde(default)] pub reasoning_effort: Option, #[serde(default)] @@ -443,17 +448,20 @@ pub struct CatalogModelControls { #[derive(Debug, Clone, PartialEq)] pub struct CatalogModelSettings { - pub api_id: String, + pub api_id: String, /// Wire dialect for this model's route (the provider codec unless the /// model row overrides it). - pub codec: CodecKind, + pub codec: CodecKind, /// Billing family for this model (the provider policy unless the model /// row overrides it). - pub billing_policy: BillingPolicy, - pub agent_profile: AgentProfileKind, - pub controls: CatalogModelControls, - pub speed_costs: HashMap, - probe: bool, + pub billing_policy: BillingPolicy, + pub agent_profile: AgentProfileKind, + /// Whether the provider route reasons when a request omits an effort + /// control. + pub reasoning_by_default: bool, + pub controls: CatalogModelControls, + pub speed_costs: HashMap, + probe: bool, } #[derive(Debug, thiserror::Error)] @@ -578,6 +586,8 @@ pub enum CatalogBuildError { ReasoningEffortControlsWithoutReasoning { model: String }, #[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")] ReasoningEffortWithoutReasoning { model: String }, + #[error("model '{model}' sets reasoning_by_default but features.reasoning is false")] + DefaultReasoningWithoutReasoning { model: String }, #[error( "model '{model}' declares cache_control_breakpoints but features.prompt_cache is false" )] @@ -1983,6 +1993,9 @@ fn merge_model_features_settings( tools: higher.tools.or(fallback.tools), vision: higher.vision.or(fallback.vision), reasoning: higher.reasoning.or(fallback.reasoning), + reasoning_by_default: higher + .reasoning_by_default + .or(fallback.reasoning_by_default), reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), cache_control_breakpoints: higher @@ -2214,6 +2227,14 @@ fn build_model( field: "features", })?; let model_features = build_model_features(model_id, features)?; + let reasoning_by_default = features + .reasoning_by_default + .unwrap_or_else(|| model_features.supports_reasoning_effort()); + if reasoning_by_default && !model_features.reasoning { + return Err(CatalogBuildError::DefaultReasoningWithoutReasoning { + model: model_id.to_string(), + }); + } let controls = build_model_controls(model_id, &model_features, settings)?; let costs = build_model_costs(settings.costs.as_ref()); let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; @@ -2255,6 +2276,7 @@ fn build_model( codec: resolve_model_codec(model_id, provider, settings.codec)?, billing_policy: settings.billing_policy.unwrap_or(provider.billing_policy), agent_profile: settings.agent_profile.unwrap_or(provider.agent_profile), + reasoning_by_default, controls, speed_costs, probe: settings.probe.unwrap_or_default(), @@ -2832,6 +2854,12 @@ enabled = true .get_on_provider(&bedrock, "claude-fable-5") .expect("fable row should be present"); assert!(!fable.features.sampling_params); + assert!( + catalog + .settings_for(fable) + .expect("fable settings should be present") + .reasoning_by_default + ); assert_eq!( catalog .model_settings_on_provider(&bedrock, "claude-fable-5") @@ -5915,6 +5943,7 @@ context_window = 1000 tools = true vision = false reasoning = true +reasoning_by_default = false reasoning_effort = "levels" prompt_cache = true @@ -5930,6 +5959,12 @@ reasoning_effort = ["low", "medium"] crate::ReasoningEffortFeature::Levels ); assert!(model.features.prompt_cache); + assert!( + !catalog + .model_settings("model") + .unwrap() + .reasoning_by_default + ); assert_eq!( catalog .model_settings("model") @@ -5974,6 +6009,12 @@ prompt_cache = true crate::ReasoningEffortFeature::AlwaysAdaptive ); assert!(model.supports_reasoning_effort()); + assert!( + catalog + .model_settings("model") + .unwrap() + .reasoning_by_default + ); // Always-adaptive models get the full default effort controls, same as // Levels. assert_eq!( @@ -6008,6 +6049,7 @@ context_window = 1000 tools = true vision = false reasoning = true +reasoning_by_default = true reasoning_effort = "none" [models.model.controls] @@ -6021,6 +6063,12 @@ reasoning_effort = ["low"] model.features.reasoning_effort, crate::ReasoningEffortFeature::None ); + assert!( + catalog + .model_settings("model") + .unwrap() + .reasoning_by_default + ); assert_eq!( catalog .model_settings("model") @@ -6098,6 +6146,38 @@ reasoning_effort = "levels" )); } + #[test] + fn catalog_from_settings_rejects_default_reasoning_without_reasoning() { + let settings = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" +agent_profile = "openai" + +[models.model] +provider = "test" +display_name = "Model" +family = "test" + +[models.model.limits] +context_window = 1000 + +[models.model.features] +tools = true +vision = false +reasoning = false +reasoning_by_default = true +"#, + ); + + assert!(matches!( + Catalog::from_settings(&settings).unwrap_err(), + CatalogBuildError::DefaultReasoningWithoutReasoning { model } + if model == "model" + )); + } + #[test] fn catalog_from_settings_rejects_cache_control_breakpoints_without_prompt_cache() { let settings = minimal_settings( diff --git a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml index aeed22e46..71c74d686 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml @@ -369,6 +369,7 @@ max_output = 128000 tools = true vision = true reasoning = true +reasoning_by_default = true prompt_cache = true sampling_params = false diff --git a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml index daa4b20c2..9da0d539a 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/kimi.toml @@ -23,6 +23,7 @@ max_output = 32768 tools = true vision = true reasoning = true +reasoning_by_default = true prompt_cache = true sampling_params = false From eddee10b35c27a2e0d31f909585ce5220071ffbf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 22:05:48 -0400 Subject: [PATCH 16/20] fix(agent): harden Kimi profile tool contracts --- docs/public/api-reference/fabro-api.yaml | 2 +- lib/components/fabro-agent/src/config.rs | 3 + .../fabro-agent/src/context_window.rs | 24 +- .../fabro-agent/src/file_tracker.rs | 51 +- lib/components/fabro-agent/src/lib.rs | 2 +- lib/components/fabro-agent/src/memory.rs | 22 +- lib/components/fabro-agent/src/native_tool.rs | 37 +- .../fabro-agent/src/profiles/kimi.rs | 64 ++- .../fabro-agent/src/profiles/kimi_tools.rs | 485 +++++++++++++----- .../fabro-agent/src/profiles/mod.rs | 6 +- .../src/profiles/prompts/kimi.md.j2 | 2 +- .../fabro-agent/src/question_tools.rs | 5 + lib/components/fabro-agent/src/session.rs | 17 +- lib/components/fabro-agent/src/skills.rs | 112 +++- .../fabro-agent/src/test_support.rs | 5 +- lib/components/fabro-agent/src/todo_tools.rs | 283 +++++----- .../fabro-agent/src/tool_registry.rs | 37 +- lib/components/fabro-agent/src/tools.rs | 147 ++++-- lib/components/fabro-agent/src/truncation.rs | 28 +- lib/components/fabro-store/src/run_state.rs | 55 +- .../fabro-workflow/src/handler/llm/api.rs | 38 +- .../tests/stage_projection_round_trip.rs | 33 +- lib/foundation/fabro-model/src/adapter.rs | 12 +- lib/foundation/fabro-types/src/todo.rs | 6 +- .../src/models/todo-list-kind.ts | 3 +- 25 files changed, 1026 insertions(+), 453 deletions(-) diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 8b011aef7..1dcbca2e5 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -11333,7 +11333,7 @@ components: TodoListKind: type: string - enum: [openai_plan, anthropic_tasks] + enum: [openai_plan, anthropic_tasks, kimi_todos] description: |- Tool surface a todo list belongs to. Determines the `list_id` prefix and scoping convention. diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index f83a721fe..45c9ba2e8 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -331,11 +331,14 @@ mod tests { fn native_tool_options_have_expected_profile_defaults() { let openai = NativeToolOptions::for_profile(AgentProfileKind::OpenAi); let anthropic = NativeToolOptions::for_profile(AgentProfileKind::Anthropic); + let kimi = NativeToolOptions::for_profile(AgentProfileKind::Kimi); assert_eq!(openai.default_command_timeout_ms, 10_000); assert_eq!(openai.max_command_timeout_ms, 600_000); assert_eq!(anthropic.default_command_timeout_ms, 120_000); assert_eq!(anthropic.max_command_timeout_ms, 600_000); + assert_eq!(kimi.default_command_timeout_ms, 60_000); + assert_eq!(kimi.max_command_timeout_ms, 600_000); } #[test] diff --git a/lib/components/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs index 0a5d1e362..851a51ea6 100644 --- a/lib/components/fabro-agent/src/context_window.rs +++ b/lib/components/fabro-agent/src/context_window.rs @@ -12,7 +12,7 @@ use fabro_types::{ }; use crate::memory::MemoryDocument; -use crate::native_tool::NativeTool; +use crate::native_tool::ToolVocabulary; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::{ToolDefinitionWithSource, ToolSource}; @@ -23,6 +23,7 @@ pub(crate) struct ContextWindowInput<'a> { pub system_prompt: &'a str, pub memory: &'a [MemoryDocument], pub skills: &'a [Skill], + pub tool_vocabulary: ToolVocabulary, pub activated_skill_context_observed: bool, pub provider: &'a str, pub model: &'a str, @@ -159,7 +160,7 @@ fn add_message_breakdown( input: &ContextWindowInput<'_>, ) { let memory_text = memory_prompt_suffix(input.memory); - let skills_text = skills_prompt_suffix(input.skills); + let skills_text = skills_prompt_suffix(input.skills, input.tool_vocabulary); let memory_tokens = estimate_text_tokens(&memory_text); let skills_tokens = estimate_text_tokens(&skills_text); let mut system_parts_seen = false; @@ -221,8 +222,8 @@ fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String { } } -fn skills_prompt_suffix(skills: &[Skill]) -> String { - let section = format_skills_prompt_section(skills, NativeTool::UseSkill.canonical_name()); +fn skills_prompt_suffix(skills: &[Skill], vocabulary: ToolVocabulary) -> String { + let section = format_skills_prompt_section(skills, vocabulary); if section.is_empty() { String::new() } else { @@ -391,7 +392,7 @@ mod tests { let system_prompt = format!( "core prompt{}{}", memory_prompt_suffix(&memory), - skills_prompt_suffix(&skills) + skills_prompt_suffix(&skills, ToolVocabulary::Fabro) ); let tools = vec![ tool("read_file", ToolSource::Native), @@ -415,6 +416,7 @@ mod tests { system_prompt: &system_prompt, memory: &memory, skills: &skills, + tool_vocabulary: ToolVocabulary::Fabro, activated_skill_context_observed: true, provider: "test", model: "model-a", @@ -447,6 +449,18 @@ mod tests { ); } + #[test] + fn skills_suffix_uses_the_profile_tool_vocabulary() { + let skills = vec![Skill { + name: "commit".to_string(), + description: "Commit changes".to_string(), + template: "commit template".to_string(), + }]; + + assert!(skills_prompt_suffix(&skills, ToolVocabulary::Fabro).contains("`use_skill`")); + assert!(skills_prompt_suffix(&skills, ToolVocabulary::KimiCode).contains("`Skill`")); + } + #[test] fn scaled_breakdown_totals_provider_count() { let local = StageContextWindowProjection { diff --git a/lib/components/fabro-agent/src/file_tracker.rs b/lib/components/fabro-agent/src/file_tracker.rs index 7f91496e9..1420822eb 100644 --- a/lib/components/fabro-agent/src/file_tracker.rs +++ b/lib/components/fabro-agent/src/file_tracker.rs @@ -3,6 +3,16 @@ use std::fmt::Write; use fabro_llm::types::{ToolCall, ToolResult}; +use crate::native_tool::NativeTool; +use crate::tool_permissions::canonical_tool_name; + +fn file_path(arguments: &serde_json::Value) -> Option<&str> { + arguments + .get("file_path") + .or_else(|| arguments.get("path")) + .and_then(serde_json::Value::as_str) +} + #[derive(Debug, Clone, Copy, Default)] struct FileOps { read: bool, @@ -59,23 +69,23 @@ impl FileTracker { if result.is_error { continue; } - match tc.name.as_str() { - "read_file" => { - if let Some(path) = tc.arguments.get("file_path").and_then(|v| v.as_str()) { + match canonical_tool_name(&tc.name) { + name if name == NativeTool::ReadFile.canonical_name() => { + if let Some(path) = file_path(&tc.arguments) { self.record_read(path); } } - "write_file" => { - if let Some(path) = tc.arguments.get("file_path").and_then(|v| v.as_str()) { + name if name == NativeTool::WriteFile.canonical_name() => { + if let Some(path) = file_path(&tc.arguments) { self.record_write(path); } } - "edit_file" => { - if let Some(path) = tc.arguments.get("file_path").and_then(|v| v.as_str()) { + name if name == NativeTool::EditFile.canonical_name() => { + if let Some(path) = file_path(&tc.arguments) { self.record_edit(path); } } - "apply_patch" => { + name if name == NativeTool::ApplyPatch.canonical_name() => { let content = match result.content.as_str() { Some(s) => s.to_string(), None => result.content.to_string(), @@ -166,6 +176,31 @@ mod tests { assert_eq!(tracker.render(), "- /tmp/baz.rs (edited)\n"); } + #[test] + fn record_from_kimi_tool_calls_uses_path_argument() { + let mut tracker = FileTracker::default(); + let tool_calls = vec![ + ToolCall::new("tc1", "Read", serde_json::json!({"path": "/tmp/a.rs"})), + ToolCall::new( + "tc2", + "Write", + serde_json::json!({"path": "/tmp/b.rs", "content": "x"}), + ), + ToolCall::new("tc3", "Edit", serde_json::json!({"path": "/tmp/c.rs"})), + ]; + let results = ["tc1", "tc2", "tc3"] + .into_iter() + .map(|id| ToolResult::success(id, serde_json::json!("ok"))) + .collect::>(); + + tracker.record_from_tool_calls(&tool_calls, &results); + + assert_eq!( + tracker.render(), + "- /tmp/a.rs (read)\n- /tmp/b.rs (written)\n- /tmp/c.rs (edited)\n" + ); + } + #[test] fn record_from_tool_calls_skips_errors() { let mut tracker = FileTracker::default(); diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index 19fee7d40..e75d4c690 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -50,7 +50,7 @@ pub use loop_detection::detect_loop; pub use memory::{MemoryDocument, discover_memory}; pub use native_tool::{NativeTool, ToolVocabulary}; pub use profiles::{ - AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile, + AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, KimiProfile, OpenAiProfile, }; pub use question_tools::{ ANTHROPIC_ASK_USER_QUESTION_TOOL, AgentQuestion, AgentQuestionAnswer, diff --git a/lib/components/fabro-agent/src/memory.rs b/lib/components/fabro-agent/src/memory.rs index bc4d40b5e..0367e4600 100644 --- a/lib/components/fabro-agent/src/memory.rs +++ b/lib/components/fabro-agent/src/memory.rs @@ -34,8 +34,8 @@ pub async fn discover_memory( AgentProfileKind::Anthropic => vec!["AGENTS.md", "CLAUDE.md"], AgentProfileKind::OpenAi => vec!["AGENTS.md", ".codex/instructions.md"], AgentProfileKind::Gemini => vec!["AGENTS.md", "GEMINI.md"], - // Kimi Code reads only AGENTS.md (and a lowercase variant); it has no - // vendor-specific instruction filename of its own. + // Kimi Code reads only AGENTS.md; it has no vendor-specific + // instruction filename of its own. AgentProfileKind::Kimi => vec!["AGENTS.md"], }; @@ -223,7 +223,7 @@ mod tests { assert_eq!(openai_docs[1].content, "copilot"); let env: Arc = Arc::new(MockSandbox { - files, + files: files.clone(), ..Default::default() }); let gemini_docs = discover_memory( @@ -238,6 +238,22 @@ mod tests { assert_eq!(gemini_docs.len(), 2); assert_eq!(gemini_docs[0].content, "agents"); assert_eq!(gemini_docs[1].content, "gemini"); + + let env: Arc = Arc::new(MockSandbox { + files, + ..Default::default() + }); + let kimi_docs = discover_memory( + env.as_ref(), + "/repo", + "/repo", + AgentProfileKind::Kimi, + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(kimi_docs.len(), 1); + assert_eq!(kimi_docs[0].content, "agents"); } #[tokio::test] diff --git a/lib/components/fabro-agent/src/native_tool.rs b/lib/components/fabro-agent/src/native_tool.rs index c2f9c8ff1..69c966bfa 100644 --- a/lib/components/fabro-agent/src/native_tool.rs +++ b/lib/components/fabro-agent/src/native_tool.rs @@ -34,27 +34,27 @@ pub enum ToolVocabulary { Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, IntoStaticStr, VariantArray, )] pub enum NativeTool { - #[strum(to_string = "read_file")] + #[strum(to_string = "read_file", serialize = "Read")] ReadFile, #[strum(to_string = "read_many_files")] ReadManyFiles, - #[strum(to_string = "write_file")] + #[strum(to_string = "write_file", serialize = "Write")] WriteFile, - #[strum(to_string = "edit_file")] + #[strum(to_string = "edit_file", serialize = "Edit")] EditFile, #[strum(to_string = "apply_patch")] ApplyPatch, #[strum(to_string = "list_dir")] ListDir, - #[strum(to_string = "grep")] + #[strum(to_string = "grep", serialize = "Grep")] Grep, - #[strum(to_string = "glob")] + #[strum(to_string = "glob", serialize = "Glob")] Glob, - #[strum(to_string = "shell")] + #[strum(to_string = "shell", serialize = "Bash")] Shell, - #[strum(to_string = "web_search")] + #[strum(to_string = "web_search", serialize = "WebSearch")] WebSearch, - #[strum(to_string = "web_fetch")] + #[strum(to_string = "web_fetch", serialize = "FetchURL")] WebFetch, #[strum(to_string = "spawn_agent")] SpawnAgent, @@ -64,7 +64,7 @@ pub enum NativeTool { Wait, #[strum(to_string = "close_agent")] CloseAgent, - #[strum(to_string = "use_skill")] + #[strum(to_string = "use_skill", serialize = "Skill")] UseSkill, #[strum(to_string = "update_plan")] UpdatePlan, @@ -93,6 +93,19 @@ impl NativeTool { self.into() } + /// Resolve a canonical fabro name to its built-in identity. + /// + /// Unlike [`Self::from_any_name`], this deliberately ignores provider + /// aliases. Registries use it while registering tools so an unrelated + /// extension named `Read` is not silently treated as fabro's file reader. + #[must_use] + pub fn from_canonical_name(name: &str) -> Option { + Self::VARIANTS + .iter() + .copied() + .find(|tool| tool.canonical_name() == name) + } + /// The name this tool is exposed under in `vocabulary`. /// /// A tool with no counterpart in the vocabulary keeps its canonical name. @@ -130,11 +143,7 @@ impl NativeTool { /// not drawn from this set. #[must_use] pub fn from_any_name(name: &str) -> Option { - Self::VARIANTS.iter().copied().find(|tool| { - ToolVocabulary::VARIANTS - .iter() - .any(|vocabulary| tool.name(*vocabulary) == name) - }) + name.parse().ok() } /// Coarse access category, or `None` when the tool is not part of the diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index 327b4868a..e986bc99a 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -12,7 +12,7 @@ use crate::skills::Skill; use crate::todo_runtime::TodoRuntime; use crate::todo_tools::make_todo_list_tool; use crate::tool_registry::ToolRegistry; -use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; +use crate::tools::{WebFetchSummarizer, register_discovery_and_web_tools}; const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2"); @@ -67,21 +67,18 @@ impl KimiProfile { // (subagent tools, skills) are renamed too. let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode); - register_core_tools(&mut registry, options, summarizer); - registry.register(make_edit_file_tool()); - - // Read, Write, and Bash differ from fabro's built-ins in what their - // parameters mean, not just what they are called, so they are separate - // tools rather than renames. Registered after the core set so they - // replace it. + // Glob and the web tools have the same contract in both vocabularies. + // The remaining Kimi tools use adapters for their different schemas, + // while reusing shared execution helpers where their behavior agrees. + register_discovery_and_web_tools(&mut registry, options, summarizer); registry.register(kimi_tools::make_kimi_read_tool()); registry.register(kimi_tools::make_kimi_write_tool()); + registry.register(kimi_tools::make_kimi_edit_tool(EDIT_FILE_DESCRIPTION)); registry.register(kimi_tools::make_kimi_grep_tool()); registry.register(kimi_tools::make_kimi_bash_tool( options.default_command_timeout_ms, options.max_command_timeout_ms, )); - registry.redescribe(NativeTool::EditFile, EDIT_FILE_DESCRIPTION); registry.redescribe(NativeTool::Glob, GLOB_DESCRIPTION); // Kimi Code drives todos with one replace-whole-list call. The @@ -172,7 +169,7 @@ mod tests { use fabro_types::AgentToolCategory; use super::*; - use crate::skills::make_use_skill_tool; + use crate::skills::make_use_skill_tool_for_vocabulary; use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; use crate::tool_permissions::{known_tool_category, tool_category}; @@ -224,9 +221,8 @@ mod tests { fn renamed_tools_keep_their_permission_category() { let profile = KimiProfile::new("kimi-k3"); for name in profile.tool_registry().names() { - let Some(tool) = NativeTool::from_any_name(&name) else { - continue; - }; + let tool = NativeTool::from_any_name(&name) + .unwrap_or_else(|| panic!("unexpected non-native Kimi profile tool: {name}")); assert_eq!( known_tool_category(&name), tool.category(), @@ -270,15 +266,27 @@ mod tests { profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0); profile .tool_registry_mut() - .register(make_use_skill_tool(Arc::new(vec![Skill { - name: "demo".into(), - description: "d".into(), - template: "t".into(), - }]))); + .register(make_use_skill_tool_for_vocabulary( + Arc::new(vec![Skill { + name: "demo".into(), + description: "d".into(), + template: "t".into(), + }]), + ToolVocabulary::KimiCode, + )); let names = profile.tool_registry().names(); assert!(names.contains(&"Skill".to_string()), "got {names:?}"); assert!(!names.contains(&"use_skill".to_string()), "got {names:?}"); + let skill_parameters = &profile + .tool_registry() + .get("Skill") + .unwrap() + .definition + .parameters; + assert!(skill_parameters["properties"].get("skill").is_some()); + assert!(skill_parameters["properties"].get("args").is_some()); + assert!(skill_parameters["properties"].get("skill_name").is_none()); // Deliberately not renamed to Kimi Code's `Agent`: fabro's subagent // tools are a supervisor model, not a call-and-return one. assert!(names.contains(&"spawn_agent".to_string()), "got {names:?}"); @@ -335,8 +343,24 @@ mod tests { let grep = describe("Grep"); assert!(grep.contains("POSIX"), "{grep}"); assert!(describe("Glob").contains("most recently modified")); - // The shared description is untouched for other profiles. - assert!(!describe("Read").contains("refuses writes")); + } + + #[test] + fn kimi_edit_schema_uses_path_like_kimi_code() { + let profile = KimiProfile::new("kimi-k3"); + let parameters = &profile + .tool_registry() + .get("Edit") + .unwrap() + .definition + .parameters; + + assert!(parameters["properties"].get("path").is_some()); + assert!(parameters["properties"].get("file_path").is_none()); + assert_eq!( + parameters["required"], + serde_json::json!(["path", "old_string", "new_string"]) + ); } #[test] diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 36892cf41..c300f7eac 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -17,20 +17,26 @@ //! [`Sandbox`](crate::sandbox::Sandbox) methods the built-ins use, so sandbox //! behavior, path policy, and the read-before-write guard are unchanged. +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; +use std::str::FromStr; use std::sync::Arc; use fabro_llm::types::ToolDefinition; use serde_json::Value; +use strum::EnumString; use crate::native_tool::NativeTool; -use crate::sandbox::GrepOptions; +use crate::sandbox::{GrepOptions, format_lines_numbered}; use crate::tool_registry::{RegisteredTool, ToolSource}; -use crate::tools::{optional_usize_arg, required_str}; +use crate::tools::{ + DEFAULT_READ_LINES, execute_grep, execute_shell_command, grep_result_path, make_edit_file_tool, + optional_usize_arg, required_str, +}; -/// Largest `n_lines` a single `Read` call returns, matching fabro's built-in -/// read default so the two tools cannot disagree about how much is "a page". -const DEFAULT_READ_LINES: usize = 2000; +const DEFAULT_GREP_RESULTS: usize = 250; +const MAX_GREP_RESULTS: usize = 2000; +const MAX_GREP_MATCHES_SCANNED: usize = 20_000; fn definition(tool: NativeTool, description: &str, parameters: Value) -> ToolDefinition { ToolDefinition { @@ -114,13 +120,15 @@ explicitly asked. Never run commands requiring superuser privileges unless expli None => default_timeout_ms, }; - let result = ctx - .env - .exec_command(command, timeout_ms, cwd, None, Some(ctx.cancel.clone())) - .await - .map_err(|e| e.display_with_causes())?; + let result = execute_shell_command(&ctx, command, timeout_ms, cwd).await?; - let mut out = result.stdout; + let mut out = String::new(); + if result.is_timed_out() { + out.push_str("Command timed out.\n"); + } else if result.is_cancelled() { + out.push_str("Command cancelled.\n"); + } + out.push_str(&result.stdout); if !result.stderr.is_empty() { if !out.is_empty() { out.push('\n'); @@ -154,8 +162,8 @@ read in this session. - If you have a concrete path, call Read directly. Do not Glob or `ls` first to check that it \ exists — a missing path returns an error you can handle. - When you need several files, emit multiple Read calls in one response rather than one per turn. -- Returns `\\t` per line. Drop the number and tab when taking text for an \ -Edit `old_string`. +- Returns ` | ` per line. Drop the number and separator when taking text for \ +an Edit `old_string`. - `line_offset` is the 1-based first line to read. A NEGATIVE value reads from the end, so -100 \ returns the last 100 lines. - `n_lines` defaults to 2000 lines. @@ -166,11 +174,14 @@ returns the last 100 lines. "path": {"type": "string", "description": "Path to the file to read."}, "line_offset": { "type": "integer", + "minimum": -2000, "description": "1-based first line to read. Negative reads from the end \ - of the file (-100 reads the last 100 lines)." + of the file (-100 reads the last 100 lines); zero is invalid." }, "n_lines": { "type": "integer", + "minimum": 1, + "maximum": 2000, "description": "Number of lines to read (default 2000)." } }, @@ -180,8 +191,16 @@ returns the last 100 lines. executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; - let n_lines = optional_usize_arg(&args, "n_lines")?; + let n_lines = optional_usize_arg(&args, "n_lines")?.unwrap_or(DEFAULT_READ_LINES); + if n_lines == 0 || n_lines > DEFAULT_READ_LINES { + return Err(format!( + "n_lines must be between 1 and {DEFAULT_READ_LINES}" + )); + } let line_offset = args.get("line_offset").and_then(Value::as_i64); + if line_offset == Some(0) { + return Err("line_offset must not be zero".to_string()); + } let content = match line_offset { // Negative offset: count the file's lines, then start that @@ -189,28 +208,30 @@ returns the last 100 lines. Some(offset) if offset < 0 => { let from_end = usize::try_from(offset.unsigned_abs()) .map_err(|_| "line_offset is too large".to_string())?; - let total = ctx + if from_end > DEFAULT_READ_LINES { + return Err(format!( + "negative line_offset must be at least -{DEFAULT_READ_LINES}" + )); + } + let raw = ctx .env - .read_file(path, None, None) + .read_file_text(path) .await - .map_err(|e| e.display_with_causes())? - .lines() - .count(); + .map_err(|e| e.display_with_causes())?; + let total = raw.lines().count(); let start = total.saturating_sub(from_end).saturating_add(1); - ctx.env - .read_file(path, Some(start), n_lines.or(Some(from_end))) - .await + Ok(format_lines_numbered( + &raw, + Some(start), + Some(n_lines.min(from_end)), + )) } Some(offset) => { let start = usize::try_from(offset) .map_err(|_| "line_offset must fit in usize".to_string())?; - ctx.env.read_file(path, Some(start), n_lines).await - } - None => { - ctx.env - .read_file(path, None, n_lines.or(Some(DEFAULT_READ_LINES))) - .await + ctx.env.read_file(path, Some(start), Some(n_lines)).await } + None => ctx.env.read_file(path, None, Some(n_lines)).await, } .map_err(|e| e.display_with_causes())?; @@ -222,6 +243,14 @@ returns the last 100 lines. } } +#[derive(Clone, Copy, Default, EnumString)] +#[strum(serialize_all = "snake_case")] +enum KimiWriteMode { + #[default] + Overwrite, + Append, +} + /// `Write`, with Kimi Code's `mode` so it can append. #[must_use] pub fn make_kimi_write_tool() -> RegisteredTool { @@ -261,27 +290,32 @@ overwrite replaces everything you did not restate. let mode = args .get("mode") .and_then(Value::as_str) - .unwrap_or("overwrite"); + .unwrap_or("overwrite") + .parse::() + .map_err(|_| "Invalid mode (expected overwrite|append)".to_string())?; - let payload = match mode { - "overwrite" => content.to_string(), + match mode { + KimiWriteMode::Overwrite => { + ctx.env + .write_file(path, content) + .await + .map_err(|e| e.display_with_causes())?; + } // The sandbox trait has no append; read-modify-write keeps // every provider working and stays inside path policy. - "append" => { - let existing = ctx.env.read_file_text(path).await.unwrap_or_default(); - format!("{existing}{content}") + KimiWriteMode::Append => { + let mut existing = ctx + .env + .read_file_text(path) + .await + .map_err(|e| e.display_with_causes())?; + existing.push_str(content); + ctx.env + .write_file(path, &existing) + .await + .map_err(|e| e.display_with_causes())?; } - other => { - return Err(format!( - "Invalid mode `{other}` (expected overwrite|append)" - )); - } - }; - - ctx.env - .write_file(path, &payload) - .await - .map_err(|e| e.display_with_causes())?; + } Ok(format!("Wrote {path}")) }) }), @@ -289,6 +323,48 @@ overwrite replaces everything you did not restate. } } +/// Kimi Code's `Edit` schema names the target `path`; fabro's shared edit +/// executor calls it `file_path`. Translate only that adapter field and reuse +/// the exact-match/read-before-write implementation. +#[must_use] +pub fn make_kimi_edit_tool(description: &str) -> RegisteredTool { + let shared = make_edit_file_tool(); + let shared_executor = shared.executor; + RegisteredTool { + definition: definition( + NativeTool::EditFile, + description, + serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the text file to edit."}, + "old_string": {"type": "string", "description": "Exact content to replace."}, + "new_string": {"type": "string", "description": "Replacement text."}, + "replace_all": { + "type": "boolean", + "description": "Replace every occurrence (default false)." + } + }, + "required": ["path", "old_string", "new_string"] + }), + ), + executor: Arc::new(move |mut args, ctx| { + let shared_executor = shared_executor.clone(); + Box::pin(async move { + let object = args + .as_object_mut() + .ok_or_else(|| "Edit arguments must be an object".to_string())?; + let path = object + .remove("path") + .ok_or_else(|| "Missing required parameter: path".to_string())?; + object.insert("file_path".to_string(), path); + shared_executor(args, ctx).await + }) + }), + source: ToolSource::Native, + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -297,7 +373,7 @@ mod tests { use tokio_util::sync::CancellationToken; use super::*; - use crate::sandbox::Sandbox; + use crate::sandbox::{ExecResult, Sandbox}; use crate::test_support::{MockSandbox, MutableMockSandbox}; use crate::tool_registry::ToolContext; @@ -358,6 +434,22 @@ mod tests { assert!(!out.contains("line8"), "{out}"); } + #[tokio::test] + async fn read_positive_offset_still_applies_the_default_limit() { + let lines: Vec = (1..=DEFAULT_READ_LINES + 5) + .map(|n| format!("line{n}")) + .collect(); + let env = sandbox_with("/f.txt", &lines.join("\n")); + let tool = make_kimi_read_tool(); + + let out = (tool.executor)(json!({"path": "/f.txt", "line_offset": 2}), ctx(env)) + .await + .unwrap(); + + assert!(out.contains("2001 | line2001"), "{out}"); + assert!(!out.contains("2002 | line2002"), "{out}"); + } + /// The reason Write is a separate tool: it has a mode, so it can append. #[tokio::test] async fn write_append_mode_preserves_existing_content() { @@ -400,6 +492,47 @@ mod tests { assert!(err.contains("expected overwrite|append"), "{err}"); } + #[tokio::test] + async fn write_append_propagates_a_missing_file_error() { + let env = Arc::new(MutableMockSandbox::new(HashMap::new())); + let tool = make_kimi_write_tool(); + + let err = (tool.executor)( + json!({"path": "/missing.txt", "content": "new", "mode": "append"}), + ctx(env), + ) + .await + .unwrap_err(); + + assert!(err.contains("missing.txt"), "{err}"); + } + + #[tokio::test] + async fn edit_translates_kimi_path_to_the_shared_executor() { + let env = sandbox_with("/f.txt", "before"); + env.mark_agent_read("/f.txt"); + let tool = make_kimi_edit_tool("Edit"); + + (tool.executor)( + json!({"path": "/f.txt", "old_string": "before", "new_string": "after"}), + ctx(env.clone()), + ) + .await + .unwrap(); + + assert_eq!(env.read_file_text("/f.txt").await.unwrap(), "after"); + assert!( + tool.definition.parameters["properties"] + .get("path") + .is_some() + ); + assert!( + tool.definition.parameters["properties"] + .get("file_path") + .is_none() + ); + } + /// `files_with_matches` and `count` both need the file path, which the /// underlying search only prefixes when scanning a directory. #[test] @@ -432,7 +565,7 @@ mod tests { #[tokio::test] async fn grep_content_mode_returns_matching_lines() { - let out = grep_with(json!({"pattern": "x"}), vec![ + let out = grep_with(json!({"pattern": "x", "output_mode": "content"}), vec![ "a.rs:1:x".into(), "b.rs:2:x".into(), ]) @@ -441,6 +574,18 @@ mod tests { assert_eq!(out, "a.rs:1:x\nb.rs:2:x"); } + #[tokio::test] + async fn grep_defaults_to_files_with_matches() { + let out = grep_with(json!({"pattern": "x"}), vec![ + "a.rs:1:x".into(), + "a.rs:2:x".into(), + "b.rs:2:x".into(), + ]) + .await + .unwrap(); + assert_eq!(out, "a.rs\nb.rs"); + } + #[tokio::test] async fn grep_files_with_matches_deduplicates_paths_in_order() { let out = grep_with( @@ -454,11 +599,10 @@ mod tests { #[tokio::test] async fn grep_count_mode_counts_per_file() { - let out = grep_with(json!({"pattern": "x", "output_mode": "count"}), vec![ - "a.rs:1:x".into(), - "a.rs:9:x".into(), - "b.rs:2:x".into(), - ]) + let out = grep_with( + json!({"pattern": "x", "output_mode": "count_matches"}), + vec!["a.rs:1:x".into(), "a.rs:9:x".into(), "b.rs:2:x".into()], + ) .await .unwrap(); assert_eq!(out, "a.rs:2\nb.rs:1"); @@ -467,9 +611,17 @@ mod tests { #[tokio::test] async fn grep_offset_and_head_limit_page_results() { let lines: Vec = (1..=6).map(|n| format!("f{n}.rs:1:x")).collect(); - let out = grep_with(json!({"pattern": "x", "offset": 2, "head_limit": 2}), lines) - .await - .unwrap(); + let out = grep_with( + json!({ + "pattern": "x", + "output_mode": "content", + "offset": 2, + "head_limit": 2 + }), + lines, + ) + .await + .unwrap(); assert_eq!(out, "f3.rs:1:x\nf4.rs:1:x"); } @@ -479,7 +631,7 @@ mod tests { .await .unwrap_err(); assert!( - err.contains("expected content|files_with_matches|count"), + err.contains("expected content|files_with_matches|count_matches"), "{err}" ); } @@ -490,6 +642,17 @@ mod tests { assert_eq!(out, "No matches found"); } + #[test] + fn grep_schema_uses_kimi_code_modes_and_flags() { + let parameters = make_kimi_grep_tool().definition.parameters; + assert_eq!( + parameters["properties"]["output_mode"]["enum"], + json!(["content", "files_with_matches", "count_matches"]) + ); + assert!(parameters["properties"].get("-i").is_some()); + assert!(parameters["properties"].get("case_insensitive").is_none()); + } + /// The reason Bash is a separate tool: `timeout` is seconds, not /// milliseconds. A rename would have made every timeout 1000x wrong. #[test] @@ -511,49 +674,57 @@ mod tests { // Fabro has no background shell, so none is promised. assert!(!tool.definition.description.contains("run_in_background")); } + + #[tokio::test] + async fn bash_reuses_session_env_cwd_and_timeout_rendering() { + use fabro_types::CommandTermination; + + let tool = make_kimi_bash_tool(60_000, 600_000); + let env = Arc::new(MockSandbox { + exec_result: ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: None, + termination: CommandTermination::TimedOut, + duration_ms: 7_000, + }, + ..MockSandbox::default() + }); + let mut tool_ctx = ctx(env.clone()); + let tool_env = HashMap::from([("TOKEN".to_string(), "value".to_string())]); + tool_ctx.tool_env_provider = Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))); + + let output = (tool.executor)( + json!({"command": "echo $TOKEN", "cwd": "/repo", "timeout": 7}), + tool_ctx, + ) + .await + .unwrap(); + + assert!(output.starts_with("Command timed out.\n"), "{output}"); + assert_eq!(*env.captured_timeout.lock().unwrap(), Some(7_000)); + assert_eq!(env.captured_working_dirs.lock().unwrap().as_slice(), &[ + Some("/repo".to_string()) + ]); + assert_eq!(*env.captured_env_vars.lock().unwrap(), Some(tool_env)); + assert!( + env.captured_command + .lock() + .unwrap() + .as_deref() + .is_some_and(|command| command.starts_with("exec 2>&1\n")) + ); + } } /// Output shapes Kimi Code's `Grep` supports. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Default, PartialEq, Eq, EnumString)] +#[strum(serialize_all = "snake_case")] enum GrepOutputMode { Content, + #[default] FilesWithMatches, - Count, -} - -impl GrepOutputMode { - fn parse(value: Option<&str>) -> Result { - match value.unwrap_or("content") { - "content" => Ok(Self::Content), - "files_with_matches" => Ok(Self::FilesWithMatches), - "count" => Ok(Self::Count), - other => Err(format!( - "Invalid output_mode `{other}` (expected content|files_with_matches|count)" - )), - } - } -} - -/// Extract the file path from a grep result line. -/// -/// The underlying search emits `::` when scanning a -/// directory, but omits the path when scanning a single file, so fall back to -/// the path that was searched. -fn grep_result_path<'a>(line: &'a str, searched: &'a str) -> &'a str { - // Walk candidate separators so absolute Windows-style paths and paths - // containing colons still split at the line-number field. - let mut rest = line; - let mut consumed = 0usize; - while let Some(idx) = rest.find(':') { - let after = &rest[idx + 1..]; - let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); - if !digits.is_empty() && after[digits.len()..].starts_with(':') { - return &line[..consumed + idx]; - } - consumed += idx + 1; - rest = after; - } - searched + CountMatches, } /// `Grep` with Kimi Code's `output_mode`, `head_limit`, and `offset`. @@ -576,11 +747,11 @@ will not flood the conversation. - Backed by ripgrep when available and POSIX `grep` otherwise, so keep patterns portable across \ both rather than relying on ripgrep-only syntax. -- `output_mode` selects what comes back: `content` (matching lines, the default), \ -`files_with_matches` (just the paths), or `count` (matches per file). +- `output_mode` selects what comes back: `files_with_matches` (just the paths, the default), \ +`content` (matching lines), or `count_matches` (matches per file). - `head_limit` caps how many results are returned and `offset` skips that many first, so you can \ page through a large result set. -- `glob` limits which files are searched; `case_insensitive` folds case.", +- `glob` limits which files are searched; `-i` folds case.", serde_json::json!({ "type": "object", "properties": { @@ -589,12 +760,22 @@ page through a large result set. "glob": {"type": "string", "description": "Only search files matching this glob."}, "output_mode": { "type": "string", - "enum": ["content", "files_with_matches", "count"], - "description": "Shape of the results (default content)." + "enum": ["content", "files_with_matches", "count_matches"], + "description": "Shape of the results (default files_with_matches)." }, - "head_limit": {"type": "integer", "description": "Return at most this many results."}, - "offset": {"type": "integer", "description": "Skip this many results before returning."}, - "case_insensitive": {"type": "boolean", "description": "Fold case when matching."} + "head_limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": "Return at most this many results (default 250)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "maximum": 20000, + "description": "Skip this many results before returning." + }, + "-i": {"type": "boolean", "description": "Perform a case-insensitive search."} }, "required": ["pattern"] }), @@ -604,66 +785,88 @@ page through a large result set. let pattern = required_str(&args, "pattern")?; // The trait requires a search root; "." is the working directory. let path = args.get("path").and_then(Value::as_str).unwrap_or("."); - let mode = GrepOutputMode::parse(args.get("output_mode").and_then(Value::as_str))?; - let head_limit = optional_usize_arg(&args, "head_limit")?; + let mode = GrepOutputMode::from_str( + args.get("output_mode") + .and_then(Value::as_str) + .unwrap_or("files_with_matches"), + ) + .map_err(|_| { + "Invalid output_mode (expected content|files_with_matches|count_matches)" + .to_string() + })?; + let head_limit = + optional_usize_arg(&args, "head_limit")?.unwrap_or(DEFAULT_GREP_RESULTS); + if head_limit == 0 || head_limit > MAX_GREP_RESULTS { + return Err(format!( + "head_limit must be between 1 and {MAX_GREP_RESULTS}" + )); + } let offset = optional_usize_arg(&args, "offset")?.unwrap_or(0); + if offset > MAX_GREP_MATCHES_SCANNED { + return Err(format!("offset must be at most {MAX_GREP_MATCHES_SCANNED}")); + } + if offset.saturating_add(head_limit) > MAX_GREP_MATCHES_SCANNED { + return Err(format!( + "offset + head_limit must be at most {MAX_GREP_MATCHES_SCANNED}" + )); + } let options = GrepOptions { glob_filter: args.get("glob").and_then(Value::as_str).map(str::to_string), - case_insensitive: args - .get("case_insensitive") - .and_then(Value::as_bool) - .unwrap_or(false), - // Only push the cap down for `content`, where results and - // lines are the same thing. Capping lines early would - // undercount files for the other modes. + case_insensitive: args.get("-i").and_then(Value::as_bool).unwrap_or(false), max_results: match mode { - GrepOutputMode::Content => head_limit.map(|n| n.saturating_add(offset)), - _ => None, + GrepOutputMode::Content => Some( + head_limit + .saturating_add(offset) + .min(MAX_GREP_MATCHES_SCANNED), + ), + GrepOutputMode::FilesWithMatches | GrepOutputMode::CountMatches => { + Some(MAX_GREP_MATCHES_SCANNED) + } }, }; - let lines = ctx - .env - .grep(pattern, path, &options) - .await - .map_err(|e| e.display_with_causes())?; + let lines = execute_grep(&ctx, pattern, path, &options).await?; let searched = path; - let mut results: Vec = match mode { + let results: Vec = match mode { GrepOutputMode::Content => lines, GrepOutputMode::FilesWithMatches => { - let mut seen: Vec = Vec::new(); - for line in &lines { - let file = grep_result_path(line, searched).to_string(); - if !seen.contains(&file) { - seen.push(file); + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for line in lines { + let file = grep_result_path(&line, searched).to_string(); + if seen.insert(file.clone()) { + files.push(file); } } - seen + files } - GrepOutputMode::Count => { - let mut counts: Vec<(String, usize)> = Vec::new(); - for line in &lines { - let file = grep_result_path(line, searched).to_string(); - match counts.iter_mut().find(|(name, _)| *name == file) { - Some((_, count)) => *count += 1, - None => counts.push((file, 1)), + GrepOutputMode::CountMatches => { + let mut counts: HashMap = HashMap::new(); + let mut order = Vec::new(); + for line in lines { + let file = grep_result_path(&line, searched).to_string(); + if let Some(count) = counts.get_mut(&file) { + *count += 1; + } else { + counts.insert(file.clone(), 1); + order.push(file); } } - counts + order .into_iter() - .map(|(file, count)| format!("{file}:{count}")) + .map(|file| { + let count = counts[&file]; + format!("{file}:{count}") + }) .collect() } - }; - - if offset > 0 { - results = results.into_iter().skip(offset).collect(); - } - if let Some(limit) = head_limit { - results.truncate(limit); } + .into_iter() + .skip(offset) + .take(head_limit) + .collect(); if results.is_empty() { return Ok("No matches found".to_string()); diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 7855617bf..05bc99d6c 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -16,7 +16,7 @@ pub use openai::OpenAiProfile; use crate::agent_profile::AgentProfile; use crate::config::{NativeToolOptions, ToolSecrets}; -use crate::native_tool::{NativeTool, ToolVocabulary}; +use crate::native_tool::ToolVocabulary; use crate::sandbox::Sandbox; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::ToolRegistry; @@ -196,7 +196,7 @@ pub fn assemble_system_prompt( skills: &[Skill], ) -> String { let env_block = build_env_context_block_with(env, env_context); - let skill_tool = NativeTool::UseSkill.name(template.vocabulary); + let vocabulary = template.vocabulary; let prompt = template.render(env_block); let docs_section = if memory.is_empty() { @@ -205,7 +205,7 @@ pub fn assemble_system_prompt( format!("\n\n{}", memory.join("\n\n")) }; let skills_section = { - let s = format_skills_prompt_section(skills, skill_tool); + let s = format_skills_prompt_section(skills, vocabulary); if s.is_empty() { String::new() } else { diff --git a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 index 6f8c8306d..0c5c0f041 100644 --- a/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 +++ b/lib/components/fabro-agent/src/profiles/prompts/kimi.md.j2 @@ -67,7 +67,7 @@ Apply the same care beyond git: weigh the reversibility and blast radius of any If the codebase has tests or the ability to build or run, use them to verify your work. Start as specific as possible to the code you changed to catch issues efficiently, then widen to broader tests as you build confidence. -Long-running commands need a raised timeout rather than a retry. `Bash` takes a `timeout_ms` argument; use it for builds, test suites, and installs instead of letting the default elapse and trying again. +Long-running commands need a raised timeout rather than a retry. `Bash` takes a `timeout` argument in seconds; use it for builds, test suites, and installs instead of letting the default elapse and trying again. # Ultimate Reminders diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 60767399b..b778c2810 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -587,6 +587,11 @@ mod tests { assert!(anthropic.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some()); assert!(anthropic.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none()); + let mut kimi = ToolRegistry::new(); + register_question_tools(AgentProfileKind::Kimi, &mut kimi); + assert!(kimi.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some()); + assert!(kimi.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none()); + let mut gemini = ToolRegistry::new(); register_question_tools(AgentProfileKind::Gemini, &mut gemini); assert!(gemini.names().is_empty()); diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index afb8a0c63..1e569da15 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -38,14 +38,17 @@ use crate::file_tracker::FileTracker; use crate::history::History; use crate::loop_detection::detect_loop; use crate::memory::{BUDGET_BYTES, MemoryDocument, discover_memory}; +use crate::native_tool::NativeTool; use crate::profiles::EnvContext; use crate::question_tools::AgentToolRuntime; use crate::sandbox::Sandbox; use crate::skills::{ - ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, + ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill, + make_use_skill_tool_for_vocabulary, }; use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentSupervisor}; use crate::tool_execution::execute_tool_calls; +use crate::tool_permissions::canonical_tool_name; use crate::tool_registry::ToolDefinitionWithSource; use crate::types::{ AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState, @@ -649,9 +652,10 @@ impl Session { if !self.skills.is_empty() { let skills_arc = Arc::new(self.skills.clone()); if let Some(profile) = Arc::get_mut(&mut self.provider_profile) { + let vocabulary = profile.tool_registry().vocabulary(); profile .tool_registry_mut() - .register(make_use_skill_tool(skills_arc)); + .register(make_use_skill_tool_for_vocabulary(skills_arc, vocabulary)); } } @@ -1866,10 +1870,10 @@ impl Session { .await; timing.tool = timing.tool.saturating_add(tool_start.elapsed()); composite_watcher.abort(); - if tool_calls - .iter() - .any(|tool_call| tool_call.name == "use_skill") - { + if tool_calls.iter().zip(&results).any(|(tool_call, result)| { + !result.is_error + && canonical_tool_name(&tool_call.name) == NativeTool::UseSkill.canonical_name() + }) { self.activated_skill_context_observed = true; } @@ -2052,6 +2056,7 @@ impl Session { system_prompt: &self.system_prompt, memory: &self.memory, skills: &self.skills, + tool_vocabulary: self.provider_profile.tool_registry().vocabulary(), activated_skill_context_observed: self.activated_skill_context_observed, provider: &provider, model: &model, diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index 026464d75..624214c01 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -4,6 +4,7 @@ use fabro_llm::types::ToolDefinition; use tokio_util::sync::CancellationToken; use crate::error::{Error, InterruptReason}; +use crate::native_tool::{NativeTool, ToolVocabulary}; use crate::sandbox::Sandbox; use crate::tool_registry::{RegisteredTool, ToolSource}; use crate::tools::required_str; @@ -160,13 +161,21 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result>) -> RegisteredTool { - RegisteredTool { - definition: ToolDefinition { - name: "use_skill".into(), - description: "Load a skill's instructions by name. Call this when the user's \ - request matches an available skill." - .into(), - parameters: serde_json::json!({ + make_use_skill_tool_for_vocabulary(skills, ToolVocabulary::Fabro) +} + +/// Build the skill loader with the argument schema used by `vocabulary`. +/// +/// Kimi Code calls the fields `skill` and `args`; fabro's native surface uses +/// `skill_name`. The executor keeps one implementation for both. +pub fn make_use_skill_tool_for_vocabulary( + skills: Arc>, + vocabulary: ToolVocabulary, +) -> RegisteredTool { + let (name_parameter, parameters) = match vocabulary { + ToolVocabulary::Fabro => ( + "skill_name", + serde_json::json!({ "type": "object", "properties": { "skill_name": { @@ -176,11 +185,37 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { }, "required": ["skill_name"] }), + ), + ToolVocabulary::KimiCode => ( + "skill", + serde_json::json!({ + "type": "object", + "properties": { + "skill": { + "type": "string", + "description": "Exact name of the skill to invoke" + }, + "args": { + "type": "string", + "description": "Optional argument string to pass to the skill" + } + }, + "required": ["skill"] + }), + ), + }; + RegisteredTool { + definition: ToolDefinition { + name: NativeTool::UseSkill.canonical_name().into(), + description: "Load a skill's instructions by name. Call this when the user's \ + request matches an available skill." + .into(), + parameters, }, executor: Arc::new(move |args, ctx| { let skills = skills.clone(); Box::pin(async move { - let name = required_str(&args, "skill_name")?; + let name = required_str(&args, name_parameter)?; let skill = skills .iter() .find(|s| s.name == name) @@ -189,7 +224,15 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { skill_name: name.to_string(), source: SkillActivationSource::Tool, }); - Ok(skill.template.clone()) + let skill_args = args.get("args").and_then(serde_json::Value::as_str); + let content = match skill_args.filter(|value| !value.is_empty()) { + Some(value) if skill.template.contains("{{user_input}}") => { + skill.template.replace("{{user_input}}", value) + } + Some(value) => format!("{}\n\nARGUMENTS:\n{value}", skill.template), + None => skill.template.clone(), + }; + Ok(content) }) }), source: ToolSource::Skill, @@ -197,15 +240,12 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { } /// Render the skills section of a system prompt. -/// -/// `skill_tool` is the name the skill tool is exposed under, which depends on -/// the profile's vocabulary — telling a model to call a tool it was not given -/// is worse than omitting the guidance. -pub fn format_skills_prompt_section(skills: &[Skill], skill_tool: &str) -> String { +pub fn format_skills_prompt_section(skills: &[Skill], vocabulary: ToolVocabulary) -> String { if skills.is_empty() { return String::new(); } + let skill_tool = NativeTool::UseSkill.name(vocabulary); let mut lines = vec![ "# Available Skills".to_string(), format!( @@ -458,13 +498,13 @@ name: trimmed #[test] fn format_empty() { - assert_eq!(format_skills_prompt_section(&[], "use_skill"), ""); + assert_eq!(format_skills_prompt_section(&[], ToolVocabulary::Fabro), ""); } #[test] fn format_lists_skills() { let skills = test_skills(); - let section = format_skills_prompt_section(&skills, "use_skill"); + let section = format_skills_prompt_section(&skills, ToolVocabulary::Fabro); assert!(section.contains("# Available Skills")); assert!(section.contains("call the `use_skill` tool")); assert!(section.contains("- `commit`: Create a commit")); @@ -647,4 +687,44 @@ name: trimmed assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing required parameter")); } + + #[tokio::test] + async fn kimi_skill_schema_and_args_match_kimi_code() { + let skills = Arc::new(test_skills()); + let tool = make_use_skill_tool_for_vocabulary(skills, ToolVocabulary::KimiCode); + let env: Arc = Arc::new(MockSandbox::default()); + let ctx = ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }; + + let result = (tool.executor)( + serde_json::json!({"skill": "commit", "args": "only staged files"}), + ctx, + ) + .await + .unwrap(); + + assert!(result.contains("only staged files"), "{result}"); + assert!( + tool.definition.parameters["properties"] + .get("skill") + .is_some() + ); + assert!( + tool.definition.parameters["properties"] + .get("args") + .is_some() + ); + assert!( + tool.definition.parameters["properties"] + .get("skill_name") + .is_none() + ); + } } diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index e9c2d8112..9c8cdbd69 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -15,7 +15,7 @@ use futures::stream; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; -use crate::native_tool::NativeTool; +use crate::native_tool::ToolVocabulary; use crate::profiles::EnvContext; use crate::sandbox::*; use crate::session::Session; @@ -81,8 +81,7 @@ impl AgentProfile for TestProfile { user_instructions: Option<&str>, skills: &[Skill], ) -> String { - let skills_section = - format_skills_prompt_section(skills, NativeTool::UseSkill.canonical_name()); + let skills_section = format_skills_prompt_section(skills, ToolVocabulary::Fabro); let skills_part = if skills_section.is_empty() { String::new() } else { diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index fc6b28e89..764bf8bbc 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -1,8 +1,9 @@ //! Model-facing todo / task tools. //! -//! Two surfaces share one engine ([`TodoRuntime`]): +//! Three surfaces share one engine ([`TodoRuntime`]): //! //! - [`make_update_plan_tool`] — Codex-compatible OpenAI `update_plan`. +//! - [`make_todo_list_tool`] — Kimi Code-compatible whole-list `TodoList`. //! - [`make_task_create_tool`] / [`make_task_update_tool`] / //! [`make_task_get_tool`] / [`make_task_list_tool`] — Claude task tools. @@ -15,17 +16,22 @@ use std::sync::{Arc, Mutex}; use fabro_llm::types::ToolDefinition; use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps}; use serde_json::Value; +use strum::{EnumString, IntoStaticStr}; use crate::todo_runtime::TodoRuntime; use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource}; -/// Compute the OpenAI plan scope (`openai_plan:`). Returns an -/// error string the model can see if no session ID is bound to the call. -fn openai_plan_scope(ctx: &ToolContext) -> Result { +/// Compute a session-scoped todo-list ID. Returns an error the model can see +/// when a tool is invoked without an active session. +fn session_todo_scope( + ctx: &ToolContext, + kind: TodoListKind, + tool_name: &str, +) -> Result { ctx.session_id .as_ref() - .map(|sid| TodoListKind::OpenAiPlan.list_id(sid)) - .ok_or_else(|| "update_plan requires an active session".to_string()) + .map(|session_id| kind.list_id(session_id)) + .ok_or_else(|| format!("{tool_name} requires an active session")) } /// Compute the Anthropic task scope @@ -72,15 +78,14 @@ description and dependency details."; const TASK_GET_DESCRIPTION: &str = "Get one task by taskId, including subject, status, \ description, owner, blockedBy, and blocks."; -/// Deterministic todo id derived from `::`. Codex identifies -/// a plan step by the exact step text, so the projection ID is the -/// `sha256(list_id, step)` truncated for compactness. -fn openai_step_id(list_id: &str, step: &str) -> String { +/// Deterministic todo id derived from `::`. Whole-list tools +/// identify an item by its exact text, so unchanged entries preserve identity. +fn todo_text_id(list_id: &str, text: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(list_id.as_bytes()); hasher.update(b"\x00"); - hasher.update(step.as_bytes()); + hasher.update(text.as_bytes()); let digest = hasher.finalize(); let mut out = String::with_capacity(16); for byte in &digest[..8] { @@ -89,6 +94,60 @@ fn openai_step_id(list_id: &str, step: &str) -> String { out } +struct ReplacementTodo { + id: String, + subject: String, + status: TodoStatus, +} + +fn reconcile_replacement_list( + runtime: &TodoRuntime, + ctx: &ToolContext, + kind: TodoListKind, + list_id: &str, + incoming: &[ReplacementTodo], +) { + let previous = runtime + .snapshot(list_id) + .map(|list| list.items) + .unwrap_or_default(); + let previous_by_id: HashMap<&str, &TodoProjection> = previous + .iter() + .map(|todo| (todo.id.as_str(), todo)) + .collect(); + let incoming_ids: HashSet<&str> = incoming.iter().map(|todo| todo.id.as_str()).collect(); + + for todo in &previous { + if !incoming_ids.contains(todo.id.as_str()) { + runtime.delete(ctx, kind, list_id.to_string(), todo.id.clone()); + } + } + + for (index, todo) in incoming.iter().enumerate() { + let order = u32::try_from(index).unwrap_or(u32::MAX); + match previous_by_id.get(todo.id.as_str()) { + Some(previous) + if previous.status == todo.status + && previous.order == order + && previous.subject == todo.subject => {} + Some(_) => { + runtime.update(ctx, TodoUpdatedProps { + status: Some(todo.status), + order: Some(order), + subject: Some(todo.subject.clone()), + ..TodoUpdatedProps::new(list_id, kind, &todo.id) + }); + } + None => { + let mut projection = + TodoProjection::new(todo.id.clone(), order, todo.subject.clone()); + projection.status = todo.status; + runtime.create(ctx, kind, list_id.to_string(), projection); + } + } + } +} + /// OpenAI `update_plan` tool. See plan summary for semantics. #[must_use] pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { @@ -127,15 +186,14 @@ pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { - let list_id = openai_plan_scope(&ctx)?; + let list_id = session_todo_scope(&ctx, TodoListKind::OpenAiPlan, "update_plan")?; let plan = args .get("plan") .and_then(Value::as_array) .ok_or_else(|| "Missing required parameter: plan".to_string())?; // Parse incoming steps, precompute ids, and enforce step-text uniqueness. - let mut incoming: Vec<(String, String, TodoStatus)> = - Vec::with_capacity(plan.len()); + let mut incoming = Vec::with_capacity(plan.len()); let mut seen_steps: HashSet<&str> = HashSet::with_capacity(plan.len()); for (index, entry) in plan.iter().enumerate() { let step = entry @@ -152,57 +210,20 @@ pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { "Duplicate plan step `{step}` — step text must be unique" )); } - let todo_id = openai_step_id(&list_id, step); - incoming.push((todo_id, step.to_string(), status)); + incoming.push(ReplacementTodo { + id: todo_text_id(&list_id, step), + subject: step.to_string(), + status, + }); } - // Snapshot previous state into a HashMap for O(1) lookup. - let previous: HashMap = runtime - .snapshot(&list_id) - .map(|list| list.items.into_iter().map(|t| (t.id.clone(), t)).collect()) - .unwrap_or_default(); - let incoming_ids: HashSet<&str> = - incoming.iter().map(|(id, _, _)| id.as_str()).collect(); - - // Deletes: anything in previous but not in incoming. - for id in previous.keys() { - if !incoming_ids.contains(id.as_str()) { - runtime.delete(&ctx, TodoListKind::OpenAiPlan, list_id.clone(), id.clone()); - } - } - - // Upserts: each incoming step becomes a create (new) or update. - for (index, (todo_id, step, status)) in incoming.iter().enumerate() { - let order = u32::try_from(index).unwrap_or(u32::MAX); - match previous.get(todo_id) { - Some(prev) - if prev.status == *status - && prev.order == order - && prev.subject == *step => - { - // No change. - } - Some(_) => { - runtime.update(&ctx, TodoUpdatedProps { - status: Some(*status), - order: Some(order), - subject: Some(step.clone()), - ..TodoUpdatedProps::new(&list_id, TodoListKind::OpenAiPlan, todo_id) - }); - } - None => { - let mut projection = - TodoProjection::new(todo_id.clone(), order, step.clone()); - projection.status = *status; - runtime.create( - &ctx, - TodoListKind::OpenAiPlan, - list_id.clone(), - projection, - ); - } - } - } + reconcile_replacement_list( + &runtime, + &ctx, + TodoListKind::OpenAiPlan, + &list_id, + &incoming, + ); Ok("Plan updated".to_string()) }) @@ -211,42 +232,56 @@ pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { } } -/// Compute the Kimi todo scope (`kimi_todos:`). -fn kimi_todo_scope(ctx: &ToolContext) -> Result { - ctx.session_id - .as_ref() - .map(|sid| TodoListKind::KimiTodos.list_id(sid)) - .ok_or_else(|| "TodoList requires an active session".to_string()) +#[derive(Clone, Copy, EnumString, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +enum KimiTodoStatus { + Pending, + InProgress, + #[strum(to_string = "done")] + Done, +} + +impl From for TodoStatus { + fn from(status: KimiTodoStatus) -> Self { + match status { + KimiTodoStatus::Pending => Self::Pending, + KimiTodoStatus::InProgress => Self::InProgress, + KimiTodoStatus::Done => Self::Completed, + } + } +} + +impl From for KimiTodoStatus { + fn from(status: TodoStatus) -> Self { + match status { + TodoStatus::Pending => Self::Pending, + TodoStatus::InProgress => Self::InProgress, + TodoStatus::Completed | TodoStatus::Deleted => Self::Done, + } + } } /// Kimi Code spells the terminal status `done`; internally it is /// [`TodoStatus::Completed`]. fn parse_kimi_status(value: &str) -> Result { - match value { - "pending" => Ok(TodoStatus::Pending), - "in_progress" => Ok(TodoStatus::InProgress), - "done" => Ok(TodoStatus::Completed), - other => Err(format!( - "Invalid status `{other}` (expected pending|in_progress|done)" - )), - } + value + .parse::() + .map(TodoStatus::from) + .map_err(|_| format!("Invalid status `{value}` (expected pending|in_progress|done)")) } fn kimi_status_name(status: TodoStatus) -> &'static str { - match status { - TodoStatus::Pending => "pending", - TodoStatus::InProgress => "in_progress", - TodoStatus::Completed | TodoStatus::Deleted => "done", - } + KimiTodoStatus::from(status).into() } -fn render_kimi_todos(items: &[TodoProjection]) -> String { - if items.is_empty() { +fn render_kimi_todos<'a>(items: impl IntoIterator) -> String { + let mut items = items.into_iter().peekable(); + if items.peek().is_none() { return "The todo list is empty.".to_string(); } let mut out = String::new(); - for todo in items { - let _ = writeln!(out, "[{}] {}", kimi_status_name(todo.status), todo.subject); + for (status, subject) in items { + let _ = writeln!(out, "[{}] {subject}", kimi_status_name(status)); } out.truncate(out.trim_end().len()); out @@ -302,7 +337,7 @@ pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { - let list_id = kimi_todo_scope(&ctx)?; + let list_id = session_todo_scope(&ctx, TodoListKind::KimiTodos, "TodoList")?; // Read mode: `todos` omitted entirely. let Some(todos) = args.get("todos") else { @@ -310,14 +345,17 @@ pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { .snapshot(&list_id) .map(|l| l.items) .unwrap_or_default(); - return Ok(render_kimi_todos(&items)); + return Ok(render_kimi_todos( + items + .iter() + .map(|todo| (todo.status, todo.subject.as_str())), + )); }; let todos = todos .as_array() .ok_or_else(|| "`todos` must be an array".to_string())?; - let mut incoming: Vec<(String, String, TodoStatus)> = - Vec::with_capacity(todos.len()); + let mut incoming = Vec::with_capacity(todos.len()); let mut seen: HashSet<&str> = HashSet::with_capacity(todos.len()); for (index, entry) in todos.iter().enumerate() { let title = entry @@ -332,56 +370,25 @@ pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { if !seen.insert(title) { return Err(format!("Duplicate todo `{title}` — titles must be unique")); } - incoming.push((openai_step_id(&list_id, title), title.to_string(), status)); + incoming.push(ReplacementTodo { + id: todo_text_id(&list_id, title), + subject: title.to_string(), + status, + }); } - let previous: HashMap = runtime - .snapshot(&list_id) - .map(|list| list.items.into_iter().map(|t| (t.id.clone(), t)).collect()) - .unwrap_or_default(); - let incoming_ids: HashSet<&str> = - incoming.iter().map(|(id, _, _)| id.as_str()).collect(); - - for id in previous.keys() { - if !incoming_ids.contains(id.as_str()) { - runtime.delete(&ctx, TodoListKind::KimiTodos, list_id.clone(), id.clone()); - } - } - - for (index, (todo_id, title, status)) in incoming.iter().enumerate() { - let order = u32::try_from(index).unwrap_or(u32::MAX); - match previous.get(todo_id) { - Some(prev) - if prev.status == *status - && prev.order == order - && prev.subject == *title => {} - Some(_) => { - runtime.update(&ctx, TodoUpdatedProps { - status: Some(*status), - order: Some(order), - subject: Some(title.clone()), - ..TodoUpdatedProps::new(&list_id, TodoListKind::KimiTodos, todo_id) - }); - } - None => { - let mut projection = - TodoProjection::new(todo_id.clone(), order, title.clone()); - projection.status = *status; - runtime.create( - &ctx, - TodoListKind::KimiTodos, - list_id.clone(), - projection, - ); - } - } - } - - let items = runtime - .snapshot(&list_id) - .map(|l| l.items) - .unwrap_or_default(); - Ok(render_kimi_todos(&items)) + reconcile_replacement_list( + &runtime, + &ctx, + TodoListKind::KimiTodos, + &list_id, + &incoming, + ); + Ok(render_kimi_todos( + incoming + .iter() + .map(|todo| (todo.status, todo.subject.as_str())), + )) }) }), source: ToolSource::Native, diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index 55f06222c..74f2fe701 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -156,7 +156,14 @@ impl ToolRegistry { } pub fn register(&mut self, mut tool: RegisteredTool) { - if let Some(native) = NativeTool::from_any_name(&tool.definition.name) { + let native = match &tool.source { + ToolSource::Native => NativeTool::from_canonical_name(&tool.definition.name), + ToolSource::Skill if tool.definition.name == NativeTool::UseSkill.canonical_name() => { + Some(NativeTool::UseSkill) + } + ToolSource::Skill | ToolSource::Mcp { .. } => None, + }; + if let Some(native) = native { tool.definition.name = native.name(self.vocabulary).to_string(); } self.tools.insert(tool.definition.name.clone(), tool); @@ -168,9 +175,8 @@ impl ToolRegistry { /// identity rather than by whatever string it is currently exposed under. pub fn redescribe(&mut self, tool: NativeTool, description: impl Into) { let exposed = tool.name(self.vocabulary); - if let Some(mut registered) = self.tools.remove(exposed) { + if let Some(registered) = self.tools.get_mut(exposed) { registered.definition.description = description.into(); - self.tools.insert(exposed.to_string(), registered); } } @@ -298,6 +304,31 @@ mod tests { assert_eq!(tool.unwrap().definition.name, "read_file"); } + #[test] + fn kimi_registry_renames_canonical_native_tools_only() { + let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode); + registry.register(make_tool("read_file")); + registry.register(make_tool("Read")); + + assert!(registry.get("Read").is_some()); + assert!(registry.get("read_file").is_none()); + } + + #[test] + fn registry_does_not_reinterpret_mcp_names_as_native_tools() { + let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode); + let mut tool = make_tool("read_file"); + tool.source = ToolSource::Mcp { + server_name: "files".to_string(), + original_name: "read_file".to_string(), + }; + + registry.register(tool); + + assert!(registry.get("read_file").is_some()); + assert!(registry.get("Read").is_none()); + } + #[test] fn get_missing_returns_none() { let registry = ToolRegistry::new(); diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index f787d3c8e..a0a6f85c0 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -10,11 +10,12 @@ use fabro_static::EnvVars; use futures::{StreamExt, stream}; use crate::config::NativeToolOptions; -use crate::sandbox::GrepOptions; -use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource}; +use crate::sandbox::{ExecResult, GrepOptions}; +use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource}; const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; const MAX_READ_MANY_FILES_CONCURRENCY: usize = 8; +pub(crate) const DEFAULT_READ_LINES: usize = 2000; /// Configuration for the optional LLM-based summarizer used by `web_fetch`. #[derive(Clone)] @@ -65,6 +66,15 @@ pub fn register_core_tools( registry.register(make_write_file_tool()); registry.register(make_shell_tool_with_options(options)); registry.register(make_grep_tool()); + register_discovery_and_web_tools(registry, options, summarizer); +} + +/// Register the core tools whose Kimi Code contracts match fabro's own. +pub(crate) fn register_discovery_and_web_tools( + registry: &mut ToolRegistry, + options: &NativeToolOptions, + summarizer: Option, +) { registry.register(make_glob_tool()); if let Some(api_key) = &options.secrets.brave_search_api_key { registry.register(make_web_search_tool_with_api_key(api_key.clone())); @@ -110,7 +120,8 @@ pub fn make_read_file_tool() -> RegisteredTool { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let offset_usize = optional_usize_arg(&args, "offset")?; - let limit_usize = optional_usize_arg(&args, "limit")?; + let limit_usize = + optional_usize_arg(&args, "limit")?.or(Some(DEFAULT_READ_LINES)); let content = ctx .env @@ -242,29 +253,13 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; - let command = format!("exec 2>&1\n{command}"); let timeout_ms = args .get("timeout_ms") .and_then(serde_json::Value::as_u64) .unwrap_or(default_timeout) .min(max_timeout); - let tool_env = ctx.resolve_tool_env().await.map_err(|e| format!("{e:#}"))?; - tracing::debug!( - env_var_count = tool_env.as_ref().map_or(0, std::collections::HashMap::len), - "Injecting sandbox env vars into tool execution" - ); - let result = ctx - .env - .exec_command( - &command, - timeout_ms, - None, - tool_env.as_ref(), - Some(ctx.cancel), - ) - .await - .map_err(|e| e.display_with_causes())?; + let result = execute_shell_command(&ctx, command, timeout_ms, None).await?; let mut output = String::new(); if result.is_timed_out() { @@ -287,6 +282,33 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo } } +/// Execute a shell command with the session's environment and cancellation +/// plumbing. Provider profiles can vary their wire schema and result +/// rendering without accidentally bypassing those shared semantics. +pub(crate) async fn execute_shell_command( + ctx: &ToolContext, + command: &str, + timeout_ms: u64, + cwd: Option<&str>, +) -> Result { + let command = format!("exec 2>&1\n{command}"); + let tool_env = ctx.resolve_tool_env().await.map_err(|e| format!("{e:#}"))?; + tracing::debug!( + env_var_count = tool_env.as_ref().map_or(0, std::collections::HashMap::len), + "Injecting sandbox env vars into tool execution" + ); + ctx.env + .exec_command( + &command, + timeout_ms, + cwd, + tool_env.as_ref(), + Some(ctx.cancel.clone()), + ) + .await + .map_err(|e| e.display_with_causes()) +} + #[must_use] pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { @@ -332,19 +354,7 @@ pub fn make_grep_tool() -> RegisteredTool { max_results, }; - let results = ctx - .env - .grep(pattern, path, &options) - .await - .map_err(|e| e.display_with_causes())?; - let mut seen_files = std::collections::HashSet::new(); - for line in &results { - if let Some(file_path) = line.split(':').next() { - if !file_path.is_empty() && seen_files.insert(file_path) { - ctx.env.mark_agent_read(file_path); - } - } - } + let results = execute_grep(&ctx, pattern, path, &options).await?; Ok(results.join("\n")) }) }), @@ -352,6 +362,49 @@ pub fn make_grep_tool() -> RegisteredTool { } } +/// Run a content search and mark every returned file as observed by the +/// agent's read-before-write guard. +pub(crate) async fn execute_grep( + ctx: &ToolContext, + pattern: &str, + path: &str, + options: &GrepOptions, +) -> Result, String> { + let results = ctx + .env + .grep(pattern, path, options) + .await + .map_err(|e| e.display_with_causes())?; + let mut seen_files = std::collections::HashSet::new(); + for line in &results { + let file_path = grep_result_path(line, path); + if !file_path.is_empty() && seen_files.insert(file_path) { + ctx.env.mark_agent_read(file_path); + } + } + Ok(results) +} + +/// Extract the file path from `::` grep output. +/// +/// A search of one concrete file may omit ``, in which case the searched +/// path itself is returned. Candidate separators are walked so paths that +/// contain colons (including Windows drive prefixes) still parse correctly. +pub(crate) fn grep_result_path<'a>(line: &'a str, searched: &'a str) -> &'a str { + let mut rest = line; + let mut consumed = 0usize; + while let Some(index) = rest.find(':') { + let after = &rest[index + 1..]; + let digit_count = after.chars().take_while(char::is_ascii_digit).count(); + if digit_count > 0 && after[digit_count..].starts_with(':') { + return &line[..consumed + index]; + } + consumed += index + 1; + rest = after; + } + searched +} + #[must_use] pub fn make_glob_tool() -> RegisteredTool { RegisteredTool { @@ -774,6 +827,34 @@ mod tests { assert_eq!(result.unwrap(), "1 | hello\n2 | world\n"); } + #[tokio::test] + async fn read_file_applies_the_documented_default_limit() { + let tool = make_read_file_tool(); + let content = (1..=DEFAULT_READ_LINES + 1) + .map(|line| format!("line{line}")) + .collect::>() + .join("\n"); + let env: Arc = Arc::new(MockSandbox { + files: HashMap::from([("/test.txt".to_string(), content)]), + ..Default::default() + }); + + let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env_provider: None, + session_id: None, + root_session_id: None, + tool_call_id: None, + agent_event_emitter: None, + }) + .await + .unwrap(); + + assert!(result.contains("2000 | line2000"), "{result}"); + assert!(!result.contains("2001 | line2001"), "{result}"); + } + #[tokio::test] async fn read_file_with_offset_and_limit() { let tool = make_read_file_tool(); diff --git a/lib/components/fabro-agent/src/truncation.rs b/lib/components/fabro-agent/src/truncation.rs index d5cb0201e..d7fbe3ffc 100644 --- a/lib/components/fabro-agent/src/truncation.rs +++ b/lib/components/fabro-agent/src/truncation.rs @@ -1,4 +1,5 @@ use crate::config::SessionOptions; +use crate::tool_permissions::canonical_tool_name; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TruncationMode { @@ -86,14 +87,16 @@ pub fn truncate_lines(output: &str, max_lines: usize) -> String { #[must_use] pub fn truncate_tool_output(output: &str, tool_name: &str, config: &SessionOptions) -> String { - let mode = default_truncation_mode(tool_name); + let canonical_name = canonical_tool_name(tool_name); + let mode = default_truncation_mode(canonical_name); // Char truncation first let char_limit = config .tool_output_limits .get(tool_name) .copied() - .or_else(|| default_char_limit(tool_name)); + .or_else(|| config.tool_output_limits.get(canonical_name).copied()) + .or_else(|| default_char_limit(canonical_name)); let after_chars = match char_limit { Some(limit) => truncate_output(output, limit, mode), @@ -105,7 +108,8 @@ pub fn truncate_tool_output(output: &str, tool_name: &str, config: &SessionOptio .tool_line_limits .get(tool_name) .copied() - .or_else(|| default_line_limit(tool_name)); + .or_else(|| config.tool_line_limits.get(canonical_name).copied()) + .or_else(|| default_line_limit(canonical_name)); match line_limit { Some(limit) => truncate_lines(&after_chars, limit), @@ -172,6 +176,24 @@ mod tests { assert!(result.len() < output.len()); } + #[test] + fn kimi_aliases_use_canonical_limits() { + let config = SessionOptions::default(); + let shell_output = "x".repeat(40_000); + let write_output = "x".repeat(2_000); + + assert!(truncate_tool_output(&shell_output, "Bash", &config).len() < shell_output.len()); + assert!(truncate_tool_output(&write_output, "Write", &config).len() < write_output.len()); + } + + #[test] + fn canonical_config_override_applies_to_kimi_alias() { + let mut config = SessionOptions::default(); + config.tool_output_limits.insert("shell".into(), 100); + let result = truncate_tool_output(&"x".repeat(1_000), "Bash", &config); + assert!(result.contains("Tool output was truncated")); + } + #[test] fn config_override_char_limit() { let output = "x".repeat(5000); diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index 979d54406..da9152e00 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -5260,35 +5260,34 @@ mod tests { } #[test] - fn child_openai_plan_does_not_project_when_root_has_no_plan() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 2, - created( - "openai_plan:child_session", - TodoListKind::OpenAiPlan, - "c-a", - 0, - "child work", - ), - stage_id.clone(), - )) - .unwrap(); + fn child_session_whole_lists_do_not_project_when_root_has_no_list() { + for (kind, child_list) in [ + (TodoListKind::OpenAiPlan, "openai_plan:child_session"), + (TodoListKind::KimiTodos, "kimi_todos:child_session"), + ] { + let mut state = initialized_projection(); + let stage_id = stage_id(); + state + .apply_event(&test_stage_event( + 1, + EventBody::StageStarted(started_props()), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&child_stage_event( + 2, + created(child_list, kind, "c-a", 0, "child work"), + stage_id.clone(), + )) + .unwrap(); - let stage = state.stage(&stage_id).expect("stage projection present"); - assert!( - stage.root_agent_todos.is_none(), - "a child session's plan must not become the stage's root plan" - ); + let stage = state.stage(&stage_id).expect("stage projection present"); + assert!( + stage.root_agent_todos.is_none(), + "a child session's {kind} list must not become the stage's root list" + ); + } } #[test] diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index b9d7a2b84..57ed4121f 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -8,7 +8,7 @@ use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, Tool use fabro_agent::{ AgentEvent, AgentProfile, AgentProfileBuilder, CompletionCoordinator, Message as AgentMessage, Sandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider, - ToolSecrets, register_question_tools, + ToolSecrets, canonical_tool_name, register_question_tools, }; use fabro_auth::{CredentialSource, EnvCredentialSource}; use fabro_graphviz::graph::{AttrValue, Node}; @@ -444,8 +444,12 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { tool_name, tool_call_id, arguments, - } if tool_name == "write_file" || tool_name == "edit_file" => { - if let Some(path) = arguments.get("file_path").and_then(|v| v.as_str()) { + } if matches!(canonical_tool_name(tool_name), "write_file" | "edit_file") => { + if let Some(path) = arguments + .get("file_path") + .or_else(|| arguments.get("path")) + .and_then(|v| v.as_str()) + { state.pending.insert(tool_call_id.clone(), path.to_string()); } } @@ -2622,6 +2626,34 @@ reasoning = false assert_eq!(state.last.as_deref(), Some("/src/lib.rs")); } + #[test] + fn track_file_event_tracks_kimi_write_alias() { + let mut state = new_file_tracking(); + track_file_event( + &AgentEvent::ToolCallStarted { + tool_name: "Write".to_string(), + tool_call_id: "tc-kimi".to_string(), + arguments: serde_json::json!({ + "path": "/src/kimi.rs", + "content": "new" + }), + }, + &mut state, + ); + track_file_event( + &AgentEvent::ToolCallCompleted { + tool_call_id: "tc-kimi".to_string(), + tool_name: "Write".to_string(), + is_error: false, + output: serde_json::Value::String("ok".to_string()), + }, + &mut state, + ); + + assert!(state.touched.contains("/src/kimi.rs")); + assert_eq!(state.last.as_deref(), Some("/src/kimi.rs")); + } + #[test] fn track_file_event_error_removes_pending() { let mut state = new_file_tracking(); diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index 1fa05b7ca..c386ab03d 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -269,18 +269,27 @@ fn permission_level_matches_openapi_json_shape() { #[test] fn nested_agent_state_types_match_openapi_json_shape() { - let todo_list = TodoListProjection::new(TodoListKind::OpenAiPlan, "openai_plan:ses_root"); - let todo_json = serde_json::to_value(&todo_list).unwrap(); - assert_eq!( - todo_json, - json!({ - "kind": "openai_plan", - "list_id": "openai_plan:ses_root", - "items": [] - }) - ); - let api_todo_list: ApiTodoListProjection = serde_json::from_value(todo_json).unwrap(); - assert_eq!(api_todo_list, todo_list); + for (kind, list_id, wire_kind) in [ + ( + TodoListKind::OpenAiPlan, + "openai_plan:ses_root", + "openai_plan", + ), + (TodoListKind::KimiTodos, "kimi_todos:ses_root", "kimi_todos"), + ] { + let todo_list = TodoListProjection::new(kind, list_id); + let todo_json = serde_json::to_value(&todo_list).unwrap(); + assert_eq!( + todo_json, + json!({ + "kind": wire_kind, + "list_id": list_id, + "items": [] + }) + ); + let api_todo_list: ApiTodoListProjection = serde_json::from_value(todo_json).unwrap(); + assert_eq!(api_todo_list, todo_list); + } let subagent = SubAgentProjection { agent_id: "sub-1".to_string(), diff --git a/lib/foundation/fabro-model/src/adapter.rs b/lib/foundation/fabro-model/src/adapter.rs index 091fcf896..e8ba94528 100644 --- a/lib/foundation/fabro-model/src/adapter.rs +++ b/lib/foundation/fabro-model/src/adapter.rs @@ -105,17 +105,13 @@ mod tests { #[test] fn agent_profile_kind_round_trips_as_settings_strings() { - for (kind, expected) in [ - (AgentProfileKind::Anthropic, "anthropic"), - (AgentProfileKind::OpenAi, "openai"), - (AgentProfileKind::Gemini, "gemini"), - ] { + for kind in AgentProfileKind::VARIANTS { + let expected = kind.to_string(); let json = serde_json::to_string(&kind).unwrap(); assert_eq!(json, format!("\"{expected}\"")); let parsed: AgentProfileKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, kind); - assert_eq!(expected.parse::().unwrap(), kind); - assert_eq!(kind.to_string(), expected); + assert_eq!(parsed, *kind); + assert_eq!(expected.parse::().unwrap(), *kind); } } } diff --git a/lib/foundation/fabro-types/src/todo.rs b/lib/foundation/fabro-types/src/todo.rs index e1dc38202..a8560e458 100644 --- a/lib/foundation/fabro-types/src/todo.rs +++ b/lib/foundation/fabro-types/src/todo.rs @@ -1,10 +1,12 @@ -//! Shared todo / task domain types used by `update_plan` (OpenAI) and the -//! Claude task tools (`TaskCreate`, `TaskUpdate`, `TaskList`). +//! Shared todo / task domain types used by `update_plan` (OpenAI), `TodoList` +//! (Kimi Code), and the Claude task tools (`TaskCreate`, `TaskUpdate`, +//! `TaskList`). //! //! Both tool families share the same event-sourced projection. The only //! difference is the scoping convention captured by [`TodoListKind`]: //! //! - `openai_plan:` — one list per emitting session. +//! - `kimi_todos:` — one list per emitting session. //! - `anthropic_tasks:` — one list shared by a root session //! and all of its subagent sessions. //! diff --git a/lib/packages/fabro-api-client/src/models/todo-list-kind.ts b/lib/packages/fabro-api-client/src/models/todo-list-kind.ts index 1f9f30612..20f1da8ee 100644 --- a/lib/packages/fabro-api-client/src/models/todo-list-kind.ts +++ b/lib/packages/fabro-api-client/src/models/todo-list-kind.ts @@ -20,7 +20,8 @@ export const TodoListKind = { OPENAI_PLAN: 'openai_plan', - ANTHROPIC_TASKS: 'anthropic_tasks' + ANTHROPIC_TASKS: 'anthropic_tasks', + KIMI_TODOS: 'kimi_todos' } as const; export type TodoListKind = typeof TodoListKind[keyof typeof TodoListKind]; From debd612b52b2b0e1b24fc79ed6cb99376df4ad43 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 22:13:12 -0400 Subject: [PATCH 17/20] docs(agent): clarify Kimi append precondition --- lib/components/fabro-agent/src/profiles/kimi_tools.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index c300f7eac..3a7559fd6 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -262,11 +262,11 @@ pub fn make_kimi_write_tool() -> RegisteredTool { Read an existing file with Read before writing to it — this workspace refuses writes to files \ that have not been read, and the call will fail. -- `mode` defaults to `overwrite`, which replaces the whole file. `append` adds to the end without \ -inserting a newline. +- `mode` defaults to `overwrite`, which replaces the whole file. `append` requires an existing file \ +and adds to its end without inserting a newline. - Write is NOT for incremental changes to an existing file, however small. Use Edit instead: \ overwrite replaces everything you did not restate. -- Use Write when the file does not exist, or when you intend a complete replacement. +- Use `overwrite` when the file does not exist, or when you intend a complete replacement. - Do not create documentation files that were not asked for.", serde_json::json!({ "type": "object", From bf62450a281cf6804758e786fa93cf5dffe00e97 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 24 Jul 2026 22:21:52 -0400 Subject: [PATCH 18/20] fix(agent): align summary prompt with output cap Interpolate the visible summary allowance after applying the model max_output cap, so low-output models are not asked to produce more text than the request permits. --- lib/components/fabro-agent/src/compaction.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index 698d68275..2b09192b4 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -13,7 +13,7 @@ use crate::types::{AgentEvent, Message}; const APPROX_CHARS_PER_TOKEN: usize = 4; -/// Output budget for the visible summary text itself. +/// Maximum output budget for the visible summary text itself. const SUMMARY_MAX_TOKENS: i64 = 4096; /// Extra output budget for models that reason on every request. `max_tokens` @@ -115,6 +115,12 @@ pub(crate) async fn compact_context( ) }; + let max_tokens = summary_max_tokens( + provider_profile.reasons_by_default(), + provider_profile.max_output_tokens(), + ); + let visible_max_tokens = SUMMARY_MAX_TOKENS.min(max_tokens); + let summarization_prompt = format!( "You are creating a handoff document for a different coding assistant that will take over \ this task. That assistant will only see your summary and the most recent messages — nothing else \ @@ -126,17 +132,12 @@ Write a summary using EXACTLY these sections:\n\n\ ## Failed Approaches\nWhat was tried and didn't work, and why.\n\n\ ## Open Issues\nBugs, edge cases, or TODOs that remain.\n\n\ ## Next Steps\nWhat should happen next to make progress.\n\n\ -Keep the entire response under {SUMMARY_MAX_TOKENS} tokens.\n\n\ +Keep the entire response under {visible_max_tokens} tokens.\n\n\ Be thorough and specific — the assistant taking over has no prior context. Include file paths, \ function names, error messages, and exact values. Omit pleasantries and conversational filler.\ {file_ops_section}" ); - let max_tokens = summary_max_tokens( - provider_profile.reasons_by_default(), - provider_profile.max_output_tokens(), - ); - let summary_request = Request { model: provider_profile.model().to_string(), messages: vec![ From 4666f51d98fa7ea320390bea020eb2cfc0dc5890 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 22:34:02 -0400 Subject: [PATCH 19/20] feat(model): add Claude Opus 5 to OpenRouter --- docs/public/changelog/2026-07-24.mdx | 2 +- docs/public/integrations/openrouter.mdx | 2 +- lib/foundation/fabro-model/src/catalog.rs | 20 +++++++++++++ .../src/catalog/providers/openrouter.toml | 28 ++++++++++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/public/changelog/2026-07-24.mdx b/docs/public/changelog/2026-07-24.mdx index 35e35d846..9ae1d18ab 100644 --- a/docs/public/changelog/2026-07-24.mdx +++ b/docs/public/changelog/2026-07-24.mdx @@ -40,7 +40,7 @@ When a run resumes after a node was cancelled or lost mid-flight, the replay now -- Added `claude-opus-5` to the first-party Anthropic model catalog; the `opus` and `claude-opus` aliases now resolve to Opus 5 +- Added `claude-opus-5` to the first-party Anthropic and optional OpenRouter model catalogs; the `opus` and `claude-opus` aliases now resolve to Opus 5 - Added `gpt-sol`, `gpt-terra`, and `gpt-luna` aliases for GPT-5.6 offerings - Added portable `glm`, `glm52`, `glm5.2`, `deepseek`, and `deepseek-flash` aliases across direct and OpenRouter offerings diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index bd0c186dd..c3bf7ff2c 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -48,7 +48,7 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug | Fabro model slug | OpenRouter API ID / notes | | --- | --- | -| `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7` | Matching `anthropic/...` API IDs; Anthropic-style cache billing | +| `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | Matching `anthropic/...` API IDs; Anthropic-style cache billing | | `claude-sonnet-4-6` | `anthropic/claude-sonnet-4.6`; provider default | | `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default | | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs | diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 7fa9eb161..f012a62ff 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3083,6 +3083,19 @@ enabled = true false, BillingPolicy::OpenAi, ), + ( + "claude-opus-5", + "anthropic/claude-opus-5", + "claude-5", + 1_000_000, + 5.0, + 25.0, + 0.5, + ReasoningEffortFeature::Levels, + false, + true, + BillingPolicy::Anthropic, + ), ( "claude-opus-4-8", "anthropic/claude-opus-4.8", @@ -3161,6 +3174,13 @@ enabled = true "{id}" ); } + + for alias in ["opus", "claude-opus"] { + let model = catalog + .resolve_on_provider(&ProviderId::new("openrouter"), alias) + .unwrap_or_else(|error| panic!("{alias} should resolve on OpenRouter: {error}")); + assert_eq!(model.id, "claude-opus-5", "{alias}"); + } } #[test] diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index eea168785..2878ff33d 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -58,6 +58,33 @@ input_cost_per_mtok = 10.0 output_cost_per_mtok = 50.0 cache_input_cost_per_mtok = 1.0 +[providers.openrouter.models."claude-opus-5"] +api_id = "anthropic/claude-opus-5" +display_name = "Claude Opus 5 (via OpenRouter)" +family = "claude-5" +billing_policy = "anthropic" +training = "2026-05-01" +knowledge_cutoff = "May 2026" +aliases = ["opus", "claude-opus"] + +[providers.openrouter.models."claude-opus-5".limits] +context_window = 1000000 +max_output = 128000 + +[providers.openrouter.models."claude-opus-5".features] +tools = true +vision = true +reasoning = true +reasoning_effort = "levels" +prompt_cache = true +cache_control_breakpoints = true +sampling_params = false + +[providers.openrouter.models."claude-opus-5".costs] +input_cost_per_mtok = 5.0 +output_cost_per_mtok = 25.0 +cache_input_cost_per_mtok = 0.5 + [providers.openrouter.models."claude-opus-4-8"] api_id = "anthropic/claude-opus-4.8" display_name = "Claude Opus 4.8 (via OpenRouter)" @@ -65,7 +92,6 @@ family = "claude-4" billing_policy = "anthropic" training = "2026-01-01" knowledge_cutoff = "Jan 2026" -aliases = ["opus", "claude-opus"] [providers.openrouter.models."claude-opus-4-8".limits] context_window = 1000000 From 4d5458b64cea5d3acc81302ea2734fcfbba8e5bf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 22:35:51 -0400 Subject: [PATCH 20/20] test(agent): honor Docker shell integration preconditions --- .../fabro-agent/tests/it/docker_shell.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/components/fabro-agent/tests/it/docker_shell.rs b/lib/components/fabro-agent/tests/it/docker_shell.rs index ac0692fba..48c0d25b3 100644 --- a/lib/components/fabro-agent/tests/it/docker_shell.rs +++ b/lib/components/fabro-agent/tests/it/docker_shell.rs @@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken; #[tokio::test] #[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"] async fn shell_reports_real_docker_process_outcome() { - let sandbox = DockerSandbox::new( + let Ok(sandbox) = DockerSandbox::new( DockerSandboxOptions { image: "buildpack-deps:noble".to_string(), auto_pull: false, @@ -28,12 +28,13 @@ async fn shell_reports_real_docker_process_outcome() { None, None, None, - ) - .expect("docker sandbox should construct"); - sandbox - .initialize() - .await - .expect("docker sandbox should initialize"); + ) else { + return; + }; + // No Docker daemon or no local image: the integration precondition is not met. + if sandbox.initialize().await.is_err() { + return; + } let sandbox = Arc::new(sandbox); let emitter = Emitter::new(); @@ -49,8 +50,8 @@ async fn shell_reports_real_docker_process_outcome() { root_session_id: Some("test-session".to_string()), tool_call_id: Some("call_1".to_string()), agent_event_emitter: Some(Arc::new(SessionBoundEmitter { - emitter, - session_id: "test-session".to_string(), + emitter: emitter.clone(), + session_id: "test-session".to_string(), tool_call_id: Some("call_1".to_string()), })), },