1use super::delegation_result::{
23 MESSAGE_SCHEMA_SPEC_KEY, RESULT_SCHEMA_SPEC_KEY, normalize_message_schema,
24 normalize_result_schema, required_result_is_missing, result_value_for_task, truncate_summary,
25};
26#[cfg(test)]
27use super::delegation_result::{ReportResultTool, ReportTaskProgressTool};
28use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel, SpawnMode};
29use crate::platform_store::{PlatformCreateSessionRequest, PlatformStore};
30use crate::session::SessionSeedMode;
31use crate::session_task::{
32 CreateSessionTask, SessionTask, SessionTaskFilter, SessionTaskState, SessionTaskUpdate,
33 TASK_KIND_SESSION, TASK_KIND_SUBAGENT, TaskError, TaskExecutor, TaskExecutorPlugin, TaskLinks,
34 TaskMessage, TaskWakePolicy, task_message_text,
35};
36use crate::tool_types::ToolHints;
37use crate::tools::{
38 BackgroundRunPermit, Tool, ToolExecutionResult, try_acquire_background_run_permit,
39};
40use crate::traits::{SessionStore, SpawnClaimResult, ToolContext};
41use crate::typed_id::SessionId;
42use async_trait::async_trait;
43
44pub(crate) const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
45use serde_json::{Value, json};
46use std::collections::{HashSet, VecDeque};
47use std::sync::Arc;
48
49pub const SUBAGENTS_CAPABILITY_ID: &str = "subagents";
50
51pub struct SubagentCapability;
53
54impl Capability for SubagentCapability {
55 fn id(&self) -> &str {
56 SUBAGENTS_CAPABILITY_ID
57 }
58
59 fn name(&self) -> &str {
60 "Subagents"
61 }
62
63 fn description(&self) -> &str {
64 "Spawn and manage subagents for parallel task execution in isolated context windows."
65 }
66
67 fn localizations(&self) -> Vec<CapabilityLocalization> {
68 vec![CapabilityLocalization::text(
69 "uk",
70 "Субагенти",
71 "Запускайте субагентів і керуйте ними для паралельного виконання завдань в ізольованих контекстних вікнах.",
72 )]
73 }
74
75 fn status(&self) -> CapabilityStatus {
76 CapabilityStatus::Available
77 }
78
79 fn icon(&self) -> Option<&str> {
80 Some("git-branch")
81 }
82
83 fn category(&self) -> Option<&str> {
84 Some("Core")
85 }
86
87 fn risk_level(&self) -> RiskLevel {
88 RiskLevel::High
91 }
92
93 fn features(&self) -> Vec<&'static str> {
94 vec!["subagents"]
95 }
96
97 fn config_schema(&self) -> Option<Value> {
98 Some(json!({
99 "type": "object",
100 "additionalProperties": false,
101 "properties": {
102 "max_subagent_depth": {
103 "type": "integer",
104 "minimum": 0,
105 "maximum": 16,
106 "default": crate::traits::DEFAULT_MAX_SUBAGENT_DEPTH,
107 "description": "Maximum child depth allowed from a top-level session. Top-level sessions are depth 0; setting 0 blocks all subagent spawning."
108 },
109 "max_depth": {
110 "type": "integer",
111 "minimum": 0,
112 "maximum": 16,
113 "description": "Alias for max_subagent_depth."
114 },
115 "max_active_descendant_tasks": {
116 "type": "integer",
117 "minimum": 0,
118 "maximum": 1024,
119 "default": crate::traits::DEFAULT_MAX_ACTIVE_DESCENDANT_SUBAGENT_TASKS,
120 "description": "Maximum non-terminal descendant subagent tasks allowed under one root session. Counts queued, running, and awaiting_input tasks."
121 },
122 "max_concurrent_descendant_tasks": {
123 "type": "integer",
124 "minimum": 0,
125 "maximum": 1024,
126 "description": "Alias for max_active_descendant_tasks."
127 },
128 "max_total_descendant_tasks": {
129 "type": "integer",
130 "minimum": 0,
131 "maximum": 10000,
132 "default": crate::traits::DEFAULT_MAX_TOTAL_DESCENDANT_SUBAGENT_TASKS,
133 "description": "Maximum descendant subagent task records allowed under one root session before rejecting new spawns."
134 },
135 "max_active_detached_tasks": {
136 "type": "integer",
137 "minimum": 0,
138 "maximum": 1024,
139 "default": crate::traits::DEFAULT_MAX_ACTIVE_DETACHED_TASKS,
140 "description": "Maximum non-terminal detached peer sessions allowed under one origin root session. Detached spawns reset depth but are still capped here so a loop cannot run unbounded (EVE-767)."
141 },
142 "max_total_detached_tasks": {
143 "type": "integer",
144 "minimum": 0,
145 "maximum": 10000,
146 "default": crate::traits::DEFAULT_MAX_TOTAL_DETACHED_TASKS,
147 "description": "Maximum detached peer session task records allowed under one origin root session before rejecting new detached spawns."
148 }
149 }
150 }))
151 }
152
153 fn validate_config(&self, config: &Value) -> Result<(), String> {
154 for key in ["max_subagent_depth", "max_depth"] {
155 let Some(value) = config.get(key) else {
156 continue;
157 };
158 let Some(depth) = value.as_u64() else {
159 return Err(format!("{key} must be a non-negative integer"));
160 };
161 if depth > 16 {
162 return Err(format!("{key} must be <= 16"));
163 }
164 }
165 for key in [
166 "max_active_descendant_tasks",
167 "max_concurrent_descendant_tasks",
168 ] {
169 let Some(value) = config.get(key) else {
170 continue;
171 };
172 let Some(max_active) = value.as_u64() else {
173 return Err(format!("{key} must be a non-negative integer"));
174 };
175 if max_active > 1024 {
176 return Err(format!("{key} must be <= 1024"));
177 }
178 }
179 for key in ["max_total_descendant_tasks", "max_total_detached_tasks"] {
180 let Some(value) = config.get(key) else {
181 continue;
182 };
183 let Some(max_total) = value.as_u64() else {
184 return Err(format!("{key} must be a non-negative integer"));
185 };
186 if max_total > 10_000 {
187 return Err(format!("{key} must be <= 10000"));
188 }
189 }
190 if let Some(value) = config.get("max_active_detached_tasks") {
191 let Some(max_active) = value.as_u64() else {
192 return Err("max_active_detached_tasks must be a non-negative integer".to_string());
193 };
194 if max_active > 1024 {
195 return Err("max_active_detached_tasks must be <= 1024".to_string());
196 }
197 }
198 Ok(())
199 }
200
201 fn system_prompt_addition(&self) -> Option<&str> {
202 Some(SUBAGENT_SYSTEM_PROMPT)
203 }
204
205 fn tools(&self) -> Vec<Box<dyn Tool>> {
206 vec![]
207 }
208}
209
210const 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.";
211const PUSH_CONFIGS_SPEC_KEY: &str = "push_configs";
215const VALID_PUSH_EVENT_FILTERS: [&str; 3] = ["terminal", "awaiting_input", "message"];
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum SpawnLifetime {
220 Linked,
221 Detached,
222}
223
224impl SpawnLifetime {
225 fn parse(arguments: &Value) -> Result<Self, ToolExecutionResult> {
226 match arguments.get("lifetime").and_then(Value::as_str) {
227 None | Some("linked") => Ok(Self::Linked),
228 Some("detached") => Ok(Self::Detached),
229 Some(other) => Err(ToolExecutionResult::tool_error(format!(
230 "Invalid lifetime: {other}. Expected 'linked' or 'detached'."
231 ))),
232 }
233 }
234
235 fn as_str(self) -> &'static str {
236 match self {
237 Self::Linked => "linked",
238 Self::Detached => "detached",
239 }
240 }
241}
242
243fn parse_seed(arguments: &Value) -> Result<SessionSeedMode, ToolExecutionResult> {
244 match arguments.get("seed").and_then(Value::as_str) {
245 None | Some("fresh") => Ok(SessionSeedMode::Fresh),
246 Some("fork") => Ok(SessionSeedMode::Fork),
247 Some("workspace") => Ok(SessionSeedMode::Workspace),
248 Some(other) => Err(ToolExecutionResult::tool_error(format!(
249 "Invalid seed: {other}. Expected 'fresh', 'fork', or 'workspace'."
250 ))),
251 }
252}
253
254const BACKGROUND_WAIT_SLICE_SECS: u64 = 300;
257const BACKGROUND_MAX_WAIT_SECS: u64 = 6 * 60 * 60;
260const BACKGROUND_HEARTBEAT_INTERVAL_SECS: u64 = 15;
263const BACKGROUND_POLL_BACKOFF_SECS: u64 = 5;
266
267fn terminal_subagent_status(wait_status: &str) -> Option<crate::session::SubagentStatus> {
268 match wait_status {
269 "completed" => Some(crate::session::SubagentStatus::Completed),
273 "error" | "failed" => Some(crate::session::SubagentStatus::Failed),
274 "cancelled" => Some(crate::session::SubagentStatus::Cancelled),
275 "max_iterations_reached" => Some(crate::session::SubagentStatus::MaxIterationsReached),
276 "sealed" => Some(crate::session::SubagentStatus::Sealed),
279 _ => None,
280 }
281}
282
283fn terminal_subagent_task_state(
284 subagent_status: &crate::session::SubagentStatus,
285) -> SessionTaskState {
286 match subagent_status {
287 crate::session::SubagentStatus::Completed => SessionTaskState::Succeeded,
288 crate::session::SubagentStatus::Cancelled => SessionTaskState::Canceled,
289 _ => SessionTaskState::Failed,
290 }
291}
292
293fn normalize_push_configs(arguments: &Value) -> Result<Option<Value>, ToolExecutionResult> {
303 let Some(raw) = arguments
304 .get(PUSH_CONFIGS_SPEC_KEY)
305 .filter(|v| !v.is_null())
306 else {
307 return Ok(None);
308 };
309 let Some(entries) = raw.as_array() else {
310 return Err(ToolExecutionResult::tool_error(
311 "push_configs must be an array of { url, secret?, event_filter? } objects.",
312 ));
313 };
314 if entries.is_empty() {
315 return Ok(None);
316 }
317 let mut normalized = Vec::with_capacity(entries.len());
318 for entry in entries {
319 let Some(url) = entry.get("url").and_then(Value::as_str) else {
320 return Err(ToolExecutionResult::tool_error(
321 "Each push_configs entry requires a string `url`.",
322 ));
323 };
324 if let Err(e) = crate::url_validation::validate_safe_url(url) {
325 return Err(ToolExecutionResult::tool_error(format!(
326 "Invalid push_configs url \"{url}\": {e}"
327 )));
328 }
329 let mut obj = serde_json::Map::new();
330 obj.insert("url".to_string(), Value::String(url.to_string()));
331 if let Some(secret) = entry
332 .get("secret")
333 .and_then(Value::as_str)
334 .filter(|s| !s.is_empty())
335 {
336 obj.insert("secret".to_string(), Value::String(secret.to_string()));
337 }
338 if let Some(filters) = entry.get("event_filter").filter(|v| !v.is_null()) {
339 let Some(arr) = filters.as_array() else {
340 return Err(ToolExecutionResult::tool_error(
341 "push_configs event_filter must be an array of strings.",
342 ));
343 };
344 let mut out: Vec<Value> = Vec::new();
345 for f in arr {
346 let Some(f) = f.as_str() else {
347 return Err(ToolExecutionResult::tool_error(
348 "push_configs event_filter members must be strings.",
349 ));
350 };
351 if !VALID_PUSH_EVENT_FILTERS.contains(&f) {
352 return Err(ToolExecutionResult::tool_error(format!(
353 "Unknown push_configs event_filter \"{f}\". Valid: {}.",
354 VALID_PUSH_EVENT_FILTERS.join(", ")
355 )));
356 }
357 if !out.iter().any(|x| x.as_str() == Some(f)) {
358 out.push(Value::String(f.to_string()));
359 }
360 }
361 if !out.is_empty() {
362 obj.insert("event_filter".to_string(), Value::Array(out));
363 }
364 }
365 normalized.push(Value::Object(obj));
366 }
367 Ok(Some(Value::Array(normalized)))
368}
369
370use super::util::{get_platform_store, require_str_nonblank as require_str};
375
376fn get_session_store(
377 context: &ToolContext,
378) -> Result<&dyn crate::traits::SessionStore, ToolExecutionResult> {
379 context
380 .session_store
381 .as_ref()
382 .map(|s| s.as_ref())
383 .ok_or_else(|| {
384 ToolExecutionResult::tool_error("Subagent tools require session_store context")
385 })
386}
387
388async fn current_subagent_depth(
389 session_store: &dyn SessionStore,
390 session: &crate::session::Session,
391 max_subagent_depth: u32,
392) -> Result<u32, ToolExecutionResult> {
393 let mut depth = 0_u32;
394 let mut cursor = session.parent_session_id;
395
396 while let Some(parent_id) = cursor {
397 depth = depth.saturating_add(1);
398 if depth > max_subagent_depth {
399 return Ok(depth);
400 }
401
402 let parent = match session_store.get_session(parent_id).await {
403 Ok(Some(parent)) => parent,
404 Ok(None) => {
405 return Err(ToolExecutionResult::tool_error(format!(
406 "Cannot enforce max_subagent_depth: parent session {parent_id} was not found."
407 )));
408 }
409 Err(error) => return Err(ToolExecutionResult::internal_error(error)),
410 };
411 cursor = parent.parent_session_id;
412 }
413
414 Ok(depth)
415}
416
417async fn root_session_for_subagent_tree(
418 session_store: &dyn SessionStore,
419 session: &crate::session::Session,
420) -> Result<SessionId, ToolExecutionResult> {
421 let mut root_id = session.id;
422 let mut cursor = session.parent_session_id;
423 let mut seen = HashSet::new();
424 seen.insert(session.id);
425
426 while let Some(parent_id) = cursor {
427 if !seen.insert(parent_id) {
428 return Err(ToolExecutionResult::tool_error(format!(
429 "Cannot enforce subagent descendant task caps: session parent cycle detected at {parent_id}."
430 )));
431 }
432
433 let parent = match session_store.get_session(parent_id).await {
434 Ok(Some(parent)) => parent,
435 Ok(None) => {
436 return Err(ToolExecutionResult::tool_error(format!(
437 "Cannot enforce subagent descendant task caps: parent session {parent_id} was not found."
438 )));
439 }
440 Err(error) => return Err(ToolExecutionResult::internal_error(error)),
441 };
442 root_id = parent.id;
443 cursor = parent.parent_session_id;
444 }
445
446 Ok(root_id)
447}
448
449#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
450struct DescendantTaskCounts {
451 active: u32,
452 total: u32,
453}
454
455async fn descendant_subagent_task_counts(
456 registry: &dyn crate::session_task::SessionTaskRegistry,
457 root_session_id: SessionId,
458 max_active: u32,
459 max_total: u32,
460) -> Result<DescendantTaskCounts, ToolExecutionResult> {
461 let mut counts = DescendantTaskCounts::default();
462 let mut queue = VecDeque::from([root_session_id]);
463 let mut visited_sessions = HashSet::from([root_session_id]);
464
465 while let Some(session_id) = queue.pop_front() {
466 let tasks = registry
467 .list(
468 session_id,
469 Some(&SessionTaskFilter {
470 kind: Some(TASK_KIND_SUBAGENT.to_string()),
471 state: None,
472 }),
473 )
474 .await
475 .map_err(ToolExecutionResult::internal_error)?;
476
477 for task in tasks {
478 counts.total = counts.total.saturating_add(1);
479 if !task.state.is_terminal() {
480 counts.active = counts.active.saturating_add(1);
481 }
482
483 if let Some(child_session_id) = task.links.child_session_id
484 && visited_sessions.insert(child_session_id)
485 {
486 queue.push_back(child_session_id);
487 }
488
489 if counts.active >= max_active || counts.total >= max_total {
490 return Ok(counts);
491 }
492 }
493 }
494
495 Ok(counts)
496}
497
498async fn enforce_subagent_task_caps(
499 session_store: &dyn SessionStore,
500 session: &crate::session::Session,
501 context: &ToolContext,
502) -> Result<(), ToolExecutionResult> {
503 let Some(registry) = context.session_task_registry.as_ref() else {
504 return Ok(());
505 };
506 let policy = context.subagent_nesting_policy;
507 let max_active = policy.max_active_descendant_tasks();
508 let max_total = policy.max_total_descendant_tasks();
509 let root_session_id = root_session_for_subagent_tree(session_store, session).await?;
510 let counts =
511 descendant_subagent_task_counts(registry.as_ref(), root_session_id, max_active, max_total)
512 .await?;
513
514 if counts.active >= max_active {
515 let attempted = counts.active.saturating_add(1);
516 return Err(ToolExecutionResult::tool_error(format!(
517 "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}."
518 )));
519 }
520
521 if counts.total >= max_total {
522 let attempted = counts.total.saturating_add(1);
523 return Err(ToolExecutionResult::tool_error(format!(
524 "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}."
525 )));
526 }
527
528 Ok(())
529}
530
531async fn descendant_detached_task_counts(
538 registry: &dyn crate::session_task::SessionTaskRegistry,
539 root_session_id: SessionId,
540 max_active: u32,
541 max_total: u32,
542) -> Result<DescendantTaskCounts, ToolExecutionResult> {
543 let mut counts = DescendantTaskCounts::default();
544 let mut queue = VecDeque::from([root_session_id]);
545 let mut visited_sessions = HashSet::from([root_session_id]);
546
547 while let Some(session_id) = queue.pop_front() {
548 let tasks = registry
551 .list(session_id, None)
552 .await
553 .map_err(ToolExecutionResult::internal_error)?;
554
555 for task in tasks {
556 if task.kind == TASK_KIND_SESSION {
557 counts.total = counts.total.saturating_add(1);
558 if !task.state.is_terminal() {
559 counts.active = counts.active.saturating_add(1);
560 }
561 }
562
563 if let Some(child_session_id) = task.links.child_session_id
564 && visited_sessions.insert(child_session_id)
565 {
566 queue.push_back(child_session_id);
567 }
568
569 if counts.active >= max_active || counts.total >= max_total {
570 return Ok(counts);
571 }
572 }
573 }
574
575 Ok(counts)
576}
577
578async fn enforce_detached_spawn_caps(
584 context: &ToolContext,
585 root_session_id: SessionId,
586) -> Result<(), ToolExecutionResult> {
587 let Some(registry) = context.session_task_registry.as_ref() else {
588 return Ok(());
589 };
590 let policy = context.subagent_nesting_policy;
591 let max_active = policy.max_active_detached_tasks();
592 let max_total = policy.max_total_detached_tasks();
593 let counts =
594 descendant_detached_task_counts(registry.as_ref(), root_session_id, max_active, max_total)
595 .await?;
596
597 if counts.active >= max_active {
598 let attempted = counts.active.saturating_add(1);
599 return Err(ToolExecutionResult::tool_error(format!(
600 "Detached spawn active cap exceeded: spawning this detached session would create {attempted} non-terminal detached peer tasks under origin root session {root_session_id}, but max_active_detached_tasks is {max_active}."
601 )));
602 }
603
604 if counts.total >= max_total {
605 let attempted = counts.total.saturating_add(1);
606 return Err(ToolExecutionResult::tool_error(format!(
607 "Detached spawn total cap exceeded: spawning this detached session would create {attempted} detached peer task records under origin root session {root_session_id}, but max_total_detached_tasks is {max_total}."
608 )));
609 }
610
611 Ok(())
612}
613
614async fn enforce_subagent_depth_cap(
615 session_store: &dyn SessionStore,
616 session: &crate::session::Session,
617 context: &ToolContext,
618) -> Result<(), ToolExecutionResult> {
619 let max_subagent_depth = context.subagent_nesting_policy.max_subagent_depth();
620 let current_depth = current_subagent_depth(session_store, session, max_subagent_depth).await?;
621 let child_depth = current_depth.saturating_add(1);
622
623 if child_depth > max_subagent_depth {
624 return Err(ToolExecutionResult::tool_error(format!(
625 "Subagent nesting depth cap exceeded: spawning this subagent would create depth {child_depth}, but max_subagent_depth is {max_subagent_depth}."
626 )));
627 }
628
629 Ok(())
630}
631
632fn last_agent_message(messages: &[crate::platform_store::PlatformMessage]) -> Option<String> {
634 messages
635 .iter()
636 .rfind(|m| m.role == "agent" || m.role == "assistant")
637 .map(|m| m.content.clone())
638}
639
640async fn finish_subagent_task(
643 context: &ToolContext,
644 task_id: Option<&str>,
645 state: SessionTaskState,
646 summary: Option<String>,
647 error: Option<TaskError>,
648) {
649 let (Some(registry), Some(task_id)) = (context.session_task_registry.as_ref(), task_id) else {
650 return;
651 };
652 let _ = registry
653 .update(
654 context.session_id,
655 task_id,
656 SessionTaskUpdate {
657 state: Some(state),
658 summary,
659 error,
660 ..Default::default()
661 },
662 )
663 .await;
664}
665
666async fn find_subagent_task(context: &ToolContext, child_id: SessionId) -> Option<SessionTask> {
668 let registry = context.session_task_registry.as_ref()?;
669 let tasks = registry
670 .list(
671 context.session_id,
672 Some(&SessionTaskFilter {
673 kind: Some(TASK_KIND_SUBAGENT.to_string()),
674 state: None,
675 }),
676 )
677 .await
678 .ok()?;
679 tasks
680 .into_iter()
681 .find(|task| task.links.child_session_id == Some(child_id))
682}
683
684pub struct SpawnSubagentAsAgentTool;
687
688#[async_trait]
689impl Tool for SpawnSubagentAsAgentTool {
690 fn narrate(
691 &self,
692 tool_call: &crate::tool_types::ToolCall,
693 phase: crate::tool_narration::ToolNarrationPhase,
694 locale: Option<&str>,
695 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
696 ) -> Option<String> {
697 Some(crate::tool_narration::narrate_subagent_spawn(
698 &tool_call.arguments,
699 phase,
700 locale,
701 ))
702 }
703
704 fn name(&self) -> &str {
705 "spawn_agent"
706 }
707
708 fn display_name(&self) -> Option<&str> {
709 Some("Spawn Agent")
710 }
711
712 fn description(&self) -> &str {
713 "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."
714 }
715
716 fn parameters_schema(&self) -> Value {
717 json!({
718 "type": "object",
719 "properties": {
720 "name": {
721 "type": "string",
722 "description": "Human-readable name for the subagent (e.g. 'Test Runner', 'Auth Explorer'). Must be unique within this session."
723 },
724 "instructions": {
725 "type": "string",
726 "description": "Instructions for the subagent — what it should do."
727 },
728 "target": {
729 "type": "object",
730 "properties": {
731 "type": {
732 "type": "string",
733 "enum": ["subagent"],
734 "description": "Delegation target type. Use \"subagent\" for a same-agent child session."
735 }
736 },
737 "required": ["type"],
738 "additionalProperties": false
739 },
740 "mode": {
741 "type": "string",
742 "enum": ["background", "foreground"],
743 "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."
744 },
745 "blueprint": {
746 "type": "string",
747 "description": "Blueprint ID to spawn a specialist agent with its own tools and model. Omit to inherit parent's configuration."
748 },
749 "config": {
750 "type": "object",
751 "description": "Blueprint-specific configuration. Only valid when `blueprint` is set. Validated against the blueprint's config schema."
752 },
753 "result_schema": {
754 "type": "object",
755 "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."
756 },
757 "message_schema": {
758 "type": "object",
759 "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."
760 },
761 "push_configs": {
762 "type": "array",
763 "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.",
764 "items": {
765 "type": "object",
766 "properties": {
767 "url": { "type": "string" },
768 "secret": { "type": "string" },
769 "event_filter": {
770 "type": "array",
771 "items": {
772 "type": "string",
773 "enum": ["terminal", "awaiting_input", "message"]
774 }
775 }
776 },
777 "required": ["url"],
778 "additionalProperties": false
779 }
780 }
781 },
782 "required": ["name", "instructions", "target"],
783 "additionalProperties": false
784 })
785 }
786
787 fn hints(&self) -> ToolHints {
788 ToolHints::default()
789 .with_long_running(true)
790 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS)
791 }
792
793 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
794 ToolExecutionResult::tool_error(
795 "spawn_agent requires context. This tool must be executed with session context.",
796 )
797 }
798
799 async fn execute_with_context(
800 &self,
801 arguments: Value,
802 context: &ToolContext,
803 ) -> ToolExecutionResult {
804 let target = arguments.get("target").unwrap_or(&Value::Null);
805 if target.get("type").and_then(Value::as_str) != Some("subagent") {
806 return ToolExecutionResult::tool_error(
807 "spawn_agent target.type must be \"subagent\" for the subagents capability",
808 );
809 }
810 spawn_agent_subagent_impl(arguments, context)
811 .await
812 .unwrap_or_else(|e| e)
813 }
814
815 fn requires_context(&self) -> bool {
816 true
817 }
818}
819
820fn resolve_spawn_mode(
827 arguments: &Value,
828 context: &ToolContext,
829) -> Result<SpawnMode, ToolExecutionResult> {
830 let explicit = match arguments
831 .get("mode")
832 .and_then(Value::as_str)
833 .map(str::trim)
834 .filter(|s| !s.is_empty())
835 {
836 None => None,
837 Some(value) if let Some(mode) = SpawnMode::parse(value) => Some(mode),
838 Some(other) => {
839 return Err(ToolExecutionResult::tool_error(format!(
840 "Invalid mode: \"{other}\". Valid modes: background, foreground."
841 )));
842 }
843 };
844 let has_registry = context.session_task_registry.is_some();
845 match explicit {
846 Some(SpawnMode::Background) if !has_registry => Err(ToolExecutionResult::tool_error(
847 "Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead.",
848 )),
849 Some(mode) => Ok(mode),
850 None if has_registry => Ok(SpawnMode::Background),
851 None => Ok(SpawnMode::Foreground),
852 }
853}
854
855async fn spawn_agent_subagent_impl(
856 arguments: Value,
857 context: &ToolContext,
858) -> Result<ToolExecutionResult, ToolExecutionResult> {
859 let name = require_str(&arguments, "name")?.trim().to_string();
860 let instructions = require_str(&arguments, "instructions")?.to_string();
861 let goal = arguments
862 .get("goal")
863 .and_then(Value::as_str)
864 .map(str::trim)
865 .filter(|value| !value.is_empty())
866 .map(str::to_string);
867 let mode = resolve_spawn_mode(&arguments, context)?;
868 let lifetime = SpawnLifetime::parse(&arguments)?;
869 let seed = parse_seed(&arguments)?;
870
871 let store = get_platform_store(context)?;
872 let session_store = get_session_store(context)?;
873
874 let blueprint_param = arguments
875 .get("blueprint")
876 .and_then(|v| v.as_str())
877 .filter(|s| !s.trim().is_empty())
878 .map(|s| s.to_string());
879 let config_param = arguments.get("config").filter(|v| !v.is_null()).cloned();
880 let result_schema = normalize_result_schema(&arguments)?;
881 let message_schema = normalize_message_schema(&arguments)?;
882 let push_configs = normalize_push_configs(&arguments)?;
884
885 if config_param.is_some() && blueprint_param.is_none() {
887 return Ok(ToolExecutionResult::tool_error(
888 "The `config` parameter is only valid when `blueprint` is set.",
889 ));
890 }
891
892 let parent_session = match session_store.get_session(context.session_id).await {
894 Ok(Some(s)) => s,
895 Ok(None) => return Ok(ToolExecutionResult::tool_error("Current session not found")),
896 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
897 };
898
899 if lifetime == SpawnLifetime::Linked
900 && let Err(error) =
901 enforce_subagent_depth_cap(session_store, &parent_session, context).await
902 {
903 return Ok(error);
904 }
905
906 if let Some(ref bp_id) = blueprint_param {
908 let Some(ref registry) = context.capability_registry else {
909 return Ok(ToolExecutionResult::tool_error(
910 "Blueprint support requires capability_registry context.",
911 ));
912 };
913
914 let Some((blueprint_capability_id, blueprint)) = registry.blueprint_with_capability(bp_id)
915 else {
916 return Ok(ToolExecutionResult::tool_error(format!(
917 "Unknown blueprint: \"{bp_id}\". Check available blueprints."
918 )));
919 };
920
921 if let Some(ref schema) = blueprint.config_schema
923 && config_param.is_none()
924 && schema
925 .get("required")
926 .is_some_and(|r| r.as_array().is_some_and(|arr| !arr.is_empty()))
927 {
928 return Ok(ToolExecutionResult::tool_error(format!(
929 "Blueprint \"{bp_id}\" requires config. Schema: {}",
930 serde_json::to_string_pretty(schema).unwrap_or_default()
931 )));
932 }
933
934 let allowed_capability_ids = if let Some(agent_id) = parent_session.agent_id {
935 match store.get_agent_by_id(agent_id).await {
936 Ok(Some(agent)) => agent
937 .capabilities
938 .iter()
939 .map(|c| c.capability_id().to_string())
940 .collect::<Vec<_>>(),
941 Ok(None) => vec![],
942 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
943 }
944 } else {
945 match store.get_harness(parent_session.harness_id).await {
946 Ok(Some(harness)) => harness
947 .capabilities
948 .iter()
949 .map(|c| c.capability_id().to_string())
950 .collect::<Vec<_>>(),
951 Ok(None) => vec![],
952 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
953 }
954 };
955
956 if !allowed_capability_ids
957 .iter()
958 .any(|capability_id| capability_id == &blueprint_capability_id)
959 {
960 return Ok(ToolExecutionResult::tool_error(format!(
961 "Blueprint \"{bp_id}\" is not enabled for this session."
962 )));
963 }
964 }
965
966 if lifetime == SpawnLifetime::Linked
972 && let (Some(spawn_store), Some(tool_call_id)) =
973 (&context.subagent_spawn_store, &context.tool_call_id)
974 {
975 let claim_token = uuid::Uuid::new_v4();
976
977 let claim = match spawn_store
978 .try_claim_spawn(context.session_id, tool_call_id, claim_token)
979 .await
980 {
981 Ok(c) => c,
982 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
983 };
984
985 match claim {
986 SpawnClaimResult::AlreadySettled {
987 child_session_id,
988 terminal_status,
989 terminal_result,
990 } => {
991 let task_id = find_subagent_task(context, child_session_id)
993 .await
994 .map(|t| t.id);
995 return Ok(ToolExecutionResult::success(json!({
996 "subagent_id": child_session_id.to_string(),
997 "name": name,
998 "status": terminal_status,
999 "result": terminal_result,
1000 "task_id": task_id,
1001 "blueprint": blueprint_param,
1002 })));
1003 }
1004 SpawnClaimResult::AlreadyRunning {
1005 child_session_id,
1006 claim_token: stored_claim_token,
1007 } => {
1008 let task = find_subagent_task(context, child_session_id).await;
1011 let (task_id, task_attempt) =
1012 task.map(|t| (Some(t.id), t.attempt)).unwrap_or((None, 1));
1013 match mode {
1014 SpawnMode::Foreground => {
1015 return Ok(run_subagent_wait_and_settle(
1016 store,
1017 context,
1018 child_session_id,
1019 &name,
1020 &instructions,
1021 &blueprint_param,
1022 task_id,
1023 Some((
1024 spawn_store.as_ref(),
1025 tool_call_id.as_str(),
1026 stored_claim_token,
1027 )),
1028 )
1029 .await);
1030 }
1031 SpawnMode::Background => {
1032 let background_run_permit =
1033 match try_acquire_background_run_permit(context.session_id) {
1034 Ok(permit) => permit,
1035 Err(message) => {
1036 return Ok(ToolExecutionResult::tool_error(message));
1037 }
1038 };
1039 spawn_background_watcher(
1042 context,
1043 child_session_id,
1044 &name,
1045 None,
1046 task_id.clone(),
1047 task_attempt,
1048 Some(stored_claim_token),
1049 background_run_permit,
1050 );
1051 return Ok(background_running_result(
1052 child_session_id,
1053 &name,
1054 &task_id,
1055 &blueprint_param,
1056 ));
1057 }
1058 }
1059 }
1060 SpawnClaimResult::Claimed {
1061 spawn_handle_id,
1062 claim_token: actual_claim_token,
1063 }
1064 | SpawnClaimResult::ClaimedPendingChild {
1065 spawn_handle_id,
1066 claim_token: actual_claim_token,
1067 } => {
1068 return Ok(spawn_create_and_wait(
1071 store,
1072 context,
1073 &parent_session,
1074 &name,
1075 goal.as_deref(),
1076 &instructions,
1077 &blueprint_param,
1078 &config_param,
1079 &result_schema,
1080 &message_schema,
1081 &push_configs,
1082 mode,
1083 lifetime,
1084 seed,
1085 Some((
1086 spawn_store.as_ref(),
1087 tool_call_id.as_str(),
1088 spawn_handle_id,
1089 actual_claim_token,
1090 )),
1091 )
1092 .await);
1093 }
1094 }
1095 }
1096
1097 Ok(spawn_create_and_wait(
1099 store,
1100 context,
1101 &parent_session,
1102 &name,
1103 goal.as_deref(),
1104 &instructions,
1105 &blueprint_param,
1106 &config_param,
1107 &result_schema,
1108 &message_schema,
1109 &push_configs,
1110 mode,
1111 lifetime,
1112 seed,
1113 None,
1114 )
1115 .await)
1116}
1117
1118fn background_running_result(
1121 child_id: crate::typed_id::SessionId,
1122 name: &str,
1123 task_id: &Option<String>,
1124 blueprint_param: &Option<String>,
1125) -> ToolExecutionResult {
1126 ToolExecutionResult::success(json!({
1127 "subagent_id": child_id.to_string(),
1128 "name": name,
1129 "status": "running",
1130 "mode": "background",
1131 "task_id": task_id,
1132 "blueprint": blueprint_param,
1133 "message": "Subagent started in the background. Monitor it with get_task or wait_task using task_id; the session is notified when it finishes.",
1134 }))
1135}
1136
1137#[allow(clippy::too_many_arguments)]
1149async fn spawn_create_and_wait(
1150 store: &dyn PlatformStore,
1151 context: &ToolContext,
1152 parent_session: &crate::session::Session,
1153 name: &str,
1154 goal: Option<&str>,
1155 instructions: &str,
1156 blueprint_param: &Option<String>,
1157 config_param: &Option<Value>,
1158 result_schema: &Option<Value>,
1159 message_schema: &Option<Value>,
1160 push_configs: &Option<Value>,
1161 mode: SpawnMode,
1162 lifetime: SpawnLifetime,
1163 seed: SessionSeedMode,
1164 settle_ctx: Option<(
1165 &dyn crate::traits::SubagentSpawnStore,
1166 &str,
1167 uuid::Uuid,
1168 uuid::Uuid,
1169 )>,
1170) -> ToolExecutionResult {
1171 let background_run_permit = if mode == SpawnMode::Background {
1172 match try_acquire_background_run_permit(context.session_id) {
1173 Ok(permit) => Some(permit),
1174 Err(message) => return ToolExecutionResult::tool_error(message),
1175 }
1176 } else {
1177 None
1178 };
1179
1180 let Some(session_store) = context.session_store.as_ref() else {
1181 return ToolExecutionResult::tool_error("Subagent spawn requires session_store context");
1182 };
1183 let budget_root_session_id = if lifetime == SpawnLifetime::Detached {
1188 let Some(authority) = context.session_creation_authority.as_ref() else {
1189 return ToolExecutionResult::tool_error(
1190 "Detached spawn requires session-creation authority.",
1191 );
1192 };
1193 match authority
1194 .authorize_session_creation(context.session_id)
1195 .await
1196 {
1197 Ok(root_session_id) => Some(root_session_id),
1198 Err(error) => {
1199 return ToolExecutionResult::tool_error(format!(
1200 "Detached spawn is not authorized to create a session: {error}"
1201 ));
1202 }
1203 }
1204 } else {
1205 None
1206 };
1207
1208 let caps_result = match lifetime {
1213 SpawnLifetime::Linked => {
1214 enforce_subagent_task_caps(session_store.as_ref(), parent_session, context).await
1215 }
1216 SpawnLifetime::Detached => {
1217 enforce_detached_spawn_caps(
1218 context,
1219 budget_root_session_id.expect("detached authority returned a root"),
1220 )
1221 .await
1222 }
1223 };
1224 if let Err(error) = caps_result {
1225 return error;
1226 }
1227
1228 let child_session = match store
1231 .create_session_with_options(PlatformCreateSessionRequest {
1232 harness_id: parent_session.harness_id,
1233 agent_id: if blueprint_param.is_some() {
1234 None } else {
1236 parent_session.agent_id
1237 },
1238 title: Some(name.to_string()),
1239 goal: goal.map(str::to_string),
1240 locale: parent_session.locale.clone(),
1241 blueprint_id: blueprint_param.clone(),
1242 blueprint_config: config_param.clone(),
1243 parent_session_id: (lifetime == SpawnLifetime::Linked).then_some(context.session_id),
1244 forked_from_session_id: (lifetime == SpawnLifetime::Detached)
1245 .then_some(context.session_id),
1246 budget_root_session_id,
1247 seed,
1248 })
1249 .await
1250 {
1251 Ok(s) => s,
1252 Err(e) => return ToolExecutionResult::internal_error(e),
1253 };
1254 let mut task_id: Option<String> = None;
1259 let mut task_attempt: i32 = 1;
1260 let mut task_spec = json!({
1261 "instructions": instructions,
1262 "blueprint_id": blueprint_param,
1263 "mode": mode.as_str(),
1264 "lifetime": lifetime.as_str(),
1265 "seed": seed.as_str(),
1266 });
1267 if let Some(schema) = result_schema
1268 && let Some(spec) = task_spec.as_object_mut()
1269 {
1270 spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1271 }
1272 if let Some(schema) = message_schema
1273 && let Some(spec) = task_spec.as_object_mut()
1274 {
1275 spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1276 }
1277 if let Some(configs) = push_configs
1281 && let Some(spec) = task_spec.as_object_mut()
1282 {
1283 spec.insert(PUSH_CONFIGS_SPEC_KEY.to_string(), configs.clone());
1284 }
1285
1286 if let Some(ref task_registry) = context.session_task_registry
1287 && let Ok(created) = task_registry
1288 .create(CreateSessionTask {
1289 session_id: context.session_id,
1290 id: None,
1291 kind: match lifetime {
1292 SpawnLifetime::Linked => TASK_KIND_SUBAGENT,
1293 SpawnLifetime::Detached => TASK_KIND_SESSION,
1294 }
1295 .to_string(),
1296 display_name: name.to_string(),
1297 spec: task_spec,
1298 state: SessionTaskState::Running,
1299 links: TaskLinks {
1300 child_session_id: Some(child_session.id),
1301 ..Default::default()
1302 },
1303 wake_policy: match (lifetime, mode, message_schema.is_some()) {
1304 (SpawnLifetime::Detached, _, _) => TaskWakePolicy::Silent,
1305 (SpawnLifetime::Linked, SpawnMode::Background, true) => {
1306 TaskWakePolicy::OnActivity
1307 }
1308 (SpawnLifetime::Linked, SpawnMode::Background, false) => {
1309 TaskWakePolicy::OnTerminal
1310 }
1311 (SpawnLifetime::Linked, SpawnMode::Foreground, _) => TaskWakePolicy::Silent,
1312 },
1313 })
1314 .await
1315 {
1316 task_id = Some(created.id);
1317 task_attempt = created.attempt;
1318 }
1319
1320 let wait_settle_ctx = if let Some((spawn_store, tool_call_id, spawn_handle_id, claim_token)) =
1324 settle_ctx
1325 {
1326 if let Err(e) = spawn_store
1327 .register_child_session(spawn_handle_id, claim_token, child_session.id)
1328 .await
1329 {
1330 tracing::warn!(
1331 tool_call_id,
1332 error = %e,
1333 "Failed to register child session in spawn handle; proceeding without durable reattach"
1334 );
1335 }
1336 Some((spawn_store, tool_call_id, claim_token))
1337 } else {
1338 None
1339 };
1340
1341 if mode == SpawnMode::Background {
1342 spawn_background_watcher(
1346 context,
1347 child_session.id,
1348 name,
1349 Some(instructions.to_string()),
1350 task_id.clone(),
1351 task_attempt,
1352 wait_settle_ctx.map(|(_, _, claim_token)| claim_token),
1353 background_run_permit.expect("background permit acquired for background mode"),
1354 );
1355 return background_running_result(child_session.id, name, &task_id, blueprint_param);
1356 }
1357
1358 if let Err(e) = store.send_message(child_session.id, instructions).await {
1360 finish_subagent_task(
1361 context,
1362 task_id.as_deref(),
1363 SessionTaskState::Failed,
1364 None,
1365 Some(TaskError {
1366 kind: "error".to_string(),
1367 message: e.to_string(),
1368 }),
1369 )
1370 .await;
1371 return ToolExecutionResult::internal_error(e);
1372 }
1373
1374 run_subagent_wait_and_settle(
1375 store,
1376 context,
1377 child_session.id,
1378 name,
1379 instructions,
1380 blueprint_param,
1381 task_id,
1382 wait_settle_ctx,
1383 )
1384 .await
1385}
1386
1387#[allow(clippy::too_many_arguments)]
1390async fn run_subagent_wait_and_settle(
1391 store: &dyn PlatformStore,
1392 context: &ToolContext,
1393 child_id: crate::typed_id::SessionId,
1394 name: &str,
1395 _instructions: &str,
1396 blueprint_param: &Option<String>,
1397 task_id: Option<String>,
1398 settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1399) -> ToolExecutionResult {
1400 let status = match store.wait_for_idle(child_id, Some(300)).await {
1402 Ok(s) => s,
1403 Err(e) => {
1404 finish_subagent_task(
1405 context,
1406 task_id.as_deref(),
1407 SessionTaskState::Failed,
1408 None,
1409 Some(TaskError {
1410 kind: "error".to_string(),
1411 message: e.to_string(),
1412 }),
1413 )
1414 .await;
1415 return ToolExecutionResult::success(json!({
1416 "subagent_id": child_id.to_string(),
1417 "name": name,
1418 "status": "failed",
1419 "error": e.to_string(),
1420 "task_id": task_id,
1421 "blueprint": blueprint_param,
1422 }));
1423 }
1424 };
1425
1426 let result_text = match settle_subagent_outcome(
1427 store,
1428 context,
1429 child_id,
1430 &status,
1431 task_id.as_deref(),
1432 settle_ctx,
1433 )
1434 .await
1435 {
1436 Ok(text) => text,
1437 Err(error) => return error,
1438 };
1439 let result = result_value_for_task(context, task_id.as_deref())
1440 .await
1441 .unwrap_or_else(|| json!(result_text));
1442
1443 ToolExecutionResult::success(json!({
1444 "subagent_id": child_id.to_string(),
1445 "name": name,
1446 "status": status,
1447 "result": result,
1448 "task_id": task_id,
1449 "blueprint": blueprint_param,
1450 }))
1451}
1452
1453async fn settle_subagent_outcome(
1458 store: &dyn PlatformStore,
1459 context: &ToolContext,
1460 child_id: crate::typed_id::SessionId,
1461 status: &str,
1462 task_id: Option<&str>,
1463 settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1464) -> Result<String, ToolExecutionResult> {
1465 let messages = match store.get_messages(child_id, Some(5)).await {
1467 Ok(m) => m,
1468 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
1469 };
1470
1471 let result_text = last_agent_message(&messages)
1472 .unwrap_or_else(|| format!("Subagent completed with status: {status}"));
1473
1474 let terminal_status = terminal_subagent_status(status);
1475
1476 if let Some((spawn_store, tool_call_id, claim_token)) = settle_ctx
1479 && terminal_status.is_some()
1480 && let Err(e) = spawn_store
1481 .settle_spawn(
1482 context.session_id,
1483 tool_call_id,
1484 claim_token,
1485 status,
1486 &result_text,
1487 )
1488 .await
1489 {
1490 tracing::warn!(
1492 tool_call_id,
1493 error = %e,
1494 "Failed to settle subagent spawn handle"
1495 );
1496 }
1497
1498 if let Some(subagent_status) = terminal_status {
1500 let mut task_state = terminal_subagent_task_state(&subagent_status);
1501 let mut task_error = if task_state == SessionTaskState::Failed {
1502 Some(TaskError {
1503 kind: status.to_string(),
1504 message: format!("Subagent session ended with status: {status}"),
1505 })
1506 } else {
1507 None
1508 };
1509 let mut summary = Some(truncate_summary(&result_text));
1510 if task_state == SessionTaskState::Succeeded
1511 && required_result_is_missing(context, task_id).await
1512 {
1513 task_state = SessionTaskState::Failed;
1514 task_error = Some(TaskError {
1515 kind: "no_result".to_string(),
1516 message:
1517 "Subagent completed without calling report_result for its result_schema task."
1518 .to_string(),
1519 });
1520 summary = Some("Subagent completed without reporting a structured result.".to_string());
1521 }
1522 finish_subagent_task(context, task_id, task_state, summary, task_error).await;
1523 }
1524
1525 Ok(result_text)
1526}
1527
1528#[allow(clippy::too_many_arguments)]
1534fn spawn_background_watcher(
1535 context: &ToolContext,
1536 child_id: crate::typed_id::SessionId,
1537 name: &str,
1538 first_message: Option<String>,
1539 task_id: Option<String>,
1540 task_attempt: i32,
1541 claim_token: Option<uuid::Uuid>,
1542 background_run_permit: BackgroundRunPermit,
1543) {
1544 let context = context.clone();
1545 let name = name.to_string();
1546 tokio::spawn(async move {
1547 let _background_run_permit = background_run_permit;
1548 let Some(store) = context.platform_store.clone() else {
1549 return;
1551 };
1552
1553 if let Some(instructions) = first_message
1554 && let Err(e) = store.send_message(child_id, &instructions).await
1555 {
1556 finish_subagent_task(
1557 &context,
1558 task_id.as_deref(),
1559 SessionTaskState::Failed,
1560 None,
1561 Some(TaskError {
1562 kind: "error".to_string(),
1563 message: e.to_string(),
1564 }),
1565 )
1566 .await;
1567 return;
1568 }
1569
1570 let heartbeat = async {
1575 let (Some(registry), Some(task_id)) =
1576 (context.session_task_registry.clone(), task_id.clone())
1577 else {
1578 return std::future::pending::<()>().await;
1579 };
1580 loop {
1581 tokio::time::sleep(std::time::Duration::from_secs(
1582 BACKGROUND_HEARTBEAT_INTERVAL_SECS,
1583 ))
1584 .await;
1585 let _ = registry
1586 .update(
1587 context.session_id,
1588 &task_id,
1589 SessionTaskUpdate {
1590 heartbeat_at: Some(chrono::Utc::now()),
1591 expected_attempt: Some(task_attempt),
1592 ..Default::default()
1593 },
1594 )
1595 .await;
1596 }
1597 };
1598
1599 let wait_and_settle = async {
1600 let started = tokio::time::Instant::now();
1601 loop {
1602 let status = match store
1603 .wait_for_idle(child_id, Some(BACKGROUND_WAIT_SLICE_SECS))
1604 .await
1605 {
1606 Ok(s) => s,
1607 Err(e) => {
1608 finish_subagent_task(
1609 &context,
1610 task_id.as_deref(),
1611 SessionTaskState::Failed,
1612 None,
1613 Some(TaskError {
1614 kind: "error".to_string(),
1615 message: e.to_string(),
1616 }),
1617 )
1618 .await;
1619 return;
1620 }
1621 };
1622
1623 let effective = if status == "idle" {
1629 "completed".to_string()
1630 } else {
1631 status
1632 };
1633
1634 if terminal_subagent_status(&effective).is_some() {
1635 let settle_ctx = match (
1636 context.subagent_spawn_store.as_ref(),
1637 context.tool_call_id.as_ref(),
1638 claim_token,
1639 ) {
1640 (Some(spawn_store), Some(tool_call_id), Some(token)) => Some((
1641 spawn_store.as_ref() as &dyn crate::traits::SubagentSpawnStore,
1642 tool_call_id.as_str(),
1643 token,
1644 )),
1645 _ => None,
1646 };
1647 if let Err(error) = settle_subagent_outcome(
1648 store.as_ref(),
1649 &context,
1650 child_id,
1651 &effective,
1652 task_id.as_deref(),
1653 settle_ctx,
1654 )
1655 .await
1656 {
1657 tracing::warn!(
1658 subagent_name = name,
1659 child_session_id = %child_id,
1660 ?error,
1661 "Background subagent settle failed; marking task failed"
1662 );
1663 finish_subagent_task(
1664 &context,
1665 task_id.as_deref(),
1666 SessionTaskState::Failed,
1667 None,
1668 Some(TaskError {
1669 kind: "error".to_string(),
1670 message: "Failed to read subagent result".to_string(),
1671 }),
1672 )
1673 .await;
1674 }
1675 return;
1676 }
1677
1678 if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
1679 finish_subagent_task(
1680 &context,
1681 task_id.as_deref(),
1682 SessionTaskState::Failed,
1683 None,
1684 Some(TaskError {
1685 kind: "timeout".to_string(),
1686 message: format!(
1687 "Background subagent did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {effective})"
1688 ),
1689 }),
1690 )
1691 .await;
1692 return;
1693 }
1694
1695 if let (Some(registry), Some(task_id)) =
1699 (context.session_task_registry.as_ref(), task_id.as_deref())
1700 {
1701 let _ = registry
1702 .update(
1703 context.session_id,
1704 task_id,
1705 SessionTaskUpdate {
1706 state_detail: Some(format!(
1707 "waiting for subagent ({}s elapsed, last status: {effective})",
1708 started.elapsed().as_secs()
1709 )),
1710 expected_attempt: Some(task_attempt),
1711 ..Default::default()
1712 },
1713 )
1714 .await;
1715 }
1716 if !effective.starts_with("timeout") {
1717 tokio::time::sleep(std::time::Duration::from_secs(
1718 BACKGROUND_POLL_BACKOFF_SECS,
1719 ))
1720 .await;
1721 }
1722 }
1723 };
1724
1725 tokio::select! {
1726 () = wait_and_settle => {}
1727 () = heartbeat => {}
1728 }
1729 });
1730}
1731
1732pub struct SubagentTaskExecutor;
1740
1741#[async_trait]
1742impl TaskExecutor for SubagentTaskExecutor {
1743 fn kind(&self) -> &str {
1744 TASK_KIND_SUBAGENT
1745 }
1746
1747 async fn deliver(
1748 &self,
1749 task: &SessionTask,
1750 message: &TaskMessage,
1751 context: &ToolContext,
1752 ) -> crate::error::Result<()> {
1753 let Some(store) = context.platform_store.as_ref() else {
1754 return Err(crate::error::AgentLoopError::tool(
1755 "subagent task delivery requires platform_store context",
1756 ));
1757 };
1758 let Some(child_id) = task.links.child_session_id else {
1759 return Err(crate::error::AgentLoopError::tool(format!(
1760 "subagent task {} has no child session link",
1761 task.id
1762 )));
1763 };
1764 let text = task_message_text(&message.content);
1765 store.send_message(child_id, &text).await
1766 }
1767
1768 async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1769 let Some(store) = context.platform_store.as_ref() else {
1770 return Err(crate::error::AgentLoopError::tool(
1771 "subagent task cancellation requires platform_store context",
1772 ));
1773 };
1774 let Some(child_id) = task.links.child_session_id else {
1775 return Err(crate::error::AgentLoopError::tool(format!(
1776 "subagent task {} has no child session link",
1777 task.id
1778 )));
1779 };
1780 store
1782 .send_message(
1783 child_id,
1784 "Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
1785 )
1786 .await
1787 }
1788
1789 async fn reconcile(
1794 &self,
1795 task: &SessionTask,
1796 context: &ToolContext,
1797 ) -> crate::error::Result<()> {
1798 if task.state.is_terminal() {
1799 return Ok(());
1800 }
1801 let (Some(store), Some(child_id)) =
1802 (context.platform_store.as_ref(), task.links.child_session_id)
1803 else {
1804 return Ok(());
1805 };
1806 let status = store.wait_for_idle(child_id, Some(0)).await?;
1809 if terminal_subagent_status(&status).is_none() {
1810 return Ok(());
1811 }
1812 settle_subagent_outcome(
1813 store.as_ref(),
1814 context,
1815 child_id,
1816 &status,
1817 Some(&task.id),
1818 None,
1819 )
1820 .await
1821 .map(|_| ())
1822 .map_err(|_| {
1823 crate::error::AgentLoopError::tool("Failed to read subagent result during reconcile")
1824 })
1825 }
1826}
1827
1828inventory::submit! {
1829 TaskExecutorPlugin {
1830 executor: || Arc::new(SubagentTaskExecutor),
1831 }
1832}
1833
1834pub struct DetachedSessionTaskExecutor;
1841
1842#[async_trait]
1843impl TaskExecutor for DetachedSessionTaskExecutor {
1844 fn kind(&self) -> &str {
1845 TASK_KIND_SESSION
1846 }
1847
1848 async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1849 let Some(registry) = context.session_task_registry.as_ref() else {
1850 return Ok(());
1851 };
1852 let summary = match (context.platform_store.as_ref(), task.links.child_session_id) {
1857 (Some(store), Some(peer_id)) => {
1858 store
1859 .send_message(
1860 peer_id,
1861 "Cancellation requested by the session that spawned you. Stop work, wind down, and end your run.",
1862 )
1863 .await?;
1864 "Peer session cancellation requested; tracking settled canceled.".to_string()
1865 }
1866 _ => "Detached session tracking canceled; no peer session link to signal.".to_string(),
1869 };
1870 registry
1871 .update(
1872 task.session_id,
1873 &task.id,
1874 SessionTaskUpdate {
1875 state: Some(SessionTaskState::Canceled),
1876 summary: Some(summary),
1877 ..Default::default()
1878 },
1879 )
1880 .await?;
1881 Ok(())
1882 }
1883}
1884
1885inventory::submit! {
1886 TaskExecutorPlugin {
1887 executor: || Arc::new(DetachedSessionTaskExecutor),
1888 }
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894 use crate::Tool;
1895 use crate::session_task::{TaskMessageDirection, TaskMessagePart, task_result_path};
1896
1897 #[test]
1900 fn capability_features() {
1901 let cap = SubagentCapability;
1902 assert_eq!(cap.features(), vec!["subagents"]);
1903 }
1904
1905 #[test]
1906 fn terminal_subagent_status_maps_only_terminal_wait_states() {
1907 assert_eq!(terminal_subagent_status("idle"), None);
1910 assert_eq!(
1911 terminal_subagent_status("completed"),
1912 Some(crate::session::SubagentStatus::Completed)
1913 );
1914 assert_eq!(
1915 terminal_subagent_status("failed"),
1916 Some(crate::session::SubagentStatus::Failed)
1917 );
1918 assert_eq!(
1919 terminal_subagent_status("cancelled"),
1920 Some(crate::session::SubagentStatus::Cancelled)
1921 );
1922 assert_eq!(
1923 terminal_subagent_status("sealed"),
1924 Some(crate::session::SubagentStatus::Sealed)
1925 );
1926 assert_eq!(
1927 terminal_subagent_task_state(&crate::session::SubagentStatus::Completed),
1928 SessionTaskState::Succeeded
1929 );
1930 assert_eq!(
1932 terminal_subagent_task_state(&crate::session::SubagentStatus::Sealed),
1933 SessionTaskState::Failed
1934 );
1935 assert_eq!(
1936 terminal_subagent_task_state(&crate::session::SubagentStatus::Cancelled),
1937 SessionTaskState::Canceled
1938 );
1939 assert_eq!(
1940 terminal_subagent_task_state(&crate::session::SubagentStatus::MaxIterationsReached),
1941 SessionTaskState::Failed
1942 );
1943 assert_eq!(terminal_subagent_status("waiting_for_tool_results"), None);
1944 assert_eq!(terminal_subagent_status("paused"), None);
1945 }
1946
1947 #[test]
1948 fn subagent_nesting_policy_resolves_platform_org_agent_precedence() {
1949 let platform = crate::traits::SubagentNestingPolicy::default().with_platform_default(4);
1950 assert_eq!(platform.max_subagent_depth(), 4);
1951
1952 let org = platform.with_org_override(Some(3));
1953 assert_eq!(org.max_subagent_depth(), 3);
1954
1955 let agent = org.with_agent_override(Some(1));
1956 assert_eq!(agent.max_subagent_depth(), 1);
1957 }
1958
1959 #[test]
1960 fn subagent_capability_is_high_risk() {
1961 assert_eq!(SubagentCapability.risk_level(), RiskLevel::High);
1962 }
1963
1964 #[test]
1965 fn spawn_agent_subagent_schema_advertises_only_subagent_target() {
1966 let tool = SpawnSubagentAsAgentTool;
1967 let schema = tool.parameters_schema();
1968 assert_eq!(
1969 schema["properties"]["target"]["properties"]["type"]["enum"],
1970 json!(["subagent"])
1971 );
1972 let required = schema["required"].as_array().unwrap();
1973 assert!(required.contains(&json!("target")));
1974 assert!(required.contains(&json!("name")));
1975 assert!(required.contains(&json!("instructions")));
1976 let props = schema["properties"].as_object().unwrap();
1977 assert!(props.contains_key("blueprint"));
1978 assert!(props.contains_key("config"));
1979 assert!(props.contains_key("result_schema"));
1980 assert!(props.contains_key("message_schema"));
1981 assert!(!required.contains(&json!("blueprint")));
1982 assert!(!required.contains(&json!("config")));
1983 assert_eq!(
1984 schema["properties"]["mode"]["enum"],
1985 json!(["background", "foreground"])
1986 );
1987 assert_eq!(
1988 tool.hints().concurrency_class.as_deref(),
1989 Some(SPAWN_AGENT_CONCURRENCY_CLASS),
1990 "spawn_agent calls share one scheduler class so cap admission is serialized"
1991 );
1992 }
1993
1994 use crate::traits::{NoopSubagentSpawnStore, SpawnClaimResult, SubagentSpawnStore};
1999 use std::sync::Arc;
2000
2001 #[tokio::test]
2003 async fn noop_spawn_store_always_claims() {
2004 let store = NoopSubagentSpawnStore;
2005 let parent = crate::typed_id::SessionId::new();
2006 let token = uuid::Uuid::new_v4();
2007
2008 let result = store
2009 .try_claim_spawn(parent, "call-1", token)
2010 .await
2011 .expect("noop should not error");
2012
2013 assert!(
2014 matches!(result, SpawnClaimResult::Claimed { claim_token, .. } if claim_token == token),
2015 "noop store should return Claimed with the supplied token"
2016 );
2017 }
2018
2019 #[tokio::test]
2021 async fn noop_spawn_store_register_and_settle_are_noops() {
2022 let store = NoopSubagentSpawnStore;
2023 let parent = crate::typed_id::SessionId::new();
2024 let child = crate::typed_id::SessionId::new();
2025 let handle_id = uuid::Uuid::new_v4();
2026 let token = uuid::Uuid::new_v4();
2027
2028 store
2029 .register_child_session(handle_id, token, child)
2030 .await
2031 .expect("noop register should not error");
2032
2033 store
2034 .settle_spawn(parent, "call-1", token, "idle", "result text")
2035 .await
2036 .expect("noop settle should not error");
2037 }
2038
2039 #[tokio::test]
2041 async fn arc_spawn_store_delegates() {
2042 let store: Arc<dyn SubagentSpawnStore> = Arc::new(NoopSubagentSpawnStore);
2043 let parent = crate::typed_id::SessionId::new();
2044 let token = uuid::Uuid::new_v4();
2045
2046 let result = store
2047 .try_claim_spawn(parent, "call-arc", token)
2048 .await
2049 .expect("arc delegation should not error");
2050
2051 assert!(matches!(result, SpawnClaimResult::Claimed { .. }));
2052 }
2053
2054 use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
2059 use crate::platform_store::tests::MockPlatformStore;
2060 use crate::session_file::SessionFile;
2061 use crate::session_task::SessionTaskRegistry;
2062 use crate::traits::SessionFileSystem;
2063 use chrono::Utc;
2064 use std::collections::HashMap;
2065 use std::sync::Mutex;
2066
2067 struct MockSessionStore(Arc<MockPlatformStore>);
2069
2070 struct MockSessionCreationAuthority {
2071 root: crate::typed_id::SessionId,
2072 allowed: bool,
2073 }
2074
2075 #[async_trait]
2076 impl crate::traits::SessionCreationAuthority for MockSessionCreationAuthority {
2077 async fn authorize_session_creation(
2078 &self,
2079 _session_id: crate::typed_id::SessionId,
2080 ) -> crate::error::Result<crate::typed_id::SessionId> {
2081 if self.allowed {
2082 Ok(self.root)
2083 } else {
2084 Err(crate::error::AgentLoopError::tool(
2085 "org:sessions:manage is required",
2086 ))
2087 }
2088 }
2089 }
2090
2091 #[async_trait]
2092 impl crate::traits::SessionStore for MockSessionStore {
2093 async fn get_session(
2094 &self,
2095 session_id: crate::typed_id::SessionId,
2096 ) -> crate::error::Result<Option<crate::session::Session>> {
2097 self.0.get_session_by_id(session_id).await
2098 }
2099 }
2100
2101 fn spawn_context(
2102 store: &Arc<MockPlatformStore>,
2103 registry: Option<Arc<InMemorySessionTaskRegistry>>,
2104 ) -> ToolContext {
2105 spawn_context_for_session(store, registry, store.session.id)
2106 }
2107
2108 fn spawn_context_for_session(
2109 store: &Arc<MockPlatformStore>,
2110 registry: Option<Arc<InMemorySessionTaskRegistry>>,
2111 session_id: crate::typed_id::SessionId,
2112 ) -> ToolContext {
2113 let mut context = ToolContext::new(session_id);
2114 context.platform_store = Some(store.clone());
2115 context.session_store = Some(Arc::new(MockSessionStore(store.clone())));
2116 context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2117 root: store.session.id,
2118 allowed: true,
2119 }));
2120 if let Some(registry) = registry {
2121 context.session_task_registry = Some(registry);
2122 }
2123 context
2124 }
2125
2126 async fn spawn(context: &ToolContext, args: Value) -> ToolExecutionResult {
2127 let mut args = args;
2128 if let Some(object) = args.as_object_mut() {
2129 object
2130 .entry("target")
2131 .or_insert_with(|| json!({"type": "subagent"}));
2132 }
2133 SpawnSubagentAsAgentTool
2134 .execute_with_context(args, context)
2135 .await
2136 }
2137
2138 async fn wait_for_task_state(
2141 registry: &InMemorySessionTaskRegistry,
2142 session_id: crate::typed_id::SessionId,
2143 task_id: &str,
2144 state: crate::session_task::SessionTaskState,
2145 ) -> crate::session_task::SessionTask {
2146 for _ in 0..200 {
2147 let task = registry
2148 .get(session_id, task_id)
2149 .await
2150 .expect("registry get")
2151 .expect("task exists");
2152 if task.state == state {
2153 return task;
2154 }
2155 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2156 }
2157 panic!("task {task_id} did not reach {state:?}");
2158 }
2159
2160 #[derive(Default)]
2161 struct MemoryFileStore {
2162 files: Mutex<HashMap<(uuid::Uuid, String), String>>,
2163 }
2164
2165 #[async_trait]
2166 impl SessionFileSystem for MemoryFileStore {
2167 fn is_mount_resolver(&self) -> bool {
2168 false
2169 }
2170
2171 async fn read_file(
2172 &self,
2173 session_id: crate::typed_id::SessionId,
2174 path: &str,
2175 ) -> crate::error::Result<Option<SessionFile>> {
2176 let content = self
2177 .files
2178 .lock()
2179 .unwrap()
2180 .get(&(session_id.uuid(), path.to_string()))
2181 .cloned();
2182 Ok(content.map(|content| SessionFile {
2183 id: uuid::Uuid::new_v4(),
2184 session_id: session_id.uuid(),
2185 path: path.to_string(),
2186 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2187 content: Some(content.clone()),
2188 encoding: "utf-8".to_string(),
2189 is_directory: false,
2190 is_readonly: false,
2191 size_bytes: content.len() as i64,
2192 created_at: Utc::now(),
2193 updated_at: Utc::now(),
2194 }))
2195 }
2196
2197 async fn write_file(
2198 &self,
2199 session_id: crate::typed_id::SessionId,
2200 path: &str,
2201 content: &str,
2202 _encoding: &str,
2203 ) -> crate::error::Result<SessionFile> {
2204 self.files
2205 .lock()
2206 .unwrap()
2207 .insert((session_id.uuid(), path.to_string()), content.to_string());
2208 Ok(SessionFile {
2209 id: uuid::Uuid::new_v4(),
2210 session_id: session_id.uuid(),
2211 path: path.to_string(),
2212 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2213 content: Some(content.to_string()),
2214 encoding: "utf-8".to_string(),
2215 is_directory: false,
2216 is_readonly: false,
2217 size_bytes: content.len() as i64,
2218 created_at: Utc::now(),
2219 updated_at: Utc::now(),
2220 })
2221 }
2222
2223 async fn delete_file(
2224 &self,
2225 session_id: crate::typed_id::SessionId,
2226 path: &str,
2227 _recursive: bool,
2228 ) -> crate::error::Result<bool> {
2229 Ok(self
2230 .files
2231 .lock()
2232 .unwrap()
2233 .remove(&(session_id.uuid(), path.to_string()))
2234 .is_some())
2235 }
2236
2237 async fn list_directory(
2238 &self,
2239 _session_id: crate::typed_id::SessionId,
2240 _path: &str,
2241 ) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
2242 Ok(vec![])
2243 }
2244
2245 async fn stat_file(
2246 &self,
2247 session_id: crate::typed_id::SessionId,
2248 path: &str,
2249 ) -> crate::error::Result<Option<crate::session_file::FileStat>> {
2250 let content = self
2251 .files
2252 .lock()
2253 .unwrap()
2254 .get(&(session_id.uuid(), path.to_string()))
2255 .cloned();
2256 Ok(content.map(|content| crate::session_file::FileStat {
2257 path: path.to_string(),
2258 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2259 is_directory: false,
2260 is_readonly: false,
2261 size_bytes: content.len() as i64,
2262 created_at: Utc::now(),
2263 updated_at: Utc::now(),
2264 }))
2265 }
2266
2267 async fn grep_files(
2268 &self,
2269 _session_id: crate::typed_id::SessionId,
2270 _pattern: &str,
2271 _path_pattern: Option<&str>,
2272 ) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
2273 Ok(vec![])
2274 }
2275
2276 async fn create_directory(
2277 &self,
2278 session_id: crate::typed_id::SessionId,
2279 path: &str,
2280 ) -> crate::error::Result<crate::session_file::FileInfo> {
2281 Ok(crate::session_file::FileInfo {
2282 id: uuid::Uuid::new_v4(),
2283 session_id: session_id.uuid(),
2284 path: path.to_string(),
2285 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2286 is_directory: true,
2287 is_readonly: false,
2288 size_bytes: 0,
2289 created_at: Utc::now(),
2290 updated_at: Utc::now(),
2291 })
2292 }
2293 }
2294
2295 #[tokio::test]
2296 async fn spawn_agent_subagent_rejects_invalid_mode() {
2297 let context = ToolContext::new(crate::typed_id::SessionId::new());
2298 let result = spawn(
2299 &context,
2300 json!({"name": "Runner", "instructions": "go", "mode": "asap"}),
2301 )
2302 .await;
2303 let ToolExecutionResult::ToolError(msg) = result else {
2304 panic!("expected ToolError, got {result:?}");
2305 };
2306 assert!(msg.contains("Invalid mode"), "got: {msg}");
2307 }
2308
2309 #[tokio::test]
2310 async fn spawn_agent_subagent_rejects_other_target_types() {
2311 let context = ToolContext::new(crate::typed_id::SessionId::new());
2312 let result = SpawnSubagentAsAgentTool
2313 .execute_with_context(
2314 json!({
2315 "name": "Runner",
2316 "instructions": "go",
2317 "target": {"type": "external_a2a"}
2318 }),
2319 &context,
2320 )
2321 .await;
2322 let ToolExecutionResult::ToolError(msg) = result else {
2323 panic!("expected ToolError, got {result:?}");
2324 };
2325 assert!(msg.contains("subagent"), "got: {msg}");
2326 }
2327
2328 #[tokio::test]
2329 async fn spawn_agent_subagent_creates_subagent_task() {
2330 let store = Arc::new(MockPlatformStore::new());
2331 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2332 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2333 let context = spawn_context(&store, Some(registry.clone()));
2334
2335 let result = SpawnSubagentAsAgentTool
2336 .execute_with_context(
2337 json!({
2338 "name": "Runner",
2339 "instructions": "go",
2340 "target": {"type": "subagent"},
2341 "mode": "foreground"
2342 }),
2343 &context,
2344 )
2345 .await;
2346 let ToolExecutionResult::Success(value) = result else {
2347 panic!("expected success, got {result:?}");
2348 };
2349 let task_id = value["task_id"].as_str().expect("task_id");
2350 let task = registry
2351 .get(context.session_id, task_id)
2352 .await
2353 .unwrap()
2354 .unwrap();
2355 assert_eq!(task.kind, TASK_KIND_SUBAGENT);
2356 assert_eq!(task.spec["mode"], "foreground");
2357 assert!(task.links.child_session_id.is_some());
2358 }
2359
2360 #[tokio::test]
2361 async fn detached_spawn_creates_peer_session_task_with_goal_and_lineage() {
2362 let store = Arc::new(MockPlatformStore::new());
2363 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2364 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2365 let context = spawn_context(&store, Some(registry.clone()));
2366
2367 let result = spawn(
2368 &context,
2369 json!({
2370 "name": "Research Peer",
2371 "goal": "Investigate latency",
2372 "instructions": "go",
2373 "lifetime": "detached",
2374 "seed": "workspace",
2375 "mode": "foreground"
2376 }),
2377 )
2378 .await;
2379 let ToolExecutionResult::Success(value) = result else {
2380 panic!("expected success, got {result:?}");
2381 };
2382 let child_id: crate::typed_id::SessionId = value["subagent_id"]
2383 .as_str()
2384 .expect("subagent_id")
2385 .parse()
2386 .expect("valid session id");
2387 let child = store
2388 .get_session_by_id(child_id)
2389 .await
2390 .unwrap()
2391 .expect("child session");
2392 assert_eq!(child.parent_session_id, None);
2393 assert_eq!(child.forked_from_session_id, Some(context.session_id));
2394 assert_eq!(child.title.as_deref(), Some("Research Peer"));
2395 assert_eq!(child.goal.as_deref(), Some("Investigate latency"));
2396 assert_eq!(
2397 store
2398 .created_session_budget_roots
2399 .lock()
2400 .unwrap()
2401 .as_slice(),
2402 &[Some(store.session.id)]
2403 );
2404
2405 let task_id = value["task_id"].as_str().expect("task_id");
2406 let task = registry
2407 .get(context.session_id, task_id)
2408 .await
2409 .unwrap()
2410 .expect("task");
2411 assert_eq!(task.kind, TASK_KIND_SESSION);
2412 assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
2413 assert_eq!(task.links.child_session_id, Some(child_id));
2414 assert_eq!(task.spec["lifetime"], "detached");
2415 assert_eq!(task.spec["seed"], "workspace");
2416 }
2417
2418 #[tokio::test]
2419 async fn detached_spawn_requires_session_creation_authority_before_creation() {
2420 let store = Arc::new(MockPlatformStore::new());
2421 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2422 let mut context = spawn_context(&store, Some(registry));
2423 context.session_creation_authority = None;
2424
2425 let result = spawn(
2426 &context,
2427 json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2428 )
2429 .await;
2430 let ToolExecutionResult::ToolError(message) = result else {
2431 panic!("expected authority ToolError, got {result:?}");
2432 };
2433 assert!(message.contains("session-creation authority"));
2434 assert!(
2435 store
2436 .created_session_budget_roots
2437 .lock()
2438 .unwrap()
2439 .is_empty()
2440 );
2441 }
2442
2443 #[tokio::test]
2444 async fn detached_spawn_reports_permission_denial_before_creation() {
2445 let store = Arc::new(MockPlatformStore::new());
2446 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2447 let mut context = spawn_context(&store, Some(registry));
2448 context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2449 root: store.session.id,
2450 allowed: false,
2451 }));
2452
2453 let result = spawn(
2454 &context,
2455 json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2456 )
2457 .await;
2458 let ToolExecutionResult::ToolError(message) = result else {
2459 panic!("expected permission ToolError, got {result:?}");
2460 };
2461 assert!(message.contains("not authorized"));
2462 assert!(message.contains("org:sessions:manage"));
2463 assert!(
2464 store
2465 .created_session_budget_roots
2466 .lock()
2467 .unwrap()
2468 .is_empty()
2469 );
2470 }
2471
2472 #[tokio::test]
2473 async fn detached_spawn_bypasses_subagent_depth_guard() {
2474 let store = Arc::new(MockPlatformStore::new());
2475 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2476 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2477 crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2478 );
2479
2480 let linked = spawn(
2481 &context,
2482 json!({"name": "Linked", "instructions": "go", "mode": "foreground"}),
2483 )
2484 .await;
2485 assert!(matches!(linked, ToolExecutionResult::ToolError(_)));
2486
2487 let detached = spawn(
2488 &context,
2489 json!({
2490 "name": "Detached",
2491 "instructions": "go",
2492 "mode": "foreground",
2493 "lifetime": "detached"
2494 }),
2495 )
2496 .await;
2497 assert!(
2498 matches!(detached, ToolExecutionResult::Success(_)),
2499 "detached spawn should bypass linked depth guard, got {detached:?}"
2500 );
2501 }
2502
2503 fn session_task_under(
2507 root: crate::typed_id::SessionId,
2508 kind: &str,
2509 state: SessionTaskState,
2510 ) -> CreateSessionTask {
2511 CreateSessionTask {
2512 session_id: root,
2513 id: None,
2514 kind: kind.to_string(),
2515 display_name: "t".to_string(),
2516 spec: json!({}),
2517 state,
2518 links: TaskLinks {
2519 child_session_id: Some(crate::typed_id::SessionId::new()),
2520 ..Default::default()
2521 },
2522 wake_policy: TaskWakePolicy::Silent,
2523 }
2524 }
2525
2526 #[tokio::test]
2527 async fn detached_task_counts_ignore_subagent_and_terminal_active() {
2528 let store = Arc::new(MockPlatformStore::new());
2529 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2530 let root = store.session.id;
2531
2532 registry
2533 .create(session_task_under(
2534 root,
2535 TASK_KIND_SESSION,
2536 SessionTaskState::Running,
2537 ))
2538 .await
2539 .unwrap();
2540 registry
2541 .create(session_task_under(
2542 root,
2543 TASK_KIND_SESSION,
2544 SessionTaskState::Running,
2545 ))
2546 .await
2547 .unwrap();
2548 registry
2550 .create(session_task_under(
2551 root,
2552 TASK_KIND_SESSION,
2553 SessionTaskState::Canceled,
2554 ))
2555 .await
2556 .unwrap();
2557 registry
2559 .create(session_task_under(
2560 root,
2561 TASK_KIND_SUBAGENT,
2562 SessionTaskState::Running,
2563 ))
2564 .await
2565 .unwrap();
2566
2567 let counts = descendant_detached_task_counts(registry.as_ref(), root, 100, 100)
2568 .await
2569 .unwrap();
2570 assert_eq!(
2571 counts.active, 2,
2572 "only non-terminal session tasks are active"
2573 );
2574 assert_eq!(
2575 counts.total, 3,
2576 "terminal session task counts toward total; subagent task excluded"
2577 );
2578 }
2579
2580 #[tokio::test]
2581 async fn detached_spawn_rejected_at_cap_and_allowed_under_cap() {
2582 let store = Arc::new(MockPlatformStore::new());
2583 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2584 let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2585 crate::traits::SubagentNestingPolicy::default()
2586 .with_agent_detached_task_caps_override(Some(1), Some(4)),
2587 );
2588
2589 let ok = spawn(
2591 &context,
2592 json!({"name": "D0", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2593 )
2594 .await;
2595 assert!(
2596 matches!(ok, ToolExecutionResult::Success(_)),
2597 "detached spawn under cap should succeed, got {ok:?}"
2598 );
2599
2600 let refused = spawn(
2603 &context,
2604 json!({"name": "D1", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2605 )
2606 .await;
2607 let ToolExecutionResult::ToolError(msg) = refused else {
2608 panic!("expected detached active cap ToolError, got {refused:?}");
2609 };
2610 assert!(
2611 msg.contains("max_active_detached_tasks is 1"),
2612 "cap error should name the limit, got: {msg}"
2613 );
2614 }
2615
2616 #[tokio::test]
2617 async fn detached_cap_does_not_affect_linked_subagent_spawn() {
2618 let store = Arc::new(MockPlatformStore::new());
2621 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2622 let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2623 crate::traits::SubagentNestingPolicy::default()
2624 .with_agent_detached_task_caps_override(Some(1), Some(4)),
2625 );
2626 let root = store.session.id;
2627
2628 registry
2630 .create(session_task_under(
2631 root,
2632 TASK_KIND_SESSION,
2633 SessionTaskState::Running,
2634 ))
2635 .await
2636 .unwrap();
2637
2638 let refused = spawn(
2640 &context,
2641 json!({"name": "D", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2642 )
2643 .await;
2644 assert!(matches!(refused, ToolExecutionResult::ToolError(_)));
2645
2646 let linked = spawn(
2648 &context,
2649 json!({"name": "L", "instructions": "go", "mode": "background"}),
2650 )
2651 .await;
2652 assert!(
2653 matches!(linked, ToolExecutionResult::Success(_)),
2654 "linked subagent spawn must not be blocked by the detached cap, got {linked:?}"
2655 );
2656 }
2657
2658 #[tokio::test]
2659 async fn detached_session_task_cancel_requests_peer_cancellation() {
2660 let store = Arc::new(MockPlatformStore::new());
2663 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2664 let context = spawn_context(&store, Some(registry.clone()));
2665 let child_id = crate::typed_id::SessionId::new();
2666 let task = registry
2667 .create(CreateSessionTask {
2668 session_id: context.session_id,
2669 id: None,
2670 kind: TASK_KIND_SESSION.to_string(),
2671 display_name: "Peer".to_string(),
2672 spec: json!({}),
2673 state: SessionTaskState::Running,
2674 links: TaskLinks {
2675 child_session_id: Some(child_id),
2676 ..Default::default()
2677 },
2678 wake_policy: TaskWakePolicy::Silent,
2679 })
2680 .await
2681 .unwrap();
2682
2683 DetachedSessionTaskExecutor
2684 .cancel(&task, &context)
2685 .await
2686 .unwrap();
2687
2688 let sent = store.sent_messages.lock().unwrap().clone();
2690 assert_eq!(
2691 sent.len(),
2692 1,
2693 "exactly one cooperative-cancel message expected, got {sent:?}"
2694 );
2695 assert_eq!(sent[0].0, child_id, "cancel must target the peer session");
2696 assert!(
2697 sent[0].1.contains("Cancellation requested"),
2698 "cancel message should ask the peer to stop, got {:?}",
2699 sent[0].1
2700 );
2701
2702 let updated = registry
2704 .get(context.session_id, &task.id)
2705 .await
2706 .unwrap()
2707 .expect("task should remain present");
2708 assert_eq!(updated.state, SessionTaskState::Canceled);
2709 assert_eq!(updated.links.child_session_id, Some(child_id));
2710 assert_eq!(
2711 updated.summary.as_deref(),
2712 Some("Peer session cancellation requested; tracking settled canceled.")
2713 );
2714 }
2715
2716 #[tokio::test]
2717 async fn detached_session_task_cancel_without_peer_link_still_settles() {
2718 let store = Arc::new(MockPlatformStore::new());
2721 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2722 let context = spawn_context(&store, Some(registry.clone()));
2723 let task = registry
2724 .create(CreateSessionTask {
2725 session_id: context.session_id,
2726 id: None,
2727 kind: TASK_KIND_SESSION.to_string(),
2728 display_name: "Peer".to_string(),
2729 spec: json!({}),
2730 state: SessionTaskState::Running,
2731 links: TaskLinks::default(),
2732 wake_policy: TaskWakePolicy::Silent,
2733 })
2734 .await
2735 .unwrap();
2736
2737 DetachedSessionTaskExecutor
2738 .cancel(&task, &context)
2739 .await
2740 .unwrap();
2741
2742 assert!(store.sent_messages.lock().unwrap().is_empty());
2743 let updated = registry
2744 .get(context.session_id, &task.id)
2745 .await
2746 .unwrap()
2747 .expect("task should remain present");
2748 assert_eq!(updated.state, SessionTaskState::Canceled);
2749 assert_eq!(
2750 updated.summary.as_deref(),
2751 Some("Detached session tracking canceled; no peer session link to signal.")
2752 );
2753 }
2754
2755 #[tokio::test]
2756 async fn spawn_agent_subagent_allows_depth_two_and_rejects_depth_three_by_default() {
2757 let store = Arc::new(MockPlatformStore::new());
2758 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2759 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2760 let root_context = spawn_context(&store, Some(registry.clone()));
2761
2762 let first = spawn(
2763 &root_context,
2764 json!({"name": "B", "instructions": "go", "mode": "background"}),
2765 )
2766 .await;
2767 let ToolExecutionResult::Success(first_value) = first else {
2768 panic!("expected first spawn success, got {first:?}");
2769 };
2770 let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2771 .as_str()
2772 .expect("subagent_id")
2773 .parse()
2774 .expect("valid session id");
2775
2776 let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id);
2777 let second = spawn(
2778 &b_context,
2779 json!({"name": "C", "instructions": "go", "mode": "background"}),
2780 )
2781 .await;
2782 let ToolExecutionResult::Success(second_value) = second else {
2783 panic!("expected second spawn success, got {second:?}");
2784 };
2785 let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2786 .as_str()
2787 .expect("subagent_id")
2788 .parse()
2789 .expect("valid session id");
2790
2791 let c_context = spawn_context_for_session(&store, Some(registry), c_id);
2792 let third = spawn(
2793 &c_context,
2794 json!({"name": "D", "instructions": "go", "mode": "background"}),
2795 )
2796 .await;
2797 let ToolExecutionResult::ToolError(message) = third else {
2798 panic!("expected depth cap ToolError, got {third:?}");
2799 };
2800 assert!(
2801 message.contains("max_subagent_depth is 2"),
2802 "got: {message}"
2803 );
2804 assert!(message.contains("depth 3"), "got: {message}");
2805 }
2806
2807 #[tokio::test]
2808 async fn spawn_agent_subagent_depth_zero_restores_hard_block() {
2809 let store = Arc::new(MockPlatformStore::new());
2810 let mut context = spawn_context(&store, None).with_subagent_nesting_policy(
2811 crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2812 );
2813 context.session_task_registry = Some(Arc::new(InMemorySessionTaskRegistry::default()));
2814
2815 let result = spawn(
2816 &context,
2817 json!({"name": "Blocked", "instructions": "go", "mode": "background"}),
2818 )
2819 .await;
2820 let ToolExecutionResult::ToolError(message) = result else {
2821 panic!("expected depth cap ToolError, got {result:?}");
2822 };
2823 assert!(
2824 message.contains("max_subagent_depth is 0"),
2825 "got: {message}"
2826 );
2827 assert!(message.contains("depth 1"), "got: {message}");
2828 }
2829
2830 #[tokio::test]
2831 async fn spawn_agent_subagent_rejects_when_active_descendant_cap_is_full() {
2832 let store = Arc::new(MockPlatformStore::new());
2833 *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2834 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2835 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2836 crate::traits::SubagentNestingPolicy::default()
2837 .with_agent_task_caps_override(Some(1), Some(200)),
2838 );
2839
2840 let first = spawn(
2841 &context,
2842 json!({"name": "First", "instructions": "go", "mode": "background"}),
2843 )
2844 .await;
2845 assert!(
2846 matches!(first, ToolExecutionResult::Success(_)),
2847 "expected first spawn success, got {first:?}"
2848 );
2849
2850 let second = spawn(
2851 &context,
2852 json!({"name": "Second", "instructions": "go", "mode": "background"}),
2853 )
2854 .await;
2855 let ToolExecutionResult::ToolError(message) = second else {
2856 panic!("expected active cap ToolError, got {second:?}");
2857 };
2858 assert!(
2859 message.contains("max_active_descendant_tasks is 1"),
2860 "got: {message}"
2861 );
2862 assert!(
2863 message.contains("2 non-terminal descendant tasks"),
2864 "got: {message}"
2865 );
2866 }
2867
2868 #[tokio::test]
2869 async fn spawn_agent_subagent_counts_grandchildren_for_active_descendant_cap() {
2870 let store = Arc::new(MockPlatformStore::new());
2871 *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2872 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2873 let policy = crate::traits::SubagentNestingPolicy::default()
2874 .with_agent_override(Some(4))
2875 .with_agent_task_caps_override(Some(2), Some(200));
2876 let root_context =
2877 spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(policy);
2878
2879 let first = spawn(
2880 &root_context,
2881 json!({"name": "B", "instructions": "go", "mode": "background"}),
2882 )
2883 .await;
2884 let ToolExecutionResult::Success(first_value) = first else {
2885 panic!("expected first spawn success, got {first:?}");
2886 };
2887 let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2888 .as_str()
2889 .expect("subagent_id")
2890 .parse()
2891 .expect("valid session id");
2892
2893 let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id)
2894 .with_subagent_nesting_policy(policy);
2895 let second = spawn(
2896 &b_context,
2897 json!({"name": "C", "instructions": "go", "mode": "background"}),
2898 )
2899 .await;
2900 let ToolExecutionResult::Success(second_value) = second else {
2901 panic!("expected second spawn success, got {second:?}");
2902 };
2903 let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2904 .as_str()
2905 .expect("subagent_id")
2906 .parse()
2907 .expect("valid session id");
2908
2909 let c_context = spawn_context_for_session(&store, Some(registry), c_id)
2910 .with_subagent_nesting_policy(policy);
2911 let third = spawn(
2912 &c_context,
2913 json!({"name": "D", "instructions": "go", "mode": "background"}),
2914 )
2915 .await;
2916 let ToolExecutionResult::ToolError(message) = third else {
2917 panic!("expected active cap ToolError, got {third:?}");
2918 };
2919 assert!(
2920 message.contains("max_active_descendant_tasks is 2"),
2921 "got: {message}"
2922 );
2923 assert!(message.contains("root session"), "got: {message}");
2924 }
2925
2926 #[tokio::test]
2927 async fn spawn_agent_subagent_total_descendant_cap_counts_terminal_tasks() {
2928 let store = Arc::new(MockPlatformStore::new());
2929 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2930 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2931 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2932 crate::traits::SubagentNestingPolicy::default()
2933 .with_agent_task_caps_override(Some(16), Some(1)),
2934 );
2935
2936 let first = spawn(
2937 &context,
2938 json!({"name": "First", "instructions": "go", "mode": "foreground"}),
2939 )
2940 .await;
2941 assert!(
2942 matches!(first, ToolExecutionResult::Success(_)),
2943 "expected first spawn success, got {first:?}"
2944 );
2945
2946 let second = spawn(
2947 &context,
2948 json!({"name": "Second", "instructions": "go", "mode": "foreground"}),
2949 )
2950 .await;
2951 let ToolExecutionResult::ToolError(message) = second else {
2952 panic!("expected total cap ToolError, got {second:?}");
2953 };
2954 assert!(
2955 message.contains("max_total_descendant_tasks is 1"),
2956 "got: {message}"
2957 );
2958 assert!(
2959 message.contains("2 descendant task records"),
2960 "got: {message}"
2961 );
2962 }
2963
2964 #[test]
2965 fn subagents_config_validates_descendant_task_caps() {
2966 let capability = SubagentCapability;
2967 assert!(
2968 capability
2969 .validate_config(&json!({
2970 "max_active_descendant_tasks": 16,
2971 "max_total_descendant_tasks": 200
2972 }))
2973 .is_ok()
2974 );
2975 assert_eq!(
2976 capability
2977 .validate_config(&json!({"max_active_descendant_tasks": 1025}))
2978 .unwrap_err(),
2979 "max_active_descendant_tasks must be <= 1024"
2980 );
2981 assert_eq!(
2982 capability
2983 .validate_config(&json!({"max_total_descendant_tasks": 10001}))
2984 .unwrap_err(),
2985 "max_total_descendant_tasks must be <= 10000"
2986 );
2987 }
2988
2989 #[tokio::test]
2990 async fn spawn_agent_subagent_stores_result_schema_on_task() {
2991 let store = Arc::new(MockPlatformStore::new());
2992 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2993 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2994 let context = spawn_context(&store, Some(registry.clone()));
2995
2996 let result = SpawnSubagentAsAgentTool
2997 .execute_with_context(
2998 json!({
2999 "name": "Runner",
3000 "instructions": "go",
3001 "target": {"type": "subagent"},
3002 "mode": "foreground",
3003 "result_schema": {
3004 "type": "object",
3005 "properties": {"answer": {"type": "string"}},
3006 "required": ["answer"],
3007 "additionalProperties": false
3008 }
3009 }),
3010 &context,
3011 )
3012 .await;
3013 let ToolExecutionResult::Success(value) = result else {
3014 panic!("expected success, got {result:?}");
3015 };
3016 let task_id = value["task_id"].as_str().expect("task_id");
3017 let task = registry
3018 .get(context.session_id, task_id)
3019 .await
3020 .unwrap()
3021 .unwrap();
3022 assert_eq!(task.spec["result_schema"]["required"], json!(["answer"]));
3023 assert_eq!(task.state, SessionTaskState::Failed);
3024 assert_eq!(
3025 task.error.as_ref().map(|e| e.kind.as_str()),
3026 Some("no_result")
3027 );
3028 }
3029
3030 #[tokio::test]
3031 async fn report_result_writes_result_file_and_updates_task() {
3032 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3033 let file_store = Arc::new(MemoryFileStore::default());
3034 let parent_session_id = crate::typed_id::SessionId::new();
3035 let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3036 let child_session_id = crate::typed_id::SessionId::new();
3037 let task = registry
3038 .create(CreateSessionTask {
3039 session_id: parent_session_id,
3040 id: None,
3041 kind: TASK_KIND_SUBAGENT.to_string(),
3042 display_name: "Runner".to_string(),
3043 spec: json!({
3044 "result_schema": {
3045 "type": "object",
3046 "properties": {"answer": {"type": "string"}},
3047 "required": ["answer"],
3048 "additionalProperties": false
3049 }
3050 }),
3051 state: SessionTaskState::Running,
3052 links: TaskLinks {
3053 child_session_id: Some(child_session_id),
3054 ..Default::default()
3055 },
3056 wake_policy: TaskWakePolicy::Silent,
3057 })
3058 .await
3059 .unwrap();
3060
3061 let tool = ReportResultTool::new(
3062 parent_session_id,
3063 parent_workspace_id,
3064 child_session_id,
3065 task.id.clone(),
3066 task.spec["result_schema"].clone(),
3067 )
3068 .with_file_store(file_store.clone());
3069 let mut context = ToolContext::new(child_session_id);
3070 context.session_task_registry = Some(registry.clone());
3071
3072 let result = tool
3073 .execute_with_context(json!({"answer": "done"}), &context)
3074 .await;
3075 let ToolExecutionResult::Success(value) = result else {
3076 panic!("expected success, got {result:?}");
3077 };
3078 assert_eq!(value["result_path"], task_result_path(&task.id));
3079
3080 let task = registry
3081 .get(parent_session_id, &task.id)
3082 .await
3083 .unwrap()
3084 .unwrap();
3085 let result_path = task.result_path.as_deref().expect("result_path");
3086 let file = file_store
3087 .read_file(
3088 SessionId::from_uuid(parent_workspace_id.uuid()),
3089 result_path,
3090 )
3091 .await
3092 .unwrap()
3093 .expect("result file");
3094 assert_eq!(
3095 serde_json::from_str::<Value>(file.content.as_deref().unwrap()).unwrap(),
3096 json!({"answer": "done"})
3097 );
3098 }
3099
3100 #[tokio::test]
3101 async fn report_result_rejects_terminal_task_without_overwriting_result() {
3102 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3103 let file_store = Arc::new(MemoryFileStore::default());
3104 let parent_session_id = crate::typed_id::SessionId::new();
3105 let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3106 let child_session_id = crate::typed_id::SessionId::new();
3107 let task = registry
3108 .create(CreateSessionTask {
3109 session_id: parent_session_id,
3110 id: None,
3111 kind: TASK_KIND_SUBAGENT.to_string(),
3112 display_name: "Runner".to_string(),
3113 spec: json!({
3114 "result_schema": {
3115 "type": "object",
3116 "properties": {"answer": {"type": "string"}},
3117 "required": ["answer"],
3118 "additionalProperties": false
3119 }
3120 }),
3121 state: SessionTaskState::Succeeded,
3122 links: TaskLinks {
3123 child_session_id: Some(child_session_id),
3124 ..Default::default()
3125 },
3126 wake_policy: TaskWakePolicy::Silent,
3127 })
3128 .await
3129 .unwrap();
3130 let existing_path = task_result_path(&task.id);
3131 registry
3132 .update(
3133 parent_session_id,
3134 &task.id,
3135 SessionTaskUpdate {
3136 result_path: Some(existing_path.clone()),
3137 summary: Some("original".to_string()),
3138 ..Default::default()
3139 },
3140 )
3141 .await
3142 .unwrap();
3143 file_store
3144 .write_file(
3145 SessionId::from_uuid(parent_workspace_id.uuid()),
3146 &existing_path,
3147 "{\n \"answer\": \"original\"\n}",
3148 "utf-8",
3149 )
3150 .await
3151 .unwrap();
3152
3153 let tool = ReportResultTool::new(
3154 parent_session_id,
3155 parent_workspace_id,
3156 child_session_id,
3157 task.id.clone(),
3158 task.spec["result_schema"].clone(),
3159 )
3160 .with_file_store(file_store.clone());
3161 let mut context = ToolContext::new(child_session_id);
3162 context.session_task_registry = Some(registry.clone());
3163
3164 let result = tool
3165 .execute_with_context(json!({"answer": "tampered"}), &context)
3166 .await;
3167 let ToolExecutionResult::ToolError(message) = result else {
3168 panic!("expected terminal rejection, got {result:?}");
3169 };
3170 assert!(message.contains("terminal"), "got: {message}");
3171
3172 let file = file_store
3173 .read_file(
3174 SessionId::from_uuid(parent_workspace_id.uuid()),
3175 &existing_path,
3176 )
3177 .await
3178 .unwrap()
3179 .expect("result file");
3180 assert!(
3181 file.content.as_deref().unwrap().contains("original"),
3182 "file was overwritten: {file:?}"
3183 );
3184 }
3185
3186 #[tokio::test]
3187 async fn report_result_rejects_invalid_result_schema_payload() {
3188 let tool = ReportResultTool::new(
3189 crate::typed_id::SessionId::new(),
3190 crate::typed_id::WorkspaceId::from_uuid(uuid::Uuid::new_v4()),
3191 crate::typed_id::SessionId::new(),
3192 "task_test".to_string(),
3193 json!({
3194 "type": "object",
3195 "properties": {"answer": {"type": "string"}},
3196 "required": ["answer"],
3197 "additionalProperties": false
3198 }),
3199 );
3200 let result = tool
3201 .execute_with_context(json!({"extra": true}), &ToolContext::new(SessionId::new()))
3202 .await;
3203 let ToolExecutionResult::ToolError(message) = result else {
3204 panic!("expected validation error, got {result:?}");
3205 };
3206 assert!(
3207 message.contains("answer") && message.contains("required"),
3208 "got: {message}"
3209 );
3210 assert!(
3211 message.contains("extra")
3212 && (message.contains("additional") || message.contains("not allowed")),
3213 "got: {message}"
3214 );
3215 }
3216
3217 #[tokio::test]
3218 async fn spawn_agent_subagent_stores_message_schema_and_wakes_on_activity() {
3219 let store = Arc::new(MockPlatformStore::new());
3220 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3221 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3222 let context = spawn_context(&store, Some(registry.clone()));
3223
3224 let result = SpawnSubagentAsAgentTool
3225 .execute_with_context(
3226 json!({
3227 "name": "Runner",
3228 "instructions": "go",
3229 "target": {"type": "subagent"},
3230 "message_schema": {
3231 "type": "object",
3232 "properties": {"step": {"type": "string"}},
3233 "required": ["step"],
3234 "additionalProperties": false
3235 }
3236 }),
3237 &context,
3238 )
3239 .await;
3240 let ToolExecutionResult::Success(value) = result else {
3241 panic!("expected success, got {result:?}");
3242 };
3243 let task_id = value["task_id"].as_str().expect("task_id");
3244 let task = registry
3245 .get(context.session_id, task_id)
3246 .await
3247 .unwrap()
3248 .unwrap();
3249 assert_eq!(task.spec["message_schema"]["required"], json!(["step"]));
3250 assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
3251 }
3252
3253 #[tokio::test]
3254 async fn report_task_progress_posts_structured_outbound_message() {
3255 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3256 let parent_session_id = crate::typed_id::SessionId::new();
3257 let task = registry
3258 .create(CreateSessionTask {
3259 session_id: parent_session_id,
3260 id: None,
3261 kind: TASK_KIND_SUBAGENT.to_string(),
3262 display_name: "Runner".to_string(),
3263 spec: json!({
3264 "message_schema": {
3265 "type": "object",
3266 "properties": {"step": {"type": "string"}},
3267 "required": ["step"],
3268 "additionalProperties": false
3269 }
3270 }),
3271 state: SessionTaskState::Running,
3272 links: TaskLinks::default(),
3273 wake_policy: TaskWakePolicy::OnActivity,
3274 })
3275 .await
3276 .unwrap();
3277
3278 let tool = ReportTaskProgressTool::new(
3279 parent_session_id,
3280 task.id.clone(),
3281 task.attempt,
3282 task.spec["message_schema"].clone(),
3283 );
3284 assert_eq!(tool.name(), "report_task_progress");
3285 let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3286 context.session_task_registry = Some(registry.clone());
3287
3288 let result = tool
3289 .execute_with_context(json!({"step": "tests-running"}), &context)
3290 .await;
3291 let ToolExecutionResult::Success(value) = result else {
3292 panic!("expected success, got {result:?}");
3293 };
3294 assert_eq!(value["status"], "posted");
3295
3296 let messages = registry
3297 .list_messages(parent_session_id, &task.id, None, None)
3298 .await
3299 .unwrap();
3300 assert_eq!(messages.len(), 1);
3301 assert_eq!(messages[0].direction, TaskMessageDirection::Outbound);
3302 assert_eq!(
3303 messages[0].content,
3304 vec![TaskMessagePart::Data {
3305 data: json!({"step": "tests-running"})
3306 }]
3307 );
3308 }
3309
3310 #[tokio::test]
3311 async fn report_task_progress_rejects_stale_task_attempt() {
3312 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3313 let parent_session_id = crate::typed_id::SessionId::new();
3314 let task = registry
3315 .create(CreateSessionTask {
3316 session_id: parent_session_id,
3317 id: None,
3318 kind: TASK_KIND_SUBAGENT.to_string(),
3319 display_name: "Runner".to_string(),
3320 spec: json!({"message_schema": {"type": "object"}}),
3321 state: SessionTaskState::Running,
3322 links: TaskLinks::default(),
3323 wake_policy: TaskWakePolicy::OnActivity,
3324 })
3325 .await
3326 .unwrap();
3327 let tool = ReportTaskProgressTool::new(
3328 parent_session_id,
3329 task.id.clone(),
3330 task.attempt,
3331 task.spec["message_schema"].clone(),
3332 );
3333
3334 registry
3336 .update(
3337 parent_session_id,
3338 &task.id,
3339 SessionTaskUpdate {
3340 state: Some(SessionTaskState::Failed),
3341 increment_attempt: true,
3342 ..Default::default()
3343 },
3344 )
3345 .await
3346 .unwrap();
3347
3348 let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3349 context.session_task_registry = Some(registry.clone());
3350 let result = tool
3351 .execute_with_context(json!({"step": "late"}), &context)
3352 .await;
3353 assert!(
3354 matches!(result, ToolExecutionResult::InternalError(_)),
3355 "stale progress must be rejected, got {result:?}"
3356 );
3357 let messages = registry
3358 .list_messages(parent_session_id, &task.id, None, None)
3359 .await
3360 .unwrap();
3361 assert!(
3362 messages.is_empty(),
3363 "stale progress must not append messages"
3364 );
3365 }
3366
3367 #[tokio::test]
3368 async fn report_task_progress_rejects_invalid_message_schema_payload() {
3369 let tool = ReportTaskProgressTool::new(
3370 crate::typed_id::SessionId::new(),
3371 "task_test".to_string(),
3372 1,
3373 json!({
3374 "type": "object",
3375 "properties": {"step": {"type": "string"}},
3376 "required": ["step"],
3377 "additionalProperties": false
3378 }),
3379 );
3380 let result = tool
3381 .execute_with_context(
3382 json!({"step": 42, "extra": true}),
3383 &ToolContext::new(SessionId::new()),
3384 )
3385 .await;
3386 let ToolExecutionResult::ToolError(message) = result else {
3387 panic!("expected validation error, got {result:?}");
3388 };
3389 assert!(
3390 message.contains("step") && message.contains("string"),
3391 "got: {message}"
3392 );
3393 assert!(
3394 message.contains("extra")
3395 && (message.contains("additional") || message.contains("not allowed")),
3396 "got: {message}"
3397 );
3398 }
3399
3400 #[test]
3401 fn subagent_and_channel_progress_tools_have_distinct_names() {
3402 use crate::progress_reporting::{
3407 REPORT_PROGRESS_TOOL_NAME, ReportProgressTool as ChannelReportProgressTool,
3408 };
3409 use crate::tools::ToolRegistry;
3410
3411 let subagent = ReportTaskProgressTool::new(
3412 crate::typed_id::SessionId::new(),
3413 "task_test".to_string(),
3414 1,
3415 json!({"type": "object"}),
3416 );
3417 assert_eq!(subagent.name(), "report_task_progress");
3418 assert_eq!(REPORT_PROGRESS_TOOL_NAME, "report_progress");
3419 assert_ne!(subagent.name(), REPORT_PROGRESS_TOOL_NAME);
3420
3421 let mut registry = ToolRegistry::new();
3422 registry.register(ChannelReportProgressTool);
3423 registry.register(subagent);
3424 assert!(
3425 registry.has("report_progress"),
3426 "channel report_progress tool must survive"
3427 );
3428 assert!(
3429 registry.has("report_task_progress"),
3430 "subagent report_task_progress tool must survive"
3431 );
3432 }
3433
3434 #[tokio::test]
3435 async fn explicit_background_without_registry_errors() {
3436 let context = ToolContext::new(crate::typed_id::SessionId::new());
3437 let result = spawn(
3438 &context,
3439 json!({"name": "Runner", "instructions": "go", "mode": "background"}),
3440 )
3441 .await;
3442 let ToolExecutionResult::ToolError(msg) = result else {
3443 panic!("expected ToolError, got {result:?}");
3444 };
3445 assert!(
3446 msg.contains("task registry") && msg.contains("foreground"),
3447 "got: {msg}"
3448 );
3449 }
3450
3451 #[tokio::test]
3452 async fn default_mode_without_registry_degrades_to_foreground() {
3453 let store = Arc::new(MockPlatformStore::new());
3454 let context = spawn_context(&store, None);
3455 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3456 let ToolExecutionResult::Success(value) = result else {
3457 panic!("expected success, got {result:?}");
3458 };
3459 assert_eq!(value["status"], "idle");
3461 assert_eq!(value["result"], "Hi!");
3462 assert!(value.get("mode").is_none());
3463 }
3464
3465 #[tokio::test]
3466 async fn background_spawn_returns_immediately_and_settles_task() {
3467 let store = Arc::new(MockPlatformStore::new());
3468 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3469 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3470 let context = spawn_context(&store, Some(registry.clone()));
3471
3472 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3473 let ToolExecutionResult::Success(value) = result else {
3474 panic!("expected success, got {result:?}");
3475 };
3476 assert_eq!(value["status"], "running");
3477 assert_eq!(value["mode"], "background");
3478 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3479
3480 let task = wait_for_task_state(
3481 ®istry,
3482 context.session_id,
3483 &task_id,
3484 SessionTaskState::Succeeded,
3485 )
3486 .await;
3487 assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
3489 assert_eq!(task.spec["mode"], "background");
3490 assert_eq!(task.summary.as_deref(), Some("Hi!"));
3492 }
3493
3494 #[tokio::test]
3495 async fn background_spawn_rejects_when_session_active_run_limit_reached() {
3496 let store = Arc::new(MockPlatformStore::new());
3497 *store.wait_for_idle_status.lock().unwrap() = "paused".to_string();
3498 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3499 let context = spawn_context(&store, Some(registry));
3500
3501 for index in 0..crate::tools::MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3502 let result = spawn(
3503 &context,
3504 json!({
3505 "name": format!("Runner {index}"),
3506 "instructions": "go",
3507 }),
3508 )
3509 .await;
3510 let ToolExecutionResult::Success(value) = result else {
3511 panic!("background spawn below the session limit should start: {result:?}");
3512 };
3513 assert_eq!(value["status"], "running");
3514 }
3515
3516 let result = spawn(
3517 &context,
3518 json!({
3519 "name": "Runner over limit",
3520 "instructions": "go",
3521 }),
3522 )
3523 .await;
3524 let ToolExecutionResult::ToolError(message) = result else {
3525 panic!("background spawn should reject once the session limit is reached: {result:?}");
3526 };
3527 assert!(message.contains("active background runs per session"));
3528 }
3529
3530 #[tokio::test]
3531 async fn background_settles_bare_idle_as_completed() {
3532 let store = Arc::new(MockPlatformStore::new());
3535 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3536 let context = spawn_context(&store, Some(registry.clone()));
3537
3538 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3539 let ToolExecutionResult::Success(value) = result else {
3540 panic!("expected success, got {result:?}");
3541 };
3542 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3543 wait_for_task_state(
3544 ®istry,
3545 context.session_id,
3546 &task_id,
3547 SessionTaskState::Succeeded,
3548 )
3549 .await;
3550 }
3551
3552 #[tokio::test]
3553 async fn background_failed_child_settles_task_failed() {
3554 let store = Arc::new(MockPlatformStore::new());
3555 *store.wait_for_idle_status.lock().unwrap() = "failed".to_string();
3556 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3557 let context = spawn_context(&store, Some(registry.clone()));
3558
3559 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3560 let ToolExecutionResult::Success(value) = result else {
3561 panic!("expected success, got {result:?}");
3562 };
3563 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3564 let task = wait_for_task_state(
3565 ®istry,
3566 context.session_id,
3567 &task_id,
3568 SessionTaskState::Failed,
3569 )
3570 .await;
3571 assert_eq!(task.error.as_ref().map(|e| e.kind.as_str()), Some("failed"));
3572 }
3573
3574 #[tokio::test]
3575 async fn explicit_foreground_blocks_and_returns_result() {
3576 let store = Arc::new(MockPlatformStore::new());
3577 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3578 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3579 let context = spawn_context(&store, Some(registry.clone()));
3580
3581 let result = spawn(
3582 &context,
3583 json!({"name": "Runner", "instructions": "go", "mode": "foreground"}),
3584 )
3585 .await;
3586 let ToolExecutionResult::Success(value) = result else {
3587 panic!("expected success, got {result:?}");
3588 };
3589 assert_eq!(value["status"], "completed");
3590 assert_eq!(value["result"], "Hi!");
3591 let task_id = value["task_id"].as_str().expect("task_id");
3593 let task = registry
3594 .get(context.session_id, task_id)
3595 .await
3596 .unwrap()
3597 .unwrap();
3598 assert_eq!(task.state, SessionTaskState::Succeeded);
3599 assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
3600 }
3601
3602 #[tokio::test]
3603 async fn reconcile_settles_finished_child() {
3604 let store = Arc::new(MockPlatformStore::new());
3605 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3606 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3607 let context = spawn_context(&store, Some(registry.clone()));
3608
3609 let child_id = crate::typed_id::SessionId::new();
3610 let task = registry
3611 .create(CreateSessionTask {
3612 session_id: context.session_id,
3613 id: None,
3614 kind: TASK_KIND_SUBAGENT.to_string(),
3615 display_name: "Runner".to_string(),
3616 spec: json!({"mode": "background"}),
3617 state: SessionTaskState::Running,
3618 links: TaskLinks {
3619 child_session_id: Some(child_id),
3620 ..Default::default()
3621 },
3622 wake_policy: TaskWakePolicy::OnTerminal,
3623 })
3624 .await
3625 .unwrap();
3626
3627 SubagentTaskExecutor
3628 .reconcile(&task, &context)
3629 .await
3630 .expect("reconcile succeeds");
3631
3632 let task = registry
3633 .get(context.session_id, &task.id)
3634 .await
3635 .unwrap()
3636 .unwrap();
3637 assert_eq!(task.state, SessionTaskState::Succeeded);
3638 assert_eq!(task.summary.as_deref(), Some("Hi!"));
3639 }
3640
3641 #[tokio::test]
3642 async fn reconcile_is_noop_while_child_still_working() {
3643 let store = Arc::new(MockPlatformStore::new());
3644 *store.wait_for_idle_status.lock().unwrap() = "timeout (last status: Active)".to_string();
3645 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3646 let context = spawn_context(&store, Some(registry.clone()));
3647
3648 let task = registry
3649 .create(CreateSessionTask {
3650 session_id: context.session_id,
3651 id: None,
3652 kind: TASK_KIND_SUBAGENT.to_string(),
3653 display_name: "Runner".to_string(),
3654 spec: json!({"mode": "background"}),
3655 state: SessionTaskState::Running,
3656 links: TaskLinks {
3657 child_session_id: Some(crate::typed_id::SessionId::new()),
3658 ..Default::default()
3659 },
3660 wake_policy: TaskWakePolicy::OnTerminal,
3661 })
3662 .await
3663 .unwrap();
3664
3665 SubagentTaskExecutor
3666 .reconcile(&task, &context)
3667 .await
3668 .expect("reconcile succeeds");
3669
3670 let task = registry
3671 .get(context.session_id, &task.id)
3672 .await
3673 .unwrap()
3674 .unwrap();
3675 assert_eq!(task.state, SessionTaskState::Running);
3676 }
3677}