fix(tui): compact tool-call UI and context
This commit is contained in:
@@ -8,6 +8,7 @@ use crate::compaction::estimate_tokens;
|
||||
use crate::error_taxonomy::ErrorCategory;
|
||||
use crate::models::{Message, SystemPrompt, context_window_for_model};
|
||||
use crate::tools::spec::ToolResult;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Max output tokens requested for normal agent turns. Generous on purpose:
|
||||
/// V4 thinking models can produce tens of thousands of reasoning tokens on
|
||||
@@ -126,6 +127,12 @@ fn tool_result_is_noisy(tool_name: &str) -> bool {
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_shell_cancel"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait"
|
||||
| "run_tests"
|
||||
| "run_verifiers"
|
||||
| "task_gate_run"
|
||||
| "multi_tool_use.parallel"
|
||||
| "web_search"
|
||||
)
|
||||
@@ -259,6 +266,179 @@ fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Optio
|
||||
Some(out.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn json_number_text(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.map(|n| n.to_string())
|
||||
.or_else(|| value.as_u64().map(|n| n.to_string()))
|
||||
})
|
||||
.or_else(|| {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_run_tests_result_for_context(raw: &str) -> Option<String> {
|
||||
let parsed: Value = serde_json::from_str(raw).ok()?;
|
||||
let success = parsed.get("success")?.as_bool()?;
|
||||
let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string());
|
||||
let command = json_text(&parsed, "command").unwrap_or("(unknown command)");
|
||||
let stdout = json_text(&parsed, "stdout");
|
||||
let stderr = json_text(&parsed, "stderr");
|
||||
let stream_limit = if success { 500 } else { 1_000 };
|
||||
|
||||
let mut lines = vec![
|
||||
"[run_tests result summarized for context]".to_string(),
|
||||
format!(
|
||||
"status: {}, exit_code: {exit_code}",
|
||||
if success { "passed" } else { "failed" }
|
||||
),
|
||||
format!("command: {}", summarize_text(command, 300)),
|
||||
];
|
||||
if let Some(stderr) = stderr {
|
||||
lines.push(format!(
|
||||
"stderr: {}",
|
||||
summarize_text_head_tail(stderr, stream_limit)
|
||||
));
|
||||
}
|
||||
if let Some(stdout) = stdout {
|
||||
lines.push(format!(
|
||||
"stdout: {}",
|
||||
summarize_text_head_tail(stdout, stream_limit)
|
||||
));
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn run_verifier_status_rank(status: Option<&str>) -> u8 {
|
||||
match status.unwrap_or_default() {
|
||||
"failed" | "timeout" => 0,
|
||||
"skipped" => 1,
|
||||
"passed" => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_run_verifiers_result_for_context(raw: &str) -> Option<String> {
|
||||
let parsed: Value = serde_json::from_str(raw).ok()?;
|
||||
let gates = parsed.get("gates")?.as_array()?;
|
||||
let summary = json_text(&parsed, "summary")
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string());
|
||||
let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string());
|
||||
let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string());
|
||||
format!("{passed} passed, {failed} failed, {skipped} skipped")
|
||||
});
|
||||
|
||||
let mut ordered: Vec<&Value> = gates.iter().collect();
|
||||
ordered.sort_by(|a, b| {
|
||||
run_verifier_status_rank(json_text(a, "status"))
|
||||
.cmp(&run_verifier_status_rank(json_text(b, "status")))
|
||||
.then_with(|| json_text(a, "name").cmp(&json_text(b, "name")))
|
||||
});
|
||||
|
||||
let mut lines = vec![
|
||||
"[run_verifiers result summarized for context]".to_string(),
|
||||
format!("summary: {summary}"),
|
||||
];
|
||||
let profile = json_text(&parsed, "profile");
|
||||
let level = json_text(&parsed, "level");
|
||||
if profile.is_some() || level.is_some() {
|
||||
lines.push(format!(
|
||||
"selection: profile={}, level={}",
|
||||
profile.unwrap_or("?"),
|
||||
level.unwrap_or("?")
|
||||
));
|
||||
}
|
||||
|
||||
for (idx, gate) in ordered.iter().enumerate() {
|
||||
if idx >= 12 {
|
||||
lines.push(format!(
|
||||
"- ... {} more gate(s) omitted from context summary",
|
||||
ordered.len().saturating_sub(idx)
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
let name = json_text(gate, "name").unwrap_or("gate");
|
||||
let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown");
|
||||
let status = json_text(gate, "status").unwrap_or("unknown");
|
||||
let exit = json_number_text(gate, "exit_code")
|
||||
.map(|code| format!(" exit={code}"))
|
||||
.unwrap_or_default();
|
||||
lines.push(format!("- {name} ({ecosystem}): {status}{exit}"));
|
||||
|
||||
if status != "passed" {
|
||||
if let Some(command) = json_text(gate, "command") {
|
||||
lines.push(format!(" command: {}", summarize_text(command, 240)));
|
||||
}
|
||||
if let Some(detail) = json_text(gate, "skipped_reason")
|
||||
.or_else(|| json_text(gate, "stderr"))
|
||||
.or_else(|| json_text(gate, "stdout"))
|
||||
{
|
||||
lines.push(format!(
|
||||
" detail: {}",
|
||||
summarize_text_head_tail(detail, 600)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn compact_task_gate_run_result_for_context(raw: &str) -> Option<String> {
|
||||
let parsed: Value = serde_json::from_str(raw).ok()?;
|
||||
let gate = parsed.get("gate")?;
|
||||
let gate_name = json_text(gate, "gate").unwrap_or("gate");
|
||||
let status = json_text(gate, "status").unwrap_or("unknown");
|
||||
let command = json_text(gate, "command").unwrap_or("(unknown command)");
|
||||
let summary = json_text(gate, "summary")
|
||||
.or_else(|| json_text(&parsed, "stderr_summary"))
|
||||
.or_else(|| json_text(&parsed, "stdout_summary"));
|
||||
let exit = json_number_text(gate, "exit_code")
|
||||
.map(|code| format!(", exit_code: {code}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut lines = vec![
|
||||
"[task_gate_run result summarized for context]".to_string(),
|
||||
format!("gate: {gate_name}, status: {status}{exit}"),
|
||||
format!("command: {}", summarize_text(command, 300)),
|
||||
];
|
||||
if let Some(summary) = summary {
|
||||
lines.push(format!("summary: {}", summarize_text(summary, 800)));
|
||||
}
|
||||
if let Some(log_path) = json_text(gate, "log_path") {
|
||||
lines.push(format!("log_path: {log_path}"));
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> {
|
||||
match tool_name {
|
||||
"run_tests" => compact_run_tests_result_for_context(raw),
|
||||
"run_verifiers" => compact_run_verifiers_result_for_context(raw),
|
||||
"task_gate_run" => compact_task_gate_run_result_for_context(raw),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits {
|
||||
let is_large_context =
|
||||
context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
|
||||
@@ -292,6 +472,10 @@ pub(crate) fn compact_tool_result_for_context(
|
||||
return summary;
|
||||
}
|
||||
|
||||
if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
let limits = tool_result_context_limits_for_model(model);
|
||||
let raw_chars = raw.chars().count();
|
||||
let should_compact = raw_chars > limits.hard_limit_chars
|
||||
|
||||
@@ -1524,6 +1524,143 @@ fn subagent_results_are_summarized_before_parent_context_insertion() {
|
||||
assert!(context.contains("handle_read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_verifiers_results_are_structured_before_context_insertion() {
|
||||
let noisy_failure = "node lint failure detail\n".repeat(300);
|
||||
let noisy_success = "successful check output\n".repeat(300);
|
||||
let output = ToolResult::success(
|
||||
json!({
|
||||
"success": false,
|
||||
"profile": "auto",
|
||||
"level": "quick",
|
||||
"workspace": "/repo",
|
||||
"gate_count": 3,
|
||||
"passed": 1,
|
||||
"failed": 1,
|
||||
"skipped": 1,
|
||||
"summary": "1 passed, 1 failed, 1 skipped",
|
||||
"gates": [
|
||||
{
|
||||
"name": "rust-check",
|
||||
"ecosystem": "rust",
|
||||
"status": "passed",
|
||||
"command": "cargo check --workspace --locked",
|
||||
"cwd": "/repo",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 110,
|
||||
"stdout": noisy_success.clone(),
|
||||
"stderr": "",
|
||||
"stdout_truncated": false,
|
||||
"stderr_truncated": false,
|
||||
"skipped_reason": null
|
||||
},
|
||||
{
|
||||
"name": "node-lint",
|
||||
"ecosystem": "node",
|
||||
"status": "failed",
|
||||
"command": "npm run lint",
|
||||
"cwd": "/repo",
|
||||
"exit_code": 1,
|
||||
"duration_ms": 220,
|
||||
"stdout": "",
|
||||
"stderr": noisy_failure,
|
||||
"stdout_truncated": false,
|
||||
"stderr_truncated": false,
|
||||
"skipped_reason": null
|
||||
},
|
||||
{
|
||||
"name": "python-pytest",
|
||||
"ecosystem": "python",
|
||||
"status": "skipped",
|
||||
"command": "",
|
||||
"cwd": "/repo",
|
||||
"exit_code": null,
|
||||
"duration_ms": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"stdout_truncated": false,
|
||||
"stderr_truncated": false,
|
||||
"skipped_reason": "pytest is not installed"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let context = compact_tool_result_for_context("deepseek-v4-pro", "run_verifiers", &output);
|
||||
|
||||
assert!(context.contains("[run_verifiers result summarized for context]"));
|
||||
assert!(context.contains("summary: 1 passed, 1 failed, 1 skipped"));
|
||||
assert!(context.contains("selection: profile=auto, level=quick"));
|
||||
assert!(context.contains("- node-lint (node): failed exit=1"));
|
||||
assert!(context.contains("command: npm run lint"));
|
||||
assert!(context.contains("- python-pytest (python): skipped"));
|
||||
assert!(context.contains("pytest is not installed"));
|
||||
assert!(context.contains("- rust-check (rust): passed exit=0"));
|
||||
assert!(context.len() < output.content.len());
|
||||
assert!(
|
||||
!context.contains(&noisy_success),
|
||||
"successful gate stdout should not be copied into parent context"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_tests_results_are_structured_before_context_insertion() {
|
||||
let stdout = "running test suite\n".repeat(500);
|
||||
let stderr = "error[E0425]: cannot find value `missing`\n".repeat(500);
|
||||
let output = ToolResult::success(
|
||||
json!({
|
||||
"success": false,
|
||||
"exit_code": 101,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"command": "(cd /repo && cargo test --workspace --all-features)"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let context = compact_tool_result_for_context("deepseek-v4-pro", "run_tests", &output);
|
||||
|
||||
assert!(context.contains("[run_tests result summarized for context]"));
|
||||
assert!(context.contains("status: failed, exit_code: 101"));
|
||||
assert!(context.contains("cargo test --workspace --all-features"));
|
||||
assert!(context.contains("error[E0425]"));
|
||||
assert!(context.contains("running test suite"));
|
||||
assert!(context.len() < output.content.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_gate_run_results_are_structured_before_context_insertion() {
|
||||
let output = ToolResult::success(
|
||||
json!({
|
||||
"gate": {
|
||||
"id": "gate_abcd1234",
|
||||
"gate": "clippy",
|
||||
"command": "cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings",
|
||||
"cwd": "/repo",
|
||||
"exit_code": 1,
|
||||
"status": "failed",
|
||||
"classification": "compile_failure",
|
||||
"duration_ms": 5000,
|
||||
"summary": "warning promoted to error in verifier.rs",
|
||||
"log_path": "/repo/.codewhale/runtime/gate.log",
|
||||
"recorded_at": "2026-06-01T12:00:00Z"
|
||||
},
|
||||
"stdout_summary": "",
|
||||
"stderr_summary": "warning promoted to error"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let context = compact_tool_result_for_context("deepseek-v4-pro", "task_gate_run", &output);
|
||||
|
||||
assert!(context.contains("[task_gate_run result summarized for context]"));
|
||||
assert!(context.contains("gate: clippy, status: failed, exit_code: 1"));
|
||||
assert!(context.contains("cargo clippy -p codewhale-tui"));
|
||||
assert!(context.contains("summary: warning promoted to error"));
|
||||
assert!(context.contains("log_path: /repo/.codewhale/runtime/gate.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_system_prompt_leaves_working_set_out_of_system_prompt() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::tui::ui::{
|
||||
status_color,
|
||||
};
|
||||
use crate::tui::ui_text::{concise_shell_command_label, truncate_line_to_width};
|
||||
use crate::tui::widgets::tool_card::tool_activity_label_for_name;
|
||||
use crate::tui::widgets::{FooterProps, FooterToast, FooterWidget, Renderable};
|
||||
use crate::tui::workspace_context;
|
||||
|
||||
@@ -399,7 +400,11 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu
|
||||
if matches!(generic.name.as_str(), "agent_open" | "agent_spawn") {
|
||||
return;
|
||||
}
|
||||
snapshot.record(format!("tool {}", generic.name), generic.status, None);
|
||||
snapshot.record(
|
||||
tool_activity_label_for_name(&generic.name),
|
||||
generic.status,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1279,16 @@ pub struct GenericToolCell {
|
||||
pub is_diff: bool,
|
||||
}
|
||||
|
||||
fn should_show_raw_tool_name(
|
||||
name: &str,
|
||||
family: crate::tui::widgets::tool_card::ToolFamily,
|
||||
mode: RenderMode,
|
||||
) -> bool {
|
||||
matches!(mode, RenderMode::Transcript)
|
||||
|| matches!(family, crate::tui::widgets::tool_card::ToolFamily::Generic)
|
||||
|| name.starts_with("mcp_")
|
||||
}
|
||||
|
||||
impl GenericToolCell {
|
||||
/// Render the generic tool cell into lines.
|
||||
///
|
||||
@@ -1329,12 +1339,14 @@ impl GenericToolCell {
|
||||
None,
|
||||
low_motion,
|
||||
));
|
||||
lines.extend(render_compact_kv(
|
||||
"name",
|
||||
&self.name,
|
||||
tool_value_style(),
|
||||
width,
|
||||
));
|
||||
if should_show_raw_tool_name(&self.name, family, mode) {
|
||||
lines.extend(render_compact_kv(
|
||||
"name",
|
||||
&self.name,
|
||||
tool_value_style(),
|
||||
width,
|
||||
));
|
||||
}
|
||||
|
||||
// Prefer per-prompt rows over the generic args summary when the tool
|
||||
// exposes a list of child prompts. One row per child with a `[i]`
|
||||
@@ -1878,6 +1890,18 @@ pub fn summarize_tool_args(input: &Value) -> Option<String> {
|
||||
summarize_inline_value(value, 40, false)
|
||||
));
|
||||
}
|
||||
if let Some(value) = obj.get("profile") {
|
||||
parts.push(format!(
|
||||
"profile: {}",
|
||||
summarize_inline_value(value, 40, false)
|
||||
));
|
||||
}
|
||||
if let Some(value) = obj.get("level") {
|
||||
parts.push(format!(
|
||||
"level: {}",
|
||||
summarize_inline_value(value, 40, false)
|
||||
));
|
||||
}
|
||||
if let Some(value) = obj.get("file_id") {
|
||||
parts.push(format!(
|
||||
"file_id: {}",
|
||||
@@ -4792,6 +4816,73 @@ mod tests {
|
||||
assert!(text.contains("query: foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_generic_tool_hides_raw_name_in_live_mode() {
|
||||
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "run_verifiers".to_string(),
|
||||
status: ToolStatus::Running,
|
||||
input_summary: Some("profile: auto, level: quick".to_string()),
|
||||
output: None,
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
}));
|
||||
|
||||
let text = lines_text(&cell.lines(80));
|
||||
assert!(text.contains("verify running"), "{text}");
|
||||
assert!(text.contains("profile: auto"), "{text}");
|
||||
assert!(
|
||||
!text.contains("name: run_verifiers"),
|
||||
"live card should not spend a row on internal tool id: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("run_verifiers"),
|
||||
"known tool id should not leak into compact live card: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_generic_tool_keeps_raw_name_in_transcript_mode() {
|
||||
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "run_verifiers".to_string(),
|
||||
status: ToolStatus::Running,
|
||||
input_summary: Some("profile: auto, level: quick".to_string()),
|
||||
output: None,
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
}));
|
||||
|
||||
let text = lines_text(&cell.transcript_lines(80));
|
||||
assert!(text.contains("verify running"), "{text}");
|
||||
assert!(
|
||||
text.contains("name: run_verifiers"),
|
||||
"transcript replay should preserve exact tool id: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_generic_tool_keeps_raw_name_in_live_mode() {
|
||||
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "future_private_tool".to_string(),
|
||||
status: ToolStatus::Running,
|
||||
input_summary: Some("query: foo".to_string()),
|
||||
output: None,
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
}));
|
||||
|
||||
let text = lines_text(&cell.lines(80));
|
||||
assert!(
|
||||
text.contains("name: future_private_tool"),
|
||||
"unknown tools should remain identifiable: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_tool_cell_preserves_multi_line_output_in_transcript() {
|
||||
// Repro for #80: a `git diff --stat`-shaped tool result should keep
|
||||
|
||||
@@ -4407,6 +4407,10 @@ async fn tool_result_content_for_api_message(
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if matches!(name, "run_tests" | "run_verifiers" | "task_gate_run") {
|
||||
return crate::core::engine::compact_tool_result_for_context(&app.model, name, output);
|
||||
}
|
||||
|
||||
if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS {
|
||||
let messages = live_tool_receipt_messages(app, id, raw, output.success);
|
||||
let artifacts = app.session_artifacts.clone();
|
||||
@@ -8327,6 +8331,9 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri
|
||||
HistoryCell::Thinking { .. } => "thinking".to_string(),
|
||||
HistoryCell::Error { .. } => "error".to_string(),
|
||||
HistoryCell::SubAgent(_) => "sub-agent".to_string(),
|
||||
HistoryCell::Tool(ToolCell::Generic(generic)) => {
|
||||
crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name)
|
||||
}
|
||||
HistoryCell::Tool(_) => {
|
||||
detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string())
|
||||
}
|
||||
@@ -8752,7 +8759,9 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String
|
||||
Some(format!("image {}", image.path.display()))
|
||||
}
|
||||
HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
|
||||
HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)),
|
||||
HistoryCell::Tool(ToolCell::Generic(generic)) => {
|
||||
Some(crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name))
|
||||
}
|
||||
HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -1918,7 +1918,8 @@ fn active_tool_status_label_counts_foreground_rlm_work() {
|
||||
|
||||
let label = active_tool_status_label(&app).expect("status label");
|
||||
|
||||
assert!(label.contains("tool rlm"), "label: {label}");
|
||||
assert!(label.contains("rlm"), "label: {label}");
|
||||
assert!(!label.contains("tool rlm"), "label: {label}");
|
||||
assert!(label.contains("1 active"), "label: {label}");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! This module owns:
|
||||
//!
|
||||
//! - [`ToolFamily`] — the seven canonical families plus a `Generic`
|
||||
//! - [`ToolFamily`] — the canonical semantic families plus a `Generic`
|
||||
//! fallback for anything we don't have a family for yet.
|
||||
//! - [`tool_family_for_title`] — maps the legacy `render_tool_header` title
|
||||
//! string (`"Shell"`, `"Patch"`, `"Workspace"`, etc.) to a family. Lets
|
||||
@@ -41,6 +41,8 @@ pub enum ToolFamily {
|
||||
Fanout,
|
||||
/// Recursive language model work. `⋮⋮ rlm`.
|
||||
Rlm,
|
||||
/// Verification gates, tests, and validators. `✓ verify`.
|
||||
Verify,
|
||||
/// Reasoning / chain-of-thought. `… think`. Reasoning has its own
|
||||
/// render path (`render_thinking` in `history.rs`); the family is
|
||||
/// declared here for completeness so any future code that reaches for
|
||||
@@ -77,16 +79,46 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily {
|
||||
match name {
|
||||
"read_file" | "list_dir" | "view_image" => ToolFamily::Read,
|
||||
"edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
|
||||
"exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run,
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_shell_cancel"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait" => ToolFamily::Run,
|
||||
"grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find,
|
||||
"agent_open" | "agent_eval" | "agent_close" | "agent_spawn" | "tool_agent" => {
|
||||
ToolFamily::Delegate
|
||||
}
|
||||
"rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm,
|
||||
"run_tests" | "run_verifiers" | "task_gate_run" | "validate_data" => ToolFamily::Verify,
|
||||
_ => ToolFamily::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing label for an arbitrary tool name. Known tools collapse to the
|
||||
/// semantic verb; unknown tools keep their exact name for debugging.
|
||||
#[must_use]
|
||||
pub fn tool_display_label_for_name(name: &str) -> String {
|
||||
let family = tool_family_for_name(name);
|
||||
if matches!(family, ToolFamily::Generic) {
|
||||
name.to_string()
|
||||
} else {
|
||||
family_label(family).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact activity/status label for arbitrary tool names. Known built-ins use
|
||||
/// the semantic verb; unknown tools keep the `tool NAME` form.
|
||||
#[must_use]
|
||||
pub fn tool_activity_label_for_name(name: &str) -> String {
|
||||
let family = tool_family_for_name(name);
|
||||
if matches!(family, ToolFamily::Generic) {
|
||||
format!("tool {name}")
|
||||
} else {
|
||||
tool_display_label_for_name(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a compact semantic summary for a tool header from the public tool
|
||||
/// name and the already-sanitized argument summary.
|
||||
#[must_use]
|
||||
@@ -103,6 +135,7 @@ pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) ->
|
||||
ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => {
|
||||
["prompt", "task", "model"].as_slice()
|
||||
}
|
||||
ToolFamily::Verify => ["profile", "level", "command", "args", "path"].as_slice(),
|
||||
ToolFamily::Think | ToolFamily::Generic => {
|
||||
["query", "path", "command", "prompt"].as_slice()
|
||||
}
|
||||
@@ -144,8 +177,9 @@ pub fn family_glyph(family: ToolFamily) -> &'static str {
|
||||
ToolFamily::Delegate => "\u{25D0}", // ◐
|
||||
ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
|
||||
ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
|
||||
ToolFamily::Think => "\u{2026}", // …
|
||||
ToolFamily::Generic => "\u{2022}", // •
|
||||
ToolFamily::Verify => "\u{2713}",
|
||||
ToolFamily::Think => "\u{2026}", // …
|
||||
ToolFamily::Generic => "\u{2022}", // •
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +196,7 @@ pub fn family_label(family: ToolFamily) -> &'static str {
|
||||
ToolFamily::Delegate => "delegate",
|
||||
ToolFamily::Fanout => "fanout",
|
||||
ToolFamily::Rlm => "rlm",
|
||||
ToolFamily::Verify => "verify",
|
||||
ToolFamily::Think => "think",
|
||||
ToolFamily::Generic => "tool",
|
||||
}
|
||||
@@ -198,8 +233,9 @@ pub fn rail_glyph(rail: CardRail) -> &'static str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name,
|
||||
tool_family_for_title, tool_header_summary_for_name,
|
||||
CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name,
|
||||
tool_display_label_for_name, tool_family_for_name, tool_family_for_title,
|
||||
tool_header_summary_for_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -218,15 +254,35 @@ mod tests {
|
||||
assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read);
|
||||
assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch);
|
||||
assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
|
||||
assert_eq!(tool_family_for_name("task_shell_start"), ToolFamily::Run);
|
||||
assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
|
||||
assert_eq!(tool_family_for_name("agent_open"), ToolFamily::Delegate);
|
||||
assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm);
|
||||
assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify);
|
||||
assert_eq!(
|
||||
tool_family_for_name("totally_new_tool"),
|
||||
ToolFamily::Generic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_display_label_collapses_known_tools_to_user_verbs() {
|
||||
assert_eq!(tool_display_label_for_name("exec_shell"), "run");
|
||||
assert_eq!(tool_display_label_for_name("run_verifiers"), "verify");
|
||||
assert_eq!(tool_display_label_for_name("file_search"), "find");
|
||||
assert_eq!(
|
||||
tool_display_label_for_name("future_private_tool"),
|
||||
"future_private_tool"
|
||||
);
|
||||
|
||||
assert_eq!(tool_activity_label_for_name("exec_shell"), "run");
|
||||
assert_eq!(tool_activity_label_for_name("run_verifiers"), "verify");
|
||||
assert_eq!(
|
||||
tool_activity_label_for_name("future_private_tool"),
|
||||
"tool future_private_tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_header_summary_prefers_family_specific_arguments() {
|
||||
assert_eq!(
|
||||
@@ -244,6 +300,11 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some("TODO")
|
||||
);
|
||||
assert_eq!(
|
||||
tool_header_summary_for_name("run_verifiers", Some("profile: auto, level: quick"))
|
||||
.as_deref(),
|
||||
Some("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(),
|
||||
Some("alpha: beta")
|
||||
@@ -261,6 +322,7 @@ mod tests {
|
||||
ToolFamily::Delegate,
|
||||
ToolFamily::Fanout,
|
||||
ToolFamily::Rlm,
|
||||
ToolFamily::Verify,
|
||||
ToolFamily::Think,
|
||||
ToolFamily::Generic,
|
||||
] {
|
||||
|
||||
Reference in New Issue
Block a user