feat(ui): show activated skills in CLI and gateway

End-to-end skill activation display:

1. Python orchestrator emits __emit_event__("skill_activated", skill_names=...)
   after select_skills() picks skills for the conversation
2. Rust host function parses the comma-separated names into EventKind::SkillActivated
3. Router forwards to channels as StatusUpdate::SkillActivated
4. REPL renders: ◈ skills: github, linear (cyan)
5. Web gateway emits AppEvent::SkillActivated SSE event for frontend display

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-27 23:09:41 -07:00
co-authored by Claude Opus 4.6
parent 2ce6e785e7
commit 84b182b7e2
8 changed files with 90 additions and 37 deletions
+13
View File
@@ -181,6 +181,14 @@ pub enum AppEvent {
thread_id: Option<String>,
},
/// Skills activated for a conversation turn.
#[serde(rename = "skill_activated")]
SkillActivated {
skill_names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// 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(),
@@ -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"] = []
@@ -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<String> = 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);
@@ -132,6 +132,11 @@ pub enum EventKind {
error: String,
},
// ── Skill activation ───────────────────────────────────────
SkillActivated {
skill_names: Vec<String>,
},
// ── Orchestrator versioning ───────────────────────────────
OrchestratorRollback {
from_version: u64,
+37 -30
View File
@@ -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![],
}
}
+4
View File
@@ -355,6 +355,10 @@ pub enum StatusUpdate {
output_tokens: u64,
cost_usd: String,
},
/// Skills activated for this conversation turn.
SkillActivated {
skill_names: Vec<String>,
},
}
impl StatusUpdate {
+8
View File
@@ -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(())
}
+4
View File
@@ -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.