M4: task tool, subagent spawner, board wiring

Adds the task tool and subagent spawner in harness-tools, and wires spawning/aliasing/reuse and depth limits through harness-app and the job board, so an orchestrator session can launch and track child sessions.
This commit is contained in:
2026-07-10 16:20:11 +02:00
parent 07476d1318
commit 8976b5dc09
18 changed files with 1137 additions and 67 deletions
+1
View File
@@ -149,6 +149,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}
+1
View File
@@ -232,6 +232,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}
+1
View File
@@ -130,6 +130,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}
+1
View File
@@ -159,6 +159,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}
+9
View File
@@ -4,6 +4,7 @@ mod glob;
mod grep;
mod paths;
mod read;
mod task;
mod write;
pub use bash::BashTool;
@@ -11,6 +12,7 @@ pub use edit::EditTool;
pub use glob::GlobTool;
pub use grep::GrepTool;
pub use read::ReadTool;
pub use task::TaskTool;
pub use write::WriteTool;
use std::sync::Arc;
@@ -26,3 +28,10 @@ pub fn register_builtins(registry: &mut ToolRegistry) {
registry.register(Arc::new(GlobTool));
registry.register(Arc::new(GrepTool));
}
/// Registers the multiagent `task` tool (M4). Kept separate from [`register_builtins`] so
/// non-orchestrating contexts can omit delegation; the tool no-ops with an error if the
/// session has no spawner wired in.
pub fn register_task_tool(registry: &mut ToolRegistry) {
registry.register(Arc::new(TaskTool));
}
+1
View File
@@ -139,6 +139,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}
+140
View File
@@ -0,0 +1,140 @@
//! The `task` tool: delegate work to a specialist subagent, foreground or background.
//!
//! This tool is deliberately thin — it validates input, gates on a `task/<agent>` permission,
//! and hands off to the engine's `SubagentSpawner` (owned by the composition root), which
//! resolves the agent, enforces the depth limit, applies permission intersection, and runs
//! the child session. See `docs/04-multiagent.md`.
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use harness_core::tool::{
invalid_input, SpawnError, SpawnRequest, Tool, ToolCtx, ToolError, ToolOutput,
};
#[derive(Debug, Deserialize)]
struct TaskInput {
/// Short human-facing label for the subtask (shown on the job board).
description: String,
/// The full instruction handed to the subagent.
prompt: String,
/// Which specialist to run (must be a subagent-capable agent).
subagent_type: String,
/// Alias or task id of a completed job to continue instead of starting fresh.
#[serde(default)]
task_id: Option<String>,
/// Run in the background and return immediately (tracked on the job board).
#[serde(default)]
background: bool,
}
pub struct TaskTool;
#[async_trait]
impl Tool for TaskTool {
fn name(&self) -> &str {
"task"
}
fn description(&self) -> &str {
"Delegate a self-contained unit of work to a specialist subagent. Set `background: \
true` to launch it without blocking (track progress on the job board); reuse a \
completed subagent by passing its `task_id`/alias to continue the same session."
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"description": { "type": "string", "description": "Short label for the subtask." },
"prompt": { "type": "string", "description": "Full instruction for the subagent." },
"subagent_type": { "type": "string", "description": "Specialist to run." },
"task_id": { "type": "string", "description": "Alias/id of a completed job to reuse." },
"background": { "type": "boolean", "description": "Launch without blocking." }
},
"required": ["description", "prompt", "subagent_type"]
})
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let args: TaskInput = serde_json::from_value(input).map_err(|e| invalid_input(self, e))?;
let Some(spawner) = ctx.spawner.clone() else {
return Err(ToolError::Other(
"subagent delegation is not available in this session".into(),
));
};
// Gate on task/<agent>. `Always` grants blanket delegation to this specialist.
ctx.ask
.ask(
"task",
&args.subagent_type,
&args.subagent_type,
json!({
"agent": args.subagent_type,
"description": args.description,
"background": args.background,
}),
)
.await?;
let req = SpawnRequest {
parent_session_id: ctx.session_id.clone(),
parent_message_id: ctx.message_id.clone(),
agent: args.subagent_type.clone(),
description: args.description.clone(),
prompt: args.prompt,
reuse_task_id: args.task_id,
background: args.background,
cancel: ctx.cancel.clone(),
};
let outcome = spawner.spawn(req).await.map_err(map_spawn_error)?;
if outcome.background {
let alias = outcome.alias.unwrap_or_default();
Ok(ToolOutput {
title: format!("launched {} ({alias})", args.subagent_type),
output: format!(
"Launched background task {alias} ({}). Check the job board; do not poll — \
wait for completion.",
outcome.child_session_id
),
metadata: json!({
"child_session": outcome.child_session_id,
"agent": args.subagent_type,
"alias": alias,
"background": true,
}),
})
} else {
let text = outcome.final_text.unwrap_or_default();
Ok(ToolOutput {
title: format!("{} — {}", args.subagent_type, args.description),
output: text,
metadata: json!({
"child_session": outcome.child_session_id,
"agent": args.subagent_type,
"background": false,
}),
})
}
}
}
fn map_spawn_error(err: SpawnError) -> ToolError {
match err {
// Depth/agent problems are the model's to fix — surface as tool errors it can read
// and act on, not hard failures.
SpawnError::DepthExceeded => ToolError::Other(err.to_string()),
SpawnError::InvalidAgent(_) => ToolError::Invalid(err.to_string()),
SpawnError::ReuseNotFound(_) => ToolError::Invalid(err.to_string()),
SpawnError::Other(msg) => ToolError::Other(msg),
}
}
+1
View File
@@ -114,6 +114,7 @@ mod tests {
CancellationToken::new(),
),
metadata,
spawner: None,
}
}