use super::AppState;
pub(super) fn is_virtual_orchestration_tool(tool_name: &str) -> bool {
matches!(
tool_name.trim().to_ascii_lowercase().as_str(),
"compose-subagent"
| "compose_subagent"
| "update-subagent-skills"
| "update_subagent_skills"
| "list-subagent-roster"
| "list_subagent_roster"
| "list-available-skills"
| "list_available_skills"
| "remove-subagent"
| "remove_subagent"
| "retire-unused-subagents"
| "retire_unused_subagents"
| "task-status"
| "task_status"
| "retry-task"
| "retry_task"
| "list-open-tasks"
| "list_open_tasks"
| "compose-skill"
| "compose_skill"
| "validate-subagent-roster"
| "validate_subagent_roster"
)
}
pub(super) async fn execute_virtual_orchestration_tool(
state: &AppState,
tool_name: &str,
params: &serde_json::Value,
turn_id: &str,
authority: roboticus_core::InputAuthority,
tier: roboticus_core::SurvivalTier,
) -> Result<String, String> {
let policy_result = super::check_tool_policy(
&state.policy_engine,
tool_name,
params,
authority,
tier,
roboticus_core::RiskLevel::Caution,
);
let (decision_str, rule_name, reason) = match &policy_result {
Ok(()) => ("allow".to_string(), None, None),
Err(super::super::JsonError(_status, msg)) => (
"deny".to_string(),
Some("policy_engine"),
Some(msg.as_str()),
),
};
roboticus_db::policy::record_policy_decision(
&state.db,
Some(turn_id),
tool_name,
&decision_str,
rule_name,
reason,
)
.inspect_err(|e| tracing::warn!(error = %e, "failed to record policy decision"))
.ok();
if let Err(super::super::JsonError(_status, msg)) = policy_result {
return Err(format!("Policy denied: {msg}"));
}
match state.approvals.check_tool(tool_name) {
Ok(roboticus_agent::approvals::ToolClassification::Gated) => {
let request = state
.approvals
.request_approval(
tool_name,
¶ms.to_string(),
None,
Some(turn_id),
authority,
)
.map_err(|e| format!("Approval error: {e}"))?;
roboticus_db::approvals::record_approval_request(
&state.db,
&request.id,
&request.tool_name,
&request.tool_input,
request.session_id.as_deref(),
request.turn_id.as_deref(),
"pending",
&request.timeout_at.to_rfc3339(),
)
.inspect_err(|e| tracing::warn!(error = %e, "failed to persist approval request"))
.ok();
return Err(format!(
"Tool '{tool_name}' requires approval (request: {})",
request.id
));
}
Err(e) => return Err(format!("Tool blocked: {e}")),
Ok(_) => {}
}
let action = tool_name.trim().to_ascii_lowercase();
match action.as_str() {
"compose-subagent" | "compose_subagent" => compose_subagent(state, params).await,
"update-subagent-skills" | "update_subagent_skills" => {
update_subagent_skills(state, params).await
}
"list-subagent-roster" | "list_subagent_roster" => list_subagent_roster(state).await,
"list-available-skills" | "list_available_skills" => {
list_available_skills(state, params).await
}
"remove-subagent" | "remove_subagent" => remove_subagent(state, params).await,
"retire-unused-subagents" | "retire_unused_subagents" => {
retire_unused_subagents_tool(state, params).await
}
"task-status" | "task_status" => task_status_tool(state, params).await,
"retry-task" | "retry_task" => retry_task_tool(state, params, authority, tier).await,
"list-open-tasks" | "list_open_tasks" => list_open_tasks_tool(state).await,
"compose-skill" | "compose_skill" => compose_skill(state, params).await,
"validate-subagent-roster" | "validate_subagent_roster" => {
validate_subagent_roster(state).await
}
_ => Err(format!("unrecognized orchestration tool: {tool_name}")),
}
}
async fn retire_unused_subagents_tool(
state: &AppState,
params: &serde_json::Value,
) -> Result<String, String> {
let min_age_days = params
.get("min_age_days")
.and_then(|v| v.as_i64())
.map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
.unwrap_or(30);
let dry_run = params
.get("dry_run")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let names = params.get("names").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(std::string::ToString::to_string))
.collect::<Vec<_>>()
});
let v = crate::api::routes::subagents::execute_retire_unused_subagents_tool(
state,
min_age_days,
dry_run,
names,
)
.await?;
serde_json::to_string_pretty(&v).map_err(|e| e.to_string())
}
async fn compose_subagent(state: &AppState, params: &serde_json::Value) -> Result<String, String> {
use crate::api::routes::subagents::{
ROLE_SUBAGENT, normalize_fallback_models, normalize_model_input, normalize_role,
normalize_skills, resolve_taskable_subagent_runtime_model, validate_subagent_contract,
validate_subagent_name,
};
let name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
"compose-subagent requires `name` (alphanumeric, hyphens, underscores)".to_string()
})?;
validate_subagent_name(&name).map_err(|e| format!("invalid subagent name: {}", e.1))?;
let skills_raw: Vec<String> = match params.get("skills") {
Some(v) if v.is_array() => v
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => vec![],
};
if skills_raw.is_empty() {
return Err(
"Cannot create subagent with no skills. Use compose-skill to install \
required skills first, then re-attempt compose-subagent with a `skills` array."
.to_string(),
);
}
let existing = roboticus_db::agents::list_sub_agents(&state.db)
.map_err(|e| format!("failed to query sub-agents: {e}"))?;
if existing.iter().any(|a| a.name.eq_ignore_ascii_case(&name)) {
return Err(format!(
"subagent '{name}' already exists; use update-subagent-skills to modify it"
));
}
let model_raw = params
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("auto");
let model = normalize_model_input(model_raw);
let description = params
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let skills_normalized = normalize_skills(&skills_raw);
if skills_normalized.is_empty() {
return Err(
"Cannot create subagent with no valid skills (all provided names were \
empty or invalid after normalization). Use compose-skill to install \
required skills first, then re-attempt compose-subagent."
.to_string(),
);
}
let skills = {
match crate::api::routes::subagent_integrity::validate_skills_against_registry_strict(
state,
&skills_normalized,
) {
Ok(valid) => valid,
Err(err) => {
let result = roboticus_core::composition::CompositionResult {
agent_name: name.clone(),
validated_skills: {
let registry =
crate::api::routes::subagent_integrity::skill_registry_names(state);
skills_normalized
.iter()
.filter(|s| registry.contains(&s.to_ascii_lowercase()))
.map(|s| s.to_ascii_lowercase())
.collect()
},
missing_skills: err.missing_skills,
auto_created_skills: vec![],
warnings: vec![],
};
return Err(format!(
"COMPOSITION_INCOMPLETE: {}\n\nMissing skills must be created first via \
compose-skill, then re-attempt compose-subagent.",
serde_json::to_string_pretty(&result)
.unwrap_or_else(|_| format!("{:?}", result))
));
}
}
};
let fallback_models_raw: Vec<String> = match params.get("fallback_models") {
Some(v) if v.is_array() => v
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => vec![],
};
let fallback_models = normalize_fallback_models(&fallback_models_raw, &model);
let role = params
.get("role")
.and_then(|v| v.as_str())
.unwrap_or(ROLE_SUBAGENT);
let normalized_role = normalize_role(role)
.ok_or_else(|| "role must be 'subagent' or 'model-proxy'".to_string())?;
validate_subagent_contract(normalized_role, &model, &skills, None)
.map_err(|e| format!("contract violation: {}", e.1))?;
let display_name = params
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
name.split('-')
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
});
let agent = roboticus_db::agents::SubAgentRow {
id: uuid::Uuid::new_v4().to_string(),
name: name.clone(),
display_name: Some(display_name.clone()),
model: model.clone(),
fallback_models_json: Some(
serde_json::to_string(&fallback_models).unwrap_or_else(|_| "[]".to_string()),
),
role: normalized_role.to_string(),
description: description.clone(),
skills_json: Some(serde_json::to_string(&skills).unwrap_or_else(|_| "[]".to_string())),
enabled: true,
session_count: 0,
last_used_at: None,
};
roboticus_db::agents::upsert_sub_agent(&state.db, &agent)
.map_err(|e| format!("failed to persist subagent: {e}"))?;
if normalized_role == ROLE_SUBAGENT {
let config = roboticus_agent::subagents::AgentInstanceConfig {
id: name.clone(),
name: display_name.clone(),
model: resolve_taskable_subagent_runtime_model(state, &model).await,
skills: skills.clone(),
allowed_subagents: vec![],
max_concurrent: 4,
};
let mut warnings = Vec::new();
if let Err(e) = state.registry.register(config).await {
tracing::error!(agent = %name, error = %e, "orchestration: failed to register sub-agent");
warnings.push(format!("WARNING: registration failed: {e}"));
}
if let Err(e) = state.registry.start_agent(&name).await {
tracing::error!(agent = %name, error = %e, "orchestration: failed to start sub-agent");
warnings.push(format!("WARNING: start failed: {e}"));
}
if !warnings.is_empty() {
let warn_str = warnings.join("; ");
let skills_label = if skills.is_empty() {
"(none)".to_string()
} else {
skills.join(", ")
};
return Ok(format!(
"created subagent '{name}' (display: {display_name}) model={model} fallback_models={} role={normalized_role} skills=[{skills_label}] enabled=true | {warn_str}",
serde_json::to_string(&fallback_models).unwrap_or_else(|_| "[]".to_string())
));
}
}
let skills_label = if skills.is_empty() {
"(none)".to_string()
} else {
skills.join(", ")
};
Ok(format!(
"created subagent '{name}' (display: {display_name}) model={model} fallback_models={} role={normalized_role} skills=[{skills_label}] enabled=true",
serde_json::to_string(&fallback_models).unwrap_or_else(|_| "[]".to_string())
))
}
async fn update_subagent_skills(
state: &AppState,
params: &serde_json::Value,
) -> Result<String, String> {
use crate::api::routes::subagents::normalize_skills;
let name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "update-subagent-skills requires `name`".to_string())?;
let agents = roboticus_db::agents::list_sub_agents(&state.db)
.map_err(|e| format!("failed to query sub-agents: {e}"))?;
let existing = agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(&name))
.ok_or_else(|| format!("subagent '{name}' not found"))?;
let new_skills_raw: Vec<String> = match params.get("skills") {
Some(v) if v.is_array() => v
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => return Err("update-subagent-skills requires `skills` array".to_string()),
};
let mode = params
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("replace");
let merged_skills = match mode {
"append" => {
let mut current: Vec<String> = existing
.skills_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
current.extend(new_skills_raw);
normalize_skills(¤t)
}
_ => normalize_skills(&new_skills_raw), };
let mut updated = existing.clone();
updated.skills_json =
Some(serde_json::to_string(&merged_skills).unwrap_or_else(|_| "[]".to_string()));
roboticus_db::agents::upsert_sub_agent(&state.db, &updated)
.map_err(|e| format!("failed to update subagent skills: {e}"))?;
let skills_label = if merged_skills.is_empty() {
"(none)".to_string()
} else {
merged_skills.join(", ")
};
Ok(format!(
"updated subagent '{name}' skills=[{skills_label}] (mode={mode})"
))
}
async fn list_subagent_roster(state: &AppState) -> Result<String, String> {
use crate::api::routes::subagents::{ROLE_MODEL_PROXY, parse_fallback_models_json};
let agents = roboticus_db::agents::list_sub_agents(&state.db)
.map_err(|e| format!("failed to query sub-agents: {e}"))?;
if agents.is_empty() {
return Ok("no subagents configured".to_string());
}
let runtime = state.registry.list_agents().await;
let runtime_by_name: std::collections::HashMap<
String,
roboticus_agent::subagents::AgentInstance,
> = runtime
.into_iter()
.map(|a| (a.id.to_ascii_lowercase(), a))
.collect();
let mut lines = Vec::new();
let mut taskable_count = 0usize;
let mut proxy_count = 0usize;
for a in &agents {
let is_proxy = a.role.eq_ignore_ascii_case(ROLE_MODEL_PROXY);
if is_proxy {
proxy_count += 1;
}
let skills: Vec<String> = a
.skills_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let runtime_state = if is_proxy {
"n/a".to_string()
} else if let Some(inst) = runtime_by_name.get(&a.name.to_ascii_lowercase()) {
format!("{:?}", inst.state).to_ascii_lowercase()
} else if a.enabled {
"booting".to_string()
} else {
"stopped".to_string()
};
let taskable = a.enabled && runtime_state == "running" && !is_proxy;
if taskable {
taskable_count += 1;
}
let skills_label = if skills.is_empty() {
"(none)".to_string()
} else {
skills.join(", ")
};
let fallback_models = parse_fallback_models_json(a.fallback_models_json.as_deref());
let fallback_label = if fallback_models.is_empty() {
"[]".to_string()
} else {
format!("[{}]", fallback_models.join(", "))
};
lines.push(format!(
"- {} [{}] model={} fallbacks={} skills=[{}] enabled={} runtime={}{}",
a.name,
a.role,
a.model,
fallback_label,
skills_label,
a.enabled,
runtime_state,
if taskable { " ★taskable" } else { "" },
));
}
Ok(format!(
"subagent roster ({} total, {} taskable, {} proxies):\n{}",
agents.len(),
taskable_count,
proxy_count,
lines.join("\n"),
))
}
async fn list_available_skills(
state: &AppState,
params: &serde_json::Value,
) -> Result<String, String> {
let keyword = params
.get("keyword")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let skills = if let Some(ref kw) = keyword {
roboticus_db::skills::find_by_trigger(&state.db, kw)
.map_err(|e| format!("failed to search skills: {e}"))?
} else {
roboticus_db::skills::list_skills(&state.db)
.map_err(|e| format!("failed to list skills: {e}"))?
};
if skills.is_empty() {
return Ok(if let Some(kw) = keyword {
format!("no skills match keyword '{kw}'")
} else {
"no skills registered in workspace catalog".to_string()
});
}
let mut lines: Vec<String> = skills
.iter()
.map(|s| {
let desc = s.description.as_deref().unwrap_or("(no description)");
let status = if s.enabled { "enabled" } else { "disabled" };
format!(
"- {} [{}] risk={} {}: {}",
s.name, s.kind, s.risk_level, status, desc
)
})
.collect();
lines.insert(
0,
format!("workspace skill catalog ({} skills):", skills.len()),
);
Ok(lines.join("\n"))
}
async fn remove_subagent(state: &AppState, params: &serde_json::Value) -> Result<String, String> {
let name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "remove-subagent requires `name`".to_string())?;
let deleted = roboticus_db::agents::delete_sub_agent(&state.db, &name)
.map_err(|e| format!("failed to delete subagent: {e}"))?;
if !deleted {
return Err(format!("subagent '{name}' not found"));
}
if let Err(e) = state.registry.stop_agent(&name).await {
tracing::warn!(agent = %name, error = %e, "orchestration: failed to stop agent during removal");
}
state.registry.unregister(&name).await;
Ok(format!("removed subagent '{name}'"))
}
async fn task_status_tool(state: &AppState, params: &serde_json::Value) -> Result<String, String> {
let task_id = params
.get("task_id")
.or_else(|| params.get("id"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let subagent_name = params
.get("subagent")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let parent_task_id = params
.get("parent_task_id")
.or_else(|| params.get("turn_id"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if task_id.is_none() && subagent_name.is_none() && parent_task_id.is_none() {
return Err(
"task-status requires `task_id` (or `id`), `subagent`, or `parent_task_id`/`turn_id` parameter"
.to_string(),
);
}
if let Some(ref tid) = task_id {
let events = roboticus_db::task_events::task_events_for_task(&state.db, tid)
.map_err(|e| format!("failed to query task events: {e}"))?;
if events.is_empty() {
return Ok(format!("no events found for task '{tid}'"));
}
let latest = events.last().unwrap();
let mut lines = vec![format!("task '{}' status: {}", tid, latest.event_type)];
if let Some(ref summary) = latest.summary {
lines.push(format!(" summary: {summary}"));
}
if let Some(pct) = latest.percentage {
lines.push(format!(" progress: {pct:.0}%"));
}
if latest.retry_count > 0 {
lines.push(format!(" retries: {}", latest.retry_count));
}
if let Some(ref detail) = latest.detail_json {
lines.push(format!(" detail: {detail}"));
}
lines.push(format!(" last_updated: {}", latest.created_at));
lines.push(format!(" event_history: {} events total", events.len()));
return Ok(lines.join("\n"));
}
if let Some(ref parent_id) = parent_task_id {
let subtasks = roboticus_db::task_events::subtask_events_for_parent(&state.db, parent_id)
.map_err(|e| format!("failed to query subtask events: {e}"))?;
if subtasks.is_empty() {
return Ok(format!("no subtask events found for parent '{parent_id}'"));
}
let active_count = subtasks
.iter()
.filter(|e| !e.event_type.is_terminal())
.count();
let mut lines = vec![format!(
"subtasks for parent '{}' ({} total, {} active):",
parent_id,
subtasks.len(),
active_count,
)];
for evt in &subtasks {
let assignee = evt.assigned_to.as_deref().unwrap_or("(unassigned)");
let summary = evt.summary.as_deref().unwrap_or("(no summary)");
let pct = evt
.percentage
.map(|p| format!(" {p:.0}%"))
.unwrap_or_default();
lines.push(format!(
"- [{}] {} via {} — {}{} ({})",
evt.task_id, evt.event_type, assignee, summary, pct, evt.created_at,
));
}
return Ok(lines.join("\n"));
}
if let Some(ref name) = subagent_name {
let events = roboticus_db::task_events::task_events_for_agent(&state.db, name)
.map_err(|e| format!("failed to query task events for agent: {e}"))?;
if events.is_empty() {
return Ok(format!("no task events found for subagent '{name}'"));
}
let mut seen_tasks = std::collections::HashSet::new();
let mut latest_per_task = Vec::new();
for evt in &events {
if seen_tasks.insert(&evt.task_id) {
latest_per_task.push(evt);
}
}
let active_count = latest_per_task
.iter()
.filter(|e| !e.event_type.is_terminal())
.count();
let mut lines = vec![format!(
"tasks for subagent '{name}' ({} total, {} active):",
latest_per_task.len(),
active_count,
)];
for evt in &latest_per_task {
let summary = evt.summary.as_deref().unwrap_or("(no summary)");
let pct = evt
.percentage
.map(|p| format!(" {p:.0}%"))
.unwrap_or_default();
lines.push(format!(
"- [{}] {} — {}{} ({})",
evt.task_id, evt.event_type, summary, pct, evt.created_at,
));
}
return Ok(lines.join("\n"));
}
Err("task-status: unexpected state".into())
}
async fn retry_task_tool(
state: &AppState,
params: &serde_json::Value,
authority: roboticus_core::InputAuthority,
tier: roboticus_core::SurvivalTier,
) -> Result<String, String> {
let task_id = params
.get("task_id")
.or_else(|| params.get("id"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "retry-task requires `task_id` (or `id`)".to_string())?;
let current_state = roboticus_db::task_events::current_task_state(&state.db, &task_id)
.map_err(|e| format!("failed to query task state: {e}"))?;
match current_state {
None => return Err(format!("no events found for task '{task_id}'")),
Some(roboticus_db::task_events::TaskLifecycleState::Failed) => {
}
Some(state) => {
return Err(format!(
"task '{task_id}' is in state '{state}' — only failed tasks can be retried"
));
}
}
let retry_count = roboticus_db::task_events::retry_count_for_task(&state.db, &task_id)
.map_err(|e| format!("failed to query retry count: {e}"))?;
if retry_count >= 2 {
return Err(format!(
"task '{task_id}' has already been retried {retry_count} times (max 2) — \
escalate to user instead"
));
}
let latest = roboticus_db::task_events::latest_task_event(&state.db, &task_id)
.map_err(|e| format!("failed to query latest event: {e}"))?;
let latest = latest.ok_or_else(|| format!("no latest event found for task '{task_id}'"))?;
let assigned_to = latest.assigned_to.clone();
let parent_task_id = latest
.parent_task_id
.clone()
.unwrap_or_else(|| task_id.clone());
let detail_value = latest
.detail_json
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
let retry_subtask = detail_value
.as_ref()
.and_then(|v| v.get("subtask"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let retry_event = roboticus_db::task_events::TaskEventRow {
id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(),
parent_task_id: Some(parent_task_id.clone()),
assigned_to: assigned_to.clone(),
event_type: roboticus_db::task_events::TaskLifecycleState::Retry,
summary: Some(format!("Retry attempt {} initiated", retry_count + 1)),
detail_json: latest.detail_json.clone(),
percentage: None,
retry_count: retry_count + 1,
created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
};
roboticus_db::task_events::insert_task_event(&state.db, &retry_event)
.map_err(|e| format!("failed to record retry event: {e}"))?;
if let Some(subtask) = retry_subtask {
let mut retry_params = serde_json::json!({
"task": subtask,
"task_id": task_id,
"parent_task_id": parent_task_id,
});
if let Some(name) = assigned_to.as_deref() {
retry_params["specialist"] = serde_json::Value::String(name.to_string());
}
let output = super::execute_virtual_subagent_tool_call(
state,
"assign-tasks",
&retry_params,
&parent_task_id,
authority,
tier,
)
.await
.map_err(|e| format!("retry execution failed for task '{task_id}': {e}"))?;
return Ok(format!(
"retry executed for task '{task_id}' (attempt {}/{})\n{}",
retry_count + 1,
2,
output
));
}
let open_tasks = roboticus_db::tasks::list_open_tasks(&state.db)
.map_err(|e| format!("failed to list tasks: {e}"))?;
let task_entry = open_tasks
.iter()
.find(|t| t["id"].as_str() == Some(task_id.as_str()));
let task_title = task_entry
.and_then(|t| t["title"].as_str())
.unwrap_or("(original task)");
Ok(format!(
"retry initiated for task '{task_id}' (attempt {}/{}) — \
task '{task_title}' re-queued for delegation",
retry_count + 1,
2
))
}
async fn list_open_tasks_tool(state: &AppState) -> Result<String, String> {
let tasks = roboticus_db::tasks::list_open_tasks_with_events(&state.db)
.map_err(|e| format!("failed to list open tasks: {e}"))?;
if tasks.is_empty() {
return Ok(
"no open tasks (all pending/in_progress/needs_review tasks have been resolved)"
.to_string(),
);
}
let mut lines: Vec<String> = Vec::with_capacity(tasks.len() + 1);
lines.push(format!("open tasks ({} total):", tasks.len()));
for t in &tasks {
let lifecycle = t
.get("lifecycle_state")
.and_then(|v| v.as_str())
.unwrap_or("");
let pct = t
.get("lifecycle_percentage")
.and_then(|v| v.as_f64())
.map(|p| format!(" {p:.0}%"))
.unwrap_or_default();
let lifecycle_info = if lifecycle.is_empty() {
String::new()
} else {
format!(", lifecycle={lifecycle}{pct}")
};
lines.push(format!(
"- [{}] {} (priority={}, status={}{}; created={})",
t["id"].as_str().unwrap_or("?"),
t["title"].as_str().unwrap_or("(untitled)"),
t["priority"].as_i64().unwrap_or(0),
t["status"].as_str().unwrap_or("?"),
lifecycle_info,
t["created_at"].as_str().unwrap_or("?"),
));
}
Ok(lines.join("\n"))
}
async fn compose_skill(state: &AppState, params: &serde_json::Value) -> Result<String, String> {
let name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "compose-skill requires `name`".to_string())?;
let description = params
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "compose-skill requires `description`".to_string())?;
let kind = params
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("instruction")
.to_string();
let triggers: Vec<String> = params
.get("triggers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_else(|| {
name.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4)
.map(|w| w.to_lowercase())
.collect()
});
let tool_chain: Vec<String> = params
.get("tool_chain")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let config = state.config.read().await;
let policy = config.agent.composition_policy;
drop(config);
if matches!(policy, roboticus_core::config::CompositionPolicy::Manual) {
return Ok(format!(
"PROPOSAL: Would create skill '{}' (kind={}, triggers=[{}]). \
composition_policy=manual — no action taken.",
name,
kind,
triggers.join(", ")
));
}
let existing = roboticus_db::skills::list_skills(&state.db)
.map_err(|e| format!("failed to query skills: {e}"))?;
if existing.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) {
return Err(format!("skill '{}' already exists in the registry", name));
}
let skill_kind = match kind.as_str() {
"structured" | "action" => "structured",
_ => "instruction",
};
let fingerprint_input = format!("{name}\x00{description}\x00{kind}");
let content_hash = format!("{:x}", content_fingerprint(fingerprint_input.as_bytes()));
let triggers_json = serde_json::to_string(&serde_json::json!({
"keywords": triggers,
}))
.unwrap_or_else(|_| "{}".to_string());
let tool_chain_json = if tool_chain.is_empty() {
None
} else {
let steps: Vec<serde_json::Value> = tool_chain
.iter()
.map(|t| serde_json::json!({"tool_name": t, "params": {}}))
.collect();
Some(serde_json::to_string(&steps).unwrap_or_else(|_| "[]".to_string()))
};
if matches!(policy, roboticus_core::config::CompositionPolicy::Propose) {
return Ok(format!(
"PROPOSAL: Validated skill '{}' (kind={}, triggers=[{}]). \
Awaiting user approval to persist.",
name,
kind,
triggers.join(", "),
));
}
let skill_id = roboticus_db::skills::register_skill_with_provenance(
&state.db,
&name,
skill_kind,
Some(&description),
&format!("composed://{}", name),
&content_hash,
Some(&triggers_json),
tool_chain_json.as_deref(),
None,
None,
"Caution",
"0.1.0",
"orchestrator",
"composed",
)
.map_err(|e| format!("failed to persist skill: {e}"))?;
Ok(format!(
"created skill '{}' (id={}, kind={}, triggers=[{}])",
name,
skill_id,
skill_kind,
triggers.join(", "),
))
}
fn content_fingerprint(data: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
async fn validate_subagent_roster(state: &AppState) -> Result<String, String> {
let agents = roboticus_db::agents::list_sub_agents(&state.db)
.map_err(|e| format!("failed to query sub-agents: {e}"))?;
if agents.is_empty() {
return Ok("no subagents to validate".to_string());
}
let mut lines = Vec::new();
let mut total_issues = 0usize;
for agent in &agents {
if !agent.enabled {
continue;
}
let skills: Vec<String> = agent
.skills_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
if skills.is_empty() {
lines.push(format!("- {} [WARNING] no skills assigned", agent.name));
total_issues += 1;
continue;
}
let validation =
crate::api::routes::subagent_integrity::validate_skills_against_registry_strict(
state, &skills,
);
match validation {
Ok(valid) => {
lines.push(format!(
"- {} [OK] {} skill(s) validated: [{}]",
agent.name,
valid.len(),
valid.join(", ")
));
}
Err(err) => {
lines.push(format!(
"- {} [STALE] missing skill(s): [{}]",
agent.name,
err.missing_skills.join(", ")
));
total_issues += 1;
}
}
}
let config = state.config.read().await;
let threshold = config.agent.retirement_success_threshold;
let min_delegations = config.agent.retirement_min_delegations;
drop(config);
if let Ok(stats) = roboticus_db::delegation::delegation_stats_by_agent(&state.db, 168) {
for stat in &stats {
if stat.total_delegations >= min_delegations && stat.success_rate < threshold {
lines.push(format!(
"- {} [RETIREMENT CANDIDATE] success_rate={:.0}% ({}/{}) over last 7 days — below threshold {:.0}%",
stat.agent_id,
stat.success_rate * 100.0,
stat.successes,
stat.total_delegations,
threshold * 100.0,
));
total_issues += 1;
}
}
}
let enabled_count = agents.iter().filter(|a| a.enabled).count();
Ok(format!(
"subagent roster validation ({} active, {} issues):\n{}",
enabled_count,
total_issues,
lines.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::is_virtual_orchestration_tool;
#[test]
fn recognizes_task_status_tool() {
assert!(is_virtual_orchestration_tool("task-status"));
assert!(is_virtual_orchestration_tool("task_status"));
assert!(is_virtual_orchestration_tool("TASK-STATUS"));
assert!(is_virtual_orchestration_tool(" task-status "));
}
#[test]
fn recognizes_retry_task_tool() {
assert!(is_virtual_orchestration_tool("retry-task"));
assert!(is_virtual_orchestration_tool("retry_task"));
assert!(is_virtual_orchestration_tool("RETRY-TASK"));
assert!(is_virtual_orchestration_tool(" retry-task "));
}
#[test]
fn recognizes_list_open_tasks_tool() {
assert!(is_virtual_orchestration_tool("list-open-tasks"));
assert!(is_virtual_orchestration_tool("list_open_tasks"));
assert!(is_virtual_orchestration_tool("LIST-OPEN-TASKS"));
assert!(is_virtual_orchestration_tool(" list-open-tasks "));
}
#[test]
fn total_orchestration_tools_count() {
let hyphenated = [
"compose-subagent",
"update-subagent-skills",
"list-subagent-roster",
"list-available-skills",
"remove-subagent",
"retire-unused-subagents",
"task-status",
"retry-task",
"list-open-tasks",
"compose-skill",
"validate-subagent-roster",
];
for tool in &hyphenated {
assert!(
is_virtual_orchestration_tool(tool),
"expected '{tool}' to be recognized"
);
}
let underscored = [
"compose_subagent",
"update_subagent_skills",
"list_subagent_roster",
"list_available_skills",
"remove_subagent",
"retire_unused_subagents",
"task_status",
"retry_task",
"list_open_tasks",
"compose_skill",
"validate_subagent_roster",
];
for tool in &underscored {
assert!(
is_virtual_orchestration_tool(tool),
"expected '{tool}' to be recognized"
);
}
assert_eq!(hyphenated.len(), 11);
assert_eq!(underscored.len(), 11);
}
#[test]
fn recognizes_compose_skill_tool() {
assert!(is_virtual_orchestration_tool("compose-skill"));
assert!(is_virtual_orchestration_tool("compose_skill"));
assert!(is_virtual_orchestration_tool("COMPOSE-SKILL"));
assert!(is_virtual_orchestration_tool(" compose-skill "));
}
#[test]
fn recognizes_validate_subagent_roster_tool() {
assert!(is_virtual_orchestration_tool("validate-subagent-roster"));
assert!(is_virtual_orchestration_tool("validate_subagent_roster"));
assert!(is_virtual_orchestration_tool("VALIDATE-SUBAGENT-ROSTER"));
}
}