Merge origin/main into feat/inference-observability

This commit is contained in:
Bryan Helmkamp 2026-07-24 22:55:15 -04:00
commit c81ea69c73
No known key found for this signature in database
65 changed files with 4405 additions and 385 deletions

View file

@ -1116,6 +1116,45 @@ 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 |
| `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`
Emitted when the agent encounters an error.

View file

@ -11393,7 +11393,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.

View file

@ -40,7 +40,7 @@ When a run resumes after a node was cancelled or lost mid-flight, the replay now
</Accordion>
<Accordion title="Improvements">
- 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
</Accordion>

View file

@ -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 |

View file

@ -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`). |

View file

@ -1,6 +1,7 @@
use std::convert::TryFrom;
use chrono::{DateTime, Utc};
use fabro_agent::Error as AgentError;
use fabro_types::{BilledModelUsage, EventBody, LlmOutputKind, RunEvent};
use fabro_util::error;
use fabro_workflow::event::RunNoticeLevel;
@ -165,6 +166,11 @@ pub(super) enum ProgressEvent {
preserved_turn_count: u64,
tracked_file_count: u64,
},
CompactionFailed {
stage_node_id: String,
error: String,
root_session: bool,
},
LlmRequestStarted {
stage_node_id: String,
model: String,
@ -373,6 +379,17 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
preserved_turn_count: props.preserved_turn_count as u64,
tracked_file_count: props.tracked_file_count as u64,
}),
EventBody::AgentError(props) => match display_compaction_error(&props.error) {
Some(error) => Some(ProgressEvent::CompactionFailed {
stage_node_id: node_id,
error,
root_session: stored.parent_session_id.is_none(),
}),
None if stored.parent_session_id.is_none() => Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
}),
None => None,
},
EventBody::AgentLlmStarted(props) if stored.parent_session_id.is_none() => {
Some(ProgressEvent::LlmRequestStarted {
stage_node_id: node_id,
@ -400,9 +417,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
error: display_value(&props.error).unwrap_or_else(|| "unknown error".to_string()),
})
}
EventBody::AgentError(_) | EventBody::AgentRoundInterrupted(_)
if stored.parent_session_id.is_none() =>
{
EventBody::AgentRoundInterrupted(_) if stored.parent_session_id.is_none() => {
Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
})
@ -454,6 +469,14 @@ pub(super) fn from_json_line(line: &str) -> Option<ProgressEvent> {
from_run_event(&stored)
}
fn display_compaction_error(value: &Value) -> Option<String> {
let error = serde_json::from_value::<AgentError>(value.clone()).ok()?;
match error {
AgentError::Compaction(error) => Some(error.to_string()),
_ => None,
}
}
fn display_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,

View file

@ -320,6 +320,17 @@ impl ProgressUI {
tracked_file_count,
);
}
ProgressEvent::CompactionFailed {
stage_node_id,
error,
root_session,
} => {
if root_session {
self.stage.on_llm_request_finished(&stage_node_id);
}
self.stage
.on_compaction_failed(renderer, &stage_node_id, &error);
}
ProgressEvent::LlmRequestStarted {
stage_node_id,
model,
@ -715,6 +726,8 @@ mod tests {
}),
);
assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());
emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
assert!(ui.stage.active_stages["s1"].inference_bar.is_some());
emit(
&mut ui,
@ -728,6 +741,49 @@ 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());
assert!(ui.stage.active_stages["s1"].inference_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 inference_bracket_sets_updates_and_clears_bar() {
let mut ui = ProgressUI::new(true, false);

View file

@ -482,6 +482,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);
}
}
/// Open the live line for an inference request.
///
/// It says only what is provable: a request is open and nothing has come

View file

@ -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(),
@ -941,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,

View file

@ -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,

View file

@ -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,

View file

@ -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,6 +13,15 @@ use crate::types::{AgentEvent, Message};
const APPROX_CHARS_PER_TOKEN: usize = 4;
/// 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`
/// 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 {
@ -106,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 \
@ -117,6 +132,7 @@ 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 {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}"
@ -136,7 +152,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,
@ -147,12 +163,25 @@ 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 summary_text = response.text();
let response_text = response.text();
let summary_text = response_text.trim();
// `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());
}
let (summary_text, summary_truncated) = truncate_summary_text(summary_text);
debug!(
summary_len = summary_text.len(),
"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. \
@ -172,6 +201,43 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}"
Ok(())
}
/// 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. 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>) -> i64 {
let budget = if reasoning_by_default {
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
} else {
SUMMARY_MAX_TOKENS
};
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(
system_prompt: &str,
history: &History,
@ -298,17 +364,101 @@ 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};
use fabro_model::{Catalog, Model, ProviderId};
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;
fn catalog_model(provider: &ProviderId, id: &str) -> &'static Model {
Catalog::builtin()
.get_on_provider(provider, id)
.unwrap_or_else(|| panic!("{provider}/{id} missing from builtin catalog"))
}
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(false, None), SUMMARY_MAX_TOKENS);
// The default agent test profile has no catalog behind it.
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!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-haiku-4-5"),
SUMMARY_MAX_TOKENS
);
}
#[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 = catalog_model(&ProviderId::anthropic(), "claude-sonnet-4-5");
assert!(model.supports_reasoning());
assert!(!model.supports_reasoning_effort());
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() {
assert_eq!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-fable-5"),
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
);
}
#[test]
fn summary_budget_for_effort_levels_model_adds_reasoning_headroom() {
assert_eq!(
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
);
}
#[test]
fn summary_budget_never_exceeds_model_max_output() {
assert_eq!(summary_max_tokens(true, Some(8_192)), 8_192);
assert_eq!(summary_max_tokens(false, Some(2_048)), 2_048);
}
#[test]
fn render_turns_produces_labeled_text() {
let turns = vec![
@ -570,4 +720,158 @@ mod tests {
assert!(matches!(event.event, AgentEvent::Warning { details, .. }
if details["estimate_method"] == "local_estimate"));
}
struct CompactionTestResult {
result: Result<(), Error>,
history: History,
original_turns: Vec<fabro_types::SessionMessage>,
events: Vec<AgentEvent>,
}
/// 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) -> CompactionTestResult {
let mut history = History::default();
for index in 0..4 {
history.push(Message::User {
content: format!("message {index}"),
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;
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);
}
CompactionTestResult {
result,
history,
original_turns,
events,
}
}
fn assert_history_untouched(history: &History, original_turns: &[fabro_types::SessionMessage]) {
assert_eq!(
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_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("blank summary must not report success");
assert!(
matches!(
&err,
Error::Compaction(CompactionError::EmptySummary {
summarized_turn_count: 3,
})
),
"unexpected error: {err}"
);
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_accepts_concise_nonempty_summary() {
let CompactionTestResult {
result,
history,
events,
..
} = compact_with_summary("Brief handoff.").await;
result.expect("a nonempty 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("Brief handoff."));
assert!(
events
.iter()
.any(|event| matches!(event, AgentEvent::CompactionCompleted { .. })),
"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"));
}
}

View file

@ -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
}
@ -327,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]

View file

@ -12,6 +12,7 @@ use fabro_types::{
};
use crate::memory::MemoryDocument;
use crate::native_tool::ToolVocabulary;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::tool_registry::{ToolDefinitionWithSource, ToolSource};
@ -22,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,
@ -158,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;
@ -220,8 +222,8 @@ fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String {
}
}
fn skills_prompt_suffix(skills: &[Skill]) -> String {
let section = format_skills_prompt_section(skills);
fn skills_prompt_suffix(skills: &[Skill], vocabulary: ToolVocabulary) -> String {
let section = format_skills_prompt_section(skills, vocabulary);
if section.is_empty() {
String::new()
} else {
@ -390,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),
@ -414,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",
@ -446,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 {

View file

@ -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<T> = std::result::Result<T, Error>;
#[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;

View file

@ -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::<Vec<_>>();
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();

View file

@ -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;
@ -39,7 +40,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;
@ -47,8 +48,9 @@ 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,
AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, KimiProfile, OpenAiProfile,
};
pub use question_tools::{
ANTHROPIC_ASK_USER_QUESTION_TOOL, AgentQuestion, AgentQuestionAnswer,
@ -70,8 +72,9 @@ 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};
pub use tools::{
WebFetchSummarizer, make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool,

View file

@ -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; it has no vendor-specific
// instruction filename of its own.
AgentProfileKind::Kimi => vec!["AGENTS.md"],
};
let mut results: Vec<MemoryDocument> = Vec::new();
@ -220,7 +223,7 @@ mod tests {
assert_eq!(openai_docs[1].content, "copilot");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
files,
files: files.clone(),
..Default::default()
});
let gemini_docs = discover_memory(
@ -235,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<dyn Sandbox> = 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]

View file

@ -0,0 +1,258 @@
//! 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", serialize = "Read")]
ReadFile,
#[strum(to_string = "read_many_files")]
ReadManyFiles,
#[strum(to_string = "write_file", serialize = "Write")]
WriteFile,
#[strum(to_string = "edit_file", serialize = "Edit")]
EditFile,
#[strum(to_string = "apply_patch")]
ApplyPatch,
#[strum(to_string = "list_dir")]
ListDir,
#[strum(to_string = "grep", serialize = "Grep")]
Grep,
#[strum(to_string = "glob", serialize = "Glob")]
Glob,
#[strum(to_string = "shell", serialize = "Bash")]
Shell,
#[strum(to_string = "web_search", serialize = "WebSearch")]
WebSearch,
#[strum(to_string = "web_fetch", serialize = "FetchURL")]
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", serialize = "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 = "TodoList")]
TodoList,
#[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()
}
/// 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> {
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.
#[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",
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(),
},
}
}
/// 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> {
name.parse().ok()
}
/// 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<AgentToolCategory> {
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::TodoList
| 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());
}
}
}
}

