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:
@@ -149,6 +149,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user