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.
141 lines
5.2 KiB
Rust
141 lines
5.2 KiB
Rust
//! 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),
|
|
}
|
|
}
|