feat(agent): emit tool status events from run_tool_call_loop

Add ToolStatusEvent enum (Thinking, ToolStart) and extract_tool_detail
helper to the agent loop. run_tool_call_loop now accepts an optional
on_tool_status sender and emits events before LLM calls and tool
executions. CLI callers pass None; the channel orchestrator uses it
for real-time draft updates.

Includes unit tests for extract_tool_detail covering all tool types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harald 2026-02-21 07:39:50 +01:00
parent 7df2102d9d
commit f426edfc17

View file

@ -15,6 +15,45 @@ use std::sync::{Arc, LazyLock};
use std::time::Instant;
use uuid::Uuid;
/// Events emitted during tool execution for real-time status display in channels.
#[derive(Debug, Clone)]
pub enum ToolStatusEvent {
/// LLM request started (thinking).
Thinking,
/// A tool is about to execute.
ToolStart {
name: String,
detail: Option<String>,
},
}
/// Extract a short display summary from tool arguments for status display.
pub fn extract_tool_detail(tool_name: &str, args: &serde_json::Value) -> Option<String> {
match tool_name {
"shell" => args.get("command").and_then(|v| v.as_str()).map(|s| {
if s.len() > 60 {
format!("{}...", &s[..57])
} else {
s.to_string()
}
}),
"file_read" | "file_write" => args.get("path").and_then(|v| v.as_str()).map(String::from),
"memory_recall" | "web_search_tool" => args
.get("query")
.and_then(|v| v.as_str())
.map(|s| format!("\"{s}\"")),
"http_request" | "browser_open" => {
args.get("url").and_then(|v| v.as_str()).map(String::from)
}
"git_operations" => args
.get("operation")
.and_then(|v| v.as_str())
.map(String::from),
"memory_store" => args.get("key").and_then(|v| v.as_str()).map(String::from),
_ => None,
}
}
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
const STREAM_CHUNK_MIN_CHARS: usize = 80;
@ -841,6 +880,7 @@ pub(crate) async fn agent_turn(
"channel",
max_tool_iterations,
None,
None,
)
.await
}
@ -861,6 +901,7 @@ pub(crate) async fn run_tool_call_loop(
channel_name: &str,
max_tool_iterations: usize,
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
on_tool_status: Option<tokio::sync::mpsc::Sender<ToolStatusEvent>>,
) -> Result<String> {
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
@ -873,6 +914,10 @@ pub(crate) async fn run_tool_call_loop(
let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
for _iteration in 0..max_iterations {
if let Some(ref tx) = on_tool_status {
let _ = tx.send(ToolStatusEvent::Thinking).await;
}
observer.record_event(&ObserverEvent::LlmRequest {
provider: provider_name.to_string(),
model: model.to_string(),
@ -1026,6 +1071,15 @@ pub(crate) async fn run_tool_call_loop(
observer.record_event(&ObserverEvent::ToolCallStart {
tool: call.name.clone(),
});
if let Some(ref tx) = on_tool_status {
let detail = extract_tool_detail(&call.name, &call.arguments);
let _ = tx
.send(ToolStatusEvent::ToolStart {
name: call.name.clone(),
detail,
})
.await;
}
let start = Instant::now();
let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
match tool.execute(call.arguments.clone()).await {
@ -1398,6 +1452,7 @@ pub async fn run(
"cli",
config.agent.max_tool_iterations,
None,
None,
)
.await?;
final_output = response.clone();
@ -1524,6 +1579,7 @@ pub async fn run(
"cli",
config.agent.max_tool_iterations,
None,
None,
)
.await
{
@ -2511,4 +2567,98 @@ browser_open/url>https://example.com"#;
assert_eq!(calls[0].arguments["command"], "pwd");
assert_eq!(text, "Done");
}
// ═══════════════════════════════════════════════════════════════════════
// Tool Status Display - extract_tool_detail
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn extract_tool_detail_shell_short() {
let args = serde_json::json!({"command": "ls -la"});
assert_eq!(extract_tool_detail("shell", &args), Some("ls -la".into()));
}
#[test]
fn extract_tool_detail_shell_truncates_long_command() {
let long = "a".repeat(80);
let args = serde_json::json!({"command": long});
let detail = extract_tool_detail("shell", &args).unwrap();
assert_eq!(detail.len(), 60); // 57 chars + "..."
assert!(detail.ends_with("..."));
}
#[test]
fn extract_tool_detail_file_read() {
let args = serde_json::json!({"path": "src/main.rs"});
assert_eq!(
extract_tool_detail("file_read", &args),
Some("src/main.rs".into())
);
}
#[test]
fn extract_tool_detail_file_write() {
let args = serde_json::json!({"path": "/tmp/out.txt", "content": "data"});
assert_eq!(
extract_tool_detail("file_write", &args),
Some("/tmp/out.txt".into())
);
}
#[test]
fn extract_tool_detail_memory_recall() {
let args = serde_json::json!({"query": "project goals"});
assert_eq!(
extract_tool_detail("memory_recall", &args),
Some("\"project goals\"".into())
);
}
#[test]
fn extract_tool_detail_web_search() {
let args = serde_json::json!({"query": "rust async"});
assert_eq!(
extract_tool_detail("web_search_tool", &args),
Some("\"rust async\"".into())
);
}
#[test]
fn extract_tool_detail_http_request() {
let args = serde_json::json!({"url": "https://example.com/api", "method": "GET"});
assert_eq!(
extract_tool_detail("http_request", &args),
Some("https://example.com/api".into())
);
}
#[test]
fn extract_tool_detail_git_operations() {
let args = serde_json::json!({"operation": "status"});
assert_eq!(
extract_tool_detail("git_operations", &args),
Some("status".into())
);
}
#[test]
fn extract_tool_detail_memory_store() {
let args = serde_json::json!({"key": "user_pref", "value": "dark mode"});
assert_eq!(
extract_tool_detail("memory_store", &args),
Some("user_pref".into())
);
}
#[test]
fn extract_tool_detail_unknown_tool_returns_none() {
let args = serde_json::json!({"foo": "bar"});
assert_eq!(extract_tool_detail("unknown_tool", &args), None);
}
#[test]
fn extract_tool_detail_missing_key_returns_none() {
let args = serde_json::json!({"other": "value"});
assert_eq!(extract_tool_detail("shell", &args), None);
}
}