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",