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:
parent
7df2102d9d
commit
f426edfc17
1 changed files with 150 additions and 0 deletions
|
|
@ -15,6 +15,45 @@ use std::sync::{Arc, LazyLock};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use uuid::Uuid;
|
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.
|
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
|
||||||
const STREAM_CHUNK_MIN_CHARS: usize = 80;
|
const STREAM_CHUNK_MIN_CHARS: usize = 80;
|
||||||
|
|
||||||
|
|
@ -841,6 +880,7 @@ pub(crate) async fn agent_turn(
|
||||||
"channel",
|
"channel",
|
||||||
max_tool_iterations,
|
max_tool_iterations,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -861,6 +901,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
max_tool_iterations: usize,
|
max_tool_iterations: usize,
|
||||||
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
|
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
|
||||||
|
on_tool_status: Option<tokio::sync::mpsc::Sender<ToolStatusEvent>>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let max_iterations = if max_tool_iterations == 0 {
|
let max_iterations = if max_tool_iterations == 0 {
|
||||||
DEFAULT_MAX_TOOL_ITERATIONS
|
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();
|
let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
|
||||||
|
|
||||||
for _iteration in 0..max_iterations {
|
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 {
|
observer.record_event(&ObserverEvent::LlmRequest {
|
||||||
provider: provider_name.to_string(),
|
provider: provider_name.to_string(),
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
|
|
@ -1026,6 +1071,15 @@ pub(crate) async fn run_tool_call_loop(
|
||||||
observer.record_event(&ObserverEvent::ToolCallStart {
|
observer.record_event(&ObserverEvent::ToolCallStart {
|
||||||
tool: call.name.clone(),
|
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 start = Instant::now();
|
||||||
let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
|
let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
|
||||||
match tool.execute(call.arguments.clone()).await {
|
match tool.execute(call.arguments.clone()).await {
|
||||||
|
|
@ -1398,6 +1452,7 @@ pub async fn run(
|
||||||
"cli",
|
"cli",
|
||||||
config.agent.max_tool_iterations,
|
config.agent.max_tool_iterations,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
final_output = response.clone();
|
final_output = response.clone();
|
||||||
|
|
@ -1524,6 +1579,7 @@ pub async fn run(
|
||||||
"cli",
|
"cli",
|
||||||
config.agent.max_tool_iterations,
|
config.agent.max_tool_iterations,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -2511,4 +2567,98 @@ browser_open/url>https://example.com"#;
|
||||||
assert_eq!(calls[0].arguments["command"], "pwd");
|
assert_eq!(calls[0].arguments["command"], "pwd");
|
||||||
assert_eq!(text, "Done");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue