feat(ui): show tool arguments in CLI and gateway

Add params_summary to ActionExecuted/ActionFailed events so the CLI
and gateway can display what tools are doing:

  ● http(https://api.github.com/repos/nearai/ironclaw/issues)
  ● web_search(latest AI news)
  ● memory_read(HEARTBEAT.md)

The summarize_params() helper extracts the most relevant argument
per tool type (URL for http, query for search, path for memory, etc.)
and truncates to 80 chars. Sensitive params are not included.

Router forwards the summary in both StatusUpdate (CLI/REPL) and
AppEvent (web gateway SSE) display names.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-27 23:28:28 -07:00
co-authored by Claude Opus 4.6
parent 7018ddafab
commit 6d6b7fab0e
7 changed files with 219 additions and 66 deletions
@@ -1211,7 +1211,10 @@ mod tests {
assert!(!exec_events.is_empty(), "should have ActionExecuted events");
for call_id in &exec_events {
assert!(!call_id.is_empty(), "ActionExecuted event must have non-empty call_id");
assert!(
!call_id.is_empty(),
"ActionExecuted event must have non-empty call_id"
);
}
}
@@ -1257,20 +1260,11 @@ mod tests {
let policy = Arc::new(PolicyEngine::new());
// Grant a lease that does NOT cover "restricted_tool"
leases
.grant(tid, "basic_cap", vec![], None, None)
.await;
leases.grant(tid, "basic_cap", vec![], None, None).await;
let (_tx, rx) = crate::runtime::messaging::signal_channel(16);
let mut exec = ExecutionLoop::new(
thread,
llm,
effects,
leases,
policy,
rx,
"test-user".into(),
);
let mut exec =
ExecutionLoop::new(thread, llm, effects, leases, policy, rx, "test-user".into());
exec.run().await.unwrap();
@@ -1306,10 +1300,7 @@ mod tests {
.collect();
for (call_id, _name) in &fail_events {
assert!(
!call_id.is_empty(),
"ActionFailed event must have call_id"
);
assert!(!call_id.is_empty(), "ActionFailed event must have call_id");
}
}
@@ -36,7 +36,7 @@ use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
use crate::traits::llm::{LlmBackend, LlmCallConfig};
use crate::traits::store::Store;
use crate::types::error::EngineError;
use crate::types::event::{EventKind, ThreadEvent};
use crate::types::event::{EventKind, ThreadEvent, summarize_params};
use crate::types::message::ThreadMessage;
use crate::types::project::ProjectId;
use crate::types::step::{StepId, TokenUsage};
@@ -719,6 +719,7 @@ async fn handle_execute_action(
action_name: name.clone(),
call_id: call_id.clone(),
error,
params_summary: None,
},
&call_id,
&name,
@@ -751,6 +752,7 @@ async fn handle_execute_action(
action_name: name.clone(),
call_id: call_id.clone(),
error: reason,
params_summary: None,
},
&call_id,
&name,
@@ -779,6 +781,7 @@ async fn handle_execute_action(
}
// 4. Execute
let ps = summarize_params(&name, &params);
match effects
.execute_action(&name, params, &lease, &exec_ctx)
.await
@@ -792,6 +795,7 @@ async fn handle_execute_action(
action_name: name.clone(),
call_id: call_id.clone(),
duration_ms: r.duration.as_millis() as u64,
params_summary: ps.clone(),
},
&call_id,
&name,
@@ -815,6 +819,7 @@ async fn handle_execute_action(
action_name: name.clone(),
call_id: call_id.clone(),
error: e.to_string(),
params_summary: ps,
},
&call_id,
&name,
@@ -886,6 +891,7 @@ fn handle_emit_event(
action_name,
call_id,
duration_ms: 0,
params_summary: None,
}
}
"action_failed" => {
@@ -897,6 +903,7 @@ fn handle_emit_event(
action_name,
call_id,
error,
params_summary: None,
}
}
"skill_activated" => {
@@ -443,10 +443,7 @@ pub async fn execute_code_with_skills(
let entries: Vec<(MontyObject, MontyObject)> = known_actions
.iter()
.map(|name| {
(
MontyObject::String(name.clone()),
MontyObject::Bool(true),
)
(MontyObject::String(name.clone()), MontyObject::Bool(true))
})
.collect();
ExtFunctionResult::Return(MontyObject::Dict(entries.into()))
@@ -993,6 +990,7 @@ async fn dispatch_action(
action_name: action_name.into(),
call_id: call_id.into(),
error: format!("no lease for action '{action_name}'"),
params_summary: None,
});
return DispatchResult::Ok(ExtFunctionResult::NotFound(action_name.into()));
}
@@ -1012,6 +1010,7 @@ async fn dispatch_action(
action_name: action_name.into(),
call_id: call_id.into(),
error: reason.clone(),
params_summary: None,
});
return DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new(
ExcType::RuntimeError,
@@ -1046,6 +1045,7 @@ async fn dispatch_action(
action_name: action_name.into(),
call_id: call_id.into(),
duration_ms: result.duration.as_millis() as u64,
params_summary: None,
});
let monty_obj = json_to_monty(&result.output);
action_results.push(result);
@@ -1064,6 +1064,7 @@ async fn dispatch_action(
action_name: action_name.into(),
call_id: call_id.into(),
error: e.to_string(),
params_summary: None,
});
DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new(
ExcType::RuntimeError,
@@ -68,6 +68,7 @@ pub async fn execute_action_calls(
action_name: call.action_name.clone(),
call_id: call.id.clone(),
error: format!("no lease for action '{}'", call.action_name),
params_summary: None,
});
results.push(error_result);
continue;
@@ -97,6 +98,7 @@ pub async fn execute_action_calls(
action_name: call.action_name.clone(),
call_id: call.id.clone(),
error: reason,
params_summary: None,
});
results.push(error_result);
continue;
@@ -138,6 +140,7 @@ pub async fn execute_action_calls(
action_name: call.action_name.clone(),
call_id: call.id.clone(),
duration_ms: action_result.duration.as_millis() as u64,
params_summary: None,
});
results.push(action_result);
}
@@ -153,6 +156,7 @@ pub async fn execute_action_calls(
action_name: action_name.clone(),
call_id: call_id.clone(),
error: format!("authentication required for credential '{credential_name}'"),
params_summary: None,
});
return Ok(ActionBatchResult {
results,
@@ -178,6 +182,7 @@ pub async fn execute_action_calls(
action_name: call.action_name.clone(),
call_id: call.id.clone(),
error: e.to_string(),
params_summary: None,
});
results.push(error_result);
}
@@ -272,7 +277,12 @@ mod tests {
#[tokio::test]
async fn call_id_preserved_on_successful_execution() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
vec![test_action("web_search")],
vec![Ok(ActionResult {
@@ -306,9 +316,17 @@ mod tests {
assert!(!result.results[0].is_error);
// Event should carry the same call_id
let exec_event = result.events.iter().find(|e| matches!(e, EventKind::ActionExecuted { .. }));
let exec_event = result
.events
.iter()
.find(|e| matches!(e, EventKind::ActionExecuted { .. }));
assert!(exec_event.is_some());
if let Some(EventKind::ActionExecuted { call_id, action_name, .. }) = exec_event {
if let Some(EventKind::ActionExecuted {
call_id,
action_name,
..
}) = exec_event
{
assert_eq!(call_id, "call_r2o5mqBgdNUlH8KzskncUGaX");
assert_eq!(action_name, "web_search");
}
@@ -316,7 +334,12 @@ mod tests {
#[tokio::test]
async fn call_id_preserved_on_execution_error() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
vec![test_action("shell")],
vec![Err(EngineError::Effect {
@@ -343,7 +366,10 @@ mod tests {
assert_eq!(result.results[0].call_id, "call_abc123def");
assert!(result.results[0].is_error);
let fail_event = result.events.iter().find(|e| matches!(e, EventKind::ActionFailed { .. }));
let fail_event = result
.events
.iter()
.find(|e| matches!(e, EventKind::ActionFailed { .. }));
assert!(fail_event.is_some());
if let Some(EventKind::ActionFailed { call_id, .. }) = fail_event {
assert_eq!(call_id, "call_abc123def");
@@ -352,7 +378,12 @@ mod tests {
#[tokio::test]
async fn call_id_preserved_when_no_lease() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(vec![], vec![]));
let leases = Arc::new(LeaseManager::new());
let policy = Arc::new(PolicyEngine::new());
@@ -383,7 +414,12 @@ mod tests {
#[tokio::test]
async fn multiple_calls_each_get_correct_call_id() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
vec![test_action("tool_a"), test_action("tool_b")],
vec![
@@ -616,7 +652,12 @@ mod tests {
/// ever has an empty call_id when the ActionCall provided one.
#[tokio::test]
async fn openai_empty_call_id_never_produced() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
vec![test_action("echo")],
vec![Ok(ActionResult {
@@ -653,7 +694,12 @@ mod tests {
/// but engine must never lose it).
#[tokio::test]
async fn mistral_format_call_id_preserved() {
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
let thread = Thread::new(
"test",
ThreadType::Foreground,
ProjectId::new(),
ThreadConfig::default(),
);
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
vec![test_action("web_search")],
vec![Ok(ActionResult {
+9 -2
View File
@@ -481,6 +481,7 @@ mod tests {
action_name: "web_search".into(),
call_id: "call_123".into(),
error: "No lease for action 'web_search'".into(),
params_summary: None,
},
));
@@ -546,13 +547,19 @@ mod tests {
thread.add_message(ThreadMessage::assistant("parallel calls"));
thread.add_message(ThreadMessage::action_result("", "tool_a", "result_a"));
thread.add_message(ThreadMessage::action_result("", "tool_b", "result_b"));
thread.add_message(ThreadMessage::action_result("call_ok", "tool_c", "result_c"));
thread.add_message(ThreadMessage::action_result(
"call_ok", "tool_c", "result_c",
));
let issues = analyze_trace(&thread);
let empty_issues: Vec<_> = issues
.iter()
.filter(|i| i.category == "empty_call_id")
.collect();
assert_eq!(empty_issues.len(), 2, "should flag exactly the 2 empty call_ids");
assert_eq!(
empty_issues.len(),
2,
"should flag exactly the 2 empty call_ids"
);
}
}
+71
View File
@@ -8,6 +8,71 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::types::capability::LeaseId;
/// Generate a short human-readable summary of tool parameters for display.
///
/// For `http`: shows the URL. For `web_search`: shows the query.
/// For other tools: shows the first string argument, truncated.
/// Returns `None` for empty or unrecognizable params.
pub fn summarize_params(action_name: &str, params: &serde_json::Value) -> Option<String> {
let summary = match action_name {
"http" | "web_fetch" => params.get("url").and_then(|v| v.as_str()).map(|u| {
if u.len() > 80 {
format!("{}...", &u[..77])
} else {
u.to_string()
}
}),
"web_search" | "llm_context" => params
.get("query")
.and_then(|v| v.as_str())
.map(|q| truncate(q, 60)),
"memory_search" => params
.get("query")
.and_then(|v| v.as_str())
.map(|q| truncate(q, 60)),
"memory_write" => params
.get("target")
.and_then(|v| v.as_str())
.map(|t| t.to_string()),
"memory_read" => params
.get("path")
.and_then(|v| v.as_str())
.map(|p| p.to_string()),
"shell" => params
.get("command")
.and_then(|v| v.as_str())
.map(|c| truncate(c, 60)),
"message" => params
.get("content")
.and_then(|v| v.as_str())
.map(|c| truncate(c, 40)),
_ => {
// Generic: show first string value
if let Some(obj) = params.as_object() {
obj.values()
.find_map(|v| v.as_str())
.map(|s| truncate(s, 50))
} else {
None
}
}
};
summary.filter(|s| !s.is_empty())
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
// Find a safe UTF-8 boundary
let mut end = max.min(s.len());
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
}
use crate::types::step::{StepId, TokenUsage};
use crate::types::thread::{ThreadId, ThreadState};
@@ -76,12 +141,18 @@ pub enum EventKind {
action_name: String,
call_id: String,
duration_ms: u64,
/// Short human-readable summary of parameters (e.g., URL for http tool).
#[serde(default, skip_serializing_if = "Option::is_none")]
params_summary: Option<String>,
},
ActionFailed {
step_id: StepId,
action_name: String,
call_id: String,
error: String,
/// Short human-readable summary of parameters.
#[serde(default, skip_serializing_if = "Option::is_none")]
params_summary: Option<String>,
},
// ── Capability leases ───────────────────────────────────
+63 -33
View File
@@ -1174,14 +1174,20 @@ async fn forward_event_to_channel(
EventKind::ActionExecuted {
action_name,
duration_ms,
params_summary,
..
} => {
// Emit ToolStarted then ToolCompleted so the frontend shows the card
// Format tool name with params summary: "http(https://api.github.com/...)"
let display_name = match params_summary {
Some(summary) => format!("{}({})", action_name, summary),
None => action_name.clone(),
};
let _ = channels
.send_status(
channel_name,
StatusUpdate::ToolStarted {
name: action_name.clone(),
name: display_name.clone(),
},
metadata,
)
@@ -1190,7 +1196,7 @@ async fn forward_event_to_channel(
.send_status(
channel_name,
StatusUpdate::ToolCompleted {
name: action_name.clone(),
name: display_name,
success: true,
error: None,
parameters: Some(format!("{duration_ms}ms")),
@@ -1200,13 +1206,21 @@ async fn forward_event_to_channel(
.await;
}
EventKind::ActionFailed {
action_name, error, ..
action_name,
error,
params_summary,
..
} => {
let display_name = match params_summary {
Some(summary) => format!("{}({})", action_name, summary),
None => action_name.clone(),
};
let _ = channels
.send_status(
channel_name,
StatusUpdate::ToolStarted {
name: action_name.clone(),
name: display_name.clone(),
},
metadata,
)
@@ -1215,7 +1229,7 @@ async fn forward_event_to_channel(
.send_status(
channel_name,
StatusUpdate::ToolCompleted {
name: action_name.clone(),
name: display_name,
success: false,
error: Some(error.clone()),
parameters: None,
@@ -1316,35 +1330,51 @@ fn thread_event_to_app_events(
EventKind::ActionExecuted {
action_name,
duration_ms,
params_summary,
..
} => vec![
AppEvent::ToolStarted {
name: action_name.clone(),
thread_id: Some(thread_id.into()),
},
AppEvent::ToolCompleted {
name: action_name.clone(),
success: true,
error: None,
parameters: Some(format!("{duration_ms}ms")),
thread_id: Some(thread_id.into()),
},
],
} => {
let display_name = match params_summary {
Some(s) => format!("{}({})", action_name, s),
None => action_name.clone(),
};
vec![
AppEvent::ToolStarted {
name: display_name.clone(),
thread_id: Some(thread_id.into()),
},
AppEvent::ToolCompleted {
name: display_name,
success: true,
error: None,
parameters: Some(format!("{duration_ms}ms")),
thread_id: Some(thread_id.into()),
},
]
}
EventKind::ActionFailed {
action_name, error, ..
} => vec![
AppEvent::ToolStarted {
name: action_name.clone(),
thread_id: Some(thread_id.into()),
},
AppEvent::ToolCompleted {
name: action_name.clone(),
success: false,
error: Some(error.clone()),
parameters: None,
thread_id: Some(thread_id.into()),
},
],
action_name,
error,
params_summary,
..
} => {
let display_name = match params_summary {
Some(s) => format!("{}({})", action_name, s),
None => action_name.clone(),
};
vec![
AppEvent::ToolStarted {
name: display_name.clone(),
thread_id: Some(thread_id.into()),
},
AppEvent::ToolCompleted {
name: display_name,
success: false,
error: Some(error.clone()),
parameters: None,
thread_id: Some(thread_id.into()),
},
]
}
EventKind::StepCompleted { tokens, .. } => vec![AppEvent::Status {
message: format!(
"Step complete — {} in / {} out tokens",