From 6d6b7fab0e5a084c3995a409aa3f998affff3bdb Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Fri, 27 Mar 2026 23:28:28 -0700 Subject: [PATCH] feat(ui): show tool arguments in CLI and gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/executor/loop_engine.rs | 25 ++--- .../src/executor/orchestrator.rs | 9 +- .../ironclaw_engine/src/executor/scripting.rs | 9 +- .../src/executor/structured.rs | 64 +++++++++++-- crates/ironclaw_engine/src/executor/trace.rs | 11 ++- crates/ironclaw_engine/src/types/event.rs | 71 ++++++++++++++ src/bridge/router.rs | 96 ++++++++++++------- 7 files changed, 219 insertions(+), 66 deletions(-) diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index 84db6f6e..9240d6a8 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -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"); } } diff --git a/crates/ironclaw_engine/src/executor/orchestrator.rs b/crates/ironclaw_engine/src/executor/orchestrator.rs index 1ab883e7..d782045e 100644 --- a/crates/ironclaw_engine/src/executor/orchestrator.rs +++ b/crates/ironclaw_engine/src/executor/orchestrator.rs @@ -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, ¶ms); 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" => { diff --git a/crates/ironclaw_engine/src/executor/scripting.rs b/crates/ironclaw_engine/src/executor/scripting.rs index c6dc8501..2e40464e 100644 --- a/crates/ironclaw_engine/src/executor/scripting.rs +++ b/crates/ironclaw_engine/src/executor/scripting.rs @@ -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, diff --git a/crates/ironclaw_engine/src/executor/structured.rs b/crates/ironclaw_engine/src/executor/structured.rs index a9dd8047..6c71023c 100644 --- a/crates/ironclaw_engine/src/executor/structured.rs +++ b/crates/ironclaw_engine/src/executor/structured.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = Arc::new(MockEffects::new( vec![test_action("web_search")], vec![Ok(ActionResult { diff --git a/crates/ironclaw_engine/src/executor/trace.rs b/crates/ironclaw_engine/src/executor/trace.rs index 01830c97..87004fe7 100644 --- a/crates/ironclaw_engine/src/executor/trace.rs +++ b/crates/ironclaw_engine/src/executor/trace.rs @@ -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" + ); } } diff --git a/crates/ironclaw_engine/src/types/event.rs b/crates/ironclaw_engine/src/types/event.rs index 629a37b5..e8c9553d 100644 --- a/crates/ironclaw_engine/src/types/event.rs +++ b/crates/ironclaw_engine/src/types/event.rs @@ -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 { + 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, }, 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, }, // ── Capability leases ─────────────────────────────────── diff --git a/src/bridge/router.rs b/src/bridge/router.rs index 783e0f80..a38f84ec 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -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",