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"
)
}
pub(super) async fn execute_virtual_orchestration_tool(
state: &AppState,
tool_name: &str,
params: &serde_json::Value,
turn_id: &str,
authority: ironclad_core::InputAuthority,
tier: ironclad_core::SurvivalTier,
) -> Result<String, String> {
let policy_result = super::check_tool_policy(
&state.policy_engine,
tool_name,
params,
authority,
tier,
ironclad_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()),
),
};
ironclad_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(ironclad_agent::approvals::ToolClassification::Gated) => {
let request = state
.approvals
.request_approval(tool_name, ¶ms.to_string(), Some(turn_id), authority)
.map_err(|e| format!("Approval error: {e}"))?;
ironclad_db::approvals::record_approval_request(
&state.db,
&request.id,
&request.tool_name,
&request.tool_input,
request.session_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,
_ => Err(format!("unrecognized orchestration tool: {tool_name}")),
}
}
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 existing = ironclad_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_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![],
};
let skills = normalize_skills(&skills_raw);
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 = ironclad_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,
};
ironclad_db::agents::upsert_sub_agent(&state.db, &agent)
.map_err(|e| format!("failed to persist subagent: {e}"))?;
if normalized_role == ROLE_SUBAGENT {
let config = ironclad_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,
};
if let Err(e) = state.registry.register(config).await {
tracing::error!(agent = %name, error = %e, "orchestration: failed to register sub-agent");
}
if let Err(e) = state.registry.start_agent(&name).await {
tracing::error!(agent = %name, error = %e, "orchestration: failed to start sub-agent");
}
}
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 = ironclad_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()));
ironclad_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 = ironclad_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,
ironclad_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 {
ironclad_db::skills::find_by_trigger(&state.db, kw)
.map_err(|e| format!("failed to search skills: {e}"))?
} else {
ironclad_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 = ironclad_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}'"))
}