View file

@ -0,0 +1,380 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{self, BaseProfile, EmbeddedPrompt, kimi_tools};
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::ToolRegistry;
use crate::tools::{WebFetchSummarizer, register_discovery_and_web_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 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 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,
}
impl KimiProfile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi);
Self::with_native_tools(model, &options, None)
}
pub(crate) fn with_native_tools(
model: impl Into<String>,
options: &NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
) -> Self {
// The registry carries the vocabulary, so tools registered later
// (subagent tools, skills) are renamed too.
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode);
// 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::Glob, GLOB_DESCRIPTION);
// 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_todo_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<Catalog>) -> 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)
.with_vocabulary(self.base.registry.vocabulary());
profiles::assemble_system_prompt(
template,
env,
env_context,
memory,
user_instructions,
skills,
)
}
}
#[cfg(test)]
mod tests {
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::AgentToolCategory;
use super::*;
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};
fn catalog() -> Arc<Catalog> {
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<Catalog> {
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));
}
/// 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 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(),
"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"
);
}
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_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:?}");
}
#[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", "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").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}");
}
// 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(&seconds),
"Bash should quote {seconds}s: {bash}"
);
assert!(
!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}");
// 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"));
}
#[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]
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("<environment>"));
}
}

View file

@ -0,0 +1,879 @@
//! 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::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, format_lines_numbered};
use crate::tool_registry::{RegisteredTool, ToolSource};
use crate::tools::{
DEFAULT_READ_LINES, emit_shell_process_completed, execute_grep, execute_shell_command,
grep_result_path, make_edit_file_tool, optional_usize_arg, required_str,
};
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 {
// 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 <dir>` 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 streaming = execute_shell_command(&ctx, command, timeout_ms, cwd).await?;
let result = &streaming.result;
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');
}
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}");
}
let is_success = result.is_success();
emit_shell_process_completed(&ctx, streaming).await;
if is_success { Ok(out) } else { Err(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 `<line-number> | <content>` 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.
- 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",
"minimum": -2000,
"description": "1-based first line to read. Negative reads from the end \
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)."
}
},
"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")?.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
// 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())?;
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_text(path)
.await
.map_err(|e| e.display_with_causes())?;
let total = raw.lines().count();
let start = total.saturating_sub(from_end).saturating_add(1);
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), Some(n_lines)).await
}
None => ctx.env.read_file(path, None, Some(n_lines)).await,
}
.map_err(|e| e.display_with_causes())?;
ctx.env.mark_agent_read(path);
Ok(content)
})
}),
source: ToolSource::Native,
}
}
#[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 {
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` 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 `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",
"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")
.parse::<KimiWriteMode>()
.map_err(|_| "Invalid mode (expected overwrite|append)".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.
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())?;
}
}
Ok(format!("Wrote {path}"))
})
}),
source: ToolSource::Native,
}
}
/// 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;
use serde_json::json;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::{ExecResult, Sandbox};
use crate::test_support::{MockSandbox, MutableMockSandbox};
use crate::tool_registry::ToolContext;
fn ctx(env: Arc<dyn Sandbox>) -> 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<MutableMockSandbox> {
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<String> = (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<String> = (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}");
}
#[tokio::test]
async fn read_positive_offset_still_applies_the_default_limit() {
let lines: Vec<String> = (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() {
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}");
}
#[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]
fn grep_result_path_handles_both_output_shapes() {
// Directory scan: `<path>:<line>:<content>`.
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<String>) -> Result<String, String> {
let env: Arc<dyn Sandbox> = 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", "output_mode": "content"}), 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_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(
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_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");
}
#[tokio::test]
async fn grep_offset_and_head_limit_page_results() {
let lines: Vec<String> = (1..=6).map(|n| format!("f{n}.rs:1:x")).collect();
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");
}
#[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_matches"),
"{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");
}
#[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]
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"));
}
#[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
.expect_err("a timeout is a failed tool result");
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_eq!(
env.captured_command.lock().unwrap().as_deref(),
Some("echo $TOKEN")
);
}
}
/// Output shapes Kimi Code's `Grep` supports.
#[derive(Clone, Copy, Default, PartialEq, Eq, EnumString)]
#[strum(serialize_all = "snake_case")]
enum GrepOutputMode {
Content,
#[default]
FilesWithMatches,
CountMatches,
}
/// `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: `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; `-i` 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_matches"],
"description": "Shape of the results (default files_with_matches)."
},
"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"]
}),
),
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::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("-i").and_then(Value::as_bool).unwrap_or(false),
max_results: match mode {
GrepOutputMode::Content => Some(
head_limit
.saturating_add(offset)
.min(MAX_GREP_MATCHES_SCANNED),
),
GrepOutputMode::FilesWithMatches | GrepOutputMode::CountMatches => {
Some(MAX_GREP_MATCHES_SCANNED)
}
},
};
let lines = execute_grep(&ctx, pattern, path, &options).await?;
let searched = path;
let results: Vec<String> = match mode {
GrepOutputMode::Content => lines,
GrepOutputMode::FilesWithMatches => {
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);
}
}
files
}
GrepOutputMode::CountMatches => {
let mut counts: HashMap<String, usize> = 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);
}
}
order
.into_iter()
.map(|file| {
let count = counts[&file];
format!("{file}:{count}")
})
.collect()
}
}
.into_iter()
.skip(offset)
.take(head_limit)
.collect();
if results.is_empty() {
return Ok("No matches found".to_string());
}
Ok(results.join("\n"))
})
}),
source: ToolSource::Native,
}
}

