diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs index da801e11..f44805a6 100644 --- a/crates/ironclaw_common/src/event.rs +++ b/crates/ironclaw_common/src/event.rs @@ -181,6 +181,14 @@ pub enum AppEvent { thread_id: Option, }, + /// Skills activated for a conversation turn. + #[serde(rename = "skill_activated")] + SkillActivated { + skill_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + /// Extension activation status change (WASM channels). #[serde(rename = "extension_status")] ExtensionStatus { @@ -260,6 +268,7 @@ impl AppEvent { Self::ImageGenerated { .. } => "image_generated", Self::Suggestions { .. } => "suggestions", Self::TurnCost { .. } => "turn_cost", + Self::SkillActivated { .. } => "skill_activated", Self::ExtensionStatus { .. } => "extension_status", Self::ReasoningUpdate { .. } => "reasoning_update", Self::JobReasoning { .. } => "job_reasoning", @@ -381,6 +390,10 @@ mod tests { cost_usd: String::new(), thread_id: None, }, + AppEvent::SkillActivated { + skill_names: vec![], + thread_id: None, + }, AppEvent::ExtensionStatus { extension_name: String::new(), status: String::new(), diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py index a74059bb..ae1f7a15 100644 --- a/crates/ironclaw_engine/orchestrator/default.py +++ b/crates/ironclaw_engine/orchestrator/default.py @@ -266,6 +266,9 @@ def run_loop(context, goal, actions, state, config): if active_skills: skill_text = format_skills(active_skills) __add_message__("system_append", skill_text) + # Emit skill activation event for CLI/gateway display + skill_names = ",".join(s.get("metadata", {}).get("name", "?") for s in active_skills) + __emit_event__("skill_activated", skill_names=skill_names) # Store active skill IDs in state for tracking state["active_skill_ids"] = [s.get("doc_id", "") for s in active_skills] state["skill_snippet_names"] = [] diff --git a/crates/ironclaw_engine/src/executor/orchestrator.rs b/crates/ironclaw_engine/src/executor/orchestrator.rs index e2ddeb82..1ab883e7 100644 --- a/crates/ironclaw_engine/src/executor/orchestrator.rs +++ b/crates/ironclaw_engine/src/executor/orchestrator.rs @@ -375,14 +375,10 @@ pub async fn execute_orchestrator( "__get_actions__" => handle_get_actions(thread, effects, leases).await, // __list_skills__(max_candidates, max_tokens) - "__list_skills__" => { - handle_list_skills(args, thread, store).await - } + "__list_skills__" => handle_list_skills(args, thread, store).await, // __record_skill_usage__(doc_id, success) - "__record_skill_usage__" => { - handle_record_skill_usage(args, store).await - } + "__record_skill_usage__" => handle_record_skill_usage(args, store).await, // Unknown — let Monty resolve it (user-defined functions, builtins) other => ExtFunctionResult::NotFound(other.to_string()), @@ -702,7 +698,11 @@ async fn handle_execute_action( } thread.events.push(event); thread.updated_at = chrono::Utc::now(); - thread.add_message(ThreadMessage::action_result(call_id, action_name, output.to_string())); + thread.add_message(ThreadMessage::action_result( + call_id, + action_name, + output.to_string(), + )); }; // 1. Find lease for this action @@ -899,6 +899,15 @@ fn handle_emit_event( error, } } + "skill_activated" => { + let names_str = extract_string_kwarg(kwargs, "skill_names").unwrap_or_default(); + let skill_names: Vec = names_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + EventKind::SkillActivated { skill_names } + } _ => { debug!(kind = %kind_str, "orchestrator: unknown event kind, skipping"); return ExtFunctionResult::Return(MontyObject::None); diff --git a/crates/ironclaw_engine/src/types/event.rs b/crates/ironclaw_engine/src/types/event.rs index b858e57a..629a37b5 100644 --- a/crates/ironclaw_engine/src/types/event.rs +++ b/crates/ironclaw_engine/src/types/event.rs @@ -132,6 +132,11 @@ pub enum EventKind { error: String, }, + // ── Skill activation ─────────────────────────────────────── + SkillActivated { + skill_names: Vec, + }, + // ── Orchestrator versioning ─────────────────────────────── OrchestratorRollback { from_version: u64, diff --git a/src/bridge/router.rs b/src/bridge/router.rs index 72bd3e0d..2ef67022 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -289,7 +289,10 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> { } // Create mission manager and start cron ticker - let mission_manager = Arc::new(MissionManager::new(store_dyn.clone(), Arc::clone(&thread_manager))); + let mission_manager = Arc::new(MissionManager::new( + store_dyn.clone(), + Arc::clone(&thread_manager), + )); if let Err(e) = thread_manager.recover_project_threads(project_id).await { debug!("engine v2: recover_project_threads failed: {e}"); } @@ -309,10 +312,7 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> { mission_manager.start_event_listener(agent.deps.owner_id.clone()); // Ensure all learning missions exist for this project - if let Err(e) = mission_manager - .ensure_learning_missions(project_id) - .await - { + if let Err(e) = mission_manager.ensure_learning_missions(project_id).await { debug!("engine v2: failed to create learning missions: {e}"); } @@ -320,9 +320,9 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> { // Python orchestrator at runtime via __list_skills__). if let Some(registry) = agent.deps.skill_registry.as_ref() { let skills_snapshot = { - let guard = registry.read().map_err(|e| { - engine_err("skill registry", format!("lock poisoned: {e}")) - })?; + let guard = registry + .read() + .map_err(|e| engine_err("skill registry", format!("lock poisoned: {e}")))?; guard.skills().to_vec() }; if !skills_snapshot.is_empty() { @@ -350,7 +350,9 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> { .await; // Wire mission manager into agent for /expected command - agent.set_mission_manager(Arc::clone(&mission_manager)).await; + agent + .set_mission_manager(Arc::clone(&mission_manager)) + .await; *guard = Some(EngineState { thread_manager, @@ -1064,9 +1066,7 @@ async fn await_thread_outcome( .ok() }; if let Some(cid) = v1_conv_id { - let _ = db - .add_conversation_message(cid, "assistant", text) - .await; + let _ = db.add_conversation_message(cid, "assistant", text).await; } } @@ -1138,7 +1138,9 @@ async fn await_thread_outcome( action_name ))) } - ThreadOutcome::NeedAuthentication { credential_name, .. } => { + ThreadOutcome::NeedAuthentication { + credential_name, .. + } => { // This shouldn't reach here in the non-blocking design (the error // flows through the LLM as a normal action result), but handle // gracefully in case it does. @@ -1228,11 +1230,7 @@ async fn forward_event_to_channel( tokens.input_tokens, tokens.output_tokens ); let _ = channels - .send_status( - channel_name, - StatusUpdate::Thinking(tok_msg), - metadata, - ) + .send_status(channel_name, StatusUpdate::Thinking(tok_msg), metadata) .await; } EventKind::MessageAdded { @@ -1245,8 +1243,7 @@ async fn forward_event_to_channel( } else if role == "User" && content_preview.starts_with("[code ") { Some("Code executed (no output)".to_string()) } else if role == "User" - && (content_preview.contains("Error") - || content_preview.starts_with("Traceback")) + && (content_preview.contains("Error") || content_preview.starts_with("Traceback")) { Some("Code error — retrying...".to_string()) } else if role == "Assistant" { @@ -1256,14 +1253,21 @@ async fn forward_event_to_channel( }; if let Some(text) = msg { let _ = channels - .send_status( - channel_name, - StatusUpdate::Thinking(text), - metadata, - ) + .send_status(channel_name, StatusUpdate::Thinking(text), metadata) .await; } } + EventKind::SkillActivated { skill_names } => { + let _ = channels + .send_status( + channel_name, + StatusUpdate::SkillActivated { + skill_names: skill_names.clone(), + }, + metadata, + ) + .await; + } _ => {} } } @@ -1331,8 +1335,7 @@ fn thread_event_to_app_events( } else if role == "User" && content_preview.starts_with("[code ") { Some("Code executed (no output)") } else if role == "User" - && (content_preview.contains("Error") - || content_preview.starts_with("Traceback")) + && (content_preview.contains("Error") || content_preview.starts_with("Traceback")) { Some("Code error — retrying...") } else if role == "Assistant" { @@ -1349,9 +1352,9 @@ fn thread_event_to_app_events( } EventKind::StateChanged { from, to, reason } => { vec![AppEvent::ThreadStateChanged { - thread_id: thread_id.into(), - from_state: format!("{from:?}"), - to_state: format!("{to:?}"), + thread_id: thread_id.into(), + from_state: format!("{from:?}"), + to_state: format!("{to:?}"), reason: reason.clone(), }] } @@ -1360,6 +1363,10 @@ fn thread_event_to_app_events( child_thread_id: child_id.to_string(), goal: goal.clone(), }], + EventKind::SkillActivated { skill_names } => vec![AppEvent::SkillActivated { + skill_names: skill_names.clone(), + thread_id: Some(thread_id.into()), + }], _ => vec![], } } diff --git a/src/channels/channel.rs b/src/channels/channel.rs index 784b6bcf..5d0483f8 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -355,6 +355,10 @@ pub enum StatusUpdate { output_tokens: u64, cost_usd: String, }, + /// Skills activated for this conversation turn. + SkillActivated { + skill_names: Vec, + }, } impl StatusUpdate { diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 41d73a8c..25c8f33f 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -879,6 +879,14 @@ impl Channel for ReplChannel { StatusUpdate::TurnCost { .. } => { // Cost display is handled by the TUI channel } + StatusUpdate::SkillActivated { skill_names } => { + if !skill_names.is_empty() { + eprintln!( + " \x1b[36m\u{25C8} skills: {}\x1b[0m", + skill_names.join(", ") + ); + } + } } Ok(()) } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 2b8203af..f5595e41 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -515,6 +515,10 @@ impl Channel for GatewayChannel { cost_usd, thread_id, }, + StatusUpdate::SkillActivated { skill_names } => AppEvent::SkillActivated { + skill_names, + thread_id, + }, }; // Scope events to the user when user_id is available in metadata.