mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
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:
@@ -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
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user