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",
"edit_file",
"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>,
}
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()),
}
}
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);
}
}
}
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.",
max_cached = MAX_CACHED_AGENTS,
max_breadth = MAX_INFLIGHT,
),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The task for the subagent. 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. Use the id from a prior result's [agent_id: …] trailer."
}
},
"required": ["prompt"]
}),
}
}
async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
let started = Instant::now();
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();
let (instructions, memory) =
crate::app::instructions::load_project_context(&cwd, &config.memory);
child_state.instructions = instructions;
child_state.memory = memory;
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_token = ctx.token.child_token();
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 (result, mut final_state) = drive_child(
child_state,
child_runner,
child_rx,
ctx.progress.clone(),
prompt,
description.clone(),
child_token,
Duration::from_secs(timeout_secs),
)
.await;
drop(permit);
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();
self.spawner.cache_store(
agent_id.clone(),
CachedAgent {
state: final_state,
type_name: agent_type.name.clone(),
},
);
}
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,
total_tokens: usage.total_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),
total_tokens: after.total_tokens.saturating_sub(before.total_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),
}
#[allow(clippy::too_many_arguments)]
async fn drive_child(
mut state: State,
mut runner: EffectRunner,
mut msg_rx: mpsc::Receiver<Msg>,
parent_progress: mpsc::Sender<ProgressEvent>,
prompt: String,
description: String,
token: CancellationToken,
timeout: Duration,
) -> (Result<String, DriveError>, State) {
let _ = parent_progress
.send(ProgressEvent::SubagentText(format!(
"{} — {}",
description,
prompt.chars().take(80).collect::<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(());
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, },
};
forward_child_event(&msg, &parent_progress, &state).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)
}
async fn forward_child_event(msg: &Msg, progress: &mpsc::Sender<ProgressEvent>, state: &State) {
match msg {
Msg::ToolStarted {
turn: _, call_id, ..
} => {
let tool_name = lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
let _ = progress
.send(ProgressEvent::SubagentToolCall {
child_call_id: *call_id,
tool_name,
phase: SubagentPhase::Started,
})
.await;
},
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
};
let _ = progress
.send(ProgressEvent::SubagentToolCall {
child_call_id: *call_id,
tool_name,
phase,
})
.await;
},
Msg::StreamText { chunk, .. } => {
if !chunk.trim().is_empty() {
let snippet: String = chunk.chars().take(120).collect();
let _ = progress.send(ProgressEvent::SubagentText(snippet)).await;
}
},
_ => {},
}
}
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.get_all_tools(), |name| {
manager.has_server(name)
});
}
fn apply_live_mcp(
servers: &mut std::collections::HashMap<String, crate::domain::McpServerEntry>,
live_tools: &[(String, crate::mcp::McpToolDef)],
has_server: impl Fn(&str) -> bool,
) {
for (name, entry) in servers.iter_mut() {
if !has_server(name) {
continue;
}
entry.status = crate::domain::McpServerStatus::Ready;
entry.tools = live_tools
.iter()
.filter(|(server, _)| server == name)
.map(|(_, def)| crate::domain::McpToolSpec {
name: def.name.clone(),
description: def.description.clone(),
input_schema: def.input_schema.clone(),
})
.collect();
}
}
fn build_child_registry(
providers: Arc<ProviderFactory>,
tools: Option<&[String]>,
web: &crate::app::WebConfig,
) -> Arc<ToolRegistry> {
use super::{
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("edit_file") {
r.register(Arc::new(filesystem::EditFileTool));
}
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;
#[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 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 {
command: String::new(),
args: Vec::new(),
env: std::collections::HashMap::new(),
},
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::mcp::McpToolDef {
name: "send".to_string(),
description: "send a message".to_string(),
input_schema: serde_json::json!({"type": "object"}),
},
),
(
"other".to_string(),
crate::mcp::McpToolDef {
name: "x".to_string(),
description: String::new(),
input_schema: serde_json::json!({}),
},
),
];
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, "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,
total_tokens: 140,
..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,
total_tokens: 1_200,
..TokenUsageTotals::default()
};
let after = TokenUsageTotals {
prompt_tokens: 1_600,
completion_tokens: 350,
total_tokens: 1_950,
..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 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",
"edit_file",
"delete_file",
"create_directory",
"mcp_proxy",
"agent",
] {
assert!(r.get(tool).is_none(), "explore must not carry {tool}");
}
}
}