use crate::approval_flow::request_approval;
use crate::config::KodaConfig;
use crate::db::{Database, Role};
use crate::engine::{ApprovalDecision, EngineCommand, EngineEvent};
use crate::memory;
use crate::persistence::Persistence;
use crate::preview;
use crate::prompt::build_system_prompt;
use crate::providers::{ChatMessage, ToolCall};
use crate::sub_agent_cache::SubAgentCache;
use crate::tool_dispatch::execute_one_tool;
use crate::tools::{self, ToolRegistry};
use crate::trust::{self, ToolApproval, TrustMode, derive_child_trust};
use crate::turn_context::ToolExecutionContext;
use anyhow::{Context, Result};
use koda_sandbox::{CwdProvider, GitWorktreeProvider, WorkspaceProvider};
#[cfg(target_os = "macos")]
use koda_sandbox::ClonefileProvider;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
static NEXT_INVOCATION_ID: AtomicU32 = AtomicU32::new(1);
pub(crate) const DEFAULT_SUB_AGENT_MAX_TURNS: u32 = 30;
pub(crate) fn next_invocation_id() -> u32 {
NEXT_INVOCATION_ID.fetch_add(1, Ordering::Relaxed)
}
struct InvocationCleanup<'a> {
bg: &'a std::sync::Arc<crate::child_agent::ChildAgentRegistry>,
invocation_id: u32,
}
impl Drop for InvocationCleanup<'_> {
fn drop(&mut self) {
let cancelled = self.bg.cancel_for_spawner(self.invocation_id);
if cancelled > 0 {
tracing::debug!(
spawner = self.invocation_id,
cancelled,
"execute_sub_agent exit: cancelled orphaned bg agents",
);
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_bg_agent(
project_root: std::path::PathBuf,
parent_config: KodaConfig,
db: Database,
arguments: String,
sub_agent_cache: SubAgentCache,
parent_session: String,
tx: tokio::sync::oneshot::Sender<
Result<crate::child_agent::BgPayload, crate::child_agent::BgPayload>,
>,
cancel: CancellationToken,
parent_trust: TrustMode,
parent_sandbox_policy: koda_sandbox::SandboxPolicy,
emitter: crate::child_agent::ChildStatusEmitter,
) {
emitter.send(crate::child_agent::AgentStatus::Running { iter: 0 });
let (_, mut cmd_rx) = mpsc::channel(1);
let buffering_sink = crate::engine::sink::ForwardingChildSink::new(
crate::engine::sink::BufferingSink::new(),
emitter.clone(),
);
let nested_bg = crate::child_agent::new_shared();
let mut sync_args: serde_json::Value = serde_json::from_str(&arguments).unwrap_or_default();
sync_args["background"] = serde_json::Value::Bool(false);
let sync_arguments = serde_json::to_string(&sync_args).unwrap();
let cancel_for_status = cancel.clone();
let placeholder_tools =
crate::tools::ToolRegistry::new(project_root.clone(), parent_config.max_context_tokens);
let bg_turn = crate::turn_context::TurnContext::new(
&project_root,
&parent_config,
&db,
&parent_session,
&buffering_sink,
cancel,
&sub_agent_cache,
&nested_bg,
parent_trust,
&placeholder_tools,
);
let bg_tx = crate::turn_context::ToolExecutionContext::new(&bg_turn, None);
let result = execute_sub_agent(
bg_tx,
&sync_arguments,
&mut cmd_rx,
None,
&parent_sandbox_policy,
Some(emitter.clone()),
None,
)
.await;
let events = buffering_sink.take_lines();
match &result {
Ok(output) => {
emitter.send(crate::child_agent::AgentStatus::Completed {
summary: output.clone(),
});
}
Err(e) => {
let status = if cancel_for_status.is_cancelled() {
crate::child_agent::AgentStatus::Cancelled
} else {
crate::child_agent::AgentStatus::Errored {
error: e.to_string(),
}
};
emitter.send(status);
}
}
let _ = match result {
Ok(output) => tx.send(Ok((output, events))),
Err(e) => tx.send(Err((format!("Error: {e:#}"), events))),
};
}
pub(crate) fn parse_agent_name_required(
args: &serde_json::Value,
project_root: &std::path::Path,
) -> anyhow::Result<String> {
let raw = match args.get("agent_name") {
Some(serde_json::Value::String(s)) => s,
Some(serde_json::Value::Null) | None => {
anyhow::bail!(
"InvokeAgent: 'agent_name' is required \u{2014} no default. {hint}",
hint = available_agents_hint(project_root),
);
}
Some(other) => {
let kind = match other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
serde_json::Value::String(_) => unreachable!("matched above"),
};
anyhow::bail!(
"InvokeAgent: 'agent_name' must be a string, got {kind}. {hint}",
hint = available_agents_hint(project_root),
);
}
};
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!(
"InvokeAgent: 'agent_name' must be a non-empty string. {hint}",
hint = available_agents_hint(project_root),
);
}
Ok(trimmed.to_string())
}
fn available_agents_hint(project_root: &std::path::Path) -> String {
let mut names: Vec<String> = crate::tools::agent::discover_all_agents(project_root)
.into_iter()
.map(|a| a.name)
.collect();
names.push("fork".to_string());
names.sort();
names.dedup();
if names.len() == 1 {
format!("Available agent: {}.", names[0])
} else {
format!(
"Available agents (call ListAgents for descriptions): {}.",
names.join(", "),
)
}
}
pub(crate) fn parse_background_required(args: &serde_json::Value) -> anyhow::Result<bool> {
match args.get("background") {
Some(serde_json::Value::Bool(b)) => Ok(*b),
Some(other) => {
let kind = match other {
serde_json::Value::Null => "null",
serde_json::Value::String(_) => "a string",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
serde_json::Value::Bool(_) => unreachable!("matched above"),
};
anyhow::bail!(
"InvokeAgent: 'background' must be a boolean (true or false), got {kind}. \
Pick `true` for parallel/independent work, `false` when the next \
step strictly depends on this agent's output."
);
}
None => anyhow::bail!(
"InvokeAgent: 'background' is required — no default. \
Pick `true` for parallel fan-out / independent long-running tasks (results \
auto-inject on a future iteration), or `false` when the next step strictly \
depends on this agent's output and no parallel work is possible. \
See the 'background' parameter description for the full rationale."
),
}
}
#[tracing::instrument(skip_all, fields(agent_name, cached = false))]
pub(crate) fn execute_sub_agent<'a>(
parent_tx: ToolExecutionContext<'a>,
arguments: &'a str,
cmd_rx: &'a mut mpsc::Receiver<EngineCommand>,
parent_cache: Option<crate::tools::FileReadCache>,
parent_sandbox_policy: &'a koda_sandbox::SandboxPolicy,
emitter: Option<crate::child_agent::ChildStatusEmitter>,
parent_tool_call_id: Option<&'a str>,
) -> impl std::future::Future<Output = Result<String>> + Send + 'a {
async move {
let crate::turn_context::TurnContext {
project_root,
config: parent_config,
db,
session_id: parent_session_id,
sink,
sub_agent_cache,
bg_agents,
mode,
..
} = *parent_tx.turn;
let cancel = parent_tx.turn.cancel.clone();
let parent_spawner = parent_tx.caller_spawner;
let my_invocation_id = next_invocation_id();
let args: serde_json::Value = serde_json::from_str(arguments)?;
let agent_name_owned = parse_agent_name_required(&args, project_root)?;
let agent_name = agent_name_owned.as_str();
tracing::Span::current().record("agent_name", agent_name);
let prompt = args["prompt"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'prompt'"))?;
let is_fork = agent_name == "fork";
let background = parse_background_required(&args)?;
if background {
let reservation = bg_agents.reserve(&cancel, parent_spawner, true);
let task_id = reservation.task_id;
let bg_cancel = reservation.cancel.clone();
let bg_tx = reservation.tx;
let bg_rx = reservation.rx;
let entry_cancel = reservation.cancel;
let emitter = crate::child_agent::ChildStatusEmitter::new(
task_id,
parent_spawner,
true,
reservation.status_tx,
bg_agents.clone(),
);
let entry_status_rx = reservation.status_rx;
let project_root_owned = project_root.to_path_buf();
let parent_config_owned = parent_config.clone();
let agent_name_owned = agent_name.to_string();
let prompt_owned = prompt.to_string();
let arguments_owned = arguments.to_string();
let sub_agent_cache_owned = sub_agent_cache.clone();
let parent_session_owned = parent_session_id.to_string();
let bg_db = db.clone();
let bg_policy = parent_sandbox_policy.clone();
let bg_trust = mode;
sink.emit(EngineEvent::Info {
message: format!(
" \u{1f680} {agent_name} launched in background (task {task_id})"
),
});
let handle = tokio::spawn(run_bg_agent(
project_root_owned,
parent_config_owned,
bg_db,
arguments_owned,
sub_agent_cache_owned,
parent_session_owned,
bg_tx,
bg_cancel,
bg_trust,
bg_policy,
emitter,
));
bg_agents.attach(
task_id,
&agent_name_owned,
&prompt_owned,
bg_rx,
entry_cancel,
entry_status_rx,
parent_spawner,
true,
parent_tool_call_id.map(str::to_string),
handle,
);
return Ok(format!(
"Background agent '{agent_name_owned}' started (agent:{task_id}). \
Results will be injected when complete."
));
}
let _cleanup = InvocationCleanup {
bg: bg_agents,
invocation_id: my_invocation_id,
};
if let Some(cached) = sub_agent_cache.get(agent_name, prompt) {
sink.emit(EngineEvent::Info {
message: format!(" \u{26a1} {agent_name}: cache hit, skipping LLM call"),
});
tracing::Span::current().record("cached", true);
return Ok(cached);
}
let (fg_emitter, _fg_guard) = bg_agents.register_fg_with_emitter(
agent_name,
prompt,
&cancel,
parent_spawner,
parent_tool_call_id.map(str::to_string),
);
fg_emitter.send(crate::child_agent::AgentStatus::Pending);
let fg_sink = crate::engine::sink::FgForwardingSink::new(sink, fg_emitter.clone());
let sink: &dyn crate::engine::EngineSink = &fg_sink;
fg_emitter.send(crate::child_agent::AgentStatus::Running { iter: 0 });
sink.emit(EngineEvent::SubAgentStart {
agent_name: agent_name.to_string(),
});
let (sub_config, max_turns) = if is_fork {
let mut cfg = parent_config.clone();
cfg.trust = derive_child_trust(mode, mode);
assert!(
cfg.trust == mode,
"fork must inherit parent's runtime trust mode exactly"
);
let max_turns = cfg.max_iterations;
(cfg, max_turns)
} else {
let raw = crate::config::KodaConfig::load_agent_json(project_root, agent_name)
.with_context(|| format!("Failed to load sub-agent: {agent_name}"))?;
let mut cfg = crate::config::KodaConfig::load(project_root, agent_name)
.with_context(|| format!("Failed to load sub-agent: {agent_name}"))?;
let agent_has_own_provider = raw.provider.is_some() || raw.base_url.is_some();
if !agent_has_own_provider {
let model_override = raw.model.is_none().then(|| parent_config.model.clone());
cfg = cfg.with_overrides(
Some(parent_config.base_url.clone()),
model_override,
Some(parent_config.provider_type.to_string()),
);
}
let child_trust = cfg.trust;
cfg.trust = derive_child_trust(mode, cfg.trust);
if cfg.trust != child_trust {
tracing::info!(
agent = agent_name,
parent = %mode,
child = %child_trust,
effective = %cfg.trust,
"sub-agent trust clamped to match parent",
);
}
let max_turns = raw.max_iterations.unwrap_or(DEFAULT_SUB_AGENT_MAX_TURNS);
(cfg, max_turns)
};
let sub_session = {
let sid = db
.create_session(&sub_config.agent_name, project_root)
.await?;
if is_fork {
let parent_history = db.load_context(parent_session_id).await?;
db.copy_messages_into_session(&sid, &parent_history).await?
}
sid
};
db.insert_message(&sub_session, &Role::User, Some(prompt), None, None, None)
.await?;
let provider = crate::providers::create_provider(&sub_config);
let has_write_tools = !sub_config
.disallowed_tools
.iter()
.any(|t| t == "Write" || t == "Edit");
let workspace: Box<dyn WorkspaceProvider + Send + Sync> = if has_write_tools {
pick_write_provider(project_root, agent_name)
} else {
Box::new(CwdProvider::new(project_root))
};
let effective_root = match workspace.provision(&sub_session).await {
Ok(path) => {
if path != project_root {
sink.emit(EngineEvent::Info {
message: format!(" \u{1f333} {agent_name}: isolated in worktree"),
});
}
path
}
Err(e) if has_write_tools => {
let reason = e.to_string();
tracing::warn!("Workspace provision failed for sub-agent '{agent_name}': {reason}");
sink.emit(EngineEvent::Info {
message: format!(
" \u{26a0}\u{fe0f} {agent_name}: workspace isolation failed, not dispatching ({reason})"
),
});
let marker = workspace_provision_failure_marker(agent_name, &reason);
sub_agent_cache.put(agent_name, prompt, &marker);
return Ok(marker);
}
Err(e) => {
tracing::warn!(
"Workspace provision failed (read-only sub-agent '{agent_name}'): {e}"
);
project_root.to_path_buf()
}
};
let effective_root_ref = effective_root.as_path();
let tools = {
let registry = ToolRegistry::with_trust(
effective_root.clone(),
sub_config.max_context_tokens,
sub_config.trust,
);
let composed_policy = crate::sandbox::compose_child_policy(
parent_sandbox_policy,
sub_config.trust,
effective_root_ref,
);
let registry = registry.with_sandbox_policy(composed_policy);
match parent_cache {
Some(cache) => registry.with_shared_cache(cache),
None => registry,
}
};
let tool_defs = {
let mut denied = sub_config.disallowed_tools.clone();
if !denied.contains(&"InvokeAgent".to_string()) {
denied.push("InvokeAgent".to_string());
}
if !denied.contains(&"AskUser".to_string()) {
denied.push("AskUser".to_string());
}
tools.get_definitions(&sub_config.allowed_tools, &denied)
};
let semantic_memory = if sub_config.skip_memory {
String::new()
} else {
memory::load(project_root)?
};
let env = crate::prompt::EnvironmentInfo {
project_root: effective_root_ref,
model: &sub_config.model,
platform: std::env::consts::OS,
};
let system_prompt = build_system_prompt(
&sub_config.system_prompt,
&semantic_memory,
&env,
&[], &tools.skill_registry,
);
let sub_turn = crate::turn_context::TurnContext::new(
project_root,
&sub_config,
db,
&sub_session,
sink,
cancel.clone(),
sub_agent_cache,
bg_agents,
mode,
&tools,
);
let sub_tx =
crate::turn_context::ToolExecutionContext::new(&sub_turn, Some(my_invocation_id));
let mut grace_turn_done = false;
let preflight = crate::inference_helpers::estimate_subagent_preflight(
&system_prompt,
&tool_defs,
prompt,
sub_config.max_context_tokens,
);
tracing::debug!(
agent = agent_name,
preflight = %preflight.summary(),
"sub-agent context pre-flight"
);
if preflight.is_over_budget() {
sink.emit(EngineEvent::Info {
message: format!(
" \u{1f6d1} {agent_name}: context pre-flight failed ({})",
preflight.summary()
),
});
let _ = workspace.release(&sub_session, &effective_root).await;
anyhow::bail!(
"Sub-agent '{agent_name}' context exceeds model window: {summary}. \
Reduce the prompt, drop tools (set `disallowed_tools` on the agent), \
or pick a model with a larger context window.",
summary = preflight.summary(),
);
}
for iter in 1u32.. {
if let Some(ref e) = emitter {
e.send(crate::child_agent::AgentStatus::Running { iter });
}
if cancel.is_cancelled() {
let _ = workspace.release(&sub_session, &effective_root).await;
return Ok("[cancelled by parent]".to_string());
}
let history = db.load_context(&sub_session).await?;
let mut messages = vec![ChatMessage::text("system", &system_prompt)];
for msg in &history {
let tool_calls: Option<Vec<ToolCall>> = msg
.tool_calls
.as_deref()
.and_then(|tc| serde_json::from_str(tc).ok());
messages.push(ChatMessage {
role: msg.role.as_str().to_string(),
content: msg.content.clone(),
tool_calls,
tool_call_id: msg.tool_call_id.clone(),
images: None,
});
}
sink.emit(EngineEvent::SpinnerStart {
message: format!(" \u{1f9a5} {agent_name} thinking..."),
});
if iter > max_turns && !grace_turn_done {
grace_turn_done = true;
let warning = format!(
"You have reached the maximum number of turns ({max_turns}) for this \
sub-agent invocation. You have ONE final chance to complete the task. \
You MUST respond with your best answer NOW as plain text. DO NOT call \
any more tools \u{2014} any tool calls in this response will be ignored. \
Summarize what you found, what you would do next if you had more turns, \
and explain that your investigation was interrupted by the budget."
);
messages.push(ChatMessage::text("system", &warning));
tracing::warn!(
agent = agent_name,
iter,
max_turns,
"sub-agent reached max_turns; running grace turn"
);
sink.emit(EngineEvent::Info {
message: format!(
" \u{23f3} {agent_name}: reached max turns ({max_turns}); requesting final summary"
),
});
}
let response = provider
.chat(&messages, &tool_defs, &sub_config.model_settings)
.await?;
sink.emit(EngineEvent::SpinnerStop);
let tool_calls_json = if response.tool_calls.is_empty() {
None
} else {
Some(serde_json::to_string(&response.tool_calls)?)
};
let assistant_msg_id = db
.insert_message(
&sub_session,
&Role::Assistant,
response.content.as_deref(),
tool_calls_json.as_deref(),
None,
Some(&response.usage),
)
.await?;
db.mark_message_complete(assistant_msg_id).await?;
if grace_turn_done {
let result = match response.content.as_deref() {
Some(text) if !text.trim().is_empty() => text.to_string(),
_ => format!(
"[max_turns reached: agent exceeded the {max_turns}-turn budget \
and did not produce a final summary on its grace turn. \
{n} pending tool call(s) were dropped.]",
n = response.tool_calls.len(),
),
};
sub_agent_cache.put(agent_name, prompt, &result);
if let Ok(Some(hint)) = workspace.release(&sub_session, &effective_root).await {
sink.emit(EngineEvent::Info {
message: format!(" \u{1f335} {agent_name}: {hint}"),
});
}
return Ok(result);
}
if response.tool_calls.is_empty() {
let result = response
.content
.unwrap_or_else(|| "(no output)".to_string());
sub_agent_cache.put(agent_name, prompt, &result);
if let Ok(Some(hint)) = workspace.release(&sub_session, &effective_root).await {
sink.emit(EngineEvent::Info {
message: format!(" \u{1f335} {agent_name}: {hint}"),
});
}
return Ok(result);
}
for tc in &response.tool_calls {
sink.emit(EngineEvent::ToolCallStart {
id: tc.id.clone(),
name: tc.function_name.clone(),
args: serde_json::from_str(&tc.arguments).unwrap_or_default(),
is_sub_agent: true,
});
let parsed_args: serde_json::Value =
serde_json::from_str(&tc.arguments).unwrap_or_default();
if tc.function_name == "InvokeAgent" {
let refusal = "InvokeAgent is not available inside a sub-agent. \
Sub-agents are autonomous workers and cannot spawn \
further sub-agents. Complete the task directly with \
the tools you have, or report back what additional \
dispatch the parent agent should perform.";
db.insert_message(
&sub_session,
&Role::Tool,
Some(refusal),
None,
Some(&tc.id),
None,
)
.await?;
continue;
}
if tc.function_name == "AskUser" {
let refusal = "AskUser is not available inside a sub-agent. \
Sub-agents have no channel to the user; the parent \
agent gathers any required input before delegating. \
Proceed with the information you already have or \
report what's missing.";
db.insert_message(
&sub_session,
&Role::Tool,
Some(refusal),
None,
Some(&tc.id),
None,
)
.await?;
continue;
}
let validation_error = tools::validate::validate_with_registry(
&tools,
&tc.function_name,
&parsed_args,
effective_root_ref,
)
.await;
let output = if let Some(error) = validation_error {
format!("Validation error: {error}")
} else {
let approval = trust::check_tool_for_sub_agent(
&tc.function_name,
&parsed_args,
mode,
Some(effective_root_ref),
);
match approval {
ToolApproval::AutoApprove => {
let (_id, result, _success, _full) = execute_one_tool(tc, sub_tx).await;
result
}
ToolApproval::Blocked => {
let detail = tools::describe_action(&tc.function_name, &parsed_args);
let diff_preview = preview::compute(
&tc.function_name,
&parsed_args,
effective_root_ref,
)
.await;
sink.emit(EngineEvent::ActionBlocked {
tool_name: tc.function_name.clone(),
detail,
preview: diff_preview,
});
"[safe mode] Action blocked.".to_string()
}
ToolApproval::NeedsConfirmation => {
let detail = tools::describe_action(&tc.function_name, &parsed_args);
let diff_preview = preview::compute(
&tc.function_name,
&parsed_args,
effective_root_ref,
)
.await;
let effect = crate::trust::resolve_tool_effect_with_registry(
&tc.function_name,
&parsed_args,
&tools,
);
match request_approval(
sink,
cmd_rx,
&cancel,
&tc.function_name,
&detail,
diff_preview,
effect,
)
.await
{
Some(ApprovalDecision::Approve) => {
let (_id, result, _success, _full) =
execute_one_tool(tc, sub_tx).await;
result
}
Some(ApprovalDecision::Reject) => "[rejected by user]".to_string(),
Some(ApprovalDecision::RejectWithFeedback { feedback }) => {
format!("[rejected: {feedback}]")
}
Some(ApprovalDecision::RejectAuto { reason }) => {
format!("[auto-rejected: {reason}]")
}
None => {
if cancel.is_cancelled() {
"[cancelled]".to_string()
} else {
format!(
"[auto-rejected: '{tool}' requires user \
confirmation but this sub-agent has no \
channel to the user. The parent agent \
must pre-approve destructive operations \
or run the tool itself.]",
tool = tc.function_name,
)
}
}
}
}
}
};
db.insert_message(
&sub_session,
&Role::Tool,
Some(&output),
None,
Some(&tc.id),
None,
)
.await?;
}
}
unreachable!("sub-agent loop exits via return; the iteration range is unbounded");
}
}
#[cfg(target_os = "macos")]
fn pick_write_provider(
project_root: &std::path::Path,
agent_name: &str,
) -> Box<dyn WorkspaceProvider + Send + Sync> {
match ClonefileProvider::new(project_root) {
Ok(p) => Box::new(p),
Err(e) => {
tracing::warn!("ClonefileProvider unavailable, falling back to git worktree: {e}");
Box::new(GitWorktreeProvider::new(project_root, agent_name))
}
}
}
#[cfg(not(target_os = "macos"))]
fn pick_write_provider(
project_root: &std::path::Path,
agent_name: &str,
) -> Box<dyn WorkspaceProvider + Send + Sync> {
Box::new(GitWorktreeProvider::new(project_root, agent_name))
}
fn workspace_provision_failure_marker(agent_name: &str, reason: &str) -> String {
format!(
"[ERROR: sub-agent '{agent_name}' could not provision an isolated workspace and was not \
dispatched, to avoid corrupting the parent project tree (reason: {reason}). Either \
resolve the workspace setup issue, retry without write tools, or attempt the work \
directly without delegating.]"
)
}
#[cfg(test)]
mod b21_tests {
use super::workspace_provision_failure_marker;
#[test]
fn marker_has_error_prefix() {
let m = workspace_provision_failure_marker("writer", "clonefile: ENOTSUP");
assert!(
m.starts_with("[ERROR:"),
"marker must start with `[ERROR:` so the model treats it as \
structural failure, not a sub-agent answer; got: {m}"
);
}
#[test]
fn marker_includes_agent_name() {
let m = workspace_provision_failure_marker("writer", "clonefile: ENOTSUP");
assert!(
m.contains("'writer'"),
"marker must name the sub-agent that failed so multi-agent \
flows can disambiguate; got: {m}"
);
}
#[test]
fn marker_includes_failure_reason() {
let m = workspace_provision_failure_marker("writer", "clonefile: ENOTSUP");
assert!(
m.contains("clonefile: ENOTSUP"),
"marker must include the failure reason verbatim so it's \
diagnosable; got: {m}"
);
}
#[test]
fn marker_does_not_silently_dispatch() {
let m = workspace_provision_failure_marker("writer", "x");
let lower = m.to_lowercase();
assert!(
lower.contains("not dispatched") || lower.contains("was not dispatched"),
"marker must state the sub-agent was not dispatched, so the \
parent doesn't assume the work happened; got: {m}"
);
}
#[test]
fn marker_includes_restrategize_hint() {
let m = workspace_provision_failure_marker("writer", "x");
let lower = m.to_lowercase();
assert!(
lower.contains("directly") || lower.contains("resolve") || lower.contains("retry"),
"marker must hint at re-strategizing (e.g. resolve setup, \
retry without write tools, do directly); got: {m}"
);
}
#[test]
fn marker_is_single_line() {
let m = workspace_provision_failure_marker("writer", "clonefile: ENOTSUP");
assert!(
!m.contains('\n'),
"marker must be single-line for clean tool-result formatting; got:\n{m}"
);
}
}
#[cfg(test)]
mod invocation_cleanup_tests {
use super::InvocationCleanup;
use crate::child_agent::ChildAgentRegistry;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drop_cancels_entries_tagged_with_matching_spawner() {
let reg = Arc::new(ChildAgentRegistry::new());
let (_id_a, _tx_a, _status_tx_a, cancel_a) =
reg.register_test_with_status("scout", "a", Some(7));
let (_id_b, _tx_b, _status_tx_b, cancel_b) =
reg.register_test_with_status("scout", "b", Some(7));
assert!(
!cancel_a.is_cancelled() && !cancel_b.is_cancelled(),
"setup"
);
drop(InvocationCleanup {
bg: ®,
invocation_id: 7,
});
assert!(
cancel_a.is_cancelled(),
"entry A's cancel token must fire when guard drops"
);
assert!(
cancel_b.is_cancelled(),
"entry B's cancel token must fire when guard drops"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drop_leaves_entries_with_different_spawner_alone() {
let reg = Arc::new(ChildAgentRegistry::new());
let (_id_mine, _tx_m, _status_tx_m, cancel_mine) =
reg.register_test_with_status("a", "mine", Some(7));
let (_id_sib, _tx_s, _status_tx_s, cancel_sibling) =
reg.register_test_with_status("a", "sibling", Some(8));
let (_id_top, _tx_t, _status_tx_t, cancel_toplevel) =
reg.register_test_with_status("a", "toplevel", None);
drop(InvocationCleanup {
bg: ®,
invocation_id: 7,
});
assert!(
cancel_mine.is_cancelled(),
"my own (spawner=7) entry must be cancelled"
);
assert!(
!cancel_sibling.is_cancelled(),
"sibling sub-agent's (spawner=8) entry must NOT be cancelled"
);
assert!(
!cancel_toplevel.is_cancelled(),
"top-level (spawner=None) entry must NOT be cancelled"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drop_with_no_matching_entries_is_noop() {
let reg = Arc::new(ChildAgentRegistry::new());
let (_id, _tx, _status_tx, cancel) = reg.register_test_with_status("a", "x", Some(99));
drop(InvocationCleanup {
bg: ®,
invocation_id: 7,
});
assert!(
!cancel.is_cancelled(),
"unrelated entry's cancel token must not fire"
);
}
}
#[cfg(test)]
mod parse_background_required_tests {
use super::parse_background_required;
use serde_json::json;
#[test]
fn accepts_literal_true() {
let v = parse_background_required(&json!({"background": true})).expect("ok");
assert!(v);
}
#[test]
fn accepts_literal_false() {
let v = parse_background_required(&json!({"background": false})).expect("ok");
assert!(!v);
}
#[test]
fn missing_field_errors_with_actionable_message() {
let err = parse_background_required(&json!({"prompt": "x"}))
.expect_err("missing background must be an error");
let msg = format!("{err:#}");
assert!(
msg.contains("'background' is required"),
"error must name the field; got: {msg}"
);
assert!(msg.contains("`true`"), "error must mention `true` option");
assert!(msg.contains("`false`"), "error must mention `false` option");
}
#[test]
fn string_value_rejected_with_type_hint() {
let err = parse_background_required(&json!({"background": "true"}))
.expect_err("string must be an error");
let msg = format!("{err:#}");
assert!(
msg.contains("must be a boolean"),
"error must explain the type requirement; got: {msg}"
);
assert!(
msg.contains("a string"),
"error must name the actual offending type; got: {msg}"
);
}
#[test]
fn number_value_rejected() {
let err = parse_background_required(&json!({"background": 1}))
.expect_err("number must be an error");
assert!(format!("{err:#}").contains("a number"));
}
#[test]
fn null_value_rejected() {
let err = parse_background_required(&json!({"background": null}))
.expect_err("null must be an error");
assert!(format!("{err:#}").contains("null"));
}
#[test]
fn array_and_object_rejected() {
for v in [json!({"background": []}), json!({"background": {}})] {
let err = parse_background_required(&v).expect_err("collection must be an error");
let msg = format!("{err:#}");
assert!(
msg.contains("must be a boolean"),
"non-bool collection must surface type-mismatch; got: {msg}"
);
}
}
}
#[cfg(test)]
mod parse_agent_name_required_tests {
use super::{available_agents_hint, parse_agent_name_required};
use serde_json::json;
use std::path::Path;
fn root() -> &'static Path {
Path::new(".")
}
#[test]
fn accepts_well_formed_string() {
let name = parse_agent_name_required(&json!({"agent_name": "explore"}), root())
.expect("explore must parse");
assert_eq!(name, "explore");
}
#[test]
fn trims_surrounding_whitespace() {
let name = parse_agent_name_required(&json!({"agent_name": " explore "}), root())
.expect("trimmed string must parse");
assert_eq!(name, "explore");
}
#[test]
fn rejects_missing_field_with_actionable_hint() {
let err = parse_agent_name_required(&json!({"prompt": "x"}), root())
.expect_err("missing agent_name must fail")
.to_string();
assert!(
err.contains("'agent_name' is required"),
"missing-field error must name the field: {err}"
);
assert!(
err.contains("Available agent"),
"error must list available agents: {err}"
);
assert!(err.contains("task"), "hint must list `task`: {err}");
}
#[test]
fn rejects_explicit_null() {
let err = parse_agent_name_required(&json!({"agent_name": null}), root())
.expect_err("null agent_name must fail")
.to_string();
assert!(err.contains("'agent_name' is required"), "got: {err}");
}
#[test]
fn rejects_empty_string() {
for empty in ["", " ", "\t\n"] {
let err = parse_agent_name_required(&json!({"agent_name": empty}), root())
.expect_err("empty agent_name must fail")
.to_string();
assert!(
err.contains("non-empty"),
"empty {empty:?} must produce 'non-empty' error: {err}"
);
}
}
#[test]
fn rejects_wrong_types() {
for (value, expected_kind) in [
(json!({"agent_name": true}), "a boolean"),
(json!({"agent_name": 42}), "a number"),
(json!({"agent_name": ["explore"]}), "an array"),
(json!({"agent_name": {"name": "explore"}}), "an object"),
] {
let err = parse_agent_name_required(&value, root())
.expect_err("wrong-type agent_name must fail")
.to_string();
assert!(
err.contains(expected_kind),
"got: {err} — expected to mention {expected_kind:?}"
);
assert!(
err.contains("Available"),
"wrong-type error must also surface the available list: {err}"
);
}
}
#[test]
fn hint_lists_builtins_and_fork() {
let hint = available_agents_hint(root());
for name in ["explore", "plan", "task", "verify", "fork"] {
assert!(hint.contains(name), "hint must list `{name}`: {hint}");
}
let fork_count = hint.matches("fork").count();
assert_eq!(fork_count, 1, "`fork` must not be duplicated: {hint}");
}
}
#[cfg(test)]
mod error_chain_format_tests {
use anyhow::{Context, Error};
#[test]
fn alt_display_walks_chain_in_order_root_first_topmost_last() {
let e: Error = Err::<(), _>(anyhow::anyhow!("connection refused"))
.context("error sending request for url")
.context("Failed to call LLM API")
.unwrap_err();
assert_eq!(format!("{e}"), "Failed to call LLM API");
assert_eq!(
format!("{e:#}"),
"Failed to call LLM API: error sending request for url: connection refused"
);
}
#[test]
fn fg_dispatch_error_string_format_contract() {
let e: Error = Err::<(), _>(anyhow::anyhow!("timed out"))
.context("Failed to call LLM API")
.unwrap_err();
let formatted = format!("Error invoking sub-agent: {e:#}");
assert_eq!(
formatted,
"Error invoking sub-agent: Failed to call LLM API: timed out"
);
}
#[test]
fn bg_dispatch_error_string_format_contract() {
let e: Error = Err::<(), _>(anyhow::anyhow!("dns lookup failed"))
.context("error sending request for url")
.context("Failed to call LLM API (stream)")
.unwrap_err();
let formatted = format!("Error: {e:#}");
assert_eq!(
formatted,
"Error: Failed to call LLM API (stream): error sending request for url: dns lookup failed"
);
}
#[test]
fn single_layer_error_unchanged_by_alt_format() {
let e: Error = anyhow::anyhow!(
"LLM API returned 400 Bad Request: {{\"error\":\"Context size has been exceeded.\"}}"
);
assert_eq!(format!("{e}"), format!("{e:#}"));
}
}