use std::collections::{HashMap, HashSet};
use super::super::JsonError;
use super::AppState;
pub(super) fn is_virtual_delegation_tool(tool_name: &str) -> bool {
matches!(
tool_name.trim().to_ascii_lowercase().as_str(),
"orchestrate-subagents"
| "orchestrate_subagents"
| "assign-tasks"
| "assign_tasks"
| "delegate-subagent"
| "delegate_subagent"
| "select-subagent-model"
| "select_subagent_model"
)
}
async fn resolve_subagent_runtime_model(
state: &AppState,
subagent: &ironclad_db::agents::SubAgentRow,
task: &str,
) -> String {
let configured = subagent.model.trim();
if configured.eq_ignore_ascii_case("auto") {
return super::select_routed_model(state, task).await;
}
if configured.eq_ignore_ascii_case("orchestrator") {
let llm = state.llm.read().await;
return llm.router.select_model().to_string();
}
if configured.is_empty() {
return super::select_routed_model(state, task).await;
}
configured.to_string()
}
fn pick_running_subagent<'a>(
task: &str,
specialist_hint: Option<&str>,
taskable_subagents: &'a [ironclad_db::agents::SubAgentRow],
runtime_by_name: &HashMap<String, ironclad_agent::subagents::AgentInstance>,
) -> Option<&'a ironclad_db::agents::SubAgentRow> {
let running: Vec<&ironclad_db::agents::SubAgentRow> = taskable_subagents
.iter()
.filter(|sa| {
runtime_by_name
.get(&sa.name.to_ascii_lowercase())
.is_some_and(|inst| inst.state == ironclad_agent::subagents::AgentRunState::Running)
})
.collect();
if running.is_empty() {
return None;
}
if let Some(hint_raw) = specialist_hint {
let hint = hint_raw.trim().to_ascii_lowercase();
if !hint.is_empty()
&& let Some(chosen) = running.iter().find(|sa| {
sa.name.eq_ignore_ascii_case(&hint)
|| sa
.display_name
.as_deref()
.is_some_and(|d| d.to_ascii_lowercase().contains(&hint))
})
{
return Some(chosen);
}
}
let required = super::capability_tokens(task);
let mut scored: Vec<(&ironclad_db::agents::SubAgentRow, usize)> = running
.iter()
.map(|sa| {
let skills = super::parse_skills_json(sa.skills_json.as_deref());
let skill_tokens: HashSet<String> = skills
.iter()
.flat_map(|s| super::capability_tokens(s))
.collect();
let overlap = required
.iter()
.filter(|tok| skill_tokens.contains(*tok))
.count();
(*sa, overlap)
})
.collect();
scored.sort_by_key(|(_, overlap)| std::cmp::Reverse(*overlap));
scored
.first()
.map(|(sa, _)| *sa)
.or_else(|| running.first().copied())
}
fn timeout_like_error(err: &str) -> bool {
let e = err.to_ascii_lowercase();
e.contains("timeout")
|| e.contains("timed out")
|| e.contains("no route to host")
|| e.contains("error sending request")
|| e.contains("connection refused")
}
async fn provider_breaker_blocked(state: &AppState, model: &str) -> bool {
let provider_prefix = model.split('/').next().unwrap_or("unknown");
let llm = state.llm.read().await;
llm.breakers.is_blocked(provider_prefix)
}
async fn select_cloud_rescue_model(
state: &AppState,
current_model: &str,
preferred_fallbacks: &[String],
) -> Option<String> {
let config = state.config.read().await;
let mut candidates = Vec::new();
for m in preferred_fallbacks {
if !m.trim().is_empty() && !candidates.iter().any(|c: &String| c == m) {
candidates.push(m.clone());
}
}
for m in &config.models.fallbacks {
if !m.trim().is_empty() && !candidates.iter().any(|c: &String| c == m) {
candidates.push(m.clone());
}
}
if !config.models.primary.trim().is_empty()
&& !candidates
.iter()
.any(|c: &String| c == &config.models.primary)
{
candidates.push(config.models.primary.clone());
}
drop(config);
let llm = state.llm.read().await;
for candidate in candidates {
if candidate.eq_ignore_ascii_case(current_model) {
continue;
}
let Some(provider) = llm.providers.get_by_model(&candidate) else {
continue;
};
if provider.is_local {
continue;
}
if llm
.breakers
.is_blocked(candidate.split('/').next().unwrap_or("unknown"))
{
continue;
}
return Some(candidate);
}
None
}
pub(super) async fn execute_virtual_subagent_tool_call(
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(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(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();
let mut task = params
.get("task")
.and_then(|v| v.as_str())
.or_else(|| params.get("query").and_then(|v| v.as_str()))
.or_else(|| params.get("prompt").and_then(|v| v.as_str()))
.unwrap_or("")
.trim()
.to_string();
let specialist_hint = params
.get("specialist")
.and_then(|v| v.as_str())
.or_else(|| params.get("subagent").and_then(|v| v.as_str()));
let subtasks: Vec<String> = match params.get("subtasks") {
Some(v) => match v.as_array() {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.take(6)
.collect(),
None => {
tracing::warn!("delegation 'subtasks' param is not an array, ignoring");
vec![]
}
},
None => vec![],
};
if task.is_empty() && !subtasks.is_empty() {
task = subtasks.join("; ");
}
if task.is_empty() {
return Err("delegation tool requires `task` (or `subtasks`)".to_string());
}
let all_subagents = ironclad_db::agents::list_sub_agents(&state.db)
.map_err(|e| format!("failed to query sub-agents: {e}"))?;
let taskable_subagents: Vec<ironclad_db::agents::SubAgentRow> = all_subagents
.into_iter()
.filter(|sa| !super::is_model_proxy_role(&sa.role) && sa.enabled)
.collect();
if taskable_subagents.is_empty() {
return Err("no enabled taskable subagents are configured".to_string());
}
let mut ready_subagents = Vec::new();
let mut repair_failures = Vec::new();
for sa in &taskable_subagents {
match crate::api::routes::subagent_integrity::ensure_taskable_subagent_ready(state, sa)
.await
{
Ok(ready) => ready_subagents.push(ready),
Err(err) => repair_failures.push(format!("{}: {}", sa.name, err)),
}
}
if ready_subagents.is_empty() {
return Err(format!(
"no viable taskable subagents are available after integrity repair ({}).",
repair_failures.join(" | ")
));
}
let runtime_by_name: HashMap<String, ironclad_agent::subagents::AgentInstance> = state
.registry
.list_agents()
.await
.into_iter()
.map(|a| (a.id.to_ascii_lowercase(), a))
.collect();
let booting_count = runtime_by_name
.values()
.filter(|a| {
matches!(
a.state,
ironclad_agent::subagents::AgentRunState::Starting
| ironclad_agent::subagents::AgentRunState::Idle
)
})
.count();
let running_count = runtime_by_name
.values()
.filter(|a| a.state == ironclad_agent::subagents::AgentRunState::Running)
.count();
if action == "select-subagent-model" || action == "select_subagent_model" {
let chosen =
pick_running_subagent(&task, specialist_hint, &ready_subagents, &runtime_by_name)
.or_else(|| ready_subagents.first())
.ok_or_else(|| "no candidate subagent found for model selection".to_string())?;
let model = resolve_subagent_runtime_model(state, chosen, &task).await;
return Ok(format!(
"selected_subagent={} resolved_model={} running={} booting={}",
chosen.name, model, running_count, booting_count
));
}
let chosen = pick_running_subagent(
&task,
specialist_hint,
&ready_subagents,
&runtime_by_name,
)
.ok_or_else(|| {
format!(
"no running taskable subagents are available after integrity repair (running={}, booting={}, repair_failures={})",
running_count, booting_count, repair_failures.join(" | ")
)
})?;
let model = resolve_subagent_runtime_model(state, chosen, &task).await;
let preferred_fallbacks = crate::api::routes::subagents::parse_fallback_models_json(
chosen.fallback_models_json.as_deref(),
);
let configured_is_fixed = {
let raw = chosen.model.trim().to_ascii_lowercase();
!raw.is_empty() && raw != "auto" && raw != "orchestrator"
};
let mut effective_model = model.clone();
let mut delegation_notes: Vec<String> = Vec::new();
if configured_is_fixed
&& provider_breaker_blocked(state, &effective_model).await
&& let Some(rescue) =
select_cloud_rescue_model(state, &effective_model, &preferred_fallbacks).await
{
delegation_notes.push(format!(
"breaker-open guardrail rerouted fixed model from {} to {}",
effective_model, rescue
));
effective_model = rescue;
}
let task_list = if subtasks.is_empty() {
vec![task.clone()]
} else {
subtasks
};
let mut outputs = Vec::new();
for (idx, subtask) in task_list.iter().enumerate() {
let skills = super::parse_skills_json(chosen.skills_json.as_deref());
let system_prompt = format!(
"You are specialist subagent `{}`. Skills: {}.\nYou report to the orchestrator. Complete only the assigned task and return concise factual output plus caveats.",
chosen.name,
if skills.is_empty() {
"(none)".to_string()
} else {
skills.join(", ")
}
);
let model_for_api = effective_model
.split_once('/')
.map(|(_, m)| m)
.unwrap_or(&effective_model)
.to_string();
let req = ironclad_llm::format::UnifiedRequest {
model: model_for_api,
messages: vec![
ironclad_llm::format::UnifiedMessage {
role: "system".into(),
content: system_prompt,
parts: None,
},
ironclad_llm::format::UnifiedMessage {
role: "user".into(),
content: subtask.clone(),
parts: None,
},
],
max_tokens: Some(1200),
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let budget = {
let config = state.config.read().await;
super::delegated_inference_budget(&config.models.routing)
};
let result = match super::infer_with_fallback_with_budget_and_preferred(
state,
&req,
&effective_model,
budget,
&preferred_fallbacks,
)
.await
{
Ok(r) => r,
Err(err) => {
let retry_target = if timeout_like_error(&err) {
select_cloud_rescue_model(state, &effective_model, &preferred_fallbacks).await
} else {
None
};
if let Some(rescue_model) = retry_target {
tracing::warn!(
subagent = %chosen.name,
from_model = %effective_model,
to_model = %rescue_model,
error = %err,
"delegation timeout guardrail rerouting to cloud model"
);
delegation_notes.push(format!(
"timeout guardrail rerouted delegated subtask from {} to {}",
effective_model, rescue_model
));
effective_model = rescue_model.clone();
let retry_model_for_api = rescue_model
.split_once('/')
.map(|(_, m)| m)
.unwrap_or(&rescue_model)
.to_string();
let retry_req = ironclad_llm::format::UnifiedRequest {
model: retry_model_for_api,
messages: req.messages.clone(),
max_tokens: req.max_tokens,
temperature: req.temperature,
system: req.system.clone(),
quality_target: req.quality_target,
tools: req.tools.clone(),
};
let retry_budget = {
let config = state.config.read().await;
super::delegated_inference_budget(&config.models.routing)
};
super::infer_with_fallback_with_budget_and_preferred(
state,
&retry_req,
&rescue_model,
retry_budget,
&preferred_fallbacks,
)
.await?
} else {
return Err(err);
}
}
};
outputs.push(format!(
"subtask {} -> {}\n{}",
idx + 1,
chosen.name,
result.content.trim()
));
if action == "assign-tasks" || action == "assign_tasks" {
break;
}
}
Ok(format!(
"delegated_subagent={} model={} fallback_models={}{}\n{}",
chosen.name,
effective_model,
serde_json::to_string(&preferred_fallbacks).unwrap_or_else(|_| "[]".to_string()),
if delegation_notes.is_empty() {
String::new()
} else {
format!("\nnotes={}", delegation_notes.join(" | "))
},
outputs.join("\n\n")
))
}