//! `subagent` tool — sub-agent delegation. Spawns a fresh AgentHarness with an in-memory
//! session, runs a sub-prompt to completion (its own loop, its own iteration budget), and
//! returns the final assistant text to the parent agent as a single tool result.
//!
//! v1 scope:
//! - Subagent specs: the full builtin table (explorer / planner / executor-coder /
//! checker / general — same table the DAG node launcher resolves against), inheriting
//! the parent model by default with per-call `provider` + `model` catalog overrides. ONE uniform tool set for every spec, injected from the app layer at
//! construction (engine tools minus subagent/dag_* plus local tools — the spec's
//! system prompt and the parent's task prompt define behavior); the iteration budget
//! comes from the spec table. Per-call `max_iterations`, `provider` + `model`
//! (catalog-resolved, independent of the parent session model), `thinking`, and
//! `tools` (allowlist) override the spec budget / model / narrow the tool set at
//! launch; an unknown allowlist name fails the call. MemorySessionStorage so
//! nothing leaks to disk.
//! - Concurrent execution mode (Parallel) so the parent can fire multiple subagent calls in one
//! turn and they run together.
//! - Parent abort cascades: the tool listens on the parent's cancellation token and aborts
//! its inner harness immediately.
//!
//! The sub-harness lifecycle itself (harness construction, metrics registry, final-text
//! collection, cancel watcher) lives in [`crate::multiagent::runner`], shared with the DAG
//! node launcher.
//!
//! Out of scope (follow-ups under #11):
//! - User-defined subagent specs via `~/.theway/subagents/*.toml`.
//! - Recursive sub-subagents (we'd need a depth cap).
//! - Cost rollup into the parent CostTracker (each subagent has its own tracker for now).
use async_trait::async_trait;
use serde_json::{Value, json};
use theway_contract::subagent_settings::SubagentRunSettings;
use theway_core::multiagent::jobs::SubagentJobRegistry;
use theway_core::{
AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate, StreamFn, ToolExecutionMode,
};
use theway_llm_provider::{Model, Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
use crate::subagent_settings::SubagentSettingsStore;
use theway_core::ThinkingLevel;
use theway_core::multiagent::runner::{
AgentRunOptions, filter_tool_set, resolve_run_model, run_agent,
};
use theway_core::multiagent::types::AgentRunResolver;
use theway_core::multiagent::types::ToolSetResolver;
use std::sync::Arc;
/// Closure that resolves the tool set a subagent should have access to from its spec
/// name. Same shape as the DAG node launcher's [`ToolSetResolver`](crate::multiagent::types::ToolSetResolver) — `task` and DAG
/// share one mechanism (and one app-layer instance). App-layer injection: the engine
/// does not know which tools exist — the server supplies this via `subagent_tool`
/// (`server/src/tools.rs`) / e2e tests.
pub type SubagentToolsFn = ToolSetResolver;
pub struct SubagentTool {
/// Model used by spawned subagents. Cloned from the parent at construction time so a
/// later `/model` switch doesn't change in-flight subagent settings. `None` when the
/// session has no model yet — a call then fails unless it passes an explicit
/// `provider` + `model` pair that resolves from the model catalog.
model: Option<Model>,
/// Optional stream_fn shared with the parent. `None` falls back to `theway_llm_provider::stream_simple`.
stream_fn: Option<StreamFn>,
/// App-layer tool-set resolver for subagents, keyed by spec name (see
/// [`SubagentToolsFn`]). Shared with the DAG node launcher — one mechanism.
subagent_tools: SubagentToolsFn,
/// App-layer launch resolver (spec name → launch params). The spec table lives
/// app-side; this tool only consumes it.
launch_resolver: AgentRunResolver,
/// Known spec names (for the `subagent_type` enum in the tool definition),
/// captured at construction from the app's spec table.
spec_names: Vec<String>,
/// Tool definition. Built at construction because the `subagent_type` enum is
/// populated from the app's spec table (not static).
definition: Tool,
/// Subagent job registry (graph mode metrics/output).
registry: SubagentJobRegistry,
/// Owning session stamped on every spawned job (session-resource-model). `None` for
/// session-less construction (e2e tests); the CLI wires `Some(current)` via
/// the CLI's session factory.
session_id: Option<String>,
/// Project-level last-set override memory; `None` keeps the pure per-call
/// behavior (tests / session-less construction).
settings: Option<Arc<SubagentSettingsStore>>,
}
impl SubagentTool {
pub fn new(
model: impl Into<Option<Model>>,
stream_fn: Option<StreamFn>,
subagent_tools: SubagentToolsFn,
launch_resolver: AgentRunResolver,
spec_names: Vec<String>,
registry: SubagentJobRegistry,
) -> Self {
Self {
definition: build_definition(&spec_names),
model: model.into(),
stream_fn,
subagent_tools,
launch_resolver,
spec_names,
registry,
session_id: None,
settings: None,
}
}
/// Stamp the owning session on jobs this tool spawns (session-resource-model). Each
/// harness build gets its own SubagentTool stamped with that harness's session, so jobs
/// started after an in-process session switch belong to the new session.
//
// Called only from the CLI's session factory (src/main.rs) — invisible to the app
// lib's own test targets, hence the dead_code allowance.
#[allow(dead_code)]
pub fn with_session_id(mut self, session_id: Option<String>) -> Self {
self.session_id = session_id;
self
}
/// Wire the project-level last-set override memory: explicit per-call
/// `provider` / `model` / `thinking` values merge over the remembered
/// record for the chosen `subagent_type`, and successful resolutions
/// update it.
pub fn with_settings(mut self, settings: Arc<SubagentSettingsStore>) -> Self {
self.settings = Some(settings);
self
}
}
#[async_trait]
impl AgentTool for SubagentTool {
fn definition(&self) -> &Tool {
&self.definition
}
fn label(&self) -> &str {
"subagent"
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
Some(ToolExecutionMode::Parallel)
}
async fn execute(
&self,
_id: &str,
params: Value,
parent_cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let subagent_type = params
.get("subagent_type")
.and_then(|v| v.as_str())
.unwrap_or("general");
if !self.spec_names.iter().any(|n| n == subagent_type) {
return Err(AgentToolError::Message(format!(
"unknown subagent_type: {subagent_type} (allowed: {})",
self.spec_names.join(", ")
)));
}
let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.ok_or_else(|| AgentToolError::Message("missing required arg: prompt".into()))?
.to_string();
let description = params
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let max_iterations = params
.get("max_iterations")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let tools_allow: Option<Vec<String>> =
params.get("tools").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
});
let provider = params.get("provider").and_then(|v| v.as_str());
let model_id = params.get("model").and_then(|v| v.as_str());
let thinking = params.get("thinking").and_then(|v| v.as_str());
// Merge the caller's explicit overrides with the project-level
// remembered record for this subagent type; the merged record is
// written back after the model resolves.
let remembered: Option<(Arc<SubagentSettingsStore>, String, SubagentRunSettings)> =
match &self.settings {
Some(store) => {
let effective = store
.merge_agent(subagent_type, provider, model_id, thinking)
.await;
Some((store.clone(), subagent_type.to_string(), effective))
}
None => None,
};
let (provider, model_id, thinking) = match &remembered {
Some((_, _, effective)) => (
effective.provider.clone(),
effective.model.clone(),
effective.thinking.clone(),
),
None => (
provider.map(str::to_string),
model_id.map(str::to_string),
thinking.map(str::to_string),
),
};
if let Some(raw) = thinking.as_deref() {
if raw.parse::<ThinkingLevel>().is_err() {
return Err(AgentToolError::Message(format!(
"invalid thinking level: {raw} (allowed: off, minimal, low, medium, high, xhigh, max)"
)));
}
}
// All specs in the app's table are valid subagents; the shared runner drives
// the harness from the resolved spec (system prompt); the tool set comes from
// the app-layer resolver injected at construction (same one the DAG launcher
// uses).
let mut launch = (self.launch_resolver)(subagent_type).ok_or_else(|| {
AgentToolError::Message(format!("unknown subagent_type: {subagent_type}"))
})?;
// Budget override: the call parameter wins over the spec default.
if let Some(n) = max_iterations {
launch.max_iterations = n;
}
// Tool allowlist: narrow the resolved tool set; an unknown name fails the
// call visibly to the orchestrator (retryable — same semantics as the DAG
// node launcher's synchronous node failure).
let tools = (self.subagent_tools)(subagent_type);
let tools = match tools_allow.as_deref() {
None => tools,
Some(allow) => filter_tool_set(tools, allow).map_err(AgentToolError::Message)?,
};
// Explicit `provider + model` resolves against the loaded models
// catalog independently of the parent session model (so model-less
// sessions can still delegate); `model` alone keeps the legacy
// parent-model id rewrite. The owning session has no model at all and
// no explicit pair was given: fail fast with a clear, retryable message.
let model = resolve_run_model(self.model.as_ref(), provider.as_deref(), model_id.as_deref())
.map_err(AgentToolError::Message)?
.ok_or_else(|| {
AgentToolError::Message(
"no model set for this session; select a model in the TUI before delegating (or pass provider + model)"
.to_string(),
)
})?;
// The model resolved: remember the merged overrides for this subagent
// type so later calls inherit them.
if let Some((store, name, entry)) = remembered {
store.remember_agent(&name, entry).await;
}
let result = run_agent(AgentRunOptions {
launch,
tools,
prompt,
model,
stream_fn: self.stream_fn.clone(),
timeout: None,
thinking,
registry: self.registry.clone(),
source: "subagent".into(),
run_id: None,
node_id: None,
// session-resource-model: jobs spawned by the subagent tool belong to the session
// whose harness owns this tool instance (stamped at construction).
session_id: self.session_id.clone(),
observation_parent: None,
cancel: parent_cancel.clone(),
// Keep the v1 behaviour: the task description lands in the subagent's
// system prompt, not just the tool-result details.
system_prompt_extra: Some(format!("Description of your task: {description}")),
on_turn_end: None,
})
.await;
// Parent abort cascades to the subagent (the runner finished the registry record
// as Cancelled); same "cancelled" error as before.
if parent_cancel.is_cancelled() {
return Err(AgentToolError::Message("cancelled".into()));
}
if let Some(err) = result.error {
return Err(AgentToolError::Message(format!("subagent failed: {err}")));
}
let body = if result.text.is_empty() {
"(subagent produced no text output)".to_string()
} else {
result.text
};
Ok(AgentToolResult {
content: vec![UserContentBlock::text(body.clone())],
details: json!({
"subagent_type": subagent_type,
"description": description,
"chars": body.len(),
}),
terminate: None,
})
}
}
/// Build the tool definition. The `subagent_type` enum is populated from the app's
/// spec table at construction (the engine does not know which specs exist).
fn build_definition(spec_names: &[String]) -> Tool {
Tool {
name: "subagent".into(),
description:
"Delegate a self-contained task to a fresh sub-agent. The subagent gets its own context window and the uniform subagent tool set (engine tools minus subagent/dag_* plus local tools); this tool returns a single text result from the subagent. Use this when you need to inspect a large surface area (search, file reads) or run a contained change without polluting the main conversation.\n\
Model: by default the subagent inherits the parent session's model. Pass provider + model to resolve a concrete model from the loaded catalog (deepseek:deepseek-v4-flash, anthropic:claude-haiku-4-5, …) — this works even when the session has no model set; model without provider keeps the parent provider and only swaps the model id. thinking overrides the reasoning intensity (off, minimal, low, medium, high, xhigh, max). The last-set provider/model/thinking per subagent_type are remembered per project: later calls without explicit values inherit them (pass \"\" to clear; provider without model is never remembered).\n\
Budget: the subagent defaults to 300 LLM-turn attempts — the code-harness budget (compile → fix loops need it). For short, fast tasks (a quick read, a single check) lower max_iterations to a reasonable range like 4-32.\n\
Tools: by default the subagent gets every orchestrator tool except dag_* and subagent; pass tools: [\"read\", \"bash\"] to restrict it to specific tools (unknown names fail the call).".into(),
parameters: json!({
"type": "object",
"properties": {
"subagent_type": {
"type": "string",
"enum": spec_names,
"description": "Which subagent spec to spawn. All specs share ONE uniform tool set (engine tools minus subagent/dag_* plus local tools); the spec's system prompt defines the role (e.g. explorer: research, planner: planning, executor-coder: implementation, checker: verification, general: research).",
"default": "general",
},
"description": {
"type": "string",
"description": "Short label for the task (visible in UI logs).",
},
"provider": {
"type": "string",
"description": "Provider for the subagent model override, resolved with `model` against the loaded model catalog (e.g. deepseek, anthropic). Works independently of the parent session model; omit to inherit the parent session's provider.",
},
"model": {
"type": "string",
"description": "Subagent model override. With `provider`: a catalog model id resolved independently of the parent session model. Without `provider`: swaps the model id while keeping the parent session's provider/base_url (requires a parent session model).",
},
"thinking": {
"type": "string",
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
"description": "Reasoning-intensity override for the subagent. Providers without a thinking_level_map ignore the reasoning option at stream time.",
},
"prompt": {
"type": "string",
"description": "Full prompt the subagent will receive as its user message.",
},
"max_iterations": {
"type": "number",
"description": "Iteration-budget override (LLM-turn attempts); when set it wins over the spec default.",
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Tool allowlist (tool names): restricts the subagent to exactly these tools; unknown names fail the call. Omit for the full subagent tool set.",
},
},
"required": ["prompt"],
"additionalProperties": false,
}),
}
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("tools/subagent");
#[cfg(test)]
mod subagent_extra {
tests_bridge_macro::tests_bridge!("tools/subagent/extra");
}