View file

@ -5,14 +5,18 @@ 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;
pub use gemini::GeminiProfile;
pub use kimi::KimiProfile;
pub use openai::OpenAiProfile;
use crate::agent_profile::AgentProfile;
use crate::config::{NativeToolOptions, ToolSecrets};
use crate::native_tool::ToolVocabulary;
use crate::sandbox::Sandbox;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::tool_registry::ToolRegistry;
@ -85,6 +89,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)),
),
}
}
}
@ -118,9 +127,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<String, toml::Value>,
name: &'static str,
source: &'static str,
inputs: HashMap<String, toml::Value>,
/// Vocabulary the surrounding prompt sections should name tools in.
vocabulary: ToolVocabulary,
}
impl EmbeddedPrompt {
@ -130,9 +141,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<String>) -> Self {
self.inputs
@ -177,6 +196,7 @@ pub fn assemble_system_prompt(
skills: &[Skill],
) -> String {
let env_block = build_env_context_block_with(env, env_context);
let vocabulary = template.vocabulary;
let prompt = template.render(env_block);
let docs_section = if memory.is_empty() {
@ -185,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);
let s = format_skills_prompt_section(skills, vocabulary);
if s.is_empty() {
String::new()
} else {
@ -297,6 +317,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();

View file

@ -0,0 +1,87 @@
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 810 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` 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`, `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` 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` 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` 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.
When working on an existing codebase, you should:
- 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.
- 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. `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
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.

View file

@ -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 => {}
}
}
@ -583,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());

View file

@ -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,
@ -668,9 +671,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));
}
}
@ -1427,6 +1431,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.
@ -1489,7 +1499,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();
@ -1878,7 +1890,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
@ -1933,10 +1947,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;
}
@ -1980,7 +1994,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,
@ -1989,12 +2008,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(),
@ -2006,10 +2025,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) {
@ -2119,6 +2139,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,
@ -2189,6 +2210,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::*;
@ -4784,8 +4806,9 @@ mod tests {
// provider that errors on complete() but succeeds on stream().
struct StreamOnlyProvider {
responses: Vec<Response>,
call_index: AtomicUsize,
responses: Vec<Response>,
stream_index: AtomicUsize,
complete_calls: AtomicUsize,
}
#[async_trait::async_trait]
@ -4795,6 +4818,7 @@ mod tests {
}
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
self.complete_calls.fetch_add(1, Ordering::SeqCst);
Err(LlmError::Stream {
message: "summarization failed".into(),
source: None,
@ -4802,7 +4826,7 @@ mod tests {
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
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 {
@ -4831,16 +4855,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<dyn ProviderAdapter>).await;
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
@ -4858,15 +4886,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");

View file

@ -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<ExpandedInput, Stri
}
pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> 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<Vec<Skill>>,
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<Vec<Skill>>) -> 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,23 +224,34 @@ pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> 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,
}
}
pub fn format_skills_prompt_section(skills: &[Skill]) -> String {
/// Render the skills section of a system prompt.
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(),
"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 +498,13 @@ name: trimmed
#[test]
fn format_empty() {
assert_eq!(format_skills_prompt_section(&[]), "");
assert_eq!(format_skills_prompt_section(&[], ToolVocabulary::Fabro), "");
}
#[test]
fn format_lists_skills() {
let skills = test_skills();
let section = format_skills_prompt_section(&skills);
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"));
@ -641,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<dyn Sandbox> = 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()
);
}
}

View file

@ -15,6 +15,7 @@ use futures::stream;
use crate::agent_profile::AgentProfile;
use crate::config::SessionOptions;
use crate::native_tool::ToolVocabulary;
use crate::profiles::EnvContext;
use crate::sandbox::*;
use crate::session::Session;
@ -80,7 +81,7 @@ 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, ToolVocabulary::Fabro);
let skills_part = if skills_section.is_empty() {
String::new()
} else {

View file

@ -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:<session_id>`). Returns an
/// error string the model can see if no session ID is bound to the call.
fn openai_plan_scope(ctx: &ToolContext) -> Result<String, String> {
/// 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<String, String> {
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 `<list_id>::<step>`. 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 `<list_id>::<text>`. 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<TodoRuntime>) -> RegisteredTool {
@ -127,15 +186,14 @@ pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> 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<TodoRuntime>) -> 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<String, TodoProjection> = 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,6 +232,169 @@ pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
}
}
#[derive(Clone, Copy, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
enum KimiTodoStatus {
Pending,
InProgress,
#[strum(to_string = "done")]
Done,
}
impl From<KimiTodoStatus> for TodoStatus {
fn from(status: KimiTodoStatus) -> Self {
match status {
KimiTodoStatus::Pending => Self::Pending,
KimiTodoStatus::InProgress => Self::InProgress,
KimiTodoStatus::Done => Self::Completed,
}
}
}
impl From<TodoStatus> 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<TodoStatus, String> {
value
.parse::<KimiTodoStatus>()
.map(TodoStatus::from)
.map_err(|_| format!("Invalid status `{value}` (expected pending|in_progress|done)"))
}
fn kimi_status_name(status: TodoStatus) -> &'static str {
KimiTodoStatus::from(status).into()
}
fn render_kimi_todos<'a>(items: impl IntoIterator<Item = (TodoStatus, &'a str)>) -> 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 (status, subject) in items {
let _ = writeln!(out, "[{}] {subject}", kimi_status_name(status));
}
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<TodoRuntime>) -> 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 = session_todo_scope(&ctx, TodoListKind::KimiTodos, "TodoList")?;
// 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
.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::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(ReplacementTodo {
id: todo_text_id(&list_id, title),
subject: title.to_string(),
status,
});
}
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,
}
}
/// 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 +681,120 @@ pub fn make_task_list_tool(runtime: Arc<TodoRuntime>) -> 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<dyn Sandbox> = 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<String, String> {
(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 +808,7 @@ mod tests {
use crate::types::AgentEvent;
#[derive(Default)]
struct SilentEmitter;
pub(super) struct SilentEmitter;
impl AgentEventEmitter for SilentEmitter {
fn emit(&self, _event: AgentEvent) {}
}

View file

@ -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<String, ToolAccess>,
@ -1294,4 +1297,173 @@ mod tests {
assert!(!result.is_error);
}
fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc<dyn Sandbox> {
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<dyn ToolHookCallback>>,
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,
&registry,
shell_sandbox(exec_result),
hooks,
CancellationToken::new(),
&SessionOptions::default(),
emitter,
"test-session",
"test-session",
None,
)
.await
}
fn drain(receiver: &mut broadcast::Receiver<SessionEvent>) -> Vec<SessionEvent> {
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<dyn ToolHookCallback> = 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<dyn ToolHookCallback> = 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);
}
}

