use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde_json::Value;
use tokio::sync::{Semaphore, mpsc};
use tokio_util::sync::CancellationToken;
use crate::domain::{
Msg, State, TokenUsageTotals, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata,
TurnState, update,
};
use crate::effect::{EffectRunner, MSG_CHANNEL_CAPACITY};
use crate::models::MessageRole;
use crate::providers::ProviderFactory;
use crate::providers::ctx::{ExecContext, ProgressEvent, SubagentPhase};
use crate::runtime::SafetyMode;
use super::ToolExecutor;
use super::ToolRegistry;
pub const MAX_INFLIGHT: usize = 10;
pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
pub const MAX_CACHED_AGENTS: usize = 8;
const EXPLORE_PREAMBLE: &str = "\
## Explore Agent
You are an Explore agent: read-only reconnaissance. Locate files, map \
structure, and extract exactly the facts asked for, using reads and \
read-only commands. You cannot mutate anything — do not try. Report \
concrete paths, names, and findings.";
const CHILD_TOOL_NAMES: &[&str] = &[
"read_file",
"write_file",
"apply_patch",
"delete_file",
"create_directory",
"execute_command",
"web_search",
"web_fetch",
"mcp",
];
#[derive(Debug)]
struct AgentType {
name: String,
tools: Option<Vec<String>>,
safety_ceiling: SafetyMode,
preamble: Option<String>,
model: Option<String>,
}
impl AgentType {
fn allows_tool(&self, name: &str) -> bool {
self.tools
.as_ref()
.is_none_or(|tools| tools.iter().any(|t| t == name))
}
}
fn builtin_agent_type(name: &str) -> Option<AgentType> {
match name {
"general" => Some(AgentType {
name: "general".to_string(),
tools: None,
safety_ceiling: SafetyMode::FullAccess,
preamble: None,
model: None,
}),
"explore" => Some(AgentType {
name: "explore".to_string(),
tools: Some(vec!["read_file".to_string(), "execute_command".to_string()]),
safety_ceiling: SafetyMode::ReadOnly,
preamble: Some(EXPLORE_PREAMBLE.to_string()),
model: None,
}),
_ => None,
}
}
fn resolve_agent_type(
requested: Option<&str>,
config: &crate::app::Config,
) -> Result<AgentType, String> {
let name = requested.unwrap_or("general");
if let Some(custom) = config.agents.types.get(name) {
let safety_ceiling = match custom.safety.as_deref() {
None => SafetyMode::FullAccess,
Some(s) => SafetyMode::parse(s).ok_or_else(|| {
format!(
"[agents.types.{name}] safety '{s}' is not one of \
read_only/ask/auto/full_access"
)
})?,
};
if let Some(tools) = &custom.tools
&& let Some(bad) = tools
.iter()
.find(|t| !CHILD_TOOL_NAMES.contains(&t.as_str()))
{
return Err(format!(
"[agents.types.{name}] unknown tool '{bad}'; valid tools: {}",
CHILD_TOOL_NAMES.join(", ")
));
}
return Ok(AgentType {
name: name.to_string(),
tools: custom.tools.clone(),
safety_ceiling,
preamble: custom.preamble.clone(),
model: custom.model.clone(),
});
}
builtin_agent_type(name).ok_or_else(|| {
let mut available: Vec<&str> = vec!["general", "explore"];
available.extend(config.agents.types.keys().map(String::as_str));
format!(
"unknown agent type '{name}'; available: {}",
available.join(", ")
)
})
}
struct CachedAgent {
state: State,
type_name: String,
}
#[derive(Default)]
struct AgentCache {
entries: HashMap<String, CachedAgent>,
order: VecDeque<String>,
}
pub struct SubagentSpawner {
providers: Arc<ProviderFactory>,
inflight: Arc<Semaphore>,
next_agent_id: AtomicU64,
cache: Mutex<AgentCache>,
detached_cancels: Mutex<HashMap<String, CancellationToken>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum KillResult {
Killed,
Evicted,
NotFound,
}
impl SubagentSpawner {
pub fn new(providers: Arc<ProviderFactory>) -> Self {
Self {
providers,
inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
next_agent_id: AtomicU64::new(0),
cache: Mutex::new(AgentCache::default()),
detached_cancels: Mutex::new(HashMap::new()),
}
}
fn mint_agent_id(&self) -> String {
format!(
"a{}",
self.next_agent_id.fetch_add(1, Ordering::Relaxed) + 1
)
}
fn cache_take(&self, id: &str) -> Option<CachedAgent> {
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
cache.order.retain(|x| x != id);
cache.entries.remove(id)
}
fn cache_store(&self, id: String, agent: CachedAgent) {
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
cache.order.retain(|x| x != &id);
cache.order.push_back(id.clone());
cache.entries.insert(id, agent);
while cache.entries.len() > MAX_CACHED_AGENTS {
let Some(oldest) = cache.order.pop_front() else {
break;
};
cache.entries.remove(&oldest);
}
}
fn register_detached(&self, agent_id: String, cancel: CancellationToken) {
self.detached_cancels
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(agent_id, cancel);
}
fn unregister_detached(&self, agent_id: &str) {
self.detached_cancels
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(agent_id);
}
pub fn kill_detached(&self, agent_id: &str) -> KillResult {
let cancel = self
.detached_cancels
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(agent_id);
if let Some(cancel) = cancel {
cancel.cancel();
return KillResult::Killed;
}
if self.cache_take(agent_id).is_some() {
return KillResult::Evicted;
}
KillResult::NotFound
}
pub fn kill_all_detached(&self) -> usize {
let cancels: Vec<CancellationToken> = {
let mut map = self
.detached_cancels
.lock()
.unwrap_or_else(|e| e.into_inner());
map.drain().map(|(_, c)| c).collect()
};
let n = cancels.len();
for cancel in cancels {
cancel.cancel();
}
n
}
}
pub struct SubagentTool {
spawner: Arc<SubagentSpawner>,
}
impl SubagentTool {
pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
Self { spawner }
}
}
#[async_trait]
impl ToolExecutor for SubagentTool {
fn name(&self) -> &'static str {
"agent"
}
fn schema(&self) -> ToolDefinition {
ToolDefinition {
name: "agent".to_string(),
description: format!(
"Spawn a child agent with its own context and tool access to work on an \
independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
calls in the same turn to run them concurrently) or for scoping a noisy \
sub-task (the child's tool output doesn't clutter the parent's turn). \
Types: 'general' (default — full tool access at your safety mode) and \
'explore' (read-only reconnaissance: locate files and extract facts, \
cannot mutate), plus any defined in config [agents.types]. Every result \
ends with an [agent_id: …] trailer; pass that id back as `agent_id` to \
send a follow-up prompt to the same child with its context intact (the \
{max_cached} most recent children are kept). Breadth-capped at \
{max_breadth} concurrent; subagents can't themselves spawn subagents \
and never get GUI (screenshot/click/…) access. A child moved to the \
background (the user detaches one with Ctrl+B) can be cancelled with \
action: \"kill\" plus its agent_id.",
max_cached = MAX_CACHED_AGENTS,
max_breadth = MAX_INFLIGHT,
),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["spawn", "kill"],
"description": "Default 'spawn' (also covers continuing via agent_id). 'kill' cancels a backgrounded child by agent_id — no prompt needed."
},
"prompt": {
"type": "string",
"description": "The task for the subagent (required unless action is 'kill'). Self-contained; the subagent has no access to the parent's conversation. When continuing via agent_id, this is the next user message to that child."
},
"description": {
"type": "string",
"description": "Short label shown in the parent's status line (e.g. 'list domain files')."
},
"type": {
"type": "string",
"description": "Agent type: 'general' (default), 'explore' (read-only recon), or a config-defined type. Ignored when continuing via agent_id — the child keeps the type it was built with."
},
"model": {
"type": "string",
"description": "Model id override for this child (e.g. 'ollama/qwen3:8b') — use a cheaper/faster model for search-and-summarize subtasks. Defaults to the type's model, else the session model."
},
"agent_id": {
"type": "string",
"description": "Continue a previous child (its conversation context is restored and `prompt` becomes its next user message) or, with action 'kill', the backgrounded child to cancel. Use the id from a prior result's [agent_id: …] trailer or the background notice."
}
},
"required": []
}),
}
}
async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
let started = Instant::now();
if args.get("action").and_then(|v| v.as_str()) == Some("kill") {
let Some(id) = args
.get("agent_id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return ToolOutcome::error("action 'kill' requires `agent_id`", 0.0);
};
return match self.spawner.kill_detached(id) {
KillResult::Killed => ToolOutcome::success(
format!(
"Background agent '{id}' cancelled — it unwinds at its next \
await point; a cancellation notice will appear in the \
conversation."
),
"subagent killed",
started.elapsed().as_secs_f64(),
),
KillResult::Evicted => ToolOutcome::success(
format!(
"Agent '{id}' had already finished; removed it from the \
continuation cache instead."
),
"subagent evicted",
started.elapsed().as_secs_f64(),
),
KillResult::NotFound => ToolOutcome::error(
format!(
"no background or cached agent '{id}' — it may have already \
finished and been evicted, or the id was never issued"
),
started.elapsed().as_secs_f64(),
),
};
}
let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.to_string(),
_ => {
return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
},
};
let description = args
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("subagent")
.to_string();
let requested_type = args
.get("type")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let model_override = args
.get("model")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let continue_id = args
.get("agent_id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(blocked) = super::policy_gate::gate_external(
&ctx,
"agent",
crate::runtime::ToolCategory::Subagent,
format!("subagent: {}", description),
&args,
)
.await
{
return blocked;
}
let permit = tokio::select! {
biased;
_ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
p = self.spawner.inflight.clone().acquire_owned() => match p {
Ok(permit) => permit,
Err(_) => return ToolOutcome::error(
"subagent semaphore closed",
started.elapsed().as_secs_f64(),
),
},
};
let config = (*ctx.config).clone();
let cwd = ctx.workdir.clone();
let (agent_id, cached) = match continue_id {
Some(id) => match self.spawner.cache_take(&id) {
Some(cached) => (id, Some(cached)),
None => {
return ToolOutcome::error(
format!(
"unknown agent_id '{id}': it may have expired (the \
{MAX_CACHED_AGENTS} most recent children are kept), be running \
a continuation right now, or never have existed. Omit agent_id \
to start a new agent."
),
started.elapsed().as_secs_f64(),
);
},
},
None => (self.spawner.mint_agent_id(), None),
};
let type_name = cached
.as_ref()
.map(|c| c.type_name.clone())
.or_else(|| requested_type.map(str::to_string));
let agent_type = match resolve_agent_type(type_name.as_deref(), &config) {
Ok(agent_type) => agent_type,
Err(e) => {
if let Some(cached) = cached {
self.spawner.cache_store(agent_id, cached);
}
return ToolOutcome::error(e, started.elapsed().as_secs_f64());
},
};
let child_safety = SafetyMode::least_permissive(ctx.safety_mode, agent_type.safety_ceiling);
let model_id = model_override
.map(str::to_string)
.or_else(|| agent_type.model.clone())
.unwrap_or_else(|| {
if ctx.model_id.is_empty() {
default_model_id(&config)
} else {
ctx.model_id.clone()
}
});
let (mut child_state, usage_before) = match cached {
Some(cached) => {
let before = cached.state.session.cumulative_token_usage;
(cached.state, before)
},
None => (
State::new(
config.clone(),
cwd.clone(),
model_id.clone(),
chrono::Local::now(),
),
TokenUsageTotals::default(),
),
};
if let Some(model) = model_override {
child_state.session.model_id = model.to_string();
}
let child_model_id = child_state.session.model_id.clone();
child_state.now = chrono::Local::now();
child_state.session.safety_mode = child_safety;
child_state.session.is_subagent = true;
child_state.session.agent_preamble = agent_type.preamble.clone();
child_state.session.scratchpad = ctx.scratchpad.clone();
let (instructions, memory, skills) =
crate::app::instructions::load_project_context(&cwd, &config.memory);
child_state.instructions = instructions;
child_state.memory = memory;
child_state.skills = skills;
if agent_type.allows_tool("mcp") {
seed_child_mcp(&mut child_state);
}
let child_tools = build_child_registry(
self.spawner.providers.clone(),
agent_type.tools.as_deref(),
&config.web,
);
let child_cancel = CancellationToken::new();
let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
let child_runner =
EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
let timeout_secs = match config.agents.timeout_secs {
0 => DEFAULT_TIMEOUT_SECS,
secs => secs,
};
let (child_progress_tx, mut child_progress_rx) = mpsc::channel::<ProgressEvent>(16);
let mut drive = Box::pin(drive_child(
child_state,
child_runner,
child_rx,
child_progress_tx,
prompt,
child_cancel.clone(),
Duration::from_secs(timeout_secs),
));
let mut progress_open = true;
let (result, final_state) = loop {
tokio::select! {
biased;
_ = ctx.token.cancelled() => {
child_cancel.cancel();
break drive.await;
},
_ = ctx.background.cancelled() => {
return self.detach_child(DetachArgs {
drive,
progress_rx: child_progress_rx,
permit,
cancel: child_cancel.clone(),
notify: ctx.notify.clone(),
agent_id,
description,
type_name: agent_type.name.clone(),
child_model_id,
usage_before,
timeout_secs,
started,
});
},
ev = child_progress_rx.recv(), if progress_open => match ev {
Some(ev) => { let _ = ctx.progress.send(ev).await; },
None => progress_open = false,
},
r = &mut drive => break r,
}
};
drop(permit);
finish_drive(
&self.spawner,
agent_type.name.clone(),
agent_id,
&description,
child_model_id,
usage_before,
timeout_secs,
started,
result,
final_state,
)
}
}
struct DetachArgs<F> {
drive: std::pin::Pin<Box<F>>,
progress_rx: mpsc::Receiver<ProgressEvent>,
permit: tokio::sync::OwnedSemaphorePermit,
cancel: CancellationToken,
notify: Option<mpsc::Sender<Msg>>,
agent_id: String,
description: String,
type_name: String,
child_model_id: String,
usage_before: TokenUsageTotals,
timeout_secs: u64,
started: Instant,
}
impl SubagentTool {
fn detach_child<F>(&self, args: DetachArgs<F>) -> ToolOutcome
where
F: std::future::Future<Output = (Result<String, DriveError>, State)> + Send + 'static,
{
let DetachArgs {
mut drive,
mut progress_rx,
permit,
cancel,
notify,
agent_id,
description,
type_name,
child_model_id,
usage_before,
timeout_secs,
started,
} = args;
if let Some(notify) = ¬ify {
let _ = notify.try_send(Msg::BackgroundAgentStarted {
agent_id: agent_id.clone(),
description: description.clone(),
});
}
let spawner = self.spawner.clone();
spawner.register_detached(agent_id.clone(), cancel);
let outcome_text = format!(
"Agent '{description}' ({agent_id}) moved to background — it keeps running and \
its report will be posted to the conversation when it finishes."
);
let (bg_agent_id, bg_description) = (agent_id, description);
tokio::spawn(async move {
let _permit = permit;
let mut activity = String::new();
let mut tokens = 0usize;
let mut progress_open = true;
let (result, final_state) = loop {
tokio::select! {
ev = progress_rx.recv(), if progress_open => match ev {
Some(ev) => {
match &ev {
ProgressEvent::SubagentToolCall { tool_name, phase, .. } => {
activity = match phase {
SubagentPhase::Started => format!("{tool_name}…"),
SubagentPhase::Finished => format!("{tool_name} done"),
SubagentPhase::Errored => format!("{tool_name} failed"),
};
},
ProgressEvent::SubagentActivity(label) => activity = label.clone(),
ProgressEvent::SubagentTokens(count) => tokens = *count,
_ => continue,
}
if let Some(notify) = ¬ify {
let _ = notify.try_send(Msg::BackgroundAgentProgress {
agent_id: bg_agent_id.clone(),
activity: activity.clone(),
tokens,
});
}
},
None => progress_open = false,
},
r = &mut drive => break r,
}
};
spawner.unregister_detached(&bg_agent_id);
let cancelled = matches!(result, Err(DriveError::Cancelled));
let outcome = finish_drive(
&spawner,
type_name,
bg_agent_id.clone(),
&bg_description,
child_model_id,
usage_before,
timeout_secs,
started,
result,
final_state,
);
if let Some(notify) = notify {
let usage = outcome.metadata.token_usage.clone();
let tokens_total = usage.as_ref().map_or(tokens, |u| u.total_tokens());
let _ = notify
.send(Msg::BackgroundAgentFinished {
agent_id: bg_agent_id,
description: bg_description,
report: outcome.model_content.clone(),
success: outcome.is_success(),
cancelled,
usage,
tokens: tokens_total,
duration_secs: started.elapsed().as_secs(),
})
.await;
}
});
ToolOutcome::success(
outcome_text,
"subagent backgrounded",
started.elapsed().as_secs_f64(),
)
}
}
#[allow(clippy::too_many_arguments)]
fn finish_drive(
spawner: &SubagentSpawner,
type_name: String,
agent_id: String,
description: &str,
child_model_id: String,
usage_before: TokenUsageTotals,
timeout_secs: u64,
started: Instant,
result: Result<String, DriveError>,
mut final_state: State,
) -> ToolOutcome {
let child_usage = usage_delta(final_state.session.cumulative_token_usage, usage_before);
if !matches!(result, Err(DriveError::Cancelled)) {
final_state.turn = TurnState::Idle;
final_state.ui.queued_messages.clear();
final_state.ui.live_tool_status.clear();
final_state.pending_approval.clear();
spawner.cache_store(
agent_id.clone(),
CachedAgent {
state: final_state,
type_name,
},
);
}
let elapsed = started.elapsed().as_secs_f64();
let trailer = format!("[agent_id: {agent_id} — pass agent_id to continue this child]");
let metadata = subagent_metadata(child_model_id, child_usage, agent_id);
match result {
Ok(summary) => ToolOutcome::success(
format!("{summary}\n\n{trailer}"),
"subagent completed",
elapsed,
)
.with_metadata(metadata),
Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
Err(DriveError::TimedOut) => ToolOutcome::error(
format!(
"subagent ({description}) exceeded {timeout_secs}s timeout; its context \
is preserved — {trailer}"
),
elapsed,
)
.with_metadata(metadata),
Err(DriveError::Errored(e)) => {
ToolOutcome::error(format!("subagent ({description}): {e} {trailer}"), elapsed)
.with_metadata(metadata)
},
}
}
fn subagent_metadata(
model_id: String,
usage: TokenUsageTotals,
agent_id: String,
) -> ToolRunMetadata {
let token_usage = (usage.total_tokens() > 0).then(|| crate::models::TokenUsage {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
reasoning_output_tokens: usage.reasoning_output_tokens,
source: Default::default(),
});
ToolRunMetadata {
detail: ToolMetadata::Subagent { model_id, agent_id },
token_usage,
..ToolRunMetadata::default()
}
}
fn usage_delta(after: TokenUsageTotals, before: TokenUsageTotals) -> TokenUsageTotals {
TokenUsageTotals {
prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
completion_tokens: after
.completion_tokens
.saturating_sub(before.completion_tokens),
cached_input_tokens: after
.cached_input_tokens
.saturating_sub(before.cached_input_tokens),
cache_creation_input_tokens: after
.cache_creation_input_tokens
.saturating_sub(before.cache_creation_input_tokens),
reasoning_output_tokens: after
.reasoning_output_tokens
.saturating_sub(before.reasoning_output_tokens),
}
}
enum DriveError {
Cancelled,
TimedOut,
Errored(String),
}
async fn drive_child(
mut state: State,
mut runner: EffectRunner,
mut msg_rx: mpsc::Receiver<Msg>,
parent_progress: mpsc::Sender<ProgressEvent>,
prompt: String,
token: CancellationToken,
timeout: Duration,
) -> (Result<String, DriveError>, State) {
let _ = parent_progress
.send(ProgressEvent::SubagentActivity("starting…".to_string()))
.await;
let seed = Msg::SubmitPrompt {
text: prompt,
attachment_ids: vec![],
};
let (new_state, cmds) = update(state, seed);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
let deadline = tokio::time::sleep(timeout);
tokio::pin!(deadline);
let mut outcome: Result<(), DriveError> = Ok(());
let mut child_progress = ChildProgress::new(tokio::time::Instant::now());
loop {
if token.is_cancelled() {
outcome = Err(DriveError::Cancelled);
break;
}
if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
break;
}
let msg = tokio::select! {
biased;
_ = token.cancelled() => {
outcome = Err(DriveError::Cancelled);
break;
},
_ = &mut deadline => {
outcome = Err(DriveError::TimedOut);
break;
},
recv = msg_rx.recv() => match recv {
Some(m) => m,
None => break, },
};
for event in child_progress.observe(&msg, &state, tokio::time::Instant::now()) {
let _ = parent_progress.send(event).await;
}
let (new_state, cmds) = update(state, msg);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
if state.should_exit {
break;
}
}
runner.shutdown().await;
if let Err(e) = outcome {
return (Err(e), state);
}
let summary = state
.session
.messages()
.iter()
.rev()
.find(|m| m.role == MessageRole::Assistant)
.map(|m| m.content.clone())
.unwrap_or_default();
if summary.trim().is_empty() {
return (
Err(DriveError::Errored(
"subagent produced no assistant output".to_string(),
)),
state,
);
}
(Ok(summary), state)
}
const TOKEN_PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
struct ChildProgress {
phase: &'static str,
confirmed_tokens: usize,
streamed_chars: usize,
last_tokens_sent: usize,
last_tokens_at: tokio::time::Instant,
}
impl ChildProgress {
fn new(now: tokio::time::Instant) -> Self {
Self {
phase: "",
confirmed_tokens: 0,
streamed_chars: 0,
last_tokens_sent: 0,
last_tokens_at: now,
}
}
fn total_tokens(&self) -> usize {
self.confirmed_tokens + self.streamed_chars / 4
}
fn observe(
&mut self,
msg: &Msg,
state: &State,
now: tokio::time::Instant,
) -> Vec<ProgressEvent> {
let mut out = Vec::new();
match msg {
Msg::ToolStarted {
turn: _, call_id, ..
} => {
let tool_name =
lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
out.push(ProgressEvent::SubagentToolCall {
child_call_id: *call_id,
tool_name,
phase: SubagentPhase::Started,
});
self.phase = "";
},
Msg::ToolFinished {
turn: _,
call_id,
outcome,
} => {
let tool_name =
lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
let phase = if outcome.is_success() {
SubagentPhase::Finished
} else {
SubagentPhase::Errored
};
out.push(ProgressEvent::SubagentToolCall {
child_call_id: *call_id,
tool_name,
phase,
});
self.phase = "";
},
Msg::StreamReasoning { chunk, .. } => {
self.streamed_chars += chunk.text.len();
self.set_phase("thinking", &mut out);
},
Msg::StreamText { chunk, .. } => {
self.streamed_chars += chunk.len();
self.set_phase("replying", &mut out);
},
Msg::StreamDone { usage, .. } => {
if let Some(usage) = usage {
self.confirmed_tokens += usage
.completion_tokens
.saturating_add(usage.reasoning_output_tokens);
self.streamed_chars = 0;
}
},
_ => {},
}
let total = self.total_tokens();
let due = now.duration_since(self.last_tokens_at) >= TOKEN_PROGRESS_INTERVAL;
if total != self.last_tokens_sent && (due || !out.is_empty()) {
out.push(ProgressEvent::SubagentTokens(total));
self.last_tokens_sent = total;
self.last_tokens_at = now;
}
out
}
fn set_phase(&mut self, phase: &'static str, out: &mut Vec<ProgressEvent>) {
if self.phase != phase {
self.phase = phase;
out.push(ProgressEvent::SubagentActivity(phase.to_string()));
}
}
}
fn lookup_tool_name(state: &State, call_id: crate::domain::ToolCallId) -> Option<String> {
match &state.turn {
TurnState::ExecutingTools { calls, .. } => calls
.iter()
.find(|c| c.call_id == call_id)
.map(|c| c.source.function.name.clone()),
_ => None,
}
}
fn seed_child_mcp(state: &mut State) {
let Some(manager) = crate::mcp::manager_ref::get() else {
return;
};
apply_live_mcp(&mut state.mcp.servers, &manager.all_specs(), |name| {
manager.has_server(name)
});
}
fn apply_live_mcp(
servers: &mut std::collections::HashMap<String, crate::domain::McpServerEntry>,
live_specs: &[(String, crate::domain::McpToolSpec)],
has_server: impl Fn(&str) -> bool,
) {
for (name, entry) in servers.iter_mut() {
if !has_server(name) {
continue;
}
entry.status = crate::domain::McpServerStatus::Ready;
let cfg = &entry.config;
let tools: Vec<crate::domain::McpToolSpec> = live_specs
.iter()
.filter(|(server, _)| server == name)
.filter(|(_, spec)| cfg.tool_allowed(&spec.raw_name))
.map(|(_, spec)| spec.clone())
.collect();
entry.tools = tools;
}
}
fn build_child_registry(
providers: Arc<ProviderFactory>,
tools: Option<&[String]>,
web: &crate::app::WebConfig,
) -> Arc<ToolRegistry> {
use super::{
apply_patch, computer_use, exec, filesystem, mcp,
web::{web_fetch_tool, web_search_tool},
};
let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
let mut r = ToolRegistry::new();
if allowed("read_file") {
r.register(Arc::new(filesystem::ReadFileTool));
}
if allowed("write_file") {
r.register(Arc::new(filesystem::WriteFileTool));
}
if allowed("apply_patch") {
r.register(Arc::new(apply_patch::ApplyPatchTool));
}
if allowed("delete_file") {
r.register(Arc::new(filesystem::DeleteFileTool));
}
if allowed("create_directory") {
r.register(Arc::new(filesystem::CreateDirectoryTool));
}
if allowed("execute_command") {
r.register(Arc::new(exec::ExecuteCommandTool));
}
if allowed("mcp") {
r.register(Arc::new(mcp::McpToolProxy));
}
if allowed("web_search")
&& let Some(tool) = web_search_tool(web)
{
r.register(Arc::new(tool));
}
if allowed("web_fetch")
&& let Some(tool) = web_fetch_tool(web)
{
r.register(Arc::new(tool));
}
let _ = computer_use::probe;
let _ = providers;
Arc::new(r)
}
fn default_model_id(config: &crate::app::Config) -> String {
if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
format!(
"{}/{}",
config.default_model.provider, config.default_model.name
)
} else {
config.default_model.name.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ToolCallId, TurnId};
use crate::providers::ctx::test_exec_context;
use std::path::PathBuf;
fn test_state() -> State {
State::new(
crate::app::Config::default(),
PathBuf::from("/tmp"),
"ollama/test".to_string(),
chrono::Local::now(),
)
}
fn stream_text(chunk: &str) -> Msg {
Msg::StreamText {
turn: TurnId(1),
chunk: chunk.to_string(),
}
}
#[tokio::test]
async fn child_stream_chunks_never_forward_text_only_one_phase_change() {
let state = test_state();
let now = tokio::time::Instant::now();
let mut progress = ChildProgress::new(now);
let first = progress.observe(&stream_text("chunk one — some text"), &state, now);
assert!(
first.iter().any(
|e| matches!(e, ProgressEvent::SubagentActivity(label) if label == "replying")
),
"first chunk announces the phase: {first:?}"
);
assert!(
!first
.iter()
.any(|e| matches!(e, ProgressEvent::SubagentToolCall { .. })),
"no raw text ever forwards: {first:?}"
);
for i in 0..50 {
let events = progress.observe(&stream_text(&format!("chunk {i}")), &state, now);
assert!(
events.is_empty(),
"chunk {i} must be silent inside the throttle window: {events:?}"
);
}
}
#[tokio::test]
async fn token_estimates_respect_the_throttle_and_snap_to_provider_usage() {
let state = test_state();
let start = tokio::time::Instant::now();
let mut progress = ChildProgress::new(start);
let _ = progress.observe(&stream_text("xy"), &state, start);
let silent = progress.observe(&stream_text(&"x".repeat(400)), &state, start);
assert!(
silent.is_empty(),
"inside the window stays silent: {silent:?}"
);
let later = start + TOKEN_PROGRESS_INTERVAL;
let events = progress.observe(&stream_text("y"), &state, later);
assert!(
events
.iter()
.any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 100)),
"tokens flush after the interval: {events:?}"
);
let done = Msg::StreamDone {
turn: TurnId(1),
usage: Some(crate::models::TokenUsage::provider(10, 5_000)),
provider_continuation: None,
stop_reason: None,
};
let much_later = later + TOKEN_PROGRESS_INTERVAL;
let events = progress.observe(&done, &state, much_later);
assert!(
events
.iter()
.any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 5_000)),
"provider usage snaps the counter: {events:?}"
);
}
#[tokio::test]
async fn empty_prompt_is_rejected() {
let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
))));
let tool = SubagentTool::new(spawner);
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = tool.execute(serde_json::json!({"prompt": " "}), ctx).await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
}
#[test]
fn child_state_inherits_live_safety_mode_over_config_default() {
use crate::runtime::SafetyMode;
let mut config = crate::app::Config::default();
config.safety.mode = SafetyMode::FullAccess; let mut child_state = State::new(
config,
PathBuf::from("/tmp"),
"ollama/test".to_string(),
chrono::Local::now(),
);
assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
child_state.session.safety_mode = SafetyMode::Ask;
assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
}
#[test]
fn child_state_inherits_the_parent_scratchpad() {
let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
ctx.scratchpad = Some(PathBuf::from("/data/tmp/scratchpad/-proj/s"));
let mut child_state = test_state();
assert_eq!(child_state.session.scratchpad, None);
child_state.session.scratchpad = ctx.scratchpad.clone();
assert_eq!(
child_state.session.scratchpad.as_deref(),
Some(std::path::Path::new("/data/tmp/scratchpad/-proj/s"))
);
}
#[test]
fn default_model_id_reads_config_provider_and_name() {
let mut cfg = crate::app::Config::default();
cfg.default_model.provider = "ollama".to_string();
cfg.default_model.name = "qwen3-coder:30b".to_string();
assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
}
#[test]
fn default_model_id_returns_bare_name_when_provider_empty() {
let mut cfg = crate::app::Config::default();
cfg.default_model.name = "just-a-name".to_string();
assert_eq!(default_model_id(&cfg), "just-a-name");
}
#[test]
fn apply_live_mcp_marks_running_servers_ready_with_their_tools() {
use crate::domain::{McpServerEntry, McpServerStatus};
let entry = || McpServerEntry {
config: crate::app::McpServerConfig::default(),
status: McpServerStatus::Starting,
tools: Vec::new(),
};
let mut servers = std::collections::HashMap::new();
servers.insert("slack".to_string(), entry());
servers.insert("broken".to_string(), entry());
let live = vec![
(
"slack".to_string(),
crate::domain::McpToolSpec {
name: "mcp__slack__send".to_string(),
raw_name: "send".to_string(),
description: "send a message".to_string(),
input_schema: serde_json::json!({"type": "object"}),
read_only_hint: false,
},
),
(
"other".to_string(),
crate::domain::McpToolSpec {
name: "mcp__other__x".to_string(),
raw_name: "x".to_string(),
description: String::new(),
input_schema: serde_json::json!({}),
read_only_hint: false,
},
),
];
apply_live_mcp(&mut servers, &live, |name| name == "slack");
let slack = &servers["slack"];
assert_eq!(slack.status, McpServerStatus::Ready);
assert_eq!(slack.tools.len(), 1);
assert_eq!(slack.tools[0].name, "mcp__slack__send");
assert_eq!(slack.tools[0].raw_name, "send");
assert_eq!(servers["broken"].status, McpServerStatus::Starting);
assert!(servers["broken"].tools.is_empty());
assert!(!servers.contains_key("other"));
}
#[test]
fn subagent_metadata_carries_usage_only_when_reported() {
let some = subagent_metadata(
"ollama/test".to_string(),
TokenUsageTotals {
prompt_tokens: 100,
completion_tokens: 40,
..TokenUsageTotals::default()
},
"a7".to_string(),
);
let usage = some.token_usage.expect("usage attached");
assert_eq!(usage.total_tokens(), 140);
assert_eq!(usage.completion_tokens, 40);
assert!(matches!(
some.detail,
crate::domain::ToolMetadata::Subagent { ref model_id, ref agent_id }
if model_id == "ollama/test" && agent_id == "a7"
));
let none = subagent_metadata(
"ollama/test".to_string(),
TokenUsageTotals::default(),
"a8".to_string(),
);
assert!(none.token_usage.is_none());
}
#[test]
fn usage_delta_reports_only_this_drive() {
let before = TokenUsageTotals {
prompt_tokens: 1_000,
completion_tokens: 200,
..TokenUsageTotals::default()
};
let after = TokenUsageTotals {
prompt_tokens: 1_600,
completion_tokens: 350,
..TokenUsageTotals::default()
};
let delta = usage_delta(after, before);
assert_eq!(delta.prompt_tokens, 600);
assert_eq!(delta.completion_tokens, 150);
assert_eq!(delta.total_tokens(), 750);
let fresh = usage_delta(after, TokenUsageTotals::default());
assert_eq!(fresh.total_tokens(), 1_950);
}
#[test]
fn resolve_agent_type_builtins_custom_shadowing_and_errors() {
use crate::app::AgentTypeConfig;
let mut config = crate::app::Config::default();
assert_eq!(resolve_agent_type(None, &config).unwrap().name, "general");
assert_eq!(
resolve_agent_type(None, &config).unwrap().safety_ceiling,
SafetyMode::FullAccess,
);
let explore = resolve_agent_type(Some("explore"), &config).unwrap();
assert_eq!(explore.safety_ceiling, SafetyMode::ReadOnly);
assert!(explore.preamble.as_deref().unwrap().contains("read-only"));
assert!(explore.allows_tool("read_file"));
assert!(!explore.allows_tool("write_file"));
assert!(!explore.allows_tool("mcp"));
let err = resolve_agent_type(Some("nope"), &config).unwrap_err();
assert!(err.contains("general") && err.contains("explore"), "{err}");
config.agents.types.insert(
"scout".to_string(),
AgentTypeConfig {
tools: Some(vec!["read_file".to_string()]),
safety: Some("read_only".to_string()),
preamble: Some("You are a scout.".to_string()),
model: Some("ollama/qwen3:8b".to_string()),
},
);
let scout = resolve_agent_type(Some("scout"), &config).unwrap();
assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
assert_eq!(scout.safety_ceiling, SafetyMode::ReadOnly);
config.agents.types.insert(
"explore".to_string(),
AgentTypeConfig {
safety: Some("ask".to_string()),
..AgentTypeConfig::default()
},
);
assert_eq!(
resolve_agent_type(Some("explore"), &config)
.unwrap()
.safety_ceiling,
SafetyMode::Ask,
);
config.agents.types.insert(
"bad-safety".to_string(),
AgentTypeConfig {
safety: Some("yolo".to_string()),
..AgentTypeConfig::default()
},
);
assert!(
resolve_agent_type(Some("bad-safety"), &config)
.unwrap_err()
.contains("yolo")
);
config.agents.types.insert(
"bad-tool".to_string(),
AgentTypeConfig {
tools: Some(vec!["screenshot".to_string()]),
..AgentTypeConfig::default()
},
);
assert!(
resolve_agent_type(Some("bad-tool"), &config)
.unwrap_err()
.contains("screenshot")
);
}
#[test]
fn agent_cache_stores_takes_and_evicts_oldest() {
let spawner = SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
)));
let mk_state = || {
State::new(
crate::app::Config::default(),
PathBuf::from("/tmp"),
"ollama/test".to_string(),
chrono::Local::now(),
)
};
let mk = || CachedAgent {
state: mk_state(),
type_name: "general".to_string(),
};
assert_ne!(spawner.mint_agent_id(), spawner.mint_agent_id());
spawner.cache_store("x".to_string(), mk());
assert!(spawner.cache_take("x").is_some());
assert!(spawner.cache_take("x").is_none(), "take must remove");
for i in 0..(MAX_CACHED_AGENTS + 2) {
spawner.cache_store(format!("e{i}"), mk());
}
assert!(spawner.cache_take("e0").is_none(), "oldest evicted");
assert!(spawner.cache_take("e1").is_none(), "second-oldest evicted");
assert!(
spawner
.cache_take(&format!("e{}", MAX_CACHED_AGENTS + 1))
.is_some(),
"newest survives",
);
}
#[tokio::test]
async fn continuing_an_unknown_agent_id_errors_actionably() {
let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
))));
let tool = SubagentTool::new(spawner);
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = tool
.execute(
serde_json::json!({"prompt": "follow up", "agent_id": "a99"}),
ctx,
)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
let msg = outcome.error_message().unwrap_or_default();
assert!(msg.contains("a99"), "names the bad id: {msg}");
assert!(
msg.contains("Omit agent_id"),
"tells the model how to recover: {msg}"
);
}
#[tokio::test]
async fn unknown_agent_type_errors_actionably() {
let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
))));
let tool = SubagentTool::new(spawner);
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = tool
.execute(
serde_json::json!({"prompt": "look around", "type": "wizard"}),
ctx,
)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
let msg = outcome.error_message().unwrap_or_default();
assert!(msg.contains("wizard") && msg.contains("explore"), "{msg}");
}
#[test]
fn build_child_registry_excludes_gui_and_self() {
let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
let r = build_child_registry(providers, None, &crate::app::WebConfig::default());
assert!(r.get("screenshot").is_none());
assert!(r.get("click").is_none());
assert!(r.get("type_text").is_none());
assert!(r.get("press_key").is_none());
assert!(r.get("scroll").is_none());
assert!(r.get("mouse_move").is_none());
assert!(r.get("list_windows").is_none());
assert!(r.get("agent").is_none());
assert!(r.get("read_file").is_some());
assert!(r.get("execute_command").is_some());
}
#[test]
fn kill_detached_fires_registered_tokens_and_evicts_cached_children() {
let spawner = SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
)));
let cancel = CancellationToken::new();
spawner.register_detached("a1".to_string(), cancel.clone());
assert_eq!(spawner.kill_detached("a1"), KillResult::Killed);
assert!(cancel.is_cancelled());
assert_eq!(spawner.kill_detached("a1"), KillResult::NotFound);
spawner.cache_store(
"a2".to_string(),
CachedAgent {
state: test_state(),
type_name: "general".to_string(),
},
);
assert_eq!(spawner.kill_detached("a2"), KillResult::Evicted);
assert!(spawner.cache_take("a2").is_none(), "eviction is permanent");
assert_eq!(spawner.kill_detached("a99"), KillResult::NotFound);
let (c1, c2) = (CancellationToken::new(), CancellationToken::new());
spawner.register_detached("a3".to_string(), c1.clone());
spawner.register_detached("a4".to_string(), c2.clone());
assert_eq!(spawner.kill_all_detached(), 2);
assert!(c1.is_cancelled() && c2.is_cancelled());
assert_eq!(spawner.kill_all_detached(), 0);
}
#[tokio::test]
async fn kill_action_validates_agent_id_and_skips_prompt_requirement() {
let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
crate::app::Config::default(),
))));
let tool = SubagentTool::new(spawner.clone());
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = tool
.execute(serde_json::json!({"action": "kill"}), ctx)
.await;
assert!(!outcome.is_success());
assert!(outcome.model_content.contains("requires `agent_id`"));
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
let outcome = tool
.execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
.await;
assert!(!outcome.is_success());
assert!(outcome.model_content.contains("a7"));
let cancel = CancellationToken::new();
spawner.register_detached("a7".to_string(), cancel.clone());
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
let outcome = tool
.execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
.await;
assert!(outcome.is_success(), "{}", outcome.model_content);
assert!(cancel.is_cancelled());
}
#[test]
fn explore_registry_is_a_read_only_surface() {
let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
let explore = builtin_agent_type("explore").expect("builtin");
let r = build_child_registry(
providers,
explore.tools.as_deref(),
&crate::app::WebConfig::default(),
);
assert!(r.get("read_file").is_some());
assert!(r.get("execute_command").is_some());
for tool in [
"write_file",
"apply_patch",
"delete_file",
"create_directory",
"mcp_proxy",
"agent",
] {
assert!(r.get(tool).is_none(), "explore must not carry {tool}");
}
}
}