use super::{Capability, CapabilityLocalization, CapabilityStatus};
use crate::platform_store::{PlatformCreateSessionRequest, PlatformStore};
use crate::session::SessionSeedMode;
use crate::session_task::{
CreateSessionTask, NewTaskMessage, SessionTask, SessionTaskFilter, SessionTaskState,
SessionTaskUpdate, TASK_KIND_SESSION, TASK_KIND_SUBAGENT, TaskError, TaskExecutor,
TaskExecutorPlugin, TaskLinks, TaskMessage, TaskMessageDirection, TaskMessagePart,
TaskWakePolicy, task_message_text, task_result_path,
};
use crate::tool_types::ToolHints;
use crate::tools::{Tool, ToolExecutionResult};
use crate::traits::{SessionFileSystem, SessionStore, SpawnClaimResult, ToolContext};
use crate::typed_id::{SessionId, WorkspaceId};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
pub const SUBAGENTS_CAPABILITY_ID: &str = "subagents";
pub struct SubagentCapability;
impl Capability for SubagentCapability {
fn id(&self) -> &str {
SUBAGENTS_CAPABILITY_ID
}
fn name(&self) -> &str {
"Subagents"
}
fn description(&self) -> &str {
"Spawn and manage subagents for parallel task execution in isolated context windows."
}
fn localizations(&self) -> Vec<CapabilityLocalization> {
vec![CapabilityLocalization::text(
"uk",
"Субагенти",
"Запускайте субагентів і керуйте ними для паралельного виконання завдань в ізольованих контекстних вікнах.",
)]
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn icon(&self) -> Option<&str> {
Some("git-branch")
}
fn category(&self) -> Option<&str> {
Some("Core")
}
fn features(&self) -> Vec<&'static str> {
vec!["subagents"]
}
fn config_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"additionalProperties": false,
"properties": {
"max_subagent_depth": {
"type": "integer",
"minimum": 0,
"maximum": 16,
"default": crate::traits::DEFAULT_MAX_SUBAGENT_DEPTH,
"description": "Maximum child depth allowed from a top-level session. Top-level sessions are depth 0; setting 0 blocks all subagent spawning."
},
"max_depth": {
"type": "integer",
"minimum": 0,
"maximum": 16,
"description": "Alias for max_subagent_depth."
},
"max_active_descendant_tasks": {
"type": "integer",
"minimum": 0,
"maximum": 1024,
"default": crate::traits::DEFAULT_MAX_ACTIVE_DESCENDANT_SUBAGENT_TASKS,
"description": "Maximum non-terminal descendant subagent tasks allowed under one root session. Counts queued, running, and awaiting_input tasks."
},
"max_concurrent_descendant_tasks": {
"type": "integer",
"minimum": 0,
"maximum": 1024,
"description": "Alias for max_active_descendant_tasks."
},
"max_total_descendant_tasks": {
"type": "integer",
"minimum": 0,
"maximum": 10000,
"default": crate::traits::DEFAULT_MAX_TOTAL_DESCENDANT_SUBAGENT_TASKS,
"description": "Maximum descendant subagent task records allowed under one root session before rejecting new spawns."
}
}
}))
}
fn validate_config(&self, config: &Value) -> Result<(), String> {
for key in ["max_subagent_depth", "max_depth"] {
let Some(value) = config.get(key) else {
continue;
};
let Some(depth) = value.as_u64() else {
return Err(format!("{key} must be a non-negative integer"));
};
if depth > 16 {
return Err(format!("{key} must be <= 16"));
}
}
for key in [
"max_active_descendant_tasks",
"max_concurrent_descendant_tasks",
] {
let Some(value) = config.get(key) else {
continue;
};
let Some(max_active) = value.as_u64() else {
return Err(format!("{key} must be a non-negative integer"));
};
if max_active > 1024 {
return Err(format!("{key} must be <= 1024"));
}
}
if let Some(value) = config.get("max_total_descendant_tasks") {
let Some(max_total) = value.as_u64() else {
return Err("max_total_descendant_tasks must be a non-negative integer".to_string());
};
if max_total > 10_000 {
return Err("max_total_descendant_tasks must be <= 10000".to_string());
}
}
Ok(())
}
fn system_prompt_addition(&self) -> Option<&str> {
Some(SUBAGENT_SYSTEM_PROMPT)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![]
}
}
const SUBAGENT_SYSTEM_PROMPT: &str = "Spawn subagents for independent parallel work or separate context; avoid immediate sequential steps. Spawns are background by default: you get a task_id, keep working, and are notified on completion (monitor with get_task/wait_task). Use mode \"foreground\" only when blocked on the result. Nested subagents are allowed up to max_subagent_depth and root-tree task caps. Use blueprints for specialist tools/model.";
const RESULT_SCHEMA_SPEC_KEY: &str = "result_schema";
const MESSAGE_SCHEMA_SPEC_KEY: &str = "message_schema";
const PUSH_CONFIGS_SPEC_KEY: &str = "push_configs";
const VALID_PUSH_EVENT_FILTERS: [&str; 3] = ["terminal", "awaiting_input", "message"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnMode {
Background,
Foreground,
}
impl SpawnMode {
fn as_str(self) -> &'static str {
match self {
Self::Background => "background",
Self::Foreground => "foreground",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnLifetime {
Linked,
Detached,
}
impl SpawnLifetime {
fn parse(arguments: &Value) -> Result<Self, ToolExecutionResult> {
match arguments.get("lifetime").and_then(Value::as_str) {
None | Some("linked") => Ok(Self::Linked),
Some("detached") => Ok(Self::Detached),
Some(other) => Err(ToolExecutionResult::tool_error(format!(
"Invalid lifetime: {other}. Expected 'linked' or 'detached'."
))),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Linked => "linked",
Self::Detached => "detached",
}
}
}
fn parse_seed(arguments: &Value) -> Result<SessionSeedMode, ToolExecutionResult> {
match arguments.get("seed").and_then(Value::as_str) {
None | Some("fresh") => Ok(SessionSeedMode::Fresh),
Some("fork") => Ok(SessionSeedMode::Fork),
Some("workspace") => Ok(SessionSeedMode::Workspace),
Some(other) => Err(ToolExecutionResult::tool_error(format!(
"Invalid seed: {other}. Expected 'fresh', 'fork', or 'workspace'."
))),
}
}
const BACKGROUND_WAIT_SLICE_SECS: u64 = 300;
const BACKGROUND_MAX_WAIT_SECS: u64 = 6 * 60 * 60;
const BACKGROUND_HEARTBEAT_INTERVAL_SECS: u64 = 15;
const BACKGROUND_POLL_BACKOFF_SECS: u64 = 5;
fn terminal_subagent_status(wait_status: &str) -> Option<crate::session::SubagentStatus> {
match wait_status {
"completed" => Some(crate::session::SubagentStatus::Completed),
"error" | "failed" => Some(crate::session::SubagentStatus::Failed),
"cancelled" => Some(crate::session::SubagentStatus::Cancelled),
"max_iterations_reached" => Some(crate::session::SubagentStatus::MaxIterationsReached),
"sealed" => Some(crate::session::SubagentStatus::Sealed),
_ => None,
}
}
fn terminal_subagent_task_state(
subagent_status: &crate::session::SubagentStatus,
) -> SessionTaskState {
match subagent_status {
crate::session::SubagentStatus::Completed => SessionTaskState::Succeeded,
crate::session::SubagentStatus::Cancelled => SessionTaskState::Canceled,
_ => SessionTaskState::Failed,
}
}
fn declared_result_schema(task: &SessionTask) -> Option<&Value> {
task.spec
.get(RESULT_SCHEMA_SPEC_KEY)
.filter(|schema| schema.is_object())
}
fn declared_message_schema(task: &SessionTask) -> Option<&Value> {
task.spec
.get(MESSAGE_SCHEMA_SPEC_KEY)
.filter(|schema| schema.is_object())
}
fn normalize_optional_schema(
arguments: &Value,
key: &str,
) -> Result<Option<Value>, ToolExecutionResult> {
let Some(schema) = arguments.get(key).filter(|v| !v.is_null()) else {
return Ok(None);
};
if !schema.is_object() {
return Err(ToolExecutionResult::tool_error(format!(
"{key} must be a JSON Schema object when provided.",
)));
}
Ok(Some(schema.clone()))
}
fn normalize_result_schema(arguments: &Value) -> Result<Option<Value>, ToolExecutionResult> {
normalize_optional_schema(arguments, RESULT_SCHEMA_SPEC_KEY)
}
fn normalize_message_schema(arguments: &Value) -> Result<Option<Value>, ToolExecutionResult> {
normalize_optional_schema(arguments, MESSAGE_SCHEMA_SPEC_KEY)
}
fn normalize_push_configs(arguments: &Value) -> Result<Option<Value>, ToolExecutionResult> {
let Some(raw) = arguments
.get(PUSH_CONFIGS_SPEC_KEY)
.filter(|v| !v.is_null())
else {
return Ok(None);
};
let Some(entries) = raw.as_array() else {
return Err(ToolExecutionResult::tool_error(
"push_configs must be an array of { url, secret?, event_filter? } objects.",
));
};
if entries.is_empty() {
return Ok(None);
}
let mut normalized = Vec::with_capacity(entries.len());
for entry in entries {
let Some(url) = entry.get("url").and_then(Value::as_str) else {
return Err(ToolExecutionResult::tool_error(
"Each push_configs entry requires a string `url`.",
));
};
if let Err(e) = crate::url_validation::validate_safe_url(url) {
return Err(ToolExecutionResult::tool_error(format!(
"Invalid push_configs url \"{url}\": {e}"
)));
}
let mut obj = serde_json::Map::new();
obj.insert("url".to_string(), Value::String(url.to_string()));
if let Some(secret) = entry
.get("secret")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
obj.insert("secret".to_string(), Value::String(secret.to_string()));
}
if let Some(filters) = entry.get("event_filter").filter(|v| !v.is_null()) {
let Some(arr) = filters.as_array() else {
return Err(ToolExecutionResult::tool_error(
"push_configs event_filter must be an array of strings.",
));
};
let mut out: Vec<Value> = Vec::new();
for f in arr {
let Some(f) = f.as_str() else {
return Err(ToolExecutionResult::tool_error(
"push_configs event_filter members must be strings.",
));
};
if !VALID_PUSH_EVENT_FILTERS.contains(&f) {
return Err(ToolExecutionResult::tool_error(format!(
"Unknown push_configs event_filter \"{f}\". Valid: {}.",
VALID_PUSH_EVENT_FILTERS.join(", ")
)));
}
if !out.iter().any(|x| x.as_str() == Some(f)) {
out.push(Value::String(f.to_string()));
}
}
if !out.is_empty() {
obj.insert("event_filter".to_string(), Value::Array(out));
}
}
normalized.push(Value::Object(obj));
}
Ok(Some(Value::Array(normalized)))
}
fn json_schema_type_matches(expected: &str, value: &Value) -> bool {
match expected {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"boolean" => value.is_boolean(),
"integer" => value.as_i64().is_some() || value.as_u64().is_some(),
"number" => value.is_number(),
"null" => value.is_null(),
_ => true,
}
}
fn validate_against_schema(schema: &Value, value: &Value, path: &str, errors: &mut Vec<String>) {
if let Some(enum_values) = schema.get("enum").and_then(Value::as_array)
&& !enum_values.iter().any(|candidate| candidate == value)
{
errors.push(format!("{path} is not one of the allowed enum values"));
}
if let Some(const_value) = schema.get("const")
&& const_value != value
{
errors.push(format!("{path} does not match the required const value"));
}
if let Some(type_value) = schema.get("type") {
let matches = match type_value {
Value::String(expected) => json_schema_type_matches(expected, value),
Value::Array(types) => types
.iter()
.filter_map(Value::as_str)
.any(|expected| json_schema_type_matches(expected, value)),
_ => true,
};
if !matches {
errors.push(format!("{path} has the wrong JSON type"));
return;
}
}
if let (Some(object), Some(properties)) = (
value.as_object(),
schema.get("properties").and_then(Value::as_object),
) {
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for key in required.iter().filter_map(Value::as_str) {
if !object.contains_key(key) {
errors.push(format!("{path}.{key} is required"));
}
}
}
if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
for key in object.keys() {
if !properties.contains_key(key) {
errors.push(format!("{path}.{key} is not allowed"));
}
}
}
for (key, property_schema) in properties {
if let Some(property_value) = object.get(key) {
validate_against_schema(
property_schema,
property_value,
&format!("{path}.{key}"),
errors,
);
}
}
}
if let (Some(array), Some(item_schema)) = (value.as_array(), schema.get("items")) {
for (index, item) in array.iter().enumerate() {
validate_against_schema(item_schema, item, &format!("{path}[{index}]"), errors);
}
}
}
fn schema_validation_errors(schema: &Value, value: &Value) -> Vec<String> {
let mut errors = Vec::new();
validate_against_schema(schema, value, "$", &mut errors);
errors
}
use super::util::{get_platform_store, require_str_nonblank as require_str};
fn get_session_store(
context: &ToolContext,
) -> Result<&dyn crate::traits::SessionStore, ToolExecutionResult> {
context
.session_store
.as_ref()
.map(|s| s.as_ref())
.ok_or_else(|| {
ToolExecutionResult::tool_error("Subagent tools require session_store context")
})
}
async fn current_subagent_depth(
session_store: &dyn SessionStore,
session: &crate::session::Session,
max_subagent_depth: u32,
) -> Result<u32, ToolExecutionResult> {
let mut depth = 0_u32;
let mut cursor = session.parent_session_id;
while let Some(parent_id) = cursor {
depth = depth.saturating_add(1);
if depth > max_subagent_depth {
return Ok(depth);
}
let parent = match session_store.get_session(parent_id).await {
Ok(Some(parent)) => parent,
Ok(None) => {
return Err(ToolExecutionResult::tool_error(format!(
"Cannot enforce max_subagent_depth: parent session {parent_id} was not found."
)));
}
Err(error) => return Err(ToolExecutionResult::internal_error(error)),
};
cursor = parent.parent_session_id;
}
Ok(depth)
}
async fn root_session_for_subagent_tree(
session_store: &dyn SessionStore,
session: &crate::session::Session,
) -> Result<SessionId, ToolExecutionResult> {
let mut root_id = session.id;
let mut cursor = session.parent_session_id;
let mut seen = HashSet::new();
seen.insert(session.id);
while let Some(parent_id) = cursor {
if !seen.insert(parent_id) {
return Err(ToolExecutionResult::tool_error(format!(
"Cannot enforce subagent descendant task caps: session parent cycle detected at {parent_id}."
)));
}
let parent = match session_store.get_session(parent_id).await {
Ok(Some(parent)) => parent,
Ok(None) => {
return Err(ToolExecutionResult::tool_error(format!(
"Cannot enforce subagent descendant task caps: parent session {parent_id} was not found."
)));
}
Err(error) => return Err(ToolExecutionResult::internal_error(error)),
};
root_id = parent.id;
cursor = parent.parent_session_id;
}
Ok(root_id)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct DescendantTaskCounts {
active: u32,
total: u32,
}
async fn descendant_subagent_task_counts(
registry: &dyn crate::session_task::SessionTaskRegistry,
root_session_id: SessionId,
max_active: u32,
max_total: u32,
) -> Result<DescendantTaskCounts, ToolExecutionResult> {
let mut counts = DescendantTaskCounts::default();
let mut queue = VecDeque::from([root_session_id]);
let mut visited_sessions = HashSet::from([root_session_id]);
while let Some(session_id) = queue.pop_front() {
let tasks = registry
.list(
session_id,
Some(&SessionTaskFilter {
kind: Some(TASK_KIND_SUBAGENT.to_string()),
state: None,
}),
)
.await
.map_err(ToolExecutionResult::internal_error)?;
for task in tasks {
counts.total = counts.total.saturating_add(1);
if !task.state.is_terminal() {
counts.active = counts.active.saturating_add(1);
}
if let Some(child_session_id) = task.links.child_session_id
&& visited_sessions.insert(child_session_id)
{
queue.push_back(child_session_id);
}
if counts.active >= max_active || counts.total >= max_total {
return Ok(counts);
}
}
}
Ok(counts)
}
async fn enforce_subagent_task_caps(
session_store: &dyn SessionStore,
session: &crate::session::Session,
context: &ToolContext,
) -> Result<(), ToolExecutionResult> {
let Some(registry) = context.session_task_registry.as_ref() else {
return Ok(());
};
let policy = context.subagent_nesting_policy;
let max_active = policy.max_active_descendant_tasks();
let max_total = policy.max_total_descendant_tasks();
let root_session_id = root_session_for_subagent_tree(session_store, session).await?;
let counts =
descendant_subagent_task_counts(registry.as_ref(), root_session_id, max_active, max_total)
.await?;
if counts.active >= max_active {
let attempted = counts.active.saturating_add(1);
return Err(ToolExecutionResult::tool_error(format!(
"Subagent active descendant task cap exceeded: spawning this subagent would create {attempted} non-terminal descendant tasks under root session {root_session_id}, but max_active_descendant_tasks is {max_active}."
)));
}
if counts.total >= max_total {
let attempted = counts.total.saturating_add(1);
return Err(ToolExecutionResult::tool_error(format!(
"Subagent total descendant task cap exceeded: spawning this subagent would create {attempted} descendant task records under root session {root_session_id}, but max_total_descendant_tasks is {max_total}."
)));
}
Ok(())
}
async fn enforce_subagent_depth_cap(
session_store: &dyn SessionStore,
session: &crate::session::Session,
context: &ToolContext,
) -> Result<(), ToolExecutionResult> {
let max_subagent_depth = context.subagent_nesting_policy.max_subagent_depth();
let current_depth = current_subagent_depth(session_store, session, max_subagent_depth).await?;
let child_depth = current_depth.saturating_add(1);
if child_depth > max_subagent_depth {
return Err(ToolExecutionResult::tool_error(format!(
"Subagent nesting depth cap exceeded: spawning this subagent would create depth {child_depth}, but max_subagent_depth is {max_subagent_depth}."
)));
}
Ok(())
}
fn last_agent_message(messages: &[crate::platform_store::PlatformMessage]) -> Option<String> {
messages
.iter()
.rfind(|m| m.role == "agent" || m.role == "assistant")
.map(|m| m.content.clone())
}
const MAX_TASK_SUMMARY_CHARS: usize = 2_048;
fn truncate_summary(text: &str) -> String {
let mut chars = text.chars();
let truncated: String = chars.by_ref().take(MAX_TASK_SUMMARY_CHARS).collect();
if chars.next().is_some() {
format!("{truncated}\n[truncated]")
} else {
truncated
}
}
async fn finish_subagent_task(
context: &ToolContext,
task_id: Option<&str>,
state: SessionTaskState,
summary: Option<String>,
error: Option<TaskError>,
) {
let (Some(registry), Some(task_id)) = (context.session_task_registry.as_ref(), task_id) else {
return;
};
let _ = registry
.update(
context.session_id,
task_id,
SessionTaskUpdate {
state: Some(state),
summary,
error,
..Default::default()
},
)
.await;
}
async fn find_subagent_task(context: &ToolContext, child_id: SessionId) -> Option<SessionTask> {
let registry = context.session_task_registry.as_ref()?;
let tasks = registry
.list(
context.session_id,
Some(&SessionTaskFilter {
kind: Some(TASK_KIND_SUBAGENT.to_string()),
state: None,
}),
)
.await
.ok()?;
tasks
.into_iter()
.find(|task| task.links.child_session_id == Some(child_id))
}
async fn get_subagent_task(context: &ToolContext, task_id: &str) -> Option<SessionTask> {
context
.session_task_registry
.as_ref()?
.get(context.session_id, task_id)
.await
.ok()
.flatten()
}
pub struct ReportResultTool {
parent_session_id: SessionId,
parent_workspace_id: WorkspaceId,
task_id: String,
result_schema: Value,
file_store: Option<Arc<dyn SessionFileSystem>>,
}
impl ReportResultTool {
pub fn new(
parent_session_id: SessionId,
parent_workspace_id: WorkspaceId,
task_id: String,
result_schema: Value,
) -> Self {
Self {
parent_session_id,
parent_workspace_id,
task_id,
result_schema,
file_store: None,
}
}
pub fn with_file_store(mut self, file_store: Arc<dyn SessionFileSystem>) -> Self {
self.file_store = Some(file_store);
self
}
fn result_path(&self) -> String {
task_result_path(&self.task_id)
}
}
#[async_trait]
impl Tool for ReportResultTool {
fn name(&self) -> &str {
"report_result"
}
fn display_name(&self) -> Option<&str> {
Some("Report Result")
}
fn description(&self) -> &str {
"Submit the final structured result for this subagent task. The call arguments must match the declared result schema."
}
fn parameters_schema(&self) -> Value {
self.result_schema.clone()
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"report_result requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let errors = schema_validation_errors(&self.result_schema, &arguments);
if !errors.is_empty() {
return ToolExecutionResult::tool_error(format!(
"report_result arguments do not match result_schema: {}",
errors.join("; ")
));
}
let Some(registry) = context.session_task_registry.as_ref() else {
return ToolExecutionResult::tool_error(
"report_result requires session_task_registry context",
);
};
let Some(file_store) = self.file_store.as_ref().or(context.file_store.as_ref()) else {
return ToolExecutionResult::tool_error("report_result requires file_store context");
};
let path = self.result_path();
let content = match serde_json::to_string_pretty(&arguments) {
Ok(content) => content,
Err(error) => return ToolExecutionResult::internal_error(error),
};
let parent_workspace_key = SessionId::from_uuid(self.parent_workspace_id.uuid());
if let Err(error) = file_store
.write_file(parent_workspace_key, &path, &content, "utf-8")
.await
{
return ToolExecutionResult::internal_error(error);
}
if let Err(error) = registry
.update(
self.parent_session_id,
&self.task_id,
SessionTaskUpdate {
result_path: Some(path.clone()),
summary: Some(truncate_summary(&content)),
..Default::default()
},
)
.await
{
return ToolExecutionResult::internal_error(error);
}
ToolExecutionResult::success(json!({
"status": "recorded",
"task_id": self.task_id,
"result_path": path,
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct ReportTaskProgressTool {
parent_session_id: SessionId,
task_id: String,
message_schema: Value,
}
impl ReportTaskProgressTool {
pub fn new(parent_session_id: SessionId, task_id: String, message_schema: Value) -> Self {
Self {
parent_session_id,
task_id,
message_schema,
}
}
}
#[async_trait]
impl Tool for ReportTaskProgressTool {
fn name(&self) -> &str {
"report_task_progress"
}
fn display_name(&self) -> Option<&str> {
Some("Report Task Progress")
}
fn description(&self) -> &str {
"Post a structured progress message for this subagent task. The call arguments must match the declared message schema."
}
fn parameters_schema(&self) -> Value {
self.message_schema.clone()
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"report_task_progress requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let errors = schema_validation_errors(&self.message_schema, &arguments);
if !errors.is_empty() {
return ToolExecutionResult::tool_error(format!(
"report_task_progress arguments do not match message_schema: {}",
errors.join("; ")
));
}
let Some(registry) = context.session_task_registry.as_ref() else {
return ToolExecutionResult::tool_error(
"report_task_progress requires session_task_registry context",
);
};
let message = NewTaskMessage {
direction: TaskMessageDirection::Outbound,
content: vec![TaskMessagePart::Data {
data: arguments.clone(),
}],
in_reply_to: None,
expected_attempt: None,
};
let stored = match registry
.record_message(self.parent_session_id, &self.task_id, message)
.await
{
Ok(stored) => stored,
Err(error) => return ToolExecutionResult::internal_error(error),
};
ToolExecutionResult::success(json!({
"status": "posted",
"task_id": self.task_id,
"message_id": stored.id,
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub async fn report_result_tool_for_child_session(
child_session_id: SessionId,
session_store: &dyn SessionStore,
task_registry: &dyn crate::session_task::SessionTaskRegistry,
) -> crate::error::Result<Option<ReportResultTool>> {
let Some(child) = session_store.get_session(child_session_id).await? else {
return Ok(None);
};
let Some(parent_session_id) = child.parent_session_id else {
return Ok(None);
};
let Some(parent) = session_store.get_session(parent_session_id).await? else {
return Ok(None);
};
let tasks = task_registry
.list(
parent_session_id,
Some(&SessionTaskFilter {
kind: Some(TASK_KIND_SUBAGENT.to_string()),
state: None,
}),
)
.await?;
let Some(task) = tasks
.into_iter()
.find(|task| task.links.child_session_id == Some(child_session_id))
else {
return Ok(None);
};
let Some(schema) = declared_result_schema(&task) else {
return Ok(None);
};
Ok(Some(ReportResultTool::new(
parent_session_id,
parent.workspace_id,
task.id.clone(),
schema.clone(),
)))
}
pub async fn report_task_progress_tool_for_child_session(
child_session_id: SessionId,
session_store: &dyn SessionStore,
task_registry: &dyn crate::session_task::SessionTaskRegistry,
) -> crate::error::Result<Option<ReportTaskProgressTool>> {
let Some(child) = session_store.get_session(child_session_id).await? else {
return Ok(None);
};
let Some(parent_session_id) = child.parent_session_id else {
return Ok(None);
};
let tasks = task_registry
.list(
parent_session_id,
Some(&SessionTaskFilter {
kind: Some(TASK_KIND_SUBAGENT.to_string()),
state: None,
}),
)
.await?;
let Some(task) = tasks
.into_iter()
.find(|task| task.links.child_session_id == Some(child_session_id))
else {
return Ok(None);
};
let Some(schema) = declared_message_schema(&task) else {
return Ok(None);
};
Ok(Some(ReportTaskProgressTool::new(
parent_session_id,
task.id.clone(),
schema.clone(),
)))
}
pub struct SpawnSubagentAsAgentTool;
#[async_trait]
impl Tool for SpawnSubagentAsAgentTool {
fn narrate(
&self,
tool_call: &crate::tool_types::ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
_ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
Some(crate::tool_narration::narrate_subagent_spawn(
&tool_call.arguments,
phase,
locale,
))
}
fn name(&self) -> &str {
"spawn_agent"
}
fn display_name(&self) -> Option<&str> {
Some("Spawn Agent")
}
fn description(&self) -> &str {
"Delegate a task to a subagent in its own context window. Set target.type to \"subagent\". Runs in the background by default and returns a task_id immediately; set mode to \"foreground\" to block until it completes."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name for the subagent (e.g. 'Test Runner', 'Auth Explorer'). Must be unique within this session."
},
"instructions": {
"type": "string",
"description": "Instructions for the subagent — what it should do."
},
"target": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["subagent"],
"description": "Delegation target type. Use \"subagent\" for a same-agent child session."
}
},
"required": ["type"],
"additionalProperties": false
},
"mode": {
"type": "string",
"enum": ["background", "foreground"],
"description": "Execution mode. \"background\" (default) returns immediately with a task_id — monitor with get_task/wait_task; the session is notified when the subagent finishes. \"foreground\" blocks until the subagent completes and returns its result inline."
},
"blueprint": {
"type": "string",
"description": "Blueprint ID to spawn a specialist agent with its own tools and model. Omit to inherit parent's configuration."
},
"config": {
"type": "object",
"description": "Blueprint-specific configuration. Only valid when `blueprint` is set. Validated against the blueprint's config schema."
},
"result_schema": {
"type": "object",
"description": "Optional JSON Schema for the subagent's final structured result. When set, the child receives report_result and must call it before the task can succeed."
},
"message_schema": {
"type": "object",
"description": "Optional JSON Schema for structured progress messages. When set, the child receives report_task_progress and valid calls post data messages to the task thread."
},
"push_configs": {
"type": "array",
"description": "Optional per-task webhook targets notified on task events. Each entry: { url, secret? (HMAC-SHA256 signing key), event_filter? (subset of [\"terminal\", \"awaiting_input\", \"message\"]; defaults to [\"terminal\"]) }. URLs are SSRF-validated.",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"secret": { "type": "string" },
"event_filter": {
"type": "array",
"items": {
"type": "string",
"enum": ["terminal", "awaiting_input", "message"]
}
}
},
"required": ["url"],
"additionalProperties": false
}
}
},
"required": ["name", "instructions", "target"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default().with_long_running(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"spawn_agent requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let target = arguments.get("target").unwrap_or(&Value::Null);
if target.get("type").and_then(Value::as_str) != Some("subagent") {
return ToolExecutionResult::tool_error(
"spawn_agent target.type must be \"subagent\" for the subagents capability",
);
}
spawn_agent_subagent_impl(arguments, context)
.await
.unwrap_or_else(|e| e)
}
fn requires_context(&self) -> bool {
true
}
}
fn resolve_spawn_mode(
arguments: &Value,
context: &ToolContext,
) -> Result<SpawnMode, ToolExecutionResult> {
let explicit = match arguments
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
None => None,
Some("background") => Some(SpawnMode::Background),
Some("foreground") => Some(SpawnMode::Foreground),
Some(other) => {
return Err(ToolExecutionResult::tool_error(format!(
"Invalid mode: \"{other}\". Valid modes: background, foreground."
)));
}
};
let has_registry = context.session_task_registry.is_some();
match explicit {
Some(SpawnMode::Background) if !has_registry => Err(ToolExecutionResult::tool_error(
"Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead.",
)),
Some(mode) => Ok(mode),
None if has_registry => Ok(SpawnMode::Background),
None => Ok(SpawnMode::Foreground),
}
}
async fn spawn_agent_subagent_impl(
arguments: Value,
context: &ToolContext,
) -> Result<ToolExecutionResult, ToolExecutionResult> {
let name = require_str(&arguments, "name")?.trim().to_string();
let instructions = require_str(&arguments, "instructions")?.to_string();
let goal = arguments
.get("goal")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let mode = resolve_spawn_mode(&arguments, context)?;
let lifetime = SpawnLifetime::parse(&arguments)?;
let seed = parse_seed(&arguments)?;
let store = get_platform_store(context)?;
let session_store = get_session_store(context)?;
let blueprint_param = arguments
.get("blueprint")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string());
let config_param = arguments.get("config").filter(|v| !v.is_null()).cloned();
let result_schema = normalize_result_schema(&arguments)?;
let message_schema = normalize_message_schema(&arguments)?;
let push_configs = normalize_push_configs(&arguments)?;
if config_param.is_some() && blueprint_param.is_none() {
return Ok(ToolExecutionResult::tool_error(
"The `config` parameter is only valid when `blueprint` is set.",
));
}
let parent_session = match session_store.get_session(context.session_id).await {
Ok(Some(s)) => s,
Ok(None) => return Ok(ToolExecutionResult::tool_error("Current session not found")),
Err(e) => return Err(ToolExecutionResult::internal_error(e)),
};
if lifetime == SpawnLifetime::Linked
&& let Err(error) =
enforce_subagent_depth_cap(session_store, &parent_session, context).await
{
return Ok(error);
}
if let Some(ref bp_id) = blueprint_param {
let Some(ref registry) = context.capability_registry else {
return Ok(ToolExecutionResult::tool_error(
"Blueprint support requires capability_registry context.",
));
};
let Some((blueprint_capability_id, blueprint)) = registry.blueprint_with_capability(bp_id)
else {
return Ok(ToolExecutionResult::tool_error(format!(
"Unknown blueprint: \"{bp_id}\". Check available blueprints."
)));
};
if let Some(ref schema) = blueprint.config_schema
&& config_param.is_none()
&& schema
.get("required")
.is_some_and(|r| r.as_array().is_some_and(|arr| !arr.is_empty()))
{
return Ok(ToolExecutionResult::tool_error(format!(
"Blueprint \"{bp_id}\" requires config. Schema: {}",
serde_json::to_string_pretty(schema).unwrap_or_default()
)));
}
let allowed_capability_ids = if let Some(agent_id) = parent_session.agent_id {
match store.get_agent_by_id(agent_id).await {
Ok(Some(agent)) => agent
.capabilities
.iter()
.map(|c| c.capability_id().to_string())
.collect::<Vec<_>>(),
Ok(None) => vec![],
Err(e) => return Err(ToolExecutionResult::internal_error(e)),
}
} else {
match store.get_harness(parent_session.harness_id).await {
Ok(Some(harness)) => harness
.capabilities
.iter()
.map(|c| c.capability_id().to_string())
.collect::<Vec<_>>(),
Ok(None) => vec![],
Err(e) => return Err(ToolExecutionResult::internal_error(e)),
}
};
if !allowed_capability_ids
.iter()
.any(|capability_id| capability_id == &blueprint_capability_id)
{
return Ok(ToolExecutionResult::tool_error(format!(
"Blueprint \"{bp_id}\" is not enabled for this session."
)));
}
}
if lifetime == SpawnLifetime::Linked
&& let (Some(spawn_store), Some(tool_call_id)) =
(&context.subagent_spawn_store, &context.tool_call_id)
{
let claim_token = uuid::Uuid::new_v4();
let claim = match spawn_store
.try_claim_spawn(context.session_id, tool_call_id, claim_token)
.await
{
Ok(c) => c,
Err(e) => return Err(ToolExecutionResult::internal_error(e)),
};
match claim {
SpawnClaimResult::AlreadySettled {
child_session_id,
terminal_status,
terminal_result,
} => {
let task_id = find_subagent_task(context, child_session_id)
.await
.map(|t| t.id);
return Ok(ToolExecutionResult::success(json!({
"subagent_id": child_session_id.to_string(),
"name": name,
"status": terminal_status,
"result": terminal_result,
"task_id": task_id,
"blueprint": blueprint_param,
})));
}
SpawnClaimResult::AlreadyRunning {
child_session_id,
claim_token: stored_claim_token,
} => {
let task = find_subagent_task(context, child_session_id).await;
let (task_id, task_attempt) =
task.map(|t| (Some(t.id), t.attempt)).unwrap_or((None, 1));
match mode {
SpawnMode::Foreground => {
return Ok(run_subagent_wait_and_settle(
store,
context,
child_session_id,
&name,
&instructions,
&blueprint_param,
task_id,
Some((
spawn_store.as_ref(),
tool_call_id.as_str(),
stored_claim_token,
)),
)
.await);
}
SpawnMode::Background => {
spawn_background_watcher(
context,
child_session_id,
&name,
None,
task_id.clone(),
task_attempt,
Some(stored_claim_token),
);
return Ok(background_running_result(
child_session_id,
&name,
&task_id,
&blueprint_param,
));
}
}
}
SpawnClaimResult::Claimed {
spawn_handle_id,
claim_token: actual_claim_token,
}
| SpawnClaimResult::ClaimedPendingChild {
spawn_handle_id,
claim_token: actual_claim_token,
} => {
return Ok(spawn_create_and_wait(
store,
context,
&parent_session,
&name,
goal.as_deref(),
&instructions,
&blueprint_param,
&config_param,
&result_schema,
&message_schema,
&push_configs,
mode,
lifetime,
seed,
Some((
spawn_store.as_ref(),
tool_call_id.as_str(),
spawn_handle_id,
actual_claim_token,
)),
)
.await);
}
}
}
Ok(spawn_create_and_wait(
store,
context,
&parent_session,
&name,
goal.as_deref(),
&instructions,
&blueprint_param,
&config_param,
&result_schema,
&message_schema,
&push_configs,
mode,
lifetime,
seed,
None,
)
.await)
}
fn background_running_result(
child_id: crate::typed_id::SessionId,
name: &str,
task_id: &Option<String>,
blueprint_param: &Option<String>,
) -> ToolExecutionResult {
ToolExecutionResult::success(json!({
"subagent_id": child_id.to_string(),
"name": name,
"status": "running",
"mode": "background",
"task_id": task_id,
"blueprint": blueprint_param,
"message": "Subagent started in the background. Monitor it with get_task or wait_task using task_id; the session is notified when it finishes.",
}))
}
#[allow(clippy::too_many_arguments)]
async fn spawn_create_and_wait(
store: &dyn PlatformStore,
context: &ToolContext,
parent_session: &crate::session::Session,
name: &str,
goal: Option<&str>,
instructions: &str,
blueprint_param: &Option<String>,
config_param: &Option<Value>,
result_schema: &Option<Value>,
message_schema: &Option<Value>,
push_configs: &Option<Value>,
mode: SpawnMode,
lifetime: SpawnLifetime,
seed: SessionSeedMode,
settle_ctx: Option<(
&dyn crate::traits::SubagentSpawnStore,
&str,
uuid::Uuid,
uuid::Uuid,
)>,
) -> ToolExecutionResult {
let Some(session_store) = context.session_store.as_ref() else {
return ToolExecutionResult::tool_error("Subagent spawn requires session_store context");
};
if lifetime == SpawnLifetime::Linked
&& let Err(error) =
enforce_subagent_task_caps(session_store.as_ref(), parent_session, context).await
{
return error;
}
let child_session = match store
.create_session_with_options(PlatformCreateSessionRequest {
harness_id: parent_session.harness_id,
agent_id: if blueprint_param.is_some() {
None } else {
parent_session.agent_id
},
title: Some(name.to_string()),
goal: goal.map(str::to_string),
locale: parent_session.locale.clone(),
blueprint_id: blueprint_param.clone(),
blueprint_config: config_param.clone(),
parent_session_id: (lifetime == SpawnLifetime::Linked).then_some(context.session_id),
forked_from_session_id: (lifetime == SpawnLifetime::Detached)
.then_some(context.session_id),
seed,
})
.await
{
Ok(s) => s,
Err(e) => return ToolExecutionResult::internal_error(e),
};
let mut task_id: Option<String> = None;
let mut task_attempt: i32 = 1;
let mut task_spec = json!({
"instructions": instructions,
"blueprint_id": blueprint_param,
"mode": mode.as_str(),
"lifetime": lifetime.as_str(),
"seed": seed.as_str(),
});
if let Some(schema) = result_schema
&& let Some(spec) = task_spec.as_object_mut()
{
spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
}
if let Some(schema) = message_schema
&& let Some(spec) = task_spec.as_object_mut()
{
spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
}
if let Some(configs) = push_configs
&& let Some(spec) = task_spec.as_object_mut()
{
spec.insert(PUSH_CONFIGS_SPEC_KEY.to_string(), configs.clone());
}
if let Some(ref task_registry) = context.session_task_registry
&& let Ok(created) = task_registry
.create(CreateSessionTask {
session_id: context.session_id,
id: None,
kind: match lifetime {
SpawnLifetime::Linked => TASK_KIND_SUBAGENT,
SpawnLifetime::Detached => TASK_KIND_SESSION,
}
.to_string(),
display_name: name.to_string(),
spec: task_spec,
state: SessionTaskState::Running,
links: TaskLinks {
child_session_id: Some(child_session.id),
..Default::default()
},
wake_policy: match (lifetime, mode, message_schema.is_some()) {
(SpawnLifetime::Detached, _, _) => TaskWakePolicy::Silent,
(SpawnLifetime::Linked, SpawnMode::Background, true) => {
TaskWakePolicy::OnActivity
}
(SpawnLifetime::Linked, SpawnMode::Background, false) => {
TaskWakePolicy::OnTerminal
}
(SpawnLifetime::Linked, SpawnMode::Foreground, _) => TaskWakePolicy::Silent,
},
})
.await
{
task_id = Some(created.id);
task_attempt = created.attempt;
}
let wait_settle_ctx = if let Some((spawn_store, tool_call_id, spawn_handle_id, claim_token)) =
settle_ctx
{
if let Err(e) = spawn_store
.register_child_session(spawn_handle_id, claim_token, child_session.id)
.await
{
tracing::warn!(
tool_call_id,
error = %e,
"Failed to register child session in spawn handle; proceeding without durable reattach"
);
}
Some((spawn_store, tool_call_id, claim_token))
} else {
None
};
if mode == SpawnMode::Background {
spawn_background_watcher(
context,
child_session.id,
name,
Some(instructions.to_string()),
task_id.clone(),
task_attempt,
wait_settle_ctx.map(|(_, _, claim_token)| claim_token),
);
return background_running_result(child_session.id, name, &task_id, blueprint_param);
}
if let Err(e) = store.send_message(child_session.id, instructions).await {
finish_subagent_task(
context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "error".to_string(),
message: e.to_string(),
}),
)
.await;
return ToolExecutionResult::internal_error(e);
}
run_subagent_wait_and_settle(
store,
context,
child_session.id,
name,
instructions,
blueprint_param,
task_id,
wait_settle_ctx,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_subagent_wait_and_settle(
store: &dyn PlatformStore,
context: &ToolContext,
child_id: crate::typed_id::SessionId,
name: &str,
_instructions: &str,
blueprint_param: &Option<String>,
task_id: Option<String>,
settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
) -> ToolExecutionResult {
let status = match store.wait_for_idle(child_id, Some(300)).await {
Ok(s) => s,
Err(e) => {
finish_subagent_task(
context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "error".to_string(),
message: e.to_string(),
}),
)
.await;
return ToolExecutionResult::success(json!({
"subagent_id": child_id.to_string(),
"name": name,
"status": "failed",
"error": e.to_string(),
"task_id": task_id,
"blueprint": blueprint_param,
}));
}
};
let result_text = match settle_subagent_outcome(
store,
context,
child_id,
&status,
task_id.as_deref(),
settle_ctx,
)
.await
{
Ok(text) => text,
Err(error) => return error,
};
let result = foreground_result_value(context, task_id.as_deref())
.await
.unwrap_or_else(|| json!(result_text));
ToolExecutionResult::success(json!({
"subagent_id": child_id.to_string(),
"name": name,
"status": status,
"result": result,
"task_id": task_id,
"blueprint": blueprint_param,
}))
}
async fn foreground_result_value(context: &ToolContext, task_id: Option<&str>) -> Option<Value> {
let task = get_subagent_task(context, task_id?).await?;
declared_result_schema(&task)?;
let result_path = task.result_path.as_deref()?;
let file_store = context.file_store.as_ref()?;
let file = file_store
.read_file(context.workspace_fs_key(), result_path)
.await
.ok()
.flatten()?;
serde_json::from_str(file.content.as_deref()?).ok()
}
async fn settle_subagent_outcome(
store: &dyn PlatformStore,
context: &ToolContext,
child_id: crate::typed_id::SessionId,
status: &str,
task_id: Option<&str>,
settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
) -> Result<String, ToolExecutionResult> {
let messages = match store.get_messages(child_id, Some(5)).await {
Ok(m) => m,
Err(e) => return Err(ToolExecutionResult::internal_error(e)),
};
let result_text = last_agent_message(&messages)
.unwrap_or_else(|| format!("Subagent completed with status: {status}"));
let terminal_status = terminal_subagent_status(status);
if let Some((spawn_store, tool_call_id, claim_token)) = settle_ctx
&& terminal_status.is_some()
&& let Err(e) = spawn_store
.settle_spawn(
context.session_id,
tool_call_id,
claim_token,
status,
&result_text,
)
.await
{
tracing::warn!(
tool_call_id,
error = %e,
"Failed to settle subagent spawn handle"
);
}
if let Some(subagent_status) = terminal_status {
let mut task_state = terminal_subagent_task_state(&subagent_status);
let mut task_error = if task_state == SessionTaskState::Failed {
Some(TaskError {
kind: status.to_string(),
message: format!("Subagent session ended with status: {status}"),
})
} else {
None
};
let mut summary = Some(truncate_summary(&result_text));
if task_state == SessionTaskState::Succeeded
&& let Some(task_id) = task_id
&& let Some(task) = get_subagent_task(context, task_id).await
&& declared_result_schema(&task).is_some()
&& task.result_path.is_none()
{
task_state = SessionTaskState::Failed;
task_error = Some(TaskError {
kind: "no_result".to_string(),
message:
"Subagent completed without calling report_result for its result_schema task."
.to_string(),
});
summary = Some("Subagent completed without reporting a structured result.".to_string());
}
finish_subagent_task(context, task_id, task_state, summary, task_error).await;
}
Ok(result_text)
}
fn spawn_background_watcher(
context: &ToolContext,
child_id: crate::typed_id::SessionId,
name: &str,
first_message: Option<String>,
task_id: Option<String>,
task_attempt: i32,
claim_token: Option<uuid::Uuid>,
) {
let context = context.clone();
let name = name.to_string();
tokio::spawn(async move {
let Some(store) = context.platform_store.clone() else {
return;
};
if let Some(instructions) = first_message
&& let Err(e) = store.send_message(child_id, &instructions).await
{
finish_subagent_task(
&context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "error".to_string(),
message: e.to_string(),
}),
)
.await;
return;
}
let heartbeat = async {
let (Some(registry), Some(task_id)) =
(context.session_task_registry.clone(), task_id.clone())
else {
return std::future::pending::<()>().await;
};
loop {
tokio::time::sleep(std::time::Duration::from_secs(
BACKGROUND_HEARTBEAT_INTERVAL_SECS,
))
.await;
let _ = registry
.update(
context.session_id,
&task_id,
SessionTaskUpdate {
heartbeat_at: Some(chrono::Utc::now()),
expected_attempt: Some(task_attempt),
..Default::default()
},
)
.await;
}
};
let wait_and_settle = async {
let started = tokio::time::Instant::now();
loop {
let status = match store
.wait_for_idle(child_id, Some(BACKGROUND_WAIT_SLICE_SECS))
.await
{
Ok(s) => s,
Err(e) => {
finish_subagent_task(
&context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "error".to_string(),
message: e.to_string(),
}),
)
.await;
return;
}
};
let effective = if status == "idle" {
"completed".to_string()
} else {
status
};
if terminal_subagent_status(&effective).is_some() {
let settle_ctx = match (
context.subagent_spawn_store.as_ref(),
context.tool_call_id.as_ref(),
claim_token,
) {
(Some(spawn_store), Some(tool_call_id), Some(token)) => Some((
spawn_store.as_ref() as &dyn crate::traits::SubagentSpawnStore,
tool_call_id.as_str(),
token,
)),
_ => None,
};
if let Err(error) = settle_subagent_outcome(
store.as_ref(),
&context,
child_id,
&effective,
task_id.as_deref(),
settle_ctx,
)
.await
{
tracing::warn!(
subagent_name = name,
child_session_id = %child_id,
?error,
"Background subagent settle failed; marking task failed"
);
finish_subagent_task(
&context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "error".to_string(),
message: "Failed to read subagent result".to_string(),
}),
)
.await;
}
return;
}
if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
finish_subagent_task(
&context,
task_id.as_deref(),
SessionTaskState::Failed,
None,
Some(TaskError {
kind: "timeout".to_string(),
message: format!(
"Background subagent did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {effective})"
),
}),
)
.await;
return;
}
if let (Some(registry), Some(task_id)) =
(context.session_task_registry.as_ref(), task_id.as_deref())
{
let _ = registry
.update(
context.session_id,
task_id,
SessionTaskUpdate {
state_detail: Some(format!(
"waiting for subagent ({}s elapsed, last status: {effective})",
started.elapsed().as_secs()
)),
expected_attempt: Some(task_attempt),
..Default::default()
},
)
.await;
}
if !effective.starts_with("timeout") {
tokio::time::sleep(std::time::Duration::from_secs(
BACKGROUND_POLL_BACKOFF_SECS,
))
.await;
}
}
};
tokio::select! {
() = wait_and_settle => {}
() = heartbeat => {}
}
});
}
pub struct SubagentTaskExecutor;
#[async_trait]
impl TaskExecutor for SubagentTaskExecutor {
fn kind(&self) -> &str {
TASK_KIND_SUBAGENT
}
async fn deliver(
&self,
task: &SessionTask,
message: &TaskMessage,
context: &ToolContext,
) -> crate::error::Result<()> {
let Some(store) = context.platform_store.as_ref() else {
return Err(crate::error::AgentLoopError::tool(
"subagent task delivery requires platform_store context",
));
};
let Some(child_id) = task.links.child_session_id else {
return Err(crate::error::AgentLoopError::tool(format!(
"subagent task {} has no child session link",
task.id
)));
};
let text = task_message_text(&message.content);
store.send_message(child_id, &text).await
}
async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
let Some(store) = context.platform_store.as_ref() else {
return Err(crate::error::AgentLoopError::tool(
"subagent task cancellation requires platform_store context",
));
};
let Some(child_id) = task.links.child_session_id else {
return Err(crate::error::AgentLoopError::tool(format!(
"subagent task {} has no child session link",
task.id
)));
};
store
.send_message(
child_id,
"Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
)
.await
}
async fn reconcile(
&self,
task: &SessionTask,
context: &ToolContext,
) -> crate::error::Result<()> {
if task.state.is_terminal() {
return Ok(());
}
let (Some(store), Some(child_id)) =
(context.platform_store.as_ref(), task.links.child_session_id)
else {
return Ok(());
};
let status = store.wait_for_idle(child_id, Some(0)).await?;
if terminal_subagent_status(&status).is_none() {
return Ok(());
}
settle_subagent_outcome(
store.as_ref(),
context,
child_id,
&status,
Some(&task.id),
None,
)
.await
.map(|_| ())
.map_err(|_| {
crate::error::AgentLoopError::tool("Failed to read subagent result during reconcile")
})
}
}
inventory::submit! {
TaskExecutorPlugin {
executor: || Arc::new(SubagentTaskExecutor),
}
}
pub struct DetachedSessionTaskExecutor;
#[async_trait]
impl TaskExecutor for DetachedSessionTaskExecutor {
fn kind(&self) -> &str {
TASK_KIND_SESSION
}
async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
let Some(registry) = context.session_task_registry.as_ref() else {
return Ok(());
};
registry
.update(
task.session_id,
&task.id,
SessionTaskUpdate {
state: Some(SessionTaskState::Canceled),
summary: Some(
"Detached session tracking canceled; peer session left running."
.to_string(),
),
..Default::default()
},
)
.await?;
Ok(())
}
}
inventory::submit! {
TaskExecutorPlugin {
executor: || Arc::new(DetachedSessionTaskExecutor),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Tool;
#[test]
fn capability_features() {
let cap = SubagentCapability;
assert_eq!(cap.features(), vec!["subagents"]);
}
#[test]
fn terminal_subagent_status_maps_only_terminal_wait_states() {
assert_eq!(terminal_subagent_status("idle"), None);
assert_eq!(
terminal_subagent_status("completed"),
Some(crate::session::SubagentStatus::Completed)
);
assert_eq!(
terminal_subagent_status("failed"),
Some(crate::session::SubagentStatus::Failed)
);
assert_eq!(
terminal_subagent_status("cancelled"),
Some(crate::session::SubagentStatus::Cancelled)
);
assert_eq!(
terminal_subagent_status("sealed"),
Some(crate::session::SubagentStatus::Sealed)
);
assert_eq!(
terminal_subagent_task_state(&crate::session::SubagentStatus::Completed),
SessionTaskState::Succeeded
);
assert_eq!(
terminal_subagent_task_state(&crate::session::SubagentStatus::Sealed),
SessionTaskState::Failed
);
assert_eq!(
terminal_subagent_task_state(&crate::session::SubagentStatus::Cancelled),
SessionTaskState::Canceled
);
assert_eq!(
terminal_subagent_task_state(&crate::session::SubagentStatus::MaxIterationsReached),
SessionTaskState::Failed
);
assert_eq!(terminal_subagent_status("waiting_for_tool_results"), None);
assert_eq!(terminal_subagent_status("paused"), None);
}
#[test]
fn subagent_nesting_policy_resolves_platform_org_agent_precedence() {
let platform = crate::traits::SubagentNestingPolicy::default().with_platform_default(4);
assert_eq!(platform.max_subagent_depth(), 4);
let org = platform.with_org_override(Some(3));
assert_eq!(org.max_subagent_depth(), 3);
let agent = org.with_agent_override(Some(1));
assert_eq!(agent.max_subagent_depth(), 1);
}
#[test]
fn spawn_agent_subagent_schema_advertises_only_subagent_target() {
let tool = SpawnSubagentAsAgentTool;
let schema = tool.parameters_schema();
assert_eq!(
schema["properties"]["target"]["properties"]["type"]["enum"],
json!(["subagent"])
);
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("target")));
assert!(required.contains(&json!("name")));
assert!(required.contains(&json!("instructions")));
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("blueprint"));
assert!(props.contains_key("config"));
assert!(props.contains_key("result_schema"));
assert!(props.contains_key("message_schema"));
assert!(!required.contains(&json!("blueprint")));
assert!(!required.contains(&json!("config")));
assert_eq!(
schema["properties"]["mode"]["enum"],
json!(["background", "foreground"])
);
}
use crate::traits::{NoopSubagentSpawnStore, SpawnClaimResult, SubagentSpawnStore};
use std::sync::Arc;
#[tokio::test]
async fn noop_spawn_store_always_claims() {
let store = NoopSubagentSpawnStore;
let parent = crate::typed_id::SessionId::new();
let token = uuid::Uuid::new_v4();
let result = store
.try_claim_spawn(parent, "call-1", token)
.await
.expect("noop should not error");
assert!(
matches!(result, SpawnClaimResult::Claimed { claim_token, .. } if claim_token == token),
"noop store should return Claimed with the supplied token"
);
}
#[tokio::test]
async fn noop_spawn_store_register_and_settle_are_noops() {
let store = NoopSubagentSpawnStore;
let parent = crate::typed_id::SessionId::new();
let child = crate::typed_id::SessionId::new();
let handle_id = uuid::Uuid::new_v4();
let token = uuid::Uuid::new_v4();
store
.register_child_session(handle_id, token, child)
.await
.expect("noop register should not error");
store
.settle_spawn(parent, "call-1", token, "idle", "result text")
.await
.expect("noop settle should not error");
}
#[tokio::test]
async fn arc_spawn_store_delegates() {
let store: Arc<dyn SubagentSpawnStore> = Arc::new(NoopSubagentSpawnStore);
let parent = crate::typed_id::SessionId::new();
let token = uuid::Uuid::new_v4();
let result = store
.try_claim_spawn(parent, "call-arc", token)
.await
.expect("arc delegation should not error");
assert!(matches!(result, SpawnClaimResult::Claimed { .. }));
}
use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
use crate::platform_store::tests::MockPlatformStore;
use crate::session_file::SessionFile;
use crate::session_task::SessionTaskRegistry;
use crate::traits::SessionFileSystem;
use chrono::Utc;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockSessionStore(Arc<MockPlatformStore>);
#[async_trait]
impl crate::traits::SessionStore for MockSessionStore {
async fn get_session(
&self,
session_id: crate::typed_id::SessionId,
) -> crate::error::Result<Option<crate::session::Session>> {
self.0.get_session_by_id(session_id).await
}
}
fn spawn_context(
store: &Arc<MockPlatformStore>,
registry: Option<Arc<InMemorySessionTaskRegistry>>,
) -> ToolContext {
spawn_context_for_session(store, registry, store.session.id)
}
fn spawn_context_for_session(
store: &Arc<MockPlatformStore>,
registry: Option<Arc<InMemorySessionTaskRegistry>>,
session_id: crate::typed_id::SessionId,
) -> ToolContext {
let mut context = ToolContext::new(session_id);
context.platform_store = Some(store.clone());
context.session_store = Some(Arc::new(MockSessionStore(store.clone())));
if let Some(registry) = registry {
context.session_task_registry = Some(registry);
}
context
}
async fn spawn(context: &ToolContext, args: Value) -> ToolExecutionResult {
let mut args = args;
if let Some(object) = args.as_object_mut() {
object
.entry("target")
.or_insert_with(|| json!({"type": "subagent"}));
}
SpawnSubagentAsAgentTool
.execute_with_context(args, context)
.await
}
async fn wait_for_task_state(
registry: &InMemorySessionTaskRegistry,
session_id: crate::typed_id::SessionId,
task_id: &str,
state: crate::session_task::SessionTaskState,
) -> crate::session_task::SessionTask {
for _ in 0..200 {
let task = registry
.get(session_id, task_id)
.await
.expect("registry get")
.expect("task exists");
if task.state == state {
return task;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("task {task_id} did not reach {state:?}");
}
#[derive(Default)]
struct MemoryFileStore {
files: Mutex<HashMap<(uuid::Uuid, String), String>>,
}
#[async_trait]
impl SessionFileSystem for MemoryFileStore {
fn is_mount_resolver(&self) -> bool {
false
}
async fn read_file(
&self,
session_id: crate::typed_id::SessionId,
path: &str,
) -> crate::error::Result<Option<SessionFile>> {
let content = self
.files
.lock()
.unwrap()
.get(&(session_id.uuid(), path.to_string()))
.cloned();
Ok(content.map(|content| SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
content: Some(content.clone()),
encoding: "utf-8".to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: Utc::now(),
updated_at: Utc::now(),
}))
}
async fn write_file(
&self,
session_id: crate::typed_id::SessionId,
path: &str,
content: &str,
_encoding: &str,
) -> crate::error::Result<SessionFile> {
self.files
.lock()
.unwrap()
.insert((session_id.uuid(), path.to_string()), content.to_string());
Ok(SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
content: Some(content.to_string()),
encoding: "utf-8".to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: Utc::now(),
updated_at: Utc::now(),
})
}
async fn delete_file(
&self,
session_id: crate::typed_id::SessionId,
path: &str,
_recursive: bool,
) -> crate::error::Result<bool> {
Ok(self
.files
.lock()
.unwrap()
.remove(&(session_id.uuid(), path.to_string()))
.is_some())
}
async fn list_directory(
&self,
_session_id: crate::typed_id::SessionId,
_path: &str,
) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
Ok(vec![])
}
async fn stat_file(
&self,
session_id: crate::typed_id::SessionId,
path: &str,
) -> crate::error::Result<Option<crate::session_file::FileStat>> {
let content = self
.files
.lock()
.unwrap()
.get(&(session_id.uuid(), path.to_string()))
.cloned();
Ok(content.map(|content| crate::session_file::FileStat {
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: Utc::now(),
updated_at: Utc::now(),
}))
}
async fn grep_files(
&self,
_session_id: crate::typed_id::SessionId,
_pattern: &str,
_path_pattern: Option<&str>,
) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
Ok(vec![])
}
async fn create_directory(
&self,
session_id: crate::typed_id::SessionId,
path: &str,
) -> crate::error::Result<crate::session_file::FileInfo> {
Ok(crate::session_file::FileInfo {
id: uuid::Uuid::new_v4(),
session_id: session_id.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
is_directory: true,
is_readonly: false,
size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
})
}
}
#[tokio::test]
async fn spawn_agent_subagent_rejects_invalid_mode() {
let context = ToolContext::new(crate::typed_id::SessionId::new());
let result = spawn(
&context,
json!({"name": "Runner", "instructions": "go", "mode": "asap"}),
)
.await;
let ToolExecutionResult::ToolError(msg) = result else {
panic!("expected ToolError, got {result:?}");
};
assert!(msg.contains("Invalid mode"), "got: {msg}");
}
#[tokio::test]
async fn spawn_agent_subagent_rejects_other_target_types() {
let context = ToolContext::new(crate::typed_id::SessionId::new());
let result = SpawnSubagentAsAgentTool
.execute_with_context(
json!({
"name": "Runner",
"instructions": "go",
"target": {"type": "external_a2a"}
}),
&context,
)
.await;
let ToolExecutionResult::ToolError(msg) = result else {
panic!("expected ToolError, got {result:?}");
};
assert!(msg.contains("subagent"), "got: {msg}");
}
#[tokio::test]
async fn spawn_agent_subagent_creates_subagent_task() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = SpawnSubagentAsAgentTool
.execute_with_context(
json!({
"name": "Runner",
"instructions": "go",
"target": {"type": "subagent"},
"mode": "foreground"
}),
&context,
)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let task_id = value["task_id"].as_str().expect("task_id");
let task = registry
.get(context.session_id, task_id)
.await
.unwrap()
.unwrap();
assert_eq!(task.kind, TASK_KIND_SUBAGENT);
assert_eq!(task.spec["mode"], "foreground");
assert!(task.links.child_session_id.is_some());
}
#[tokio::test]
async fn detached_spawn_creates_peer_session_task_with_goal_and_lineage() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = spawn(
&context,
json!({
"name": "Research Peer",
"goal": "Investigate latency",
"instructions": "go",
"lifetime": "detached",
"seed": "workspace",
"mode": "foreground"
}),
)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let child_id: crate::typed_id::SessionId = value["subagent_id"]
.as_str()
.expect("subagent_id")
.parse()
.expect("valid session id");
let child = store
.get_session_by_id(child_id)
.await
.unwrap()
.expect("child session");
assert_eq!(child.parent_session_id, None);
assert_eq!(child.forked_from_session_id, Some(context.session_id));
assert_eq!(child.title.as_deref(), Some("Research Peer"));
assert_eq!(child.goal.as_deref(), Some("Investigate latency"));
let task_id = value["task_id"].as_str().expect("task_id");
let task = registry
.get(context.session_id, task_id)
.await
.unwrap()
.expect("task");
assert_eq!(task.kind, TASK_KIND_SESSION);
assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
assert_eq!(task.links.child_session_id, Some(child_id));
assert_eq!(task.spec["lifetime"], "detached");
assert_eq!(task.spec["seed"], "workspace");
}
#[tokio::test]
async fn detached_spawn_bypasses_subagent_depth_guard() {
let store = Arc::new(MockPlatformStore::new());
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
);
let linked = spawn(
&context,
json!({"name": "Linked", "instructions": "go", "mode": "foreground"}),
)
.await;
assert!(matches!(linked, ToolExecutionResult::ToolError(_)));
let detached = spawn(
&context,
json!({
"name": "Detached",
"instructions": "go",
"mode": "foreground",
"lifetime": "detached"
}),
)
.await;
assert!(
matches!(detached, ToolExecutionResult::Success(_)),
"detached spawn should bypass linked depth guard, got {detached:?}"
);
}
#[tokio::test]
async fn detached_session_task_cancel_detaches_tracking_only() {
let store = Arc::new(MockPlatformStore::new());
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let child_id = crate::typed_id::SessionId::new();
let task = registry
.create(CreateSessionTask {
session_id: context.session_id,
id: None,
kind: TASK_KIND_SESSION.to_string(),
display_name: "Peer".to_string(),
spec: json!({}),
state: SessionTaskState::Running,
links: TaskLinks {
child_session_id: Some(child_id),
..Default::default()
},
wake_policy: TaskWakePolicy::Silent,
})
.await
.unwrap();
DetachedSessionTaskExecutor
.cancel(&task, &context)
.await
.unwrap();
let updated = registry
.get(context.session_id, &task.id)
.await
.unwrap()
.expect("task should remain present");
assert_eq!(updated.state, SessionTaskState::Canceled);
assert_eq!(updated.links.child_session_id, Some(child_id));
assert_eq!(
updated.summary.as_deref(),
Some("Detached session tracking canceled; peer session left running.")
);
}
#[tokio::test]
async fn spawn_agent_subagent_allows_depth_two_and_rejects_depth_three_by_default() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let root_context = spawn_context(&store, Some(registry.clone()));
let first = spawn(
&root_context,
json!({"name": "B", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::Success(first_value) = first else {
panic!("expected first spawn success, got {first:?}");
};
let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
.as_str()
.expect("subagent_id")
.parse()
.expect("valid session id");
let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id);
let second = spawn(
&b_context,
json!({"name": "C", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::Success(second_value) = second else {
panic!("expected second spawn success, got {second:?}");
};
let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
.as_str()
.expect("subagent_id")
.parse()
.expect("valid session id");
let c_context = spawn_context_for_session(&store, Some(registry), c_id);
let third = spawn(
&c_context,
json!({"name": "D", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::ToolError(message) = third else {
panic!("expected depth cap ToolError, got {third:?}");
};
assert!(
message.contains("max_subagent_depth is 2"),
"got: {message}"
);
assert!(message.contains("depth 3"), "got: {message}");
}
#[tokio::test]
async fn spawn_agent_subagent_depth_zero_restores_hard_block() {
let store = Arc::new(MockPlatformStore::new());
let mut context = spawn_context(&store, None).with_subagent_nesting_policy(
crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
);
context.session_task_registry = Some(Arc::new(InMemorySessionTaskRegistry::default()));
let result = spawn(
&context,
json!({"name": "Blocked", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::ToolError(message) = result else {
panic!("expected depth cap ToolError, got {result:?}");
};
assert!(
message.contains("max_subagent_depth is 0"),
"got: {message}"
);
assert!(message.contains("depth 1"), "got: {message}");
}
#[tokio::test]
async fn spawn_agent_subagent_rejects_when_active_descendant_cap_is_full() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
crate::traits::SubagentNestingPolicy::default()
.with_agent_task_caps_override(Some(1), Some(200)),
);
let first = spawn(
&context,
json!({"name": "First", "instructions": "go", "mode": "background"}),
)
.await;
assert!(
matches!(first, ToolExecutionResult::Success(_)),
"expected first spawn success, got {first:?}"
);
let second = spawn(
&context,
json!({"name": "Second", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::ToolError(message) = second else {
panic!("expected active cap ToolError, got {second:?}");
};
assert!(
message.contains("max_active_descendant_tasks is 1"),
"got: {message}"
);
assert!(
message.contains("2 non-terminal descendant tasks"),
"got: {message}"
);
}
#[tokio::test]
async fn spawn_agent_subagent_counts_grandchildren_for_active_descendant_cap() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let policy = crate::traits::SubagentNestingPolicy::default()
.with_agent_override(Some(4))
.with_agent_task_caps_override(Some(2), Some(200));
let root_context =
spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(policy);
let first = spawn(
&root_context,
json!({"name": "B", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::Success(first_value) = first else {
panic!("expected first spawn success, got {first:?}");
};
let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
.as_str()
.expect("subagent_id")
.parse()
.expect("valid session id");
let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id)
.with_subagent_nesting_policy(policy);
let second = spawn(
&b_context,
json!({"name": "C", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::Success(second_value) = second else {
panic!("expected second spawn success, got {second:?}");
};
let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
.as_str()
.expect("subagent_id")
.parse()
.expect("valid session id");
let c_context = spawn_context_for_session(&store, Some(registry), c_id)
.with_subagent_nesting_policy(policy);
let third = spawn(
&c_context,
json!({"name": "D", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::ToolError(message) = third else {
panic!("expected active cap ToolError, got {third:?}");
};
assert!(
message.contains("max_active_descendant_tasks is 2"),
"got: {message}"
);
assert!(message.contains("root session"), "got: {message}");
}
#[tokio::test]
async fn spawn_agent_subagent_total_descendant_cap_counts_terminal_tasks() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
crate::traits::SubagentNestingPolicy::default()
.with_agent_task_caps_override(Some(16), Some(1)),
);
let first = spawn(
&context,
json!({"name": "First", "instructions": "go", "mode": "foreground"}),
)
.await;
assert!(
matches!(first, ToolExecutionResult::Success(_)),
"expected first spawn success, got {first:?}"
);
let second = spawn(
&context,
json!({"name": "Second", "instructions": "go", "mode": "foreground"}),
)
.await;
let ToolExecutionResult::ToolError(message) = second else {
panic!("expected total cap ToolError, got {second:?}");
};
assert!(
message.contains("max_total_descendant_tasks is 1"),
"got: {message}"
);
assert!(
message.contains("2 descendant task records"),
"got: {message}"
);
}
#[test]
fn subagents_config_validates_descendant_task_caps() {
let capability = SubagentCapability;
assert!(
capability
.validate_config(&json!({
"max_active_descendant_tasks": 16,
"max_total_descendant_tasks": 200
}))
.is_ok()
);
assert_eq!(
capability
.validate_config(&json!({"max_active_descendant_tasks": 1025}))
.unwrap_err(),
"max_active_descendant_tasks must be <= 1024"
);
assert_eq!(
capability
.validate_config(&json!({"max_total_descendant_tasks": 10001}))
.unwrap_err(),
"max_total_descendant_tasks must be <= 10000"
);
}
#[tokio::test]
async fn spawn_agent_subagent_stores_result_schema_on_task() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = SpawnSubagentAsAgentTool
.execute_with_context(
json!({
"name": "Runner",
"instructions": "go",
"target": {"type": "subagent"},
"mode": "foreground",
"result_schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": false
}
}),
&context,
)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let task_id = value["task_id"].as_str().expect("task_id");
let task = registry
.get(context.session_id, task_id)
.await
.unwrap()
.unwrap();
assert_eq!(task.spec["result_schema"]["required"], json!(["answer"]));
assert_eq!(task.state, SessionTaskState::Failed);
assert_eq!(
task.error.as_ref().map(|e| e.kind.as_str()),
Some("no_result")
);
}
#[tokio::test]
async fn report_result_writes_result_file_and_updates_task() {
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let file_store = Arc::new(MemoryFileStore::default());
let parent_session_id = crate::typed_id::SessionId::new();
let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
let task = registry
.create(CreateSessionTask {
session_id: parent_session_id,
id: None,
kind: TASK_KIND_SUBAGENT.to_string(),
display_name: "Runner".to_string(),
spec: json!({
"result_schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": false
}
}),
state: SessionTaskState::Running,
links: TaskLinks::default(),
wake_policy: TaskWakePolicy::Silent,
})
.await
.unwrap();
let tool = ReportResultTool::new(
parent_session_id,
parent_workspace_id,
task.id.clone(),
task.spec["result_schema"].clone(),
)
.with_file_store(file_store.clone());
let mut context = ToolContext::new(crate::typed_id::SessionId::new());
context.session_task_registry = Some(registry.clone());
let result = tool
.execute_with_context(json!({"answer": "done"}), &context)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(value["result_path"], task_result_path(&task.id));
let task = registry
.get(parent_session_id, &task.id)
.await
.unwrap()
.unwrap();
let result_path = task.result_path.as_deref().expect("result_path");
let file = file_store
.read_file(
SessionId::from_uuid(parent_workspace_id.uuid()),
result_path,
)
.await
.unwrap()
.expect("result file");
assert_eq!(
serde_json::from_str::<Value>(file.content.as_deref().unwrap()).unwrap(),
json!({"answer": "done"})
);
}
#[tokio::test]
async fn report_result_rejects_invalid_result_schema_payload() {
let tool = ReportResultTool::new(
crate::typed_id::SessionId::new(),
crate::typed_id::WorkspaceId::from_uuid(uuid::Uuid::new_v4()),
"task_test".to_string(),
json!({
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": false
}),
);
let result = tool
.execute_with_context(json!({"extra": true}), &ToolContext::new(SessionId::new()))
.await;
let ToolExecutionResult::ToolError(message) = result else {
panic!("expected validation error, got {result:?}");
};
assert!(message.contains("$.answer is required"), "got: {message}");
assert!(message.contains("$.extra is not allowed"), "got: {message}");
}
#[tokio::test]
async fn spawn_agent_subagent_stores_message_schema_and_wakes_on_activity() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = SpawnSubagentAsAgentTool
.execute_with_context(
json!({
"name": "Runner",
"instructions": "go",
"target": {"type": "subagent"},
"message_schema": {
"type": "object",
"properties": {"step": {"type": "string"}},
"required": ["step"],
"additionalProperties": false
}
}),
&context,
)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let task_id = value["task_id"].as_str().expect("task_id");
let task = registry
.get(context.session_id, task_id)
.await
.unwrap()
.unwrap();
assert_eq!(task.spec["message_schema"]["required"], json!(["step"]));
assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
}
#[tokio::test]
async fn report_task_progress_posts_structured_outbound_message() {
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let parent_session_id = crate::typed_id::SessionId::new();
let task = registry
.create(CreateSessionTask {
session_id: parent_session_id,
id: None,
kind: TASK_KIND_SUBAGENT.to_string(),
display_name: "Runner".to_string(),
spec: json!({
"message_schema": {
"type": "object",
"properties": {"step": {"type": "string"}},
"required": ["step"],
"additionalProperties": false
}
}),
state: SessionTaskState::Running,
links: TaskLinks::default(),
wake_policy: TaskWakePolicy::OnActivity,
})
.await
.unwrap();
let tool = ReportTaskProgressTool::new(
parent_session_id,
task.id.clone(),
task.spec["message_schema"].clone(),
);
assert_eq!(tool.name(), "report_task_progress");
let mut context = ToolContext::new(crate::typed_id::SessionId::new());
context.session_task_registry = Some(registry.clone());
let result = tool
.execute_with_context(json!({"step": "tests-running"}), &context)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(value["status"], "posted");
let messages = registry
.list_messages(parent_session_id, &task.id, None, None)
.await
.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].direction, TaskMessageDirection::Outbound);
assert_eq!(
messages[0].content,
vec![TaskMessagePart::Data {
data: json!({"step": "tests-running"})
}]
);
}
#[tokio::test]
async fn report_task_progress_rejects_invalid_message_schema_payload() {
let tool = ReportTaskProgressTool::new(
crate::typed_id::SessionId::new(),
"task_test".to_string(),
json!({
"type": "object",
"properties": {"step": {"type": "string"}},
"required": ["step"],
"additionalProperties": false
}),
);
let result = tool
.execute_with_context(
json!({"step": 42, "extra": true}),
&ToolContext::new(SessionId::new()),
)
.await;
let ToolExecutionResult::ToolError(message) = result else {
panic!("expected validation error, got {result:?}");
};
assert!(
message.contains("$.step has the wrong JSON type"),
"got: {message}"
);
assert!(message.contains("$.extra is not allowed"), "got: {message}");
}
#[test]
fn subagent_and_channel_progress_tools_have_distinct_names() {
use crate::progress_reporting::{
REPORT_PROGRESS_TOOL_NAME, ReportProgressTool as ChannelReportProgressTool,
};
use crate::tools::ToolRegistry;
let subagent = ReportTaskProgressTool::new(
crate::typed_id::SessionId::new(),
"task_test".to_string(),
json!({"type": "object"}),
);
assert_eq!(subagent.name(), "report_task_progress");
assert_eq!(REPORT_PROGRESS_TOOL_NAME, "report_progress");
assert_ne!(subagent.name(), REPORT_PROGRESS_TOOL_NAME);
let mut registry = ToolRegistry::new();
registry.register(ChannelReportProgressTool);
registry.register(subagent);
assert!(
registry.has("report_progress"),
"channel report_progress tool must survive"
);
assert!(
registry.has("report_task_progress"),
"subagent report_task_progress tool must survive"
);
}
#[tokio::test]
async fn explicit_background_without_registry_errors() {
let context = ToolContext::new(crate::typed_id::SessionId::new());
let result = spawn(
&context,
json!({"name": "Runner", "instructions": "go", "mode": "background"}),
)
.await;
let ToolExecutionResult::ToolError(msg) = result else {
panic!("expected ToolError, got {result:?}");
};
assert!(
msg.contains("task registry") && msg.contains("foreground"),
"got: {msg}"
);
}
#[tokio::test]
async fn default_mode_without_registry_degrades_to_foreground() {
let store = Arc::new(MockPlatformStore::new());
let context = spawn_context(&store, None);
let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(value["status"], "idle");
assert_eq!(value["result"], "Hi!");
assert!(value.get("mode").is_none());
}
#[tokio::test]
async fn background_spawn_returns_immediately_and_settles_task() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(value["status"], "running");
assert_eq!(value["mode"], "background");
let task_id = value["task_id"].as_str().expect("task_id").to_string();
let task = wait_for_task_state(
®istry,
context.session_id,
&task_id,
SessionTaskState::Succeeded,
)
.await;
assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
assert_eq!(task.spec["mode"], "background");
assert_eq!(task.summary.as_deref(), Some("Hi!"));
}
#[tokio::test]
async fn background_settles_bare_idle_as_completed() {
let store = Arc::new(MockPlatformStore::new());
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let task_id = value["task_id"].as_str().expect("task_id").to_string();
wait_for_task_state(
®istry,
context.session_id,
&task_id,
SessionTaskState::Succeeded,
)
.await;
}
#[tokio::test]
async fn background_failed_child_settles_task_failed() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "failed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
let task_id = value["task_id"].as_str().expect("task_id").to_string();
let task = wait_for_task_state(
®istry,
context.session_id,
&task_id,
SessionTaskState::Failed,
)
.await;
assert_eq!(task.error.as_ref().map(|e| e.kind.as_str()), Some("failed"));
}
#[tokio::test]
async fn explicit_foreground_blocks_and_returns_result() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let result = spawn(
&context,
json!({"name": "Runner", "instructions": "go", "mode": "foreground"}),
)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(value["status"], "completed");
assert_eq!(value["result"], "Hi!");
let task_id = value["task_id"].as_str().expect("task_id");
let task = registry
.get(context.session_id, task_id)
.await
.unwrap()
.unwrap();
assert_eq!(task.state, SessionTaskState::Succeeded);
assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
}
#[tokio::test]
async fn reconcile_settles_finished_child() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let child_id = crate::typed_id::SessionId::new();
let task = registry
.create(CreateSessionTask {
session_id: context.session_id,
id: None,
kind: TASK_KIND_SUBAGENT.to_string(),
display_name: "Runner".to_string(),
spec: json!({"mode": "background"}),
state: SessionTaskState::Running,
links: TaskLinks {
child_session_id: Some(child_id),
..Default::default()
},
wake_policy: TaskWakePolicy::OnTerminal,
})
.await
.unwrap();
SubagentTaskExecutor
.reconcile(&task, &context)
.await
.expect("reconcile succeeds");
let task = registry
.get(context.session_id, &task.id)
.await
.unwrap()
.unwrap();
assert_eq!(task.state, SessionTaskState::Succeeded);
assert_eq!(task.summary.as_deref(), Some("Hi!"));
}
#[tokio::test]
async fn reconcile_is_noop_while_child_still_working() {
let store = Arc::new(MockPlatformStore::new());
*store.wait_for_idle_status.lock().unwrap() = "timeout (last status: Active)".to_string();
let registry = Arc::new(InMemorySessionTaskRegistry::default());
let context = spawn_context(&store, Some(registry.clone()));
let task = registry
.create(CreateSessionTask {
session_id: context.session_id,
id: None,
kind: TASK_KIND_SUBAGENT.to_string(),
display_name: "Runner".to_string(),
spec: json!({"mode": "background"}),
state: SessionTaskState::Running,
links: TaskLinks {
child_session_id: Some(crate::typed_id::SessionId::new()),
..Default::default()
},
wake_policy: TaskWakePolicy::OnTerminal,
})
.await
.unwrap();
SubagentTaskExecutor
.reconcile(&task, &context)
.await
.expect("reconcile succeeds");
let task = registry
.get(context.session_id, &task.id)
.await
.unwrap()
.unwrap();
assert_eq!(task.state, SessionTaskState::Running);
}
}