View file

@ -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<AgentToolCategory> {
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<AgentToolCategory> {
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 {

View file

@ -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,60 @@ fn agent_tool_source(source: &ToolSource) -> AgentToolSource {
}
pub struct ToolRegistry {
tools: HashMap<String, RegisteredTool>,
tools: HashMap<String, RegisteredTool>,
/// 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) {
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);
}
/// 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<String>) {
let exposed = tool.name(self.vocabulary);
if let Some(registered) = self.tools.get_mut(exposed) {
registered.definition.description = description.into();
}
}
pub fn unregister(&mut self, name: &str) -> Option<RegisteredTool> {
self.tools.remove(name)
}
@ -264,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();

View file

@ -8,13 +8,16 @@ use fabro_model::ModelHandle;
#[cfg(test)]
use fabro_static::EnvVars;
use futures::{StreamExt, stream};
use tokio::task;
use crate::config::NativeToolOptions;
use crate::sandbox::GrepOptions;
use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource};
use crate::sandbox::{ExecStreamingResult, GrepOptions};
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use crate::types::AgentEvent;
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 +68,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<WebFetchSummarizer>,
) {
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()));
@ -78,7 +90,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<Option<usize>, String> {
pub(crate) fn optional_usize_arg(
args: &serde_json::Value,
key: &str,
) -> Result<Option<usize>, String> {
args.get(key)
.and_then(serde_json::Value::as_u64)
.map(|value| {
@ -107,7 +122,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
@ -239,51 +255,125 @@ 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 streaming = execute_shell_command(&ctx, command, timeout_ms, None).await?;
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);
let is_success = streaming.result.is_success();
emit_shell_process_completed(&ctx, streaming).await;
if 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 distinguish missing process diagnostics from a reported process failure.
const SHELL_NO_PROCESS_RESULT: &str = "Shell command produced no process result";
/// 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<ExecStreamingResult, String> {
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"
);
ctx.env
.exec_command_streaming(
command,
Some(timeout_ms),
cwd,
tool_env.as_ref(),
Some(ctx.cancel.clone()),
None,
)
.await
.map_err(|e| format!("{SHELL_NO_PROCESS_RESULT}: {}", e.display_with_causes()))
}
/// Emit the subordinate process outcome after model-facing output has been
/// rendered. Consumes the raw result so redaction does not require cloning
/// potentially large process output.
pub(crate) async fn emit_shell_process_completed(
ctx: &ToolContext,
streaming: ExecStreamingResult,
) {
if ctx.agent_event_emitter.is_none() {
return;
}
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
}
};
ctx.emit_agent_event(AgentEvent::ToolProcessCompleted {
exit_code,
termination,
duration_ms,
streams_separated,
exec_output_tail,
});
}
/// 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 {
@ -329,19 +419,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"))
})
}),
@ -349,6 +427,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<Vec<String>, 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 `<path>:<line>:<content>` grep output.
///
/// A search of one concrete file may omit `<path>`, 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 {
@ -690,13 +811,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, ToolSecrets};
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::ToolContext;
use crate::truncation;
use crate::types::SessionEvent;
#[test]
fn core_tool_descriptions_include_actionable_guidance() {
@ -771,6 +897,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::<Vec<_>>()
.join("\n");
let env: Arc<dyn Sandbox> = 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();
@ -981,20 +1135,8 @@ 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<dyn Sandbox> = 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 {
fn shell_context(env: Arc<dyn Sandbox>) -> ToolContext {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env_provider: None,
@ -1002,11 +1144,87 @@ mod tests {
root_session_id: None,
tool_call_id: None,
agent_event_emitter: None,
}
}
fn shell_context_with_emitter(env: Arc<dyn Sandbox>, emitter: &Emitter) -> ToolContext {
ToolContext {
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<SessionEvent>) -> 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<MockSandbox> {
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<dyn Sandbox> = 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 +1261,260 @@ 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<dyn Sandbox> = 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<dyn Sandbox> = 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<dyn Sandbox> = Arc::new(MockSandbox {
exec_error: Some("sandbox transport is down".into()),
..Default::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),
)
.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!(matches!(
receiver.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn shell_emits_process_event_with_typed_outcome_and_redacted_tails() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
stdout: "out".into(),
stderr: "boom key=AKIAYRWQG5EJLPZLBYNP".into(),
exit_code: Some(7),
termination: CommandTermination::Exited,
duration_ms: 12,
});
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),
)
.await;
match only_process_event(&mut receiver) {
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<dyn Sandbox> = 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 = Emitter::new();
let mut receiver = emitter.subscribe();
let output = (tool.executor)(
serde_json::json!({"command": "echo interleaved"}),
shell_context_with_emitter(env, &emitter),
)
.await
.expect("exit 0 is a successful tool result");
assert!(
output.contains("output (combined):\ninterleaved"),
"got: {output}"
);
assert!(!output.contains("stderr:"), "got: {output}");
match only_process_event(&mut receiver) {
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::<Vec<_>>()
.join("\n");
assert!(stdout.len() > 30_000);
let env: Arc<dyn Sandbox> = 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<dyn Sandbox> = Arc::new(LocalSandbox::new(
std::env::current_dir().expect("current dir"),
));
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),
)
.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 only_process_event(&mut receiver) {
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:?}"),
}
}
#[test]
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]

