use super::{FunctionDeclaration, JsonSchema}; use crate::client::{Model, ModelType, call_chat_completions}; use crate::config::{ Agent, AppState, Input, RequestContext, Role, RoleLike, effective_max_concurrent_jobs, jobs_enabled, list_agents_with_descriptions, }; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox}; use crate::supervisor::notification::agent_notification; use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind}; use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal}; use crate::graph; use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; use indexmap::IndexMap; use log::{debug, warn}; use parking_lot::RwLock; use serde_json::{Value, json}; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tokio::time; use tokio::time::Instant; use uuid::Uuid; pub const AGENT_FUNCTION_PREFIX: &str = "agent__"; pub const PENDING_TASKS_GUARDRAIL_MAX: u32 = 3; fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool { match whitelist { None => true, Some(w) => w.iter().any(|a| a == target), } } fn is_job_task(supervisor: Option<&Arc>>, id: &str) -> bool { id.starts_with("job_") || supervisor.is_some_and(|sup| sup.read().has_job(id)) } fn job_id_teaching_error(id: &str) -> Value { json!({ "status": "error", "message": format!( "'{id}' is a background job, not an agent — use job__check / job__collect / job__cancel" ), }) } pub enum GuardrailAction { NoAction, Inject(String), ForceTerminate(Vec), } pub struct PendingTask { pub id: String, pub kind: TaskKind, pub finished: bool, } pub fn pending_tasks(ctx: &RequestContext) -> Vec { let Some(sup) = ctx.supervisor.as_ref() else { return Vec::new(); }; let mut tasks: Vec = sup .read() .list_tasks() .into_iter() .map(|(id, kind, finished)| PendingTask { id: id.to_string(), kind, finished, }) .collect(); // Inside a graph LLM node, jobs are node-owned: the guardrail must only // nag about jobs this node started. Jobs belonging to a parallel branch // live in the same shared registry but are that branch's to reclaim. if let Some(scope) = ctx.node_job_scope.as_ref() { tasks.retain(|t| t.kind != TaskKind::Job || scope.contains(&t.id)); } tasks.sort_by(|a, b| a.id.cmp(&b.id)); tasks } pub fn build_pending_tasks_guardrail_prompt(tasks: &[PendingTask]) -> String { let running: Vec<&PendingTask> = tasks.iter().filter(|t| !t.finished).collect(); let finished: Vec<&PendingTask> = tasks.iter().filter(|t| t.finished).collect(); let mut sections = Vec::new(); if !running.is_empty() { let id_list = running .iter() .map(|t| { let (kind, collect, cancel) = match t.kind { TaskKind::Agent => ("agent", "agent__collect", "agent__cancel"), TaskKind::Job => ("job", "job__collect", "job__cancel"), }; format!( "- {id} ({kind}): call `{collect}` (blocks until done, returns output) or \ `{cancel}` (discards)", id = t.id ) }) .collect::>() .join("\n"); sections.push(format!( "Still running ({count}):\n{id_list}\n\nThese will be abandoned if your turn ends \ now. You MUST reclaim each one before ending your turn. Do NOT emit a text-only \ response expecting them to 'report back' — they will not.", count = running.len() )); } if !finished.is_empty() { let cmd_list = finished .iter() .map(|t| { let collect = match t.kind { TaskKind::Agent => "agent__collect", TaskKind::Job => "job__collect", }; format!("- `{collect} --id {id}`", id = t.id) }) .collect::>() .join("\n"); sections.push(format!( "Completed but UNCOLLECTED — collect NOW ({count}):\n{cmd_list}\n\nCollect returns \ instantly on a finished task. Their results are LOST if your turn ends without \ collecting.", count = finished.len() )); } format!( "[SYSTEM GUARDRAIL] You attempted to end your turn with {count} unreclaimed background \ task(s).\n\n{body}", count = tasks.len(), body = sections.join("\n\n") ) } pub fn check_pending_tasks_guardrail(ctx: &mut RequestContext) -> GuardrailAction { let pending = pending_tasks(ctx); if pending.is_empty() { ctx.pending_tasks_guardrail_count = 0; return GuardrailAction::NoAction; } if ctx.pending_tasks_guardrail_count >= PENDING_TASKS_GUARDRAIL_MAX { if let Some(sup) = ctx.supervisor.as_ref().cloned() { sup.read().cancel_recursive(); let finished: Vec<&PendingTask> = pending.iter().filter(|t| t.finished).collect(); if !finished.is_empty() { let ids: Vec<&str> = finished.iter().map(|t| t.id.as_str()).collect(); warn!( "Turn-end guardrail: discarding uncollected result(s) for finished task(s) \ after max reminders: {ids:?}" ); let mut sup = sup.write(); for task in &finished { match task.kind { TaskKind::Agent => { let _ = sup.take(&task.id); } TaskKind::Job => { let _ = sup.take_job(&task.id); } } } } } ctx.pending_tasks_guardrail_count = 0; return GuardrailAction::ForceTerminate(pending.into_iter().map(|t| t.id).collect()); } ctx.pending_tasks_guardrail_count += 1; let mut prompt = build_pending_tasks_guardrail_prompt(&pending); if let Some(queue) = ctx.root_escalation_queue() && queue.has_pending() { let summary = serde_json::to_string(&queue.pending_summary()).unwrap_or_default(); prompt.push_str(&format!( "\n\nAdditionally, child agents have pending escalations blocking them. Reply to each \ via `agent__reply_escalation` first:\n{summary}" )); } GuardrailAction::Inject(prompt) } pub fn escalation_function_declarations() -> Vec { vec![FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}reply_escalation"), description: "Reply to a pending escalation from a child agent. The child is blocked waiting for this reply. \ Use this after seeing pending_escalations notifications.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([ ( "escalation_id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The escalation ID from the pending_escalations notification".into()), ..Default::default() }, ), ( "reply".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Your answer to the child agent's question. For ask/confirm questions, use \ the exact option text. For input questions, provide the text response.".into()), ..Default::default() }, ), ])), required: Some(vec!["escalation_id".to_string(), "reply".to_string()]), ..Default::default() }, agent: false, }] } pub fn agent_function_declarations() -> Vec { vec![ FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}spawn"), description: "Spawn a subagent to run in the background. Returns an `id` immediately so you can continue \ working in parallel. CRITICAL: every spawned agent MUST be reclaimed before you end your \ turn — call `agent__collect` to retrieve its output, or `agent__cancel` if you no longer \ need it. Ending your turn with pending agents will abandon their work and the system will \ reject the turn-end.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([ ( "agent".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Name of the agent to spawn (e.g. 'explore', 'coder', 'oracle')".into()), ..Default::default() }, ), ( "prompt".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The task prompt to send to the agent".into()), ..Default::default() }, ), ( "task_id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Optional task queue ID to associate with this agent".into()), ..Default::default() }, ), ])), required: Some(vec!["agent".to_string(), "prompt".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}check"), description: "Non-blocking status probe: reports whether a spawned agent is still running or finished. \ NEVER returns or consumes the result — when finished, call agent__collect to retrieve it.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([( "id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The agent ID returned by agent__spawn".into()), ..Default::default() }, )])), required: Some(vec!["id".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}collect"), description: "Block until the named spawned agent finishes and return its result. This is your primary \ wait primitive — it pauses your execution until the agent completes (or you are interrupted). \ Call this for every agent you spawned before ending your turn. Do NOT end your turn assuming \ agents will 'report back later' — they will not; they will be abandoned. If you no longer \ need an agent's result, call `agent__cancel` instead.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([( "id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The agent ID returned by agent__spawn".into()), ..Default::default() }, )])), required: Some(vec!["id".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}list_running"), description: "List all subagents YOU have spawned that are still tracked by the supervisor, with their \ status. Use this to see which of your background agents are still active. To discover which \ agent types you can spawn in the first place, use `agent__list_available` instead.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::new()), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}list_available"), description: "List all agent types installed and available to spawn (name + description). Use this to \ discover what specialists exist before calling `agent__spawn` — especially when you're unsure \ which agent to delegate to. This is the discovery counterpart to `agent__list_running` \ (which reports agents you have already spawned).".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::new()), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}cancel"), description: "Cancel a running subagent by its ID. Use this when an agent's output is no longer needed \ (e.g. you changed direction, or you're about to end your turn and don't want to wait). \ Cancellation cascades: all of the cancelled agent's own descendants are also cancelled. This \ call waits briefly for the agent to actually finish cleanup before returning.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([( "id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The agent ID to cancel".into()), ..Default::default() }, )])), required: Some(vec!["id".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}task_create"), description: "Create a task in the task queue. Returns the task ID.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([ ( "subject".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Short title for the task".into()), ..Default::default() }, ), ( "description".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Detailed description of the task".into()), ..Default::default() }, ), ( "blocked_by".to_string(), JsonSchema { type_value: Some("array".to_string()), description: Some("Task IDs that must complete before this task can run".into()), items: Some(Box::new(JsonSchema { type_value: Some("string".to_string()), ..Default::default() })), ..Default::default() }, ), ( "agent".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Agent to auto-spawn when this task becomes runnable (e.g. 'explore', 'coder'). If set, an agent will be spawned automatically when all dependencies complete.".into()), ..Default::default() }, ), ( "prompt".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Prompt to send to the auto-spawned agent. Required if agent is set.".into()), ..Default::default() }, ), ])), required: Some(vec!["subject".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}task_list"), description: "List all tasks in the task queue with their status and dependencies.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::new()), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}task_complete"), description: "Mark a task as completed. Returns any newly unblocked task IDs.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([( "task_id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The task ID to mark complete".into()), ..Default::default() }, )])), required: Some(vec!["task_id".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}task_fail"), description: "Mark a task as failed. Dependents will remain blocked.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([( "task_id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The task ID to mark as failed".into()), ..Default::default() }, )])), required: Some(vec!["task_id".to_string()]), ..Default::default() }, agent: false, }, ] } pub fn teammate_function_declarations() -> Vec { vec![ FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}send_message"), description: "Send a text message to a sibling or child agent's inbox. Use to share cross-cutting findings or coordinate with teammates.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::from([ ( "id".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The target agent ID".into()), ..Default::default() }, ), ( "message".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The message text to send".into()), ..Default::default() }, ), ])), required: Some(vec!["id".to_string(), "message".to_string()]), ..Default::default() }, agent: false, }, FunctionDeclaration { name: format!("{AGENT_FUNCTION_PREFIX}check_inbox"), description: "Check for and drain all pending messages in your inbox from sibling agents or your parent.".to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(IndexMap::new()), ..Default::default() }, agent: false, }, ] } pub async fn handle_agent_tool( ctx: &mut RequestContext, cmd_name: &str, args: &Value, ) -> Result { let action = cmd_name .strip_prefix(AGENT_FUNCTION_PREFIX) .unwrap_or(cmd_name); match action { "spawn" => handle_spawn(ctx, args).await, "check" => handle_check(ctx, args).await, "collect" => handle_collect(ctx, args).await, "list_running" => handle_list_running(ctx), "list_available" => handle_list_available(ctx), "cancel" => handle_cancel(ctx, args).await, "send_message" => handle_send_message(ctx, args), "check_inbox" => handle_check_inbox(ctx), "task_create" => handle_task_create(ctx, args), "task_list" => handle_task_list(ctx), "task_complete" => handle_task_complete(ctx, args).await, "task_fail" => handle_task_fail(ctx, args), "reply_escalation" => handle_reply_escalation(ctx, args), _ => bail!("Unknown agent action: {action}"), } } pub fn run_child_agent( mut child_ctx: RequestContext, initial_input: Input, abort_signal: AbortSignal, ) -> Pin> + Send>> { Box::pin(async move { if graph::active_agent_graph_name(&child_ctx).is_some() { return graph::run_active_agent_graph( &mut child_ctx, &initial_input.text(), abort_signal, ) .await; } let mut accumulated_output = String::new(); let mut input = initial_input; let app = Arc::clone(&child_ctx.app.config); loop { let client = input.create_client()?; child_ctx.before_chat_completion(&input)?; let (output, tool_results) = call_chat_completions( &input, false, false, client.as_ref(), &mut child_ctx, abort_signal.clone(), ) .await?; child_ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?; if !output.is_empty() { if !accumulated_output.is_empty() { accumulated_output.push('\n'); } accumulated_output.push_str(&output); } if tool_results.is_empty() { match check_pending_tasks_guardrail(&mut child_ctx) { GuardrailAction::NoAction => break, GuardrailAction::ForceTerminate(ids) => { warn!( "Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}", ids.len(), ids ); break; } GuardrailAction::Inject(prompt) => { input = Input::from_str(&child_ctx, &prompt, None)?; continue; } } } input = input.merge_tool_results(output, tool_results); } if let Some(supervisor) = child_ctx.supervisor.clone() { supervisor.read().cancel_recursive(); } Ok(accumulated_output) }) } /// Spawn an agent synchronously from a graph node and return its accumulated /// output. This is similar to `handle_spawn` but runs the child agent in the /// current task (no tokio::spawn, no supervisor handle registration) so the /// graph executor can sequence agent nodes directly. pub async fn run_agent_for_graph( parent_ctx: &mut RequestContext, agent_name: &str, prompt: &str, ) -> Result { let short_uuid = &Uuid::new_v4().to_string()[..8]; let agent_id = format!("graph_agent_{agent_name}_{short_uuid}"); let current_depth = parent_ctx.current_depth + 1; if let Some(supervisor) = parent_ctx.supervisor.as_ref().cloned() { let max_depth = supervisor.read().max_depth(); if current_depth > max_depth { bail!("Max agent depth exceeded ({current_depth}/{max_depth})"); } } if !parent_ctx.app.config.function_calling_support { bail!("Function calling support must be enabled to spawn agents."); } let child_inbox = Arc::new(Inbox::new()); parent_ctx.ensure_root_escalation_queue(); let child_abort = create_abort_signal(); let app_config = Arc::clone(&parent_ctx.app.config); let current_model = parent_ctx.current_model().clone(); let info_flag = parent_ctx.info_flag; let child_app_state = Arc::new(AppState { config: Arc::new(app_config.as_ref().clone()), vault: parent_ctx.app.vault.clone(), mcp_factory: parent_ctx.app.mcp_factory.clone(), rag_cache: parent_ctx.app.rag_cache.clone(), mcp_config: parent_ctx.app.mcp_config.clone(), mcp_log_path: parent_ctx.app.mcp_log_path.clone(), mcp_registry: parent_ctx.app.mcp_registry.clone(), functions: parent_ctx.app.functions.clone(), }); let agent = Agent::init( app_config.as_ref(), child_app_state.as_ref(), ¤t_model, info_flag, agent_name, child_abort.clone(), ) .await?; let agent_mcp_servers = agent.mcp_server_names().to_vec(); let session = agent.agent_session().map(|v| v.to_string()); let child_jobs_enabled = jobs_enabled(Some(&agent), app_config.as_ref()); let should_init_supervisor = agent.can_spawn_agents() || child_jobs_enabled; let agent_max_concurrent_subagents = if agent.can_spawn_agents() { agent.max_concurrent_agents() } else { 0 }; let agent_max_depth = agent.max_agent_depth(); let agent_max_jobs = effective_max_concurrent_jobs(Some(&agent), app_config.as_ref()); let mut child_ctx = RequestContext::new_for_child( Arc::clone(&child_app_state), parent_ctx, current_depth, Arc::clone(&child_inbox), agent_id.clone(), ); child_ctx.rag = agent.rag(); child_ctx.agent = Some(agent); if should_init_supervisor { child_ctx.supervisor = Some(Arc::new(RwLock::new( Supervisor::new(agent_max_concurrent_subagents, agent_max_depth) .with_max_concurrent_jobs(agent_max_jobs), ))); } if let Some(session) = session { child_ctx .use_session(app_config.as_ref(), Some(&session), child_abort.clone()) .await?; sync_agent_functions_to_ctx(&mut child_ctx)?; } else { populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?; sync_agent_functions_to_ctx(&mut child_ctx)?; child_ctx.init_agent_shared_variables()?; } let input = Input::from_str(&child_ctx, prompt, None)?; debug!("Spawning agent '{agent_name}' for graph node as '{agent_id}'"); run_child_agent(child_ctx, input, child_abort).await } async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[String]) -> Result<()> { if !ctx.app.config.mcp_server_support { return Ok(()); } let app = Arc::clone(&ctx.app); let server_specs = app .mcp_config .as_ref() .map(|mcp_config| { server_ids .iter() .filter_map(|id| { mcp_config .mcp_servers .get(id) .cloned() .map(|spec| (id.clone(), spec)) }) .collect::>() }) .unwrap_or_default(); for (id, spec) in server_specs { let handle = app .mcp_factory .acquire(&id, &spec, app.mcp_log_path.as_deref()) .await?; ctx.tool_scope.mcp_runtime.insert(id, handle); } Ok(()) } fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> { let server_features = ctx.tool_scope.mcp_runtime.server_features(); let functions = { let agent = ctx .agent .as_mut() .with_context(|| "Agent should be initialized")?; if !server_features.is_empty() { agent.append_mcp_meta_functions(server_features); } agent.functions().clone() }; ctx.tool_scope.functions = functions; Ok(()) } async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result { let agent_name = args .get("agent") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'agent' is required"))? .to_string(); let prompt = args .get("prompt") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'prompt' is required"))? .to_string(); let _task_id = args.get("task_id").and_then(Value::as_str); if let Some(parent) = ctx.agent.as_ref() && !agent_permitted(parent.spawnable_agents(), &agent_name) { let whitelist = parent.spawnable_agents().unwrap_or_default(); return Ok(json!({ "status": "error", "message": format!( "Agent '{agent_name}' is not in this agent's `spawnable_agents` whitelist. Allowed: {whitelist:?}. Call `agent__list_available` to see what you can spawn." ), })); } let short_uuid = &Uuid::new_v4().to_string()[..8]; let agent_id = format!("agent_{agent_name}_{short_uuid}"); let (max_depth, current_depth) = { let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active; Agent spawning not enabled"))?; let sup = supervisor.read(); if sup.active_count() >= sup.max_concurrent() { return Ok(json!({ "status": "error", "message": format!( "At capacity: {}/{} agents running. Wait for one to finish or cancel one.", sup.active_count(), sup.max_concurrent() ), })); } (sup.max_depth(), ctx.current_depth + 1) }; if current_depth > max_depth { return Ok(json!({ "status": "error", "message": format!("Max agent depth exceeded ({current_depth}/{max_depth})"), })); } let child_inbox = Arc::new(Inbox::new()); ctx.ensure_root_escalation_queue(); let child_abort = create_abort_signal(); if !ctx.app.config.function_calling_support { bail!("Please enable function calling support before using the agent."); } let app_config = Arc::clone(&ctx.app.config); let current_model = ctx.current_model().clone(); let info_flag = ctx.info_flag; let child_app_state = Arc::new(AppState { config: Arc::new(app_config.as_ref().clone()), vault: ctx.app.vault.clone(), mcp_factory: ctx.app.mcp_factory.clone(), rag_cache: ctx.app.rag_cache.clone(), mcp_config: ctx.app.mcp_config.clone(), mcp_log_path: ctx.app.mcp_log_path.clone(), mcp_registry: ctx.app.mcp_registry.clone(), functions: ctx.app.functions.clone(), }); let agent = Agent::init( app_config.as_ref(), child_app_state.as_ref(), ¤t_model, info_flag, &agent_name, child_abort.clone(), ) .await?; let agent_mcp_servers = agent.mcp_server_names().to_vec(); let session = agent.agent_session().map(|v| v.to_string()); let child_jobs_enabled = jobs_enabled(Some(&agent), app_config.as_ref()); let should_init_supervisor = agent.can_spawn_agents() || child_jobs_enabled; let max_concurrent_agents = if agent.can_spawn_agents() { agent.max_concurrent_agents() } else { 0 }; let max_depth = agent.max_agent_depth(); let max_jobs = effective_max_concurrent_jobs(Some(&agent), app_config.as_ref()); let mut child_ctx = RequestContext::new_for_child( Arc::clone(&child_app_state), ctx, current_depth, Arc::clone(&child_inbox), agent_id.clone(), ); child_ctx.rag = agent.rag(); child_ctx.agent = Some(agent); if should_init_supervisor { child_ctx.supervisor = Some(Arc::new(RwLock::new( Supervisor::new(max_concurrent_agents, max_depth).with_max_concurrent_jobs(max_jobs), ))); } if let Some(session) = session { child_ctx .use_session(app_config.as_ref(), Some(&session), child_abort.clone()) .await?; sync_agent_functions_to_ctx(&mut child_ctx)?; } else { populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?; sync_agent_functions_to_ctx(&mut child_ctx)?; child_ctx.init_agent_shared_variables()?; } let input = Input::from_str(&child_ctx, &prompt, None)?; debug!("Spawning child agent '{agent_name}' as '{agent_id}'"); let spawn_agent_id = agent_id.clone(); let spawn_agent_name = agent_name.clone(); let spawn_abort = child_abort.clone(); let spawn_notifications = Arc::clone(&ctx.notification_queue); let child_supervisor = child_ctx.supervisor.clone(); let join_handle = tokio::spawn(async move { let result = run_child_agent(child_ctx, input, spawn_abort).await; let agent_result = match result { Ok(output) => AgentResult { id: spawn_agent_id, agent_name: spawn_agent_name, output, exit_status: AgentExitStatus::Completed, }, Err(e) => AgentResult { id: spawn_agent_id, agent_name: spawn_agent_name, output: String::new(), exit_status: AgentExitStatus::Failed(e.to_string()), }, }; let success = agent_result.exit_status == AgentExitStatus::Completed; spawn_notifications.push(agent_notification( &agent_result.id, &agent_result.agent_name, success, )); Ok(agent_result) }); let handle = AgentHandle { id: agent_id.clone(), agent_name: agent_name.clone(), depth: current_depth, inbox: child_inbox, abort_signal: child_abort, join_handle, child_supervisor, }; let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let mut sup = supervisor.write(); sup.register(handle)?; Ok(json!({ "status": "ok", "id": agent_id, "agent": agent_name, "message": format!("Agent '{agent_name}' spawned as '{agent_id}' and is running in the background. CRITICAL: \ you MUST reclaim this agent before ending your turn — call `agent__collect` (blocks until \ done, returns output) or `agent__cancel` (if you no longer need it). Ending your turn with \ unreclaimed agents will be rejected and forces you to handle them. Do NOT assume the agent \ will 'report back' on its own."), })) } async fn handle_check(ctx: &mut RequestContext, args: &Value) -> Result { let id = args .get("id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'id' is required"))?; let is_finished = { let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let sup = supervisor.read(); sup.is_finished(id) }; match is_finished { Some(true) => Ok(json!({ "status": "finished", "id": id, "message": format!( "Agent '{id}' has finished; its result is ready and has NOT been consumed. \ Call `agent__collect --id {id}` to retrieve it (returns instantly on a \ finished agent). The handle stays registered until collected." ), })), Some(false) => { let mut result = json!({ "status": "pending", "id": id, "message": "Agent is still running" }); if let Some(queue) = ctx.root_escalation_queue() && queue.has_pending() { let summary = queue.pending_summary(); result["pending_escalations"] = json!(summary); result["message"] = json!( "Agent is still running. Child agents have pending escalations that need your reply via agent__reply_escalation." ); } Ok(result) } None => { if is_job_task(ctx.supervisor.as_ref(), id) { return Ok(job_id_teaching_error(id)); } Ok(json!({ "status": "error", "message": format!("No agent found with id '{id}'") })) } } } async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result { let id = args .get("id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'id' is required"))?; let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let target_abort = { let sup = supervisor.read(); if sup.is_finished(id).is_none() { if id.starts_with("job_") || sup.has_job(id) { return Ok(job_id_teaching_error(id)); } return Ok(json!({ "status": "error", "message": format!("Agent '{id}' not found. Use agent__check to verify it exists and is finished.") })); } sup.abort_signal_for(id) }; loop { let is_finished = { let sup = supervisor.read(); sup.is_finished(id).unwrap_or(false) }; if is_finished { break; } if let Some(queue) = ctx.root_escalation_queue() && queue.has_pending() { let summary = queue.pending_summary(); return Ok(json!({ "status": "pending", "id": id, "message": format!("Agent '{id}' is still running, but child agents have pending escalations that need your reply. Reply via agent__reply_escalation, then call agent__collect again."), "pending_escalations": summary, })); } match target_abort.as_ref() { Some(abort) if abort.aborted() => { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { if supervisor.read().is_finished(id).unwrap_or(false) { break; } time::sleep(Duration::from_millis(50)).await; } break; } Some(abort) => { tokio::select! { _ = time::sleep(Duration::from_millis(200)) => {} _ = wait_abort_signal(abort) => {} } } None => { time::sleep(Duration::from_millis(200)).await; } } } let handle = { let mut sup = supervisor.write(); sup.take(id) }; match handle { Some(handle) => { let result = handle .join_handle .await .map_err(|e| anyhow!("Agent task panicked: {e}"))? .map_err(|e| anyhow!("Agent failed: {e}"))?; let output = summarize_output(ctx, &result.agent_name, &result.output).await?; ctx.pending_tasks_guardrail_count = 0; Ok(json!({ "status": "completed", "id": result.id, "agent": result.agent_name, "exit_status": format!("{:?}", result.exit_status), "output": output, })) } None => Ok(json!({ "status": "error", "message": format!("Agent '{id}' completed but could not be collected. It may have been collected by another call.") })), } } fn handle_list_running(ctx: &mut RequestContext) -> Result { let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let sup = supervisor.read(); let agents: Vec = sup .list_agents() .into_iter() .map(|(id, name)| { let finished = sup.is_finished(id).unwrap_or(false); json!({ "id": id, "agent": name, "status": if finished { "finished" } else { "running" }, }) }) .collect(); Ok(json!({ "active_count": sup.active_count(), "max_concurrent": sup.max_concurrent(), "agents": agents, })) } fn handle_list_available(ctx: &RequestContext) -> Result { let whitelist: Option> = ctx .agent .as_ref() .and_then(|a| a.spawnable_agents()) .map(<[String]>::to_vec); let entries: Vec<(String, String)> = list_agents_with_descriptions() .into_iter() .filter(|(name, _)| agent_permitted(whitelist.as_deref(), name)) .collect(); let count = entries.len(); let agents: Vec = entries .into_iter() .map(|(name, description)| { if description.is_empty() { json!({ "name": name }) } else { json!({ "name": name, "description": description }) } }) .collect(); Ok(json!({ "count": count, "agents": agents, })) } async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result { let id = args .get("id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'id' is required"))?; let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let handle = { let mut sup = supervisor.write(); sup.take(id) }; match handle { Some(handle) => { let agent_name = handle.agent_name.clone(); if let Some(child_sup) = handle.child_supervisor.as_ref() { child_sup.read().cancel_recursive(); } handle.abort_signal.set_ctrlc(); let cleanup = tokio::time::timeout(Duration::from_secs(5), handle.join_handle).await; ctx.pending_tasks_guardrail_count = 0; let message = match cleanup { Ok(_) => format!("Cancelled agent '{agent_name}' and waited for cleanup."), Err(_) => format!( "Cancelled agent '{agent_name}'; cleanup did not complete within 5s. Its descendants have been signalled and will tear down asynchronously." ), }; Ok(json!({ "status": "ok", "message": message, })) } None => { if is_job_task(ctx.supervisor.as_ref(), id) { return Ok(job_id_teaching_error(id)); } Ok(json!({ "status": "error", "message": format!("No agent found with id '{id}'"), })) } } } fn handle_send_message(ctx: &mut RequestContext, args: &Value) -> Result { let id = args .get("id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'id' is required"))?; let message = args .get("message") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'message' is required"))?; let sender = ctx .self_agent_id .clone() .or_else(|| ctx.agent.as_ref().map(|a| a.name().to_string())) .unwrap_or_else(|| "parent".to_string()); let inbox = ctx .supervisor .as_ref() .and_then(|sup| sup.read().inbox(id).cloned()); let inbox = inbox.or_else(|| { ctx.parent_supervisor .as_ref() .and_then(|sup| sup.read().inbox(id).cloned()) }); match inbox { Some(inbox) => { inbox.deliver(Envelope { from: sender, to: id.to_string(), payload: EnvelopePayload::Text { content: message.to_string(), }, timestamp: Utc::now(), }); Ok(json!({ "status": "ok", "message": format!("Message delivered to agent '{id}'"), })) } None => { if is_job_task(ctx.supervisor.as_ref(), id) || is_job_task(ctx.parent_supervisor.as_ref(), id) { return Ok(job_id_teaching_error(id)); } Ok(json!({ "status": "error", "message": format!("No agent found with id '{id}'. Agent may not exist or may have already completed."), })) } } } fn handle_check_inbox(ctx: &mut RequestContext) -> Result { match ctx.inbox.as_ref() { Some(inbox) => { let messages: Vec = inbox .drain() .into_iter() .map(|e| { json!({ "from": e.from, "payload": e.payload, "timestamp": e.timestamp.to_rfc3339(), }) }) .collect(); let count = messages.len(); Ok(json!({ "messages": messages, "count": count, })) } None => Ok(json!({ "messages": [], "count": 0, })), } } fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result { let escalation_id = args .get("escalation_id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'escalation_id' is required"))?; let reply = args .get("reply") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'reply' is required"))?; let queue = ctx .escalation_queue .clone() .ok_or_else(|| anyhow!("No escalation queue available"))?; match queue.take(escalation_id) { Some(request) => { let from_agent = request.from_agent_name.clone(); let question = request.question.clone(); let _ = request.reply_tx.send(reply.to_string()); Ok(json!({ "status": "ok", "message": format!("Reply sent to agent '{from_agent}' for escalation '{escalation_id}'"), "original_question": question, })) } None => Ok(json!({ "status": "error", "message": format!("No pending escalation found with id '{escalation_id}'. It may have already been replied to or timed out."), })), } } fn handle_task_create(ctx: &mut RequestContext, args: &Value) -> Result { let subject = args .get("subject") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'subject' is required"))?; let description = args .get("description") .and_then(Value::as_str) .unwrap_or_default(); let blocked_by: Vec = args .get("blocked_by") .and_then(Value::as_array) .map(|arr| { arr.iter() .filter_map(Value::as_str) .map(String::from) .collect() }) .unwrap_or_default(); let dispatch_agent = args.get("agent").and_then(Value::as_str).map(String::from); let task_prompt = args.get("prompt").and_then(Value::as_str).map(String::from); if dispatch_agent.is_some() && task_prompt.is_none() { bail!("'prompt' is required when 'agent' is set"); } let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let mut sup = supervisor.write(); let task_id = sup.task_queue_mut().create( subject.to_string(), description.to_string(), dispatch_agent.clone(), task_prompt, ); let mut dep_errors = vec![]; for dep_id in &blocked_by { if let Err(e) = sup.task_queue_mut().add_dependency(&task_id, dep_id) { dep_errors.push(e); } } let mut result = json!({ "status": "ok", "task_id": task_id, }); if dispatch_agent.is_some() { result["auto_dispatch"] = json!(true); } if !dep_errors.is_empty() { result["warnings"] = json!(dep_errors); } Ok(result) } fn handle_task_list(ctx: &mut RequestContext) -> Result { let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let sup = supervisor.read(); let tasks: Vec = sup .task_queue() .list() .into_iter() .map(|t| { json!({ "id": t.id, "subject": t.subject, "status": t.status, "owner": t.owner, "blocked_by": t.blocked_by.iter().collect::>(), "blocks": t.blocks.iter().collect::>(), "agent": t.dispatch_agent, "prompt": t.prompt, }) }) .collect(); Ok(json!({ "tasks": tasks })) } async fn handle_task_complete(ctx: &mut RequestContext, args: &Value) -> Result { let task_id = args .get("task_id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'task_id' is required"))?; let (newly_runnable, dispatchable) = { let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let mut sup = supervisor.write(); let newly_runnable_ids = sup.task_queue_mut().complete(task_id); let mut newly_runnable = Vec::new(); let mut to_dispatch: Vec<(String, String, String)> = Vec::new(); for id in &newly_runnable_ids { if let Some(t) = sup.task_queue().get(id) { newly_runnable.push(json!({ "id": t.id, "subject": t.subject, "description": t.description, "agent": t.dispatch_agent, })); if let (Some(agent), Some(prompt)) = (&t.dispatch_agent, &t.prompt) { to_dispatch.push((id.clone(), agent.clone(), prompt.clone())); } } } let mut dispatchable = Vec::new(); for (tid, agent, prompt) in to_dispatch { if sup.task_queue_mut().claim(&tid, &format!("auto:{agent}")) { dispatchable.push((agent, prompt)); } } (newly_runnable, dispatchable) }; let mut spawned = Vec::new(); for (agent, prompt) in &dispatchable { let spawn_args = json!({ "agent": agent, "prompt": prompt, }); match handle_spawn(ctx, &spawn_args).await { Ok(result) => { let agent_id = result .get("id") .and_then(Value::as_str) .unwrap_or("unknown"); debug!("Auto-dispatched agent '{}' for task queue", agent_id); spawned.push(result); } Err(e) => { spawned.push(json!({ "status": "error", "agent": agent, "message": format!("Auto-dispatch failed: {e}"), })); } } } let mut result = json!({ "status": "ok", "task_id": task_id, "newly_runnable": newly_runnable, }); if !spawned.is_empty() { result["auto_dispatched"] = json!(spawned); } Ok(result) } fn handle_task_fail(ctx: &mut RequestContext, args: &Value) -> Result { let task_id = args .get("task_id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("'task_id' is required"))?; let supervisor = ctx .supervisor .as_ref() .cloned() .ok_or_else(|| anyhow!("No supervisor active"))?; let mut sup = supervisor.write(); let task = sup.task_queue().get(task_id); if task.is_none() { return Ok(json!({ "status": "error", "message": format!("Task '{task_id}' not found"), })); } let blocked_dependents: Vec = task.unwrap().blocks.iter().cloned().collect(); sup.task_queue_mut().fail(task_id); Ok(json!({ "status": "ok", "task_id": task_id, "blocked_dependents": blocked_dependents, "message": format!("Task '{task_id}' marked as failed. {} dependent task(s) will remain blocked.", blocked_dependents.len()), })) } const SUMMARIZATION_PROMPT: &str = r#"You are a precise summarization assistant. Your job is to condense a sub-agent's output into a compact summary that preserves all actionable information. Rules: - Preserve ALL code snippets, file paths, error messages, and concrete recommendations - Remove conversational filler, thinking-out-loud, and redundant explanations - Keep the summary under 30% of the original length - Use bullet points for multiple findings - If the output contains a final answer or conclusion, lead with it"#; async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str) -> Result { let Some(agent) = ctx.agent.as_ref() else { return Ok(output.to_string()); }; let threshold = agent.summarization_threshold(); let summarization_model_id = agent.summarization_model().map(|s| s.to_string()); if output.len() < threshold { debug!( "Output from '{}' is {} chars (threshold {}), skipping summarization", agent_name, output.len(), threshold ); return Ok(output.to_string()); } debug!( "Output from '{}' is {} chars (threshold {}), summarizing...", agent_name, output.len(), threshold ); let model = match summarization_model_id { Some(ref model_id) => { Model::retrieve_model(ctx.app.config.as_ref(), model_id, ModelType::Chat)? } None => ctx.current_model().clone(), }; let mut role = Role::new("summarizer", SUMMARIZATION_PROMPT); role.set_model(model); let user_message = format!( "Summarize the following sub-agent output from '{}':\n\n{}", agent_name, output ); let input = Input::from_str(ctx, &user_message, Some(role))?; let summary = input.fetch_chat_text().await?; debug!( "Summarized output from '{}': {} chars -> {} chars", agent_name, output.len(), summary.len() ); Ok(summary) } #[cfg(test)] mod tests { use super::*; use crate::config::test_fixtures::{FixtureServer, fixture_runtime}; use crate::config::{AgentConfig, AppState, WorkingMode}; use crate::function::jobs::RingBuf; use crate::supervisor::escalation::{EscalationQueue, EscalationRequest}; use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus}; use parking_lot::Mutex; use serde_json::json; use serial_test::serial; use std::mem; fn default_app_state() -> Arc { Arc::new(AppState::test_default()) } fn ctx_with_supervisor(max_concurrent: usize, max_depth: usize) -> RequestContext { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); ctx.supervisor = Some(Arc::new(RwLock::new(Supervisor::new( max_concurrent, max_depth, )))); ctx } fn ctx_with_job_capable_supervisor() -> RequestContext { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); ctx.supervisor = Some(Arc::new(RwLock::new( Supervisor::new(4, 3).with_max_concurrent_jobs(4), ))); ctx } fn make_fake_job(id: &str) -> JobHandle { let rt = tokio::runtime::Runtime::new().unwrap(); let join_handle = rt.spawn(async { Ok(JobResult { output: json!(null), exit_code: Some(0), output_bytes_captured: 0, }) }); mem::forget(rt); JobHandle { id: id.to_string(), tool: "execute_command".to_string(), started_at: std::time::Instant::now(), join_handle, abort_signal: create_abort_signal(), state: Arc::new(Mutex::new(JobState { status: JobStatus::Running, pgid: None, })), output_buf: Arc::new(Mutex::new(RingBuf::default())), no_change_checks: 0, last_check_state: None, } } fn register_fake_job(ctx: &mut RequestContext, id: &str) { ctx.supervisor .as_ref() .unwrap() .write() .register(make_fake_job(id)) .unwrap(); } fn assert_job_teaching_error(result: &Value, id: &str) { assert_eq!(result["status"], "error"); let message = result["message"].as_str().unwrap(); assert_eq!( message, format!( "'{id}' is a background job, not an agent — use job__check / job__collect / job__cancel" ) ); } fn register_fake_agent(ctx: &mut RequestContext, id: &str, name: &str) { register_fake_agent_with_output(ctx, id, name, "fake output"); } fn register_fake_agent_with_output( ctx: &mut RequestContext, id: &str, name: &str, output: &str, ) { let rt = tokio::runtime::Runtime::new().unwrap(); let id_owned = id.to_string(); let name_owned = name.to_string(); let output_owned = output.to_string(); let join_handle = rt.spawn(async move { Ok(AgentResult { id: id_owned, agent_name: name_owned, output: output_owned, exit_status: AgentExitStatus::Completed, }) }); mem::forget(rt); let handle = AgentHandle { id: id.to_string(), agent_name: name.to_string(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: create_abort_signal(), join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); } fn run_async(f: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(f) } fn register_running_agent(ctx: &mut RequestContext, id: &str, name: &str) -> AbortSignal { let abort = create_abort_signal(); let id_owned = id.to_string(); let name_owned = name.to_string(); let join_handle = tokio::spawn(async move { time::sleep(Duration::from_secs(60)).await; Ok(AgentResult { id: id_owned, agent_name: name_owned, output: String::new(), exit_status: AgentExitStatus::Completed, }) }); let handle = AgentHandle { id: id.to_string(), agent_name: name.to_string(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: abort.clone(), join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); abort } fn wait_until_finished(ctx: &RequestContext, id: &str) { let deadline = std::time::Instant::now() + Duration::from_secs(5); while ctx.supervisor.as_ref().unwrap().read().is_finished(id) != Some(true) { assert!( std::time::Instant::now() < deadline, "agent '{id}' never finished" ); std::thread::sleep(Duration::from_millis(10)); } } #[tokio::test] async fn sync_agent_functions_gates_meta_functions_on_live_capabilities() { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); ctx.agent = Some(Agent::test_new(AgentConfig::default())); let (runtime, _server) = fixture_runtime(FixtureServer { tools_capability: false, resources_capability: true, ..FixtureServer::default() }) .await; ctx.tool_scope.mcp_runtime = runtime; sync_agent_functions_to_ctx(&mut ctx).unwrap(); let functions = &ctx.tool_scope.functions; assert_eq!(functions.declarations().len(), 3); assert!(functions.contains("mcp_search_fixture")); assert!(functions.contains("mcp_describe_fixture")); assert!(functions.contains("mcp_read_fixture")); assert!(!functions.contains("mcp_invoke_fixture")); } #[test] fn handle_list_running_empty_supervisor() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_list_running(&mut ctx).unwrap(); assert_eq!(result["active_count"], 0); assert_eq!(result["max_concurrent"], 4); assert!(result["agents"].as_array().unwrap().is_empty()); } #[test] fn handle_list_running_with_agents() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); register_fake_agent(&mut ctx, "a2", "coder"); let result = handle_list_running(&mut ctx).unwrap(); assert_eq!(result["active_count"], 2); let agents = result["agents"].as_array().unwrap(); assert_eq!(agents.len(), 2); } #[test] fn handle_list_running_no_supervisor_errors() { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let result = handle_list_running(&mut ctx); assert!(result.is_err()); } #[test] fn handle_list_available_returns_shape() { let ctx = ctx_with_supervisor(4, 3); let result = handle_list_available(&ctx).unwrap(); assert!(result["count"].is_number()); assert!(result["agents"].is_array()); } #[test] #[serial] fn handle_list_available_unrestricted_when_no_whitelist() { let ctx = ctx_with_supervisor(4, 3); let result = handle_list_available(&ctx).unwrap(); let full_count = result["count"].as_u64().unwrap(); assert_eq!(full_count as usize, list_agents_with_descriptions().len()); } #[test] fn agent_permitted_none_whitelist_allows_all() { assert!(agent_permitted(None, "explore")); assert!(agent_permitted(None, "anything")); } #[test] fn agent_permitted_empty_whitelist_denies_all() { let empty: Vec = vec![]; assert!(!agent_permitted(Some(&empty), "explore")); } #[test] fn agent_permitted_named_whitelist_matches_exact() { let allowed = vec!["explore".to_string(), "coder".to_string()]; assert!(agent_permitted(Some(&allowed), "explore")); assert!(agent_permitted(Some(&allowed), "coder")); assert!(!agent_permitted(Some(&allowed), "oracle")); assert!(!agent_permitted(Some(&allowed), "Explore")); } #[test] fn handle_check_unknown_agent() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_check(&mut ctx, &json!({"id": "nonexistent"}))); let val = result.unwrap(); assert_eq!(val["status"], "error"); } #[test] fn handle_check_pending_agent() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let inbox = Arc::new(Inbox::new()); let abort = create_abort_signal(); let join_handle = tokio::spawn(async { time::sleep(Duration::from_secs(60)).await; Ok(AgentResult { id: "slow".into(), agent_name: "test".into(), output: String::new(), exit_status: AgentExitStatus::Completed, }) }); let handle = AgentHandle { id: "slow".into(), agent_name: "test".into(), depth: 1, inbox, abort_signal: abort, join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); let result = handle_check(&mut ctx, &json!({"id": "slow"})) .await .unwrap(); assert_eq!(result["status"], "pending"); }); } #[test] fn handle_cancel_registered_agent() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); let result = run_async(handle_cancel(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["status"], "ok"); assert_eq!(ctx.supervisor.as_ref().unwrap().read().active_count(), 0); } #[test] fn handle_cancel_unknown_agent() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_cancel(&mut ctx, &json!({"id": "missing"}))).unwrap(); assert_eq!(result["status"], "error"); } #[test] fn handle_cancel_no_supervisor_errors() { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let result = run_async(handle_cancel(&mut ctx, &json!({"id": "x"}))); assert!(result.is_err()); } #[test] fn handle_send_message_to_registered_agent() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); let result = handle_send_message( &mut ctx, &json!({"id": "a1", "message": "hello from parent"}), ) .unwrap(); assert_eq!(result["status"], "ok"); let inbox = ctx .supervisor .as_ref() .unwrap() .read() .inbox("a1") .unwrap() .clone(); let msgs = inbox.drain(); assert_eq!(msgs.len(), 1); match &msgs[0].payload { EnvelopePayload::Text { content } => assert_eq!(content, "hello from parent"), _ => panic!("expected text payload"), } } #[test] fn handle_send_message_to_unknown_agent() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_send_message(&mut ctx, &json!({"id": "missing", "message": "hi"})).unwrap(); assert_eq!(result["status"], "error"); } #[test] fn handle_check_inbox_with_messages() { let mut ctx = ctx_with_supervisor(4, 3); let inbox = Arc::new(Inbox::new()); inbox.deliver(Envelope { from: "sibling".into(), to: "me".into(), payload: EnvelopePayload::Text { content: "hey".into(), }, timestamp: Utc::now(), }); ctx.inbox = Some(inbox); let result = handle_check_inbox(&mut ctx).unwrap(); assert_eq!(result["count"], 1); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["from"], "sibling"); } #[test] fn handle_check_inbox_no_inbox() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_check_inbox(&mut ctx).unwrap(); assert_eq!(result["count"], 0); } #[test] fn handle_check_inbox_empty_inbox() { let mut ctx = ctx_with_supervisor(4, 3); ctx.inbox = Some(Arc::new(Inbox::new())); let result = handle_check_inbox(&mut ctx).unwrap(); assert_eq!(result["count"], 0); } #[test] fn handle_reply_escalation_success() { let mut ctx = ctx_with_supervisor(4, 3); let queue = Arc::new(EscalationQueue::new()); let (tx, rx) = tokio::sync::oneshot::channel(); queue.submit(EscalationRequest { id: "esc_1".into(), from_agent_id: "a1".into(), from_agent_name: "explore".into(), question: "What do?".into(), options: None, reply_tx: tx, }); ctx.escalation_queue = Some(queue); let result = handle_reply_escalation( &mut ctx, &json!({"escalation_id": "esc_1", "reply": "do X"}), ) .unwrap(); assert_eq!(result["status"], "ok"); assert_eq!(rx.blocking_recv().unwrap(), "do X"); } #[test] fn handle_reply_escalation_missing_id() { let mut ctx = ctx_with_supervisor(4, 3); ctx.escalation_queue = Some(Arc::new(EscalationQueue::new())); let result = handle_reply_escalation( &mut ctx, &json!({"escalation_id": "missing", "reply": "whatever"}), ) .unwrap(); assert_eq!(result["status"], "error"); } #[test] fn handle_reply_escalation_no_queue_errors() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_reply_escalation(&mut ctx, &json!({"escalation_id": "x", "reply": "y"})); assert!(result.is_err()); } #[test] fn handle_task_create_simple() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_task_create(&mut ctx, &json!({"subject": "Do research"})).unwrap(); assert_eq!(result["status"], "ok"); assert!(result["task_id"].as_str().is_some()); } #[test] fn handle_task_create_with_dependencies() { let mut ctx = ctx_with_supervisor(4, 3); handle_task_create(&mut ctx, &json!({"subject": "Step 1"})).unwrap(); let result = handle_task_create(&mut ctx, &json!({"subject": "Step 2", "blocked_by": ["1"]})) .unwrap(); assert_eq!(result["status"], "ok"); } #[test] fn handle_task_create_with_dispatch_agent() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_task_create( &mut ctx, &json!({"subject": "Auto task", "agent": "coder", "prompt": "do it"}), ) .unwrap(); assert_eq!(result["status"], "ok"); assert_eq!(result["auto_dispatch"], true); } #[test] fn handle_task_create_agent_without_prompt_errors() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_task_create(&mut ctx, &json!({"subject": "Bad", "agent": "coder"})); assert!(result.is_err()); } #[test] fn handle_task_list_empty() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_task_list(&mut ctx).unwrap(); assert!(result["tasks"].as_array().unwrap().is_empty()); } #[test] fn handle_task_list_with_tasks() { let mut ctx = ctx_with_supervisor(4, 3); handle_task_create(&mut ctx, &json!({"subject": "A"})).unwrap(); handle_task_create(&mut ctx, &json!({"subject": "B"})).unwrap(); let result = handle_task_list(&mut ctx).unwrap(); assert_eq!(result["tasks"].as_array().unwrap().len(), 2); } #[test] fn handle_task_complete_unblocks_dependents() { let mut ctx = ctx_with_supervisor(4, 3); handle_task_create(&mut ctx, &json!({"subject": "Step 1"})).unwrap(); handle_task_create(&mut ctx, &json!({"subject": "Step 2", "blocked_by": ["1"]})).unwrap(); let result = run_async(handle_task_complete(&mut ctx, &json!({"task_id": "1"}))).unwrap(); assert_eq!(result["status"], "ok"); let newly_runnable = result["newly_runnable"].as_array().unwrap(); assert_eq!(newly_runnable.len(), 1); assert_eq!(newly_runnable[0]["id"], "2"); } #[test] fn handle_task_fail_marks_failed() { let mut ctx = ctx_with_supervisor(4, 3); handle_task_create(&mut ctx, &json!({"subject": "Doomed"})).unwrap(); let result = handle_task_fail(&mut ctx, &json!({"task_id": "1"})).unwrap(); assert_eq!(result["status"], "ok"); } #[test] fn handle_task_fail_reports_blocked_dependents() { let mut ctx = ctx_with_supervisor(4, 3); handle_task_create(&mut ctx, &json!({"subject": "A"})).unwrap(); handle_task_create(&mut ctx, &json!({"subject": "B", "blocked_by": ["1"]})).unwrap(); let result = handle_task_fail(&mut ctx, &json!({"task_id": "1"})).unwrap(); let deps = result["blocked_dependents"].as_array().unwrap(); assert_eq!(deps.len(), 1); } #[test] fn handle_task_fail_missing_task() { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_task_fail(&mut ctx, &json!({"task_id": "nonexistent"})).unwrap(); assert_eq!(result["status"], "error"); } #[test] fn dispatch_unknown_action_errors() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_agent_tool(&mut ctx, "agent__bogus", &json!({}))); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("Unknown agent action") ); } #[test] fn dispatch_routes_list_running() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_agent_tool( &mut ctx, "agent__list_running", &json!({}), )) .unwrap(); assert!(result["active_count"].is_number()); } #[test] fn dispatch_routes_list_available() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_agent_tool( &mut ctx, "agent__list_available", &json!({}), )) .unwrap(); assert!(result["count"].is_number()); assert!(result["agents"].is_array()); } #[test] fn dispatch_routes_task_list() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_agent_tool(&mut ctx, "agent__task_list", &json!({}))).unwrap(); assert!(result["tasks"].is_array()); } #[test] fn new_for_child_inherits_escalation_queue() { let mut parent = ctx_with_supervisor(4, 3); let queue = parent.ensure_root_escalation_queue(); let child = RequestContext::new_for_child( default_app_state(), &parent, 2, Arc::new(Inbox::new()), "child_1".into(), ); assert!(child.escalation_queue.is_some()); assert!(Arc::ptr_eq( child.escalation_queue.as_ref().unwrap(), &queue )); } #[test] fn new_for_child_sets_depth_and_id() { let parent = ctx_with_supervisor(4, 3); let child = RequestContext::new_for_child( default_app_state(), &parent, 3, Arc::new(Inbox::new()), "child_xyz".into(), ); assert_eq!(child.current_depth, 3); assert_eq!(child.self_agent_id, Some("child_xyz".to_string())); } #[test] fn new_for_child_has_inbox() { let parent = ctx_with_supervisor(4, 3); let inbox = Arc::new(Inbox::new()); let child = RequestContext::new_for_child( default_app_state(), &parent, 1, Arc::clone(&inbox), "c1".into(), ); assert!(child.inbox.is_some()); assert!(Arc::ptr_eq(child.inbox.as_ref().unwrap(), &inbox)); } #[test] fn new_for_child_inherits_parent_supervisor() { let parent = ctx_with_supervisor(4, 3); let child = RequestContext::new_for_child( default_app_state(), &parent, 1, Arc::new(Inbox::new()), "c1".into(), ); assert!(child.parent_supervisor.is_some()); assert!(child.supervisor.is_none()); } #[test] fn new_for_child_starts_with_empty_scope() { let parent = ctx_with_supervisor(4, 3); let child = RequestContext::new_for_child( default_app_state(), &parent, 1, Arc::new(Inbox::new()), "c1".into(), ); assert!(child.tool_scope.functions.is_empty()); assert!(child.tool_scope.mcp_runtime.is_empty()); assert!(child.role.is_none()); assert!(child.session.is_none()); assert!(child.agent.is_none()); } #[test] fn ensure_root_escalation_queue_creates_on_first_call() { let mut ctx = ctx_with_supervisor(4, 3); assert!(ctx.escalation_queue.is_none()); let q = ctx.ensure_root_escalation_queue(); assert!(!q.has_pending()); assert!(ctx.escalation_queue.is_some()); } #[test] fn ensure_root_escalation_queue_returns_same_on_second_call() { let mut ctx = ctx_with_supervisor(4, 3); let q1 = ctx.ensure_root_escalation_queue(); let q2 = ctx.ensure_root_escalation_queue(); assert!(Arc::ptr_eq(&q1, &q2)); } #[test] fn guardrail_prompt_mentions_pending_escalations() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let join_handle = tokio::spawn(async { time::sleep(Duration::from_secs(60)).await; Ok(AgentResult { id: "slow".into(), agent_name: "test".into(), output: String::new(), exit_status: AgentExitStatus::Completed, }) }); let handle = AgentHandle { id: "slow".into(), agent_name: "test".into(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: create_abort_signal(), join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); let queue = ctx.ensure_root_escalation_queue(); let (tx, _rx) = tokio::sync::oneshot::channel(); queue.submit(EscalationRequest { id: "esc_9".into(), from_agent_id: "a1".into(), from_agent_name: "explore".into(), question: "Which option?".into(), options: None, reply_tx: tx, }); match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => { assert!(prompt.contains("agent__reply_escalation")); assert!(prompt.contains("esc_9")); } _ => panic!("expected Inject action"), } }); } #[test] fn guardrail_prompt_omits_escalations_when_none_pending() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let join_handle = tokio::spawn(async { time::sleep(Duration::from_secs(60)).await; Ok(AgentResult { id: "slow".into(), agent_name: "test".into(), output: String::new(), exit_status: AgentExitStatus::Completed, }) }); let handle = AgentHandle { id: "slow".into(), agent_name: "test".into(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: create_abort_signal(), join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => { assert!(!prompt.contains("agent__reply_escalation")); } _ => panic!("expected Inject action"), } }); } #[test] fn handle_collect_finished_agent_returns_output_and_consumes_handle() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); ctx.pending_tasks_guardrail_count = 2; let result = run_async(handle_collect(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["status"], "completed"); assert_eq!(result["id"], "a1"); assert_eq!(result["agent"], "explore"); assert_eq!(result["exit_status"], "Completed"); assert_eq!(result["output"], "fake output"); assert_eq!(ctx.pending_tasks_guardrail_count, 0); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("a1"), None ); } #[test] fn handle_collect_pending_escalations_early_out_keeps_handle() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let _abort = register_running_agent(&mut ctx, "slow", "test"); let queue = ctx.ensure_root_escalation_queue(); let (tx, _rx) = tokio::sync::oneshot::channel(); queue.submit(EscalationRequest { id: "esc_1".into(), from_agent_id: "a1".into(), from_agent_name: "explore".into(), question: "What do?".into(), options: None, reply_tx: tx, }); let result = handle_collect(&mut ctx, &json!({"id": "slow"})) .await .unwrap(); assert_eq!(result["status"], "pending"); assert!(result["pending_escalations"].is_array()); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("slow"), Some(false) ); }); } #[test] fn handle_collect_unknown_agent_errors() { let mut ctx = ctx_with_supervisor(4, 3); let result = run_async(handle_collect(&mut ctx, &json!({"id": "missing"}))).unwrap(); assert_eq!(result["status"], "error"); assert!(result["message"].as_str().unwrap().contains("not found")); } #[test] fn handle_collect_without_agent_passes_long_output_through_verbatim() { let mut ctx = ctx_with_supervisor(4, 3); let long_output = "x".repeat(10_000); register_fake_agent_with_output(&mut ctx, "a1", "explore", &long_output); let result = run_async(handle_collect(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["output"], long_output); } #[test] fn handle_collect_output_below_agent_threshold_passes_through() { let mut ctx = ctx_with_supervisor(4, 3); ctx.agent = Some(Agent::test_new(AgentConfig { summarization_threshold: 1_000_000, ..Default::default() })); register_fake_agent(&mut ctx, "a1", "explore"); let result = run_async(handle_collect(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["status"], "completed"); assert_eq!(result["output"], "fake output"); } #[test] fn handle_collect_over_threshold_with_unknown_summarization_model_errors() { let mut ctx = ctx_with_supervisor(4, 3); ctx.agent = Some(Agent::test_new(AgentConfig { summarization_threshold: 1, summarization_model: Some("nonexistent_client:model".into()), ..Default::default() })); register_fake_agent(&mut ctx, "a1", "explore"); let err = run_async(handle_collect(&mut ctx, &json!({"id": "a1"}))).unwrap_err(); assert!(err.to_string().contains("nonexistent_client")); } #[test] fn guardrail_no_supervisor_is_no_action_and_resets_counter() { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); ctx.pending_tasks_guardrail_count = 2; assert!(matches!( check_pending_tasks_guardrail(&mut ctx), GuardrailAction::NoAction )); assert_eq!(ctx.pending_tasks_guardrail_count, 0); } /// A finished-but-uncollected agent counts as pending: the turn-end /// guardrail tells the model to collect it instead of letting the result /// be silently dropped, and the handle stays registered. #[test] fn guardrail_surfaces_finished_but_uncollected_agents() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); wait_until_finished(&ctx, "a1"); ctx.pending_tasks_guardrail_count = 2; match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => { assert!(prompt.contains("a1")); assert!(prompt.contains("agent__collect --id a1")); assert!(prompt.contains("Completed but UNCOLLECTED")); } _ => panic!("expected Inject action"), } assert_eq!(ctx.pending_tasks_guardrail_count, 3); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("a1"), Some(true) ); } #[test] fn guardrail_force_terminate_discards_finished_uncollected_handles() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); wait_until_finished(&ctx, "a1"); ctx.pending_tasks_guardrail_count = PENDING_TASKS_GUARDRAIL_MAX; match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::ForceTerminate(ids) => { assert_eq!(ids, vec!["a1".to_string()]); } _ => panic!("expected ForceTerminate action"), } assert_eq!(ctx.pending_tasks_guardrail_count, 0); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("a1"), None ); } #[test] fn guardrail_prompt_renders_running_and_finished_sections() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let _abort = register_running_agent(&mut ctx, "slow", "test"); register_fake_agent(&mut ctx, "a1", "explore"); wait_until_finished(&ctx, "a1"); match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => { assert!(prompt.contains("Still running")); assert!(prompt.contains("slow (agent)")); assert!(prompt.contains("Completed but UNCOLLECTED")); assert!(prompt.contains("agent__collect --id a1")); } _ => panic!("expected Inject action"), } }); } #[test] fn guardrail_prompt_is_kind_aware_for_jobs() { let tasks = vec![ PendingTask { id: "job_1".into(), kind: TaskKind::Job, finished: false, }, PendingTask { id: "job_2".into(), kind: TaskKind::Job, finished: true, }, ]; let prompt = build_pending_tasks_guardrail_prompt(&tasks); assert!(prompt.contains("job_1 (job)")); assert!(prompt.contains("job__cancel")); assert!(prompt.contains("job__collect --id job_2")); } #[test] fn pending_tasks_includes_registered_jobs() { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "job_1"); let tasks = pending_tasks(&ctx); assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].id, "job_1"); assert_eq!(tasks[0].kind, TaskKind::Job); } #[test] fn pending_tasks_scopes_jobs_to_node_scope() { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "job_mine"); register_fake_job(&mut ctx, "job_other"); ctx.node_job_scope = Some(vec!["job_mine".to_string()]); let tasks = pending_tasks(&ctx); assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].id, "job_mine"); assert_eq!(tasks[0].kind, TaskKind::Job); ctx.node_job_scope = None; assert_eq!(pending_tasks(&ctx).len(), 2); } #[test] fn guardrail_force_terminates_at_max_and_cancels_agents() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let abort = register_running_agent(&mut ctx, "slow", "test"); ctx.pending_tasks_guardrail_count = PENDING_TASKS_GUARDRAIL_MAX; match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::ForceTerminate(ids) => { assert_eq!(ids, vec!["slow".to_string()]); } _ => panic!("expected ForceTerminate action"), } assert_eq!(ctx.pending_tasks_guardrail_count, 0); assert!(abort.aborted()); }); } #[test] fn guardrail_injects_prompt_and_increments_counter_below_max() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let _abort = register_running_agent(&mut ctx, "slow", "test"); ctx.pending_tasks_guardrail_count = 1; match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => { assert!(prompt.contains("slow")); assert!(prompt.contains("agent__collect")); } _ => panic!("expected Inject action"), } assert_eq!(ctx.pending_tasks_guardrail_count, 2); }); } #[test] fn handle_cancel_resets_guardrail_counter() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); ctx.pending_tasks_guardrail_count = 2; let result = run_async(handle_cancel(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["status"], "ok"); assert_eq!(ctx.pending_tasks_guardrail_count, 0); } #[test] fn handle_spawn_missing_agent_arg_errors() { let mut ctx = ctx_with_supervisor(4, 3); let err = run_async(handle_spawn(&mut ctx, &json!({}))).unwrap_err(); assert!(err.to_string().contains("'agent' is required")); } #[test] fn handle_spawn_missing_prompt_arg_errors() { let mut ctx = ctx_with_supervisor(4, 3); let err = run_async(handle_spawn(&mut ctx, &json!({"agent": "explore"}))).unwrap_err(); assert!(err.to_string().contains("'prompt' is required")); } #[test] fn handle_spawn_rejects_agent_outside_whitelist() { let mut ctx = ctx_with_supervisor(4, 3); ctx.agent = Some(Agent::test_new(AgentConfig { spawnable_agents: Some(vec!["allowed".into()]), ..Default::default() })); let result = run_async(handle_spawn( &mut ctx, &json!({"agent": "notallowed", "prompt": "p"}), )) .unwrap(); assert_eq!(result["status"], "error"); assert!( result["message"] .as_str() .unwrap() .contains("spawnable_agents") ); } #[test] fn handle_spawn_at_capacity_errors() { let mut ctx = ctx_with_supervisor(1, 3); register_fake_agent(&mut ctx, "a1", "explore"); let result = run_async(handle_spawn( &mut ctx, &json!({"agent": "x", "prompt": "p"}), )) .unwrap(); assert_eq!(result["status"], "error"); assert_eq!( result["message"], "At capacity: 1/1 agents running. Wait for one to finish or cancel one." ); } #[test] fn handle_spawn_exceeding_depth_errors() { let mut ctx = ctx_with_supervisor(4, 0); let result = run_async(handle_spawn( &mut ctx, &json!({"agent": "x", "prompt": "p"}), )) .unwrap(); assert_eq!(result["status"], "error"); assert!( result["message"] .as_str() .unwrap() .contains("Max agent depth exceeded") ); } #[test] fn handle_spawn_no_supervisor_errors() { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let err = run_async(handle_spawn( &mut ctx, &json!({"agent": "x", "prompt": "p"}), )) .unwrap_err(); assert!(err.to_string().contains("No supervisor active")); } /// Checking a finished agent is a pure status probe: it reports the /// agent as finished, points at agent__collect, and leaves the handle /// registered so a subsequent collect still returns the result. #[test] fn handle_check_finished_agent_reports_status_and_keeps_handle() { let mut ctx = ctx_with_supervisor(4, 3); register_fake_agent(&mut ctx, "a1", "explore"); wait_until_finished(&ctx, "a1"); let result = run_async(handle_check(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(result["status"], "finished"); assert_eq!(result["id"], "a1"); assert!( result["message"] .as_str() .unwrap() .contains("agent__collect") ); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("a1"), Some(true) ); let collected = run_async(handle_collect(&mut ctx, &json!({"id": "a1"}))).unwrap(); assert_eq!(collected["status"], "completed"); assert_eq!(collected["output"], "fake output"); } #[test] fn handle_cancel_running_agent_aborts_and_waits_for_cleanup() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_supervisor(4, 3); let sig = create_abort_signal(); let sig2 = sig.clone(); let join_handle = tokio::spawn(async move { loop { if sig2.aborted() { return Ok(AgentResult { id: "a1".into(), agent_name: "explore".into(), output: String::new(), exit_status: AgentExitStatus::Completed, }); } time::sleep(Duration::from_millis(10)).await; } }); let handle = AgentHandle { id: "a1".into(), agent_name: "explore".into(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: sig.clone(), join_handle, child_supervisor: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); ctx.pending_tasks_guardrail_count = 2; let result = handle_cancel(&mut ctx, &json!({"id": "a1"})).await.unwrap(); assert_eq!(result["status"], "ok"); let message = result["message"].as_str().unwrap(); assert!(message.contains("Cancelled agent 'explore'")); assert!(message.contains("waited for cleanup")); assert!(sig.aborted()); assert_eq!( ctx.supervisor.as_ref().unwrap().read().is_finished("a1"), None ); assert_eq!(ctx.pending_tasks_guardrail_count, 0); }); } #[test] fn handle_check_registered_job_id_teaches_job_tools() { run_async(async { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "bg_1"); let result = handle_check(&mut ctx, &json!({"id": "bg_1"})) .await .unwrap(); assert_job_teaching_error(&result, "bg_1"); }); } #[test] fn handle_check_job_prefixed_id_teaches_job_tools() { run_async(async { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_check(&mut ctx, &json!({"id": "job_deadbeef"})) .await .unwrap(); assert_job_teaching_error(&result, "job_deadbeef"); }); } #[test] fn handle_collect_registered_job_id_teaches_job_tools() { run_async(async { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "bg_1"); let result = handle_collect(&mut ctx, &json!({"id": "bg_1"})) .await .unwrap(); assert_job_teaching_error(&result, "bg_1"); }); } #[test] fn handle_collect_job_prefixed_id_teaches_job_tools() { run_async(async { let mut ctx = ctx_with_supervisor(4, 3); let result = handle_collect(&mut ctx, &json!({"id": "job_deadbeef"})) .await .unwrap(); assert_job_teaching_error(&result, "job_deadbeef"); }); } #[test] fn handle_cancel_registered_job_id_teaches_job_tools_and_keeps_job() { run_async(async { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "bg_1"); let result = handle_cancel(&mut ctx, &json!({"id": "bg_1"})) .await .unwrap(); assert_job_teaching_error(&result, "bg_1"); assert!(ctx.supervisor.as_ref().unwrap().read().has_job("bg_1")); }); } #[test] fn handle_send_message_registered_job_id_teaches_job_tools() { run_async(async { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "bg_1"); let result = handle_send_message(&mut ctx, &json!({"id": "bg_1", "message": "hi"})).unwrap(); assert_job_teaching_error(&result, "bg_1"); }); } #[test] fn handle_send_message_job_in_parent_supervisor_teaches_job_tools() { run_async(async { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let mut parent_sup = Supervisor::new(4, 3).with_max_concurrent_jobs(4); parent_sup.register(make_fake_job("bg_p")).unwrap(); ctx.parent_supervisor = Some(Arc::new(RwLock::new(parent_sup))); let result = handle_send_message(&mut ctx, &json!({"id": "bg_p", "message": "hi"})).unwrap(); assert_job_teaching_error(&result, "bg_p"); }); } #[test] fn guardrail_burns_bounded_injects_then_force_terminates_running_job() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { let mut ctx = ctx_with_job_capable_supervisor(); let abort = create_abort_signal(); let join_handle = tokio::spawn(async { time::sleep(Duration::from_secs(60)).await; Ok(JobResult { output: json!(null), exit_code: Some(0), output_bytes_captured: 0, }) }); let handle = JobHandle { id: "job_1".to_string(), tool: "execute_command".to_string(), started_at: std::time::Instant::now(), join_handle, abort_signal: abort.clone(), state: Arc::new(Mutex::new(JobState { status: JobStatus::Running, pgid: None, })), output_buf: Arc::new(Mutex::new(RingBuf::default())), no_change_checks: 0, last_check_state: None, }; ctx.supervisor .as_ref() .unwrap() .write() .register(handle) .unwrap(); for expected_count in 1..=PENDING_TASKS_GUARDRAIL_MAX { match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::Inject(prompt) => assert!(prompt.contains("job_1")), _ => panic!("expected Inject below max"), } assert_eq!(ctx.pending_tasks_guardrail_count, expected_count); } match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::ForceTerminate(ids) => { assert_eq!(ids, vec!["job_1".to_string()]); } _ => panic!("expected ForceTerminate at max"), } assert_eq!(ctx.pending_tasks_guardrail_count, 0); assert!(abort.aborted()); }); } #[test] fn guardrail_force_terminate_discards_finished_uncollected_job() { let mut ctx = ctx_with_job_capable_supervisor(); register_fake_job(&mut ctx, "job_1"); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !pending_tasks(&ctx).iter().any(|t| t.finished) { assert!( std::time::Instant::now() < deadline, "job 'job_1' never finished" ); std::thread::sleep(Duration::from_millis(10)); } ctx.pending_tasks_guardrail_count = PENDING_TASKS_GUARDRAIL_MAX; match check_pending_tasks_guardrail(&mut ctx) { GuardrailAction::ForceTerminate(ids) => { assert_eq!(ids, vec!["job_1".to_string()]); } _ => panic!("expected ForceTerminate action"), } assert_eq!(ctx.pending_tasks_guardrail_count, 0); assert!(!ctx.supervisor.as_ref().unwrap().read().has_job("job_1")); } }