harness-tools + app: task tool, subagent spawner, board wiring (M4)

The `task` tool delegates to a SubagentSpawner (owned by the composition root):
resolves the agent, enforces the depth limit, applies permission intersection,
filters tools per-agent, and runs the child session foreground or background.
Foreground returns the child's final text; background registers on the job board
and detaches under the parent run token. The run loop injects the board into
primary-agent requests and reconciles terminal jobs each step.

Integration tests cover foreground run + alias reuse continuing the same child
session, depth-limit and unknown-agent rejection, and board prompt injection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 06:29:12 +02:00
co-authored by Claude Opus 4.8
parent 8c859d91c9
commit ecb3267a50
18 changed files with 1137 additions and 67 deletions
+41 -11
View File
@@ -18,10 +18,16 @@ use crate::types::ModelRef;
const SUBAGENTS_MARKER: &str = "{{SUBAGENTS}}";
const BUNDLED: &[(&str, &str)] = &[
("orchestrator", include_str!("../../assets/agents/orchestrator.md")),
(
"orchestrator",
include_str!("../../assets/agents/orchestrator.md"),
),
("explorer", include_str!("../../assets/agents/explorer.md")),
("oracle", include_str!("../../assets/agents/oracle.md")),
("librarian", include_str!("../../assets/agents/librarian.md")),
(
"librarian",
include_str!("../../assets/agents/librarian.md"),
),
("fixer", include_str!("../../assets/agents/fixer.md")),
("designer", include_str!("../../assets/agents/designer.md")),
];
@@ -107,7 +113,10 @@ fn parse_model_ref(s: &str) -> Option<ModelRef> {
/// Splits a markdown agent file into (frontmatter, body). A file without a leading `---`
/// fence is treated as an all-body prompt with empty frontmatter.
fn split_frontmatter(content: &str) -> (&str, &str) {
let rest = match content.strip_prefix("---\n").or_else(|| content.strip_prefix("---\r\n")) {
let rest = match content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))
{
Some(r) => r,
None => return ("", content),
};
@@ -149,7 +158,11 @@ fn parse_agent(
Ok(Some(AgentDef {
name: name.to_string(),
description: fm.description.unwrap_or_default(),
mode: fm.mode.as_deref().and_then(AgentMode::parse).unwrap_or_default(),
mode: fm
.mode
.as_deref()
.and_then(AgentMode::parse)
.unwrap_or_default(),
model: fm.model.as_deref().and_then(parse_model_ref),
temperature: fm.temperature,
prompt: body.trim_end().to_string(),
@@ -207,7 +220,10 @@ impl AgentRegistry {
/// Just the bundled agents — the default when no overrides are configured (tests, headless).
pub fn bundled() -> Self {
let mut reg = Self::default();
reg.load_markdown_layer(BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)), AgentSource::Bundled);
reg.load_markdown_layer(
BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)),
AgentSource::Bundled,
);
reg.generate_routing();
reg
}
@@ -338,7 +354,14 @@ mod tests {
#[test]
fn bundled_loads_all_six_agents() {
let reg = AgentRegistry::bundled();
for name in ["orchestrator", "explorer", "oracle", "librarian", "fixer", "designer"] {
for name in [
"orchestrator",
"explorer",
"oracle",
"librarian",
"fixer",
"designer",
] {
assert!(reg.get(name).is_some(), "missing {name}");
}
assert_eq!(reg.len(), 6);
@@ -356,10 +379,15 @@ mod tests {
\x20 - { permission: \"edit\", pattern: \"*\", action: deny }\n\
---\n\
You are a test agent.\n";
let def = parse_agent("tester", AgentSource::Bundled, md).unwrap().unwrap();
let def = parse_agent("tester", AgentSource::Bundled, md)
.unwrap()
.unwrap();
assert_eq!(def.description, "test agent");
assert_eq!(def.mode, AgentMode::Subagent);
assert_eq!(def.model, Some(ModelRef::new("anthropic", "claude-haiku-4-5")));
assert_eq!(
def.model,
Some(ModelRef::new("anthropic", "claude-haiku-4-5"))
);
assert_eq!(def.temperature, Some(0.1));
assert_eq!(def.tools.get("write"), Some(&false));
assert_eq!(def.tools.get("bash"), Some(&true));
@@ -379,9 +407,11 @@ mod tests {
#[test]
fn disable_true_removes_agent() {
assert!(parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody")
.unwrap()
.is_none());
assert!(
parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody")
.unwrap()
.is_none()
);
}
#[test]
+23 -5
View File
@@ -413,15 +413,24 @@ mod tests {
async fn alias_increments_per_agent() {
let (_store, board, parent) = board(2).await;
let a1 = board
.register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1)
.register_launch(
spec("t1", parent.clone(), SessionId::new(), "explorer", None),
1,
)
.await
.unwrap();
let a2 = board
.register_launch(spec("t2", parent.clone(), SessionId::new(), "explorer", None), 2)
.register_launch(
spec("t2", parent.clone(), SessionId::new(), "explorer", None),
2,
)
.await
.unwrap();
let f1 = board
.register_launch(spec("t3", parent.clone(), SessionId::new(), "fixer", None), 3)
.register_launch(
spec("t3", parent.clone(), SessionId::new(), "fixer", None),
3,
)
.await
.unwrap();
assert_eq!(a1, "exp-1");
@@ -492,7 +501,13 @@ mod tests {
for (i, ts) in [(1, 10), (2, 20), (3, 30)] {
board
.register_launch(
spec(&format!("t{i}"), parent.clone(), SessionId::new(), "explorer", None),
spec(
&format!("t{i}"),
parent.clone(),
SessionId::new(),
"explorer",
None,
),
ts,
)
.await
@@ -543,7 +558,10 @@ mod tests {
.await
.unwrap();
board
.register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1)
.register_launch(
spec("t1", parent.clone(), SessionId::new(), "explorer", None),
1,
)
.await
.unwrap();
}
+156
View File
@@ -22,6 +22,8 @@ pub struct RunConfig {
pub instructions: Vec<String>,
/// Pricing for `model`, from models.dev metadata. `None` leaves cost at 0.
pub cost: Option<crate::types::ModelCost>,
/// Whether to append the background job board to requests (primary/delegating agents).
pub inject_job_board: bool,
}
/// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is
@@ -198,6 +200,22 @@ pub async fn run_session(
wire_messages.extend(convert_message(message, &parts));
}
// Append the (synthetic, non-persisted) job board to the last user message so the
// orchestrator sees running/reusable subtasks. docs/04-multiagent.md.
if run_config.inject_job_board {
if let Some(board) = &ctx.job_board {
if let Some(block) = board.format_for_prompt() {
if let Some(last_user) = wire_messages
.iter_mut()
.rev()
.find(|m| m.role == WireRole::User)
{
last_user.content.push(WireContent::Text { text: block });
}
}
}
}
let system_blocks = system::assemble(
system::env_header(&ctx.cwd),
&run_config.agent_prompt,
@@ -260,6 +278,15 @@ pub async fn run_session(
}
Ok(outcome) => {
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
// A completed step means the orchestrator has now seen any terminal jobs
// that were on the board this turn; mark them reconciled.
if run_config.inject_job_board {
if let Some(board) = &ctx.job_board {
if let Err(e) = board.reconcile_terminal(now_fn()).await {
tracing::warn!(error = %e, "failed to reconcile job board");
}
}
}
match outcome.result {
StepResult::Continue => continue,
StepResult::Stop => return RunOutcome::Stopped,
@@ -376,11 +403,14 @@ mod tests {
permissions,
static_rules: Vec::new(),
extra_rules: Arc::new(std::sync::Mutex::new(Vec::new())),
parent_rules: Vec::new(),
session_id,
cwd: cwd.clone(),
data_dir: cwd.join("tool-output"),
cancel: CancellationToken::new(),
now: 1,
spawner: None,
job_board: None,
}
}
@@ -462,6 +492,7 @@ mod tests {
output: 15.0,
..Default::default()
}),
inject_job_board: false,
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
@@ -569,6 +600,7 @@ mod tests {
max_steps: 10,
instructions: Vec::new(),
cost: None,
inject_job_board: false,
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
@@ -578,4 +610,128 @@ mod tests {
let messages = store.messages(session_id).await.unwrap();
assert_eq!(messages.len(), 1);
}
/// Records the last request it was asked to stream so tests can assert on prompt content.
struct CapturingProvider {
last: StdMutex<Option<LlmRequest>>,
}
#[async_trait]
impl Provider for CapturingProvider {
fn id(&self) -> &str {
"mock"
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
Ok(vec![])
}
async fn stream(
&self,
req: LlmRequest,
_cancel: CancellationToken,
) -> Result<LlmEventStream, ProviderError> {
*self.last.lock().unwrap() = Some(req);
let events = vec![
Ok(LlmEvent::TextStart { id: "t".into() }),
Ok(LlmEvent::TextDelta {
id: "t".into(),
text: "ok".into(),
}),
Ok(LlmEvent::TextEnd { id: "t".into() }),
Ok(LlmEvent::Finish {
reason: FinishReason::Stop,
usage: usage(1, 1),
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[tokio::test]
async fn job_board_is_injected_into_the_last_user_message() {
use crate::engine::jobs::{JobBoard, LaunchSpec};
let store = Store::open_in_memory().unwrap();
let bus = EventBus::new();
let model = ModelRef::new("mock", "mock-model");
let session = Session::new_root("orchestrator", model.clone(), 1);
let session_id = session.id.clone();
store.upsert_session(session).await.unwrap();
let user_message = Message::new_user(session_id.clone(), 1);
store.upsert_message(user_message.clone()).await.unwrap();
store
.upsert_part(Part {
id: crate::types::PartId::new(),
message_id: user_message.id.clone(),
session_id: session_id.clone(),
idx: 0,
body: PartBody::Text {
text: "carry on".into(),
synthetic: false,
},
})
.await
.unwrap();
// A board with one running job for this session.
let board = std::sync::Arc::new(
JobBoard::load(store.clone(), bus.clone(), &session_id, 2)
.await
.unwrap(),
);
board
.register_launch(
LaunchSpec {
task_id: "t1".into(),
parent_session: session_id.clone(),
child_session: SessionId::new(),
agent: "explorer".into(),
description: "map auth".into(),
objective: Some("map the auth flow".into()),
},
1,
)
.await
.unwrap();
let cwd = tempfile::tempdir().unwrap();
let mut ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await;
ctx.job_board = Some(board);
let run_config = RunConfig {
agent_name: "orchestrator".into(),
agent_prompt: "You orchestrate.".into(),
model,
temperature: None,
max_steps: 1,
instructions: Vec::new(),
cost: None,
inject_job_board: true,
};
let provider = std::sync::Arc::new(CapturingProvider {
last: StdMutex::new(None),
});
let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await;
assert!(matches!(outcome, RunOutcome::Stopped));
let req = provider.last.lock().unwrap().clone().expect("a request");
let last_user = req
.messages
.iter()
.rev()
.find(|m| m.role == WireRole::User)
.expect("a user message");
let text: String = last_user
.content
.iter()
.filter_map(|c| match c {
WireContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Background Job Board"), "got: {text}");
assert!(text.contains("exp-1"), "got: {text}");
assert!(text.contains("map the auth flow"), "got: {text}");
}
}
+15 -2
View File
@@ -10,10 +10,13 @@ use crate::event::{AppEvent, EventBus};
use crate::llm::{FinishReason, LlmEvent, LlmEventStream, ProviderError};
use crate::permission::{PermissionService, Ruleset};
use crate::store::Store;
use crate::tool::{MetadataSink, PermissionHandle, Tool, ToolCtx, ToolError, ToolRegistry};
use crate::tool::{
MetadataSink, PermissionHandle, SubagentSpawner, Tool, ToolCtx, ToolError, ToolRegistry,
};
use crate::types::{Message, MessageId, Part, PartBody, PartId, SessionId, TokenUsage, ToolState};
use super::doomloop::DoomLoopGuard;
use super::jobs::JobBoard;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepResult {
@@ -47,6 +50,9 @@ pub struct StepContext {
pub permissions: Arc<PermissionService>,
pub static_rules: Ruleset,
pub extra_rules: Arc<Mutex<Ruleset>>,
/// Parent-effective ruleset for a subagent session; empty for a root session. Enables
/// permission intersection on this session's tool calls.
pub parent_rules: Ruleset,
pub session_id: SessionId,
pub cwd: PathBuf,
/// Session's `tool-output` spill directory (see `tool::truncate`).
@@ -54,6 +60,11 @@ pub struct StepContext {
pub cancel: CancellationToken,
/// Wall-clock for `created_at` stamps — passed in so tests stay deterministic.
pub now: i64,
/// Lets the `task` tool spawn subagents. `None` disables delegation (headless/tests).
pub spawner: Option<Arc<dyn SubagentSpawner>>,
/// This session's background job board (as a parent). Injected into requests when the
/// running agent can delegate; `None` disables the board.
pub job_board: Option<Arc<JobBoard>>,
}
struct FlushTracker {
@@ -483,7 +494,8 @@ impl<'a> Run<'a> {
self.ctx.static_rules.clone(),
self.ctx.extra_rules.clone(),
call_cancel.clone(),
);
)
.with_parent_rules(self.ctx.parent_rules.clone());
let tool_ctx = ToolCtx {
session_id: self.ctx.session_id.clone(),
message_id: self.message_id(),
@@ -493,6 +505,7 @@ impl<'a> Run<'a> {
cancel: call_cancel.clone(),
ask,
metadata: metadata_sink,
spawner: self.ctx.spawner.clone(),
};
let result = tokio::select! {
+19 -1
View File
@@ -8,7 +8,7 @@ use ulid::Ulid;
use crate::event::{AppEvent, EventBus, PermissionRequest};
use crate::types::SessionId;
use super::rule::{evaluate, Action, Rule, Ruleset};
use super::rule::{evaluate, evaluate_intersected, Action, Rule, Ruleset};
pub struct AskInput {
pub permission: String,
@@ -73,6 +73,24 @@ impl PermissionService {
}
}
/// Like [`ask`](Self::ask), but for a subagent: the verdict is the more restrictive of
/// the `parent_stack` (rules inherited from the spawning chain) and `child_stack` (the
/// subagent's own rules). Used so a child can never widen what its parent forbids.
pub async fn ask_intersected(
&self,
session_id: &SessionId,
parent_stack: &[&Ruleset],
child_stack: &[&Ruleset],
input: AskInput,
cancel: &CancellationToken,
) -> Result<AskDecision, AskError> {
match evaluate_intersected(parent_stack, child_stack, &input.permission, &input.pattern) {
Action::Allow => Ok(AskDecision::Allowed),
Action::Deny => Err(AskError::Denied),
Action::Ask => self.ask_user(session_id, input, cancel).await,
}
}
/// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask
/// regardless of any `Allow` rule.
pub async fn force_ask(
+1 -4
View File
@@ -104,10 +104,7 @@ impl Store {
self.call(|reply| StoreCmd::DeleteJob(task_id, reply)).await
}
pub async fn jobs_for_parent(
&self,
parent: SessionId,
) -> Result<Vec<JobRecord>, StoreError> {
pub async fn jobs_for_parent(&self, parent: SessionId) -> Result<Vec<JobRecord>, StoreError> {
self.call(|reply| StoreCmd::JobsForParent(parent, reply))
.await
}
+80 -15
View File
@@ -10,6 +10,50 @@ use tokio_util::sync::CancellationToken;
use crate::permission::{AskDecision, AskError, AskInput, PermissionService, Ruleset};
use crate::types::{MessageId, SessionId};
/// A request from the `task` tool to run a subagent. The spawner (owned by the composition
/// root) resolves the agent, enforces the depth limit, applies permission intersection, and
/// runs the child session foreground or background. See `docs/04-multiagent.md`.
pub struct SpawnRequest {
pub parent_session_id: SessionId,
pub parent_message_id: MessageId,
pub agent: String,
pub description: String,
pub prompt: String,
/// Alias or task id of a completed job to reuse (continue its child session).
pub reuse_task_id: Option<String>,
pub background: bool,
/// The tool call's cancellation token — used for foreground child runs. Background runs
/// are childed from the parent session's run token by the spawner instead.
pub cancel: CancellationToken,
}
#[derive(Debug)]
pub struct SpawnOutcome {
pub child_session_id: SessionId,
pub background: bool,
/// Board alias assigned to a background launch.
pub alias: Option<String>,
/// Final assistant text of a foreground run.
pub final_text: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum SpawnError {
#[error("unknown subagent {0:?}, or it is not usable as a subagent")]
InvalidAgent(String),
#[error("subagent depth limit reached — do this work yourself instead of delegating further")]
DepthExceeded,
#[error("cannot reuse {0:?}: no completed job with that alias for this session")]
ReuseNotFound(String),
#[error("{0}")]
Other(String),
}
#[async_trait]
pub trait SubagentSpawner: Send + Sync {
async fn spawn(&self, req: SpawnRequest) -> Result<SpawnOutcome, SpawnError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("permission denied")]
@@ -60,6 +104,9 @@ pub struct PermissionHandle {
session_id: SessionId,
static_rules: Ruleset,
extra_rules: Arc<Mutex<Ruleset>>,
/// Parent-effective ruleset for a subagent session; empty for a root session. When
/// non-empty, verdicts are intersected so a child can only ever be *more* restricted.
parent_rules: Ruleset,
cancel: CancellationToken,
}
@@ -76,10 +123,17 @@ impl PermissionHandle {
session_id,
static_rules,
extra_rules,
parent_rules: Vec::new(),
cancel,
}
}
/// Sets the parent-effective ruleset so this handle intersects verdicts (subagent runs).
pub fn with_parent_rules(mut self, parent_rules: Ruleset) -> Self {
self.parent_rules = parent_rules;
self
}
pub async fn ask(
&self,
permission: impl Into<String>,
@@ -88,21 +142,29 @@ impl PermissionHandle {
metadata: serde_json::Value,
) -> Result<(), ToolError> {
let extra_snapshot = self.extra_rules.lock().unwrap().clone();
let stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot];
let decision = self
.service
.ask(
&self.session_id,
&stack,
AskInput {
permission: permission.into(),
pattern: pattern.into(),
always_pattern: always_pattern.into(),
metadata,
},
&self.cancel,
)
.await?;
let child_stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot];
let input = AskInput {
permission: permission.into(),
pattern: pattern.into(),
always_pattern: always_pattern.into(),
metadata,
};
let decision = if self.parent_rules.is_empty() {
self.service
.ask(&self.session_id, &child_stack, input, &self.cancel)
.await?
} else {
let parent_stack: [&Ruleset; 1] = [&self.parent_rules];
self.service
.ask_intersected(
&self.session_id,
&parent_stack,
&child_stack,
input,
&self.cancel,
)
.await?
};
if let AskDecision::AllowedAlways(rule) = decision {
self.extra_rules.lock().unwrap().push(rule);
}
@@ -120,6 +182,9 @@ pub struct ToolCtx {
pub cancel: CancellationToken,
pub ask: PermissionHandle,
pub metadata: MetadataSink,
/// Present when the engine can spawn subagents (the `task` tool's capability). `None`
/// in headless/test contexts with no orchestration wired in.
pub spawner: Option<Arc<dyn SubagentSpawner>>,
}
#[derive(Debug)]