View file

@ -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);

View file

@ -5,7 +5,8 @@ use fabro_llm::Error as LlmError;
use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult};
use fabro_model::{CostSource, ModelRef};
use fabro_types::{
LlmOutputKind, LlmRetryPhase, ReasoningOutput, SessionMessage, StageContextWindowProjection,
CommandTermination, ExecOutputTail, LlmOutputKind, LlmRetryPhase, ReasoningOutput,
SessionMessage, StageContextWindowProjection,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -295,6 +296,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<i32>,
termination: CommandTermination,
duration_ms: u64,
streams_separated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
exec_output_tail: Option<ExecOutputTail>,
},
Error {
error: Error,
},
@ -483,6 +497,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");
}

View file

@ -0,0 +1,95 @@
//! 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;
use fabro_agent::event::SessionBoundEmitter;
use fabro_agent::sandbox::Sandbox;
use fabro_agent::tool_registry::ToolContext;
use fabro_agent::tools::make_shell_tool;
use fabro_agent::types::AgentEvent;
use fabro_agent::{DockerSandbox, DockerSandboxOptions, Emitter};
use fabro_types::CommandTermination;
use tokio::sync::broadcast;
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 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: the integration precondition is not met.
if sandbox.initialize().await.is_err() {
return;
}
let sandbox = Arc::new(sandbox);
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"}),
ToolContext {
env: sandbox.clone() as Arc<dyn Sandbox>,
cancel: CancellationToken::new(),
tool_env_provider: None,
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()),
})),
},
)
.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 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,
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:?}"),
}
}

View file

@ -1,3 +1,5 @@
mod compaction;
#[cfg(feature = "docker")]
mod docker_shell;
mod guardrails;
mod parity_matrix;

View file

@ -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),

View file

@ -1637,7 +1637,7 @@ impl Sandbox for DaytonaSandbox {
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<ExecStreamingResult> {
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<Mutex<Vec<u8>>>,
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 {

View file

@ -340,7 +340,7 @@ impl DockerSandbox {
cmd: Vec<String>,
working_dir: Option<String>,
env: Option<Vec<String>>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<(Vec<u8>, Vec<u8>, 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<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<ExecStreamingResult> {
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<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<ExecStreamingResult> {
let dir = working_dir.map(|path| self.resolve_container_path(path));
self.docker_exec_shell_streaming(

View file

@ -419,7 +419,7 @@ impl Sandbox for LocalSandbox {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<ExecStreamingResult> {
let start = Instant::now();
@ -831,7 +831,7 @@ async fn sigterm_then_kill(child: &mut Child) {
async fn drain_command_pipe<R>(
mut reader: Option<R>,
stream: CommandOutputStream,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<Vec<u8>>
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?;
}
}
}

View file

@ -111,7 +111,7 @@ macro_rules! delegate_sandbox {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
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<ExecStreamingResult> {
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<Box<dyn AsyncWrite + Send>>,
pub stdout: Pin<Box<dyn AsyncRead + Send>>,
@ -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<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
output_callback: Option<CommandOutputCallback>,
) -> crate::Result<ExecStreamingResult> {
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(

View file

@ -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,
@ -44,6 +44,12 @@ pub struct MockSandbox {
pub event_callback: Option<SandboxEventCallback>,
pub stdio_process_error: Option<String>,
pub stdio_process: Mutex<Option<MockStdioProcess>>,
/// 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<String>,
/// 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,31 @@ 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<u64>,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: Option<crate::CommandOutputCallback>,
) -> crate::Result<crate::ExecStreamingResult> {
let result = self
.exec_command(
command,
timeout_ms.unwrap_or(u64::MAX),
working_dir,
env_vars,
cancel_token,
)
.await?;
sandbox::replay_exec_result(result, self.streams_separated, output_callback.as_ref()).await
}
async fn spawn_stdio_process(

View file

@ -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();

View file

@ -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");

View file

@ -780,12 +780,13 @@ impl RunProjectionReducer for RunProjection {
/// OpenAI plan lists are scoped per agent session (`openai_plan:<session_id>`),
/// 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:<root_session_id>`) and intentionally shared with
/// subagents, so they always project.
/// events remain in the run event log. Kimi todo lists
/// (`kimi_todos:<session_id>`) are scoped the same way. Anthropic task lists
/// are root-scoped (`anthropic_tasks:<root_session_id>`) 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,
}
}
@ -5399,35 +5400,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]

View file

@ -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,
@ -1530,6 +1546,47 @@ 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(exec_tail()),
},
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"], "last stdout line");
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 {

View file

@ -76,6 +76,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",

View file

@ -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]

View file

@ -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,

View file

@ -128,7 +128,7 @@ impl Handler for CommandHandler {
None,
env_vars,
Some(cancel_token.clone()),
output_callback,
Some(output_callback),
)
.await;
cancel_token.cancel();

View file

@ -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};
@ -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}")),
@ -444,8 +445,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 +2627,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();

View file

@ -365,18 +365,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(),

View file

@ -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,

View file

@ -244,6 +244,8 @@ pub struct ModelFeatures {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_by_default: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache: Option<bool>,
@ -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));
}

View file

@ -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`). |

View file

@ -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)]
@ -100,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::<AgentProfileKind>().unwrap(), kind);
assert_eq!(kind.to_string(), expected);
assert_eq!(parsed, *kind);
assert_eq!(expected.parse::<AgentProfileKind>().unwrap(), *kind);
}
}
}

View file

@ -146,6 +146,11 @@ pub struct SettingsModelFeatures {
pub vision: Option<bool>,
#[serde(default)]
pub reasoning: Option<bool>,
/// 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<bool>,
#[serde(default)]
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[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<Speed, ModelCosts>,
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<Speed, ModelCosts>,
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")
@ -3055,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",
@ -3133,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]
@ -5915,6 +5963,7 @@ context_window = 1000
tools = true
vision = false
reasoning = true
reasoning_by_default = false
reasoning_effort = "levels"
prompt_cache = true
@ -5930,6 +5979,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 +6029,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 +6069,7 @@ context_window = 1000
tools = true
vision = false
reasoning = true
reasoning_by_default = true
reasoning_effort = "none"
[models.model.controls]
@ -6021,6 +6083,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 +6166,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(

View file

@ -369,6 +369,7 @@ max_output = 128000
tools = true
vision = true
reasoning = true
reasoning_by_default = true
prompt_cache = true
sampling_params = false

View file

@ -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
@ -23,6 +24,7 @@ max_output = 32768
tools = true
vision = true
reasoning = true
reasoning_by_default = true
prompt_cache = true
sampling_params = false

View file

@ -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
@ -392,6 +418,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 +439,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

View file

@ -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<TurnId>,
}
/// 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<i32>,
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<ExecOutputTail>,
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentErrorProps {
pub error: Value,

View file

@ -401,3 +401,30 @@ pub struct CliEnsureFailedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}
#[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}");
}
}

View file

@ -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")]
@ -491,6 +493,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",
@ -662,6 +665,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"
@ -928,8 +932,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 {
@ -2417,6 +2421,68 @@ 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);
}
#[test]
fn agent_tool_source_and_category_use_public_json_shape() {
assert_eq!(

View file

@ -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:<session_id>` — one list per emitting session.
//! - `kimi_todos:<session_id>` — one list per emitting session.
//! - `anthropic_tasks:<root_session_id>` — one list shared by a root session
//! and all of its subagent sessions.
//!
@ -71,6 +73,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 {

View file

@ -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];