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) => match SpawnMode::parse(value) {
838 Some(mode) => Some(mode),
839 None => {
840 return Err(ToolExecutionResult::tool_error(format!(
841 "Invalid mode: \"{value}\". Valid modes: background, foreground."
842 )));
843 }
844 },
845 };
846 let has_registry = context.session_task_registry.is_some();
847 match explicit {
848 Some(SpawnMode::Background) if !has_registry => Err(ToolExecutionResult::tool_error(
849 "Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead.",
850 )),
851 Some(mode) => Ok(mode),
852 None if has_registry => Ok(SpawnMode::Background),
853 None => Ok(SpawnMode::Foreground),
854 }
855}
856
857async fn spawn_agent_subagent_impl(
858 arguments: Value,
859 context: &ToolContext,
860) -> Result<ToolExecutionResult, ToolExecutionResult> {
861 let name = require_str(&arguments, "name")?.trim().to_string();
862 let instructions = require_str(&arguments, "instructions")?.to_string();
863 let goal = arguments
864 .get("goal")
865 .and_then(Value::as_str)
866 .map(str::trim)
867 .filter(|value| !value.is_empty())
868 .map(str::to_string);
869 let mode = resolve_spawn_mode(&arguments, context)?;
870 let lifetime = SpawnLifetime::parse(&arguments)?;
871 let seed = parse_seed(&arguments)?;
872
873 let store = get_platform_store(context)?;
874 let session_store = get_session_store(context)?;
875
876 let blueprint_param = arguments
877 .get("blueprint")
878 .and_then(|v| v.as_str())
879 .filter(|s| !s.trim().is_empty())
880 .map(|s| s.to_string());
881 let config_param = arguments.get("config").filter(|v| !v.is_null()).cloned();
882 let result_schema = normalize_result_schema(&arguments)?;
883 let message_schema = normalize_message_schema(&arguments)?;
884 let push_configs = normalize_push_configs(&arguments)?;
886
887 if config_param.is_some() && blueprint_param.is_none() {
889 return Ok(ToolExecutionResult::tool_error(
890 "The `config` parameter is only valid when `blueprint` is set.",
891 ));
892 }
893
894 let parent_session = match session_store.get_session(context.session_id).await {
896 Ok(Some(s)) => s,
897 Ok(None) => return Ok(ToolExecutionResult::tool_error("Current session not found")),
898 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
899 };
900
901 if lifetime == SpawnLifetime::Linked
902 && let Err(error) =
903 enforce_subagent_depth_cap(session_store, &parent_session, context).await
904 {
905 return Ok(error);
906 }
907
908 if let Some(ref bp_id) = blueprint_param {
910 let Some(ref registry) = context.capability_registry else {
911 return Ok(ToolExecutionResult::tool_error(
912 "Blueprint support requires capability_registry context.",
913 ));
914 };
915
916 let Some((blueprint_capability_id, blueprint)) = registry.blueprint_with_capability(bp_id)
917 else {
918 return Ok(ToolExecutionResult::tool_error(format!(
919 "Unknown blueprint: \"{bp_id}\". Check available blueprints."
920 )));
921 };
922
923 if let Some(ref schema) = blueprint.config_schema
925 && config_param.is_none()
926 && schema
927 .get("required")
928 .is_some_and(|r| r.as_array().is_some_and(|arr| !arr.is_empty()))
929 {
930 return Ok(ToolExecutionResult::tool_error(format!(
931 "Blueprint \"{bp_id}\" requires config. Schema: {}",
932 serde_json::to_string_pretty(schema).unwrap_or_default()
933 )));
934 }
935
936 let allowed_capability_ids = if let Some(agent_id) = parent_session.agent_id {
937 match store.get_agent_by_id(agent_id).await {
938 Ok(Some(agent)) => agent
939 .capabilities
940 .iter()
941 .map(|c| c.capability_id().to_string())
942 .collect::<Vec<_>>(),
943 Ok(None) => vec![],
944 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
945 }
946 } else {
947 match store.get_harness(parent_session.harness_id).await {
948 Ok(Some(harness)) => harness
949 .capabilities
950 .iter()
951 .map(|c| c.capability_id().to_string())
952 .collect::<Vec<_>>(),
953 Ok(None) => vec![],
954 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
955 }
956 };
957
958 if !allowed_capability_ids
959 .iter()
960 .any(|capability_id| capability_id == &blueprint_capability_id)
961 {
962 return Ok(ToolExecutionResult::tool_error(format!(
963 "Blueprint \"{bp_id}\" is not enabled for this session."
964 )));
965 }
966 }
967
968 if lifetime == SpawnLifetime::Linked
974 && let (Some(spawn_store), Some(tool_call_id)) =
975 (&context.subagent_spawn_store, &context.tool_call_id)
976 {
977 let claim_token = uuid::Uuid::new_v4();
978
979 let claim = match spawn_store
980 .try_claim_spawn(context.session_id, tool_call_id, claim_token)
981 .await
982 {
983 Ok(c) => c,
984 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
985 };
986
987 match claim {
988 SpawnClaimResult::AlreadySettled {
989 child_session_id,
990 terminal_status,
991 terminal_result,
992 } => {
993 let task_id = find_subagent_task(context, child_session_id)
995 .await
996 .map(|t| t.id);
997 return Ok(ToolExecutionResult::success(json!({
998 "subagent_id": child_session_id.to_string(),
999 "name": name,
1000 "status": terminal_status,
1001 "result": terminal_result,
1002 "task_id": task_id,
1003 "blueprint": blueprint_param,
1004 })));
1005 }
1006 SpawnClaimResult::AlreadyRunning {
1007 child_session_id,
1008 claim_token: stored_claim_token,
1009 } => {
1010 let task = find_subagent_task(context, child_session_id).await;
1013 let (task_id, task_attempt) =
1014 task.map(|t| (Some(t.id), t.attempt)).unwrap_or((None, 1));
1015 match mode {
1016 SpawnMode::Foreground => {
1017 return Ok(run_subagent_wait_and_settle(
1018 store,
1019 context,
1020 child_session_id,
1021 &name,
1022 &instructions,
1023 &blueprint_param,
1024 task_id,
1025 Some((
1026 spawn_store.as_ref(),
1027 tool_call_id.as_str(),
1028 stored_claim_token,
1029 )),
1030 )
1031 .await);
1032 }
1033 SpawnMode::Background => {
1034 let background_run_permit =
1035 match try_acquire_background_run_permit(context.session_id) {
1036 Ok(permit) => permit,
1037 Err(message) => {
1038 return Ok(ToolExecutionResult::tool_error(message));
1039 }
1040 };
1041 spawn_background_watcher(
1044 context,
1045 child_session_id,
1046 &name,
1047 None,
1048 task_id.clone(),
1049 task_attempt,
1050 Some(stored_claim_token),
1051 background_run_permit,
1052 );
1053 return Ok(background_running_result(
1054 child_session_id,
1055 &name,
1056 &task_id,
1057 &blueprint_param,
1058 ));
1059 }
1060 }
1061 }
1062 SpawnClaimResult::Claimed {
1063 spawn_handle_id,
1064 claim_token: actual_claim_token,
1065 }
1066 | SpawnClaimResult::ClaimedPendingChild {
1067 spawn_handle_id,
1068 claim_token: actual_claim_token,
1069 } => {
1070 return Ok(spawn_create_and_wait(
1073 store,
1074 context,
1075 &parent_session,
1076 &name,
1077 goal.as_deref(),
1078 &instructions,
1079 &blueprint_param,
1080 &config_param,
1081 &result_schema,
1082 &message_schema,
1083 &push_configs,
1084 mode,
1085 lifetime,
1086 seed,
1087 Some((
1088 spawn_store.as_ref(),
1089 tool_call_id.as_str(),
1090 spawn_handle_id,
1091 actual_claim_token,
1092 )),
1093 )
1094 .await);
1095 }
1096 }
1097 }
1098
1099 Ok(spawn_create_and_wait(
1101 store,
1102 context,
1103 &parent_session,
1104 &name,
1105 goal.as_deref(),
1106 &instructions,
1107 &blueprint_param,
1108 &config_param,
1109 &result_schema,
1110 &message_schema,
1111 &push_configs,
1112 mode,
1113 lifetime,
1114 seed,
1115 None,
1116 )
1117 .await)
1118}
1119
1120fn background_running_result(
1123 child_id: crate::typed_id::SessionId,
1124 name: &str,
1125 task_id: &Option<String>,
1126 blueprint_param: &Option<String>,
1127) -> ToolExecutionResult {
1128 ToolExecutionResult::success(json!({
1129 "subagent_id": child_id.to_string(),
1130 "name": name,
1131 "status": "running",
1132 "mode": "background",
1133 "task_id": task_id,
1134 "blueprint": blueprint_param,
1135 "message": "Subagent started in the background. Monitor it with get_task or wait_task using task_id; the session is notified when it finishes.",
1136 }))
1137}
1138
1139#[allow(clippy::too_many_arguments)]
1151async fn spawn_create_and_wait(
1152 store: &dyn PlatformStore,
1153 context: &ToolContext,
1154 parent_session: &crate::session::Session,
1155 name: &str,
1156 goal: Option<&str>,
1157 instructions: &str,
1158 blueprint_param: &Option<String>,
1159 config_param: &Option<Value>,
1160 result_schema: &Option<Value>,
1161 message_schema: &Option<Value>,
1162 push_configs: &Option<Value>,
1163 mode: SpawnMode,
1164 lifetime: SpawnLifetime,
1165 seed: SessionSeedMode,
1166 settle_ctx: Option<(
1167 &dyn crate::traits::SubagentSpawnStore,
1168 &str,
1169 uuid::Uuid,
1170 uuid::Uuid,
1171 )>,
1172) -> ToolExecutionResult {
1173 let background_run_permit = if mode == SpawnMode::Background {
1174 match try_acquire_background_run_permit(context.session_id) {
1175 Ok(permit) => Some(permit),
1176 Err(message) => return ToolExecutionResult::tool_error(message),
1177 }
1178 } else {
1179 None
1180 };
1181
1182 let Some(session_store) = context.session_store.as_ref() else {
1183 return ToolExecutionResult::tool_error("Subagent spawn requires session_store context");
1184 };
1185 let budget_root_session_id = if lifetime == SpawnLifetime::Detached {
1190 let Some(authority) = context.session_creation_authority.as_ref() else {
1191 return ToolExecutionResult::tool_error(
1192 "Detached spawn requires session-creation authority.",
1193 );
1194 };
1195 match authority
1196 .authorize_session_creation(context.session_id)
1197 .await
1198 {
1199 Ok(root_session_id) => Some(root_session_id),
1200 Err(error) => {
1201 return ToolExecutionResult::tool_error(format!(
1202 "Detached spawn is not authorized to create a session: {error}"
1203 ));
1204 }
1205 }
1206 } else {
1207 None
1208 };
1209
1210 let caps_result = match lifetime {
1215 SpawnLifetime::Linked => {
1216 enforce_subagent_task_caps(session_store.as_ref(), parent_session, context).await
1217 }
1218 SpawnLifetime::Detached => {
1219 enforce_detached_spawn_caps(
1220 context,
1221 budget_root_session_id.expect("detached authority returned a root"),
1222 )
1223 .await
1224 }
1225 };
1226 if let Err(error) = caps_result {
1227 return error;
1228 }
1229
1230 let child_session = match store
1233 .create_session_with_options(PlatformCreateSessionRequest {
1234 harness_id: parent_session.harness_id,
1235 agent_id: if blueprint_param.is_some() {
1236 None } else {
1238 parent_session.agent_id
1239 },
1240 title: Some(name.to_string()),
1241 goal: goal.map(str::to_string),
1242 locale: parent_session.locale.clone(),
1243 blueprint_id: blueprint_param.clone(),
1244 blueprint_config: config_param.clone(),
1245 parent_session_id: (lifetime == SpawnLifetime::Linked).then_some(context.session_id),
1246 forked_from_session_id: (lifetime == SpawnLifetime::Detached)
1247 .then_some(context.session_id),
1248 budget_root_session_id,
1249 seed,
1250 })
1251 .await
1252 {
1253 Ok(s) => s,
1254 Err(e) => return ToolExecutionResult::internal_error(e),
1255 };
1256 let mut task_id: Option<String> = None;
1261 let mut task_attempt: i32 = 1;
1262 let mut task_spec = json!({
1263 "instructions": instructions,
1264 "blueprint_id": blueprint_param,
1265 "mode": mode.as_str(),
1266 "lifetime": lifetime.as_str(),
1267 "seed": seed.as_str(),
1268 });
1269 if let Some(schema) = result_schema
1270 && let Some(spec) = task_spec.as_object_mut()
1271 {
1272 spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1273 }
1274 if let Some(schema) = message_schema
1275 && let Some(spec) = task_spec.as_object_mut()
1276 {
1277 spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1278 }
1279 if let Some(configs) = push_configs
1283 && let Some(spec) = task_spec.as_object_mut()
1284 {
1285 spec.insert(PUSH_CONFIGS_SPEC_KEY.to_string(), configs.clone());
1286 }
1287
1288 if let Some(ref task_registry) = context.session_task_registry
1289 && let Ok(created) = task_registry
1290 .create(CreateSessionTask {
1291 session_id: context.session_id,
1292 id: None,
1293 kind: match lifetime {
1294 SpawnLifetime::Linked => TASK_KIND_SUBAGENT,
1295 SpawnLifetime::Detached => TASK_KIND_SESSION,
1296 }
1297 .to_string(),
1298 display_name: name.to_string(),
1299 spec: task_spec,
1300 state: SessionTaskState::Running,
1301 links: TaskLinks {
1302 child_session_id: Some(child_session.id),
1303 ..Default::default()
1304 },
1305 wake_policy: match (lifetime, mode, message_schema.is_some()) {
1306 (SpawnLifetime::Detached, _, _) => TaskWakePolicy::Silent,
1307 (SpawnLifetime::Linked, SpawnMode::Background, true) => {
1308 TaskWakePolicy::OnActivity
1309 }
1310 (SpawnLifetime::Linked, SpawnMode::Background, false) => {
1311 TaskWakePolicy::OnTerminal
1312 }
1313 (SpawnLifetime::Linked, SpawnMode::Foreground, _) => TaskWakePolicy::Silent,
1314 },
1315 })
1316 .await
1317 {
1318 task_id = Some(created.id);
1319 task_attempt = created.attempt;
1320 }
1321
1322 let wait_settle_ctx = if let Some((spawn_store, tool_call_id, spawn_handle_id, claim_token)) =
1326 settle_ctx
1327 {
1328 if let Err(e) = spawn_store
1329 .register_child_session(spawn_handle_id, claim_token, child_session.id)
1330 .await
1331 {
1332 tracing::warn!(
1333 tool_call_id,
1334 error = %e,
1335 "Failed to register child session in spawn handle; proceeding without durable reattach"
1336 );
1337 }
1338 Some((spawn_store, tool_call_id, claim_token))
1339 } else {
1340 None
1341 };
1342
1343 if mode == SpawnMode::Background {
1344 spawn_background_watcher(
1348 context,
1349 child_session.id,
1350 name,
1351 Some(instructions.to_string()),
1352 task_id.clone(),
1353 task_attempt,
1354 wait_settle_ctx.map(|(_, _, claim_token)| claim_token),
1355 background_run_permit.expect("background permit acquired for background mode"),
1356 );
1357 return background_running_result(child_session.id, name, &task_id, blueprint_param);
1358 }
1359
1360 if let Err(e) = store.send_message(child_session.id, instructions).await {
1362 finish_subagent_task(
1363 context,
1364 task_id.as_deref(),
1365 SessionTaskState::Failed,
1366 None,
1367 Some(TaskError {
1368 kind: "error".to_string(),
1369 message: e.to_string(),
1370 }),
1371 )
1372 .await;
1373 return ToolExecutionResult::internal_error(e);
1374 }
1375
1376 run_subagent_wait_and_settle(
1377 store,
1378 context,
1379 child_session.id,
1380 name,
1381 instructions,
1382 blueprint_param,
1383 task_id,
1384 wait_settle_ctx,
1385 )
1386 .await
1387}
1388
1389#[allow(clippy::too_many_arguments)]
1392async fn run_subagent_wait_and_settle(
1393 store: &dyn PlatformStore,
1394 context: &ToolContext,
1395 child_id: crate::typed_id::SessionId,
1396 name: &str,
1397 _instructions: &str,
1398 blueprint_param: &Option<String>,
1399 task_id: Option<String>,
1400 settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1401) -> ToolExecutionResult {
1402 let status = match store.wait_for_idle(child_id, Some(300)).await {
1404 Ok(s) => s,
1405 Err(e) => {
1406 finish_subagent_task(
1407 context,
1408 task_id.as_deref(),
1409 SessionTaskState::Failed,
1410 None,
1411 Some(TaskError {
1412 kind: "error".to_string(),
1413 message: e.to_string(),
1414 }),
1415 )
1416 .await;
1417 return ToolExecutionResult::success(json!({
1418 "subagent_id": child_id.to_string(),
1419 "name": name,
1420 "status": "failed",
1421 "error": e.to_string(),
1422 "task_id": task_id,
1423 "blueprint": blueprint_param,
1424 }));
1425 }
1426 };
1427
1428 let result_text = match settle_subagent_outcome(
1429 store,
1430 context,
1431 child_id,
1432 &status,
1433 task_id.as_deref(),
1434 settle_ctx,
1435 )
1436 .await
1437 {
1438 Ok(text) => text,
1439 Err(error) => return error,
1440 };
1441 let result = result_value_for_task(context, task_id.as_deref())
1442 .await
1443 .unwrap_or_else(|| json!(result_text));
1444
1445 ToolExecutionResult::success(json!({
1446 "subagent_id": child_id.to_string(),
1447 "name": name,
1448 "status": status,
1449 "result": result,
1450 "task_id": task_id,
1451 "blueprint": blueprint_param,
1452 }))
1453}
1454
1455async fn settle_subagent_outcome(
1460 store: &dyn PlatformStore,
1461 context: &ToolContext,
1462 child_id: crate::typed_id::SessionId,
1463 status: &str,
1464 task_id: Option<&str>,
1465 settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1466) -> Result<String, ToolExecutionResult> {
1467 let messages = match store.get_messages(child_id, Some(5)).await {
1469 Ok(m) => m,
1470 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
1471 };
1472
1473 let result_text = last_agent_message(&messages)
1474 .unwrap_or_else(|| format!("Subagent completed with status: {status}"));
1475
1476 let terminal_status = terminal_subagent_status(status);
1477
1478 if let Some((spawn_store, tool_call_id, claim_token)) = settle_ctx
1481 && terminal_status.is_some()
1482 && let Err(e) = spawn_store
1483 .settle_spawn(
1484 context.session_id,
1485 tool_call_id,
1486 claim_token,
1487 status,
1488 &result_text,
1489 )
1490 .await
1491 {
1492 tracing::warn!(
1494 tool_call_id,
1495 error = %e,
1496 "Failed to settle subagent spawn handle"
1497 );
1498 }
1499
1500 if let Some(subagent_status) = terminal_status {
1502 let mut task_state = terminal_subagent_task_state(&subagent_status);
1503 let mut task_error = if task_state == SessionTaskState::Failed {
1504 Some(TaskError {
1505 kind: status.to_string(),
1506 message: format!("Subagent session ended with status: {status}"),
1507 })
1508 } else {
1509 None
1510 };
1511 let mut summary = Some(truncate_summary(&result_text));
1512 if task_state == SessionTaskState::Succeeded
1513 && required_result_is_missing(context, task_id).await
1514 {
1515 task_state = SessionTaskState::Failed;
1516 task_error = Some(TaskError {
1517 kind: "no_result".to_string(),
1518 message:
1519 "Subagent completed without calling report_result for its result_schema task."
1520 .to_string(),
1521 });
1522 summary = Some("Subagent completed without reporting a structured result.".to_string());
1523 }
1524 finish_subagent_task(context, task_id, task_state, summary, task_error).await;
1525 }
1526
1527 Ok(result_text)
1528}
1529
1530#[allow(clippy::too_many_arguments)]
1536fn spawn_background_watcher(
1537 context: &ToolContext,
1538 child_id: crate::typed_id::SessionId,
1539 name: &str,
1540 first_message: Option<String>,
1541 task_id: Option<String>,
1542 task_attempt: i32,
1543 claim_token: Option<uuid::Uuid>,
1544 background_run_permit: BackgroundRunPermit,
1545) {
1546 let context = context.clone();
1547 let name = name.to_string();
1548 tokio::spawn(async move {
1549 let _background_run_permit = background_run_permit;
1550 let Some(store) = context.platform_store.clone() else {
1551 return;
1553 };
1554
1555 if let Some(instructions) = first_message
1556 && let Err(e) = store.send_message(child_id, &instructions).await
1557 {
1558 finish_subagent_task(
1559 &context,
1560 task_id.as_deref(),
1561 SessionTaskState::Failed,
1562 None,
1563 Some(TaskError {
1564 kind: "error".to_string(),
1565 message: e.to_string(),
1566 }),
1567 )
1568 .await;
1569 return;
1570 }
1571
1572 let heartbeat = async {
1577 let (Some(registry), Some(task_id)) =
1578 (context.session_task_registry.clone(), task_id.clone())
1579 else {
1580 return std::future::pending::<()>().await;
1581 };
1582 loop {
1583 tokio::time::sleep(std::time::Duration::from_secs(
1584 BACKGROUND_HEARTBEAT_INTERVAL_SECS,
1585 ))
1586 .await;
1587 let _ = registry
1588 .update(
1589 context.session_id,
1590 &task_id,
1591 SessionTaskUpdate {
1592 heartbeat_at: Some(chrono::Utc::now()),
1593 expected_attempt: Some(task_attempt),
1594 ..Default::default()
1595 },
1596 )
1597 .await;
1598 }
1599 };
1600
1601 let wait_and_settle = async {
1602 let started = tokio::time::Instant::now();
1603 loop {
1604 let status = match store
1605 .wait_for_idle(child_id, Some(BACKGROUND_WAIT_SLICE_SECS))
1606 .await
1607 {
1608 Ok(s) => s,
1609 Err(e) => {
1610 finish_subagent_task(
1611 &context,
1612 task_id.as_deref(),
1613 SessionTaskState::Failed,
1614 None,
1615 Some(TaskError {
1616 kind: "error".to_string(),
1617 message: e.to_string(),
1618 }),
1619 )
1620 .await;
1621 return;
1622 }
1623 };
1624
1625 let effective = if status == "idle" {
1631 "completed".to_string()
1632 } else {
1633 status
1634 };
1635
1636 if terminal_subagent_status(&effective).is_some() {
1637 let settle_ctx = match (
1638 context.subagent_spawn_store.as_ref(),
1639 context.tool_call_id.as_ref(),
1640 claim_token,
1641 ) {
1642 (Some(spawn_store), Some(tool_call_id), Some(token)) => Some((
1643 spawn_store.as_ref() as &dyn crate::traits::SubagentSpawnStore,
1644 tool_call_id.as_str(),
1645 token,
1646 )),
1647 _ => None,
1648 };
1649 if let Err(error) = settle_subagent_outcome(
1650 store.as_ref(),
1651 &context,
1652 child_id,
1653 &effective,
1654 task_id.as_deref(),
1655 settle_ctx,
1656 )
1657 .await
1658 {
1659 tracing::warn!(
1660 subagent_name = name,
1661 child_session_id = %child_id,
1662 ?error,
1663 "Background subagent settle failed; marking task failed"
1664 );
1665 finish_subagent_task(
1666 &context,
1667 task_id.as_deref(),
1668 SessionTaskState::Failed,
1669 None,
1670 Some(TaskError {
1671 kind: "error".to_string(),
1672 message: "Failed to read subagent result".to_string(),
1673 }),
1674 )
1675 .await;
1676 }
1677 return;
1678 }
1679
1680 if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
1681 finish_subagent_task(
1682 &context,
1683 task_id.as_deref(),
1684 SessionTaskState::Failed,
1685 None,
1686 Some(TaskError {
1687 kind: "timeout".to_string(),
1688 message: format!(
1689 "Background subagent did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {effective})"
1690 ),
1691 }),
1692 )
1693 .await;
1694 return;
1695 }
1696
1697 if let (Some(registry), Some(task_id)) =
1701 (context.session_task_registry.as_ref(), task_id.as_deref())
1702 {
1703 let _ = registry
1704 .update(
1705 context.session_id,
1706 task_id,
1707 SessionTaskUpdate {
1708 state_detail: Some(format!(
1709 "waiting for subagent ({}s elapsed, last status: {effective})",
1710 started.elapsed().as_secs()
1711 )),
1712 expected_attempt: Some(task_attempt),
1713 ..Default::default()
1714 },
1715 )
1716 .await;
1717 }
1718 if !effective.starts_with("timeout") {
1719 tokio::time::sleep(std::time::Duration::from_secs(
1720 BACKGROUND_POLL_BACKOFF_SECS,
1721 ))
1722 .await;
1723 }
1724 }
1725 };
1726
1727 tokio::select! {
1728 () = wait_and_settle => {}
1729 () = heartbeat => {}
1730 }
1731 });
1732}
1733
1734pub struct SubagentTaskExecutor;
1742
1743#[async_trait]
1744impl TaskExecutor for SubagentTaskExecutor {
1745 fn kind(&self) -> &str {
1746 TASK_KIND_SUBAGENT
1747 }
1748
1749 async fn deliver(
1750 &self,
1751 task: &SessionTask,
1752 message: &TaskMessage,
1753 context: &ToolContext,
1754 ) -> crate::error::Result<()> {
1755 let Some(store) = context.platform_store.as_ref() else {
1756 return Err(crate::error::AgentLoopError::tool(
1757 "subagent task delivery requires platform_store context",
1758 ));
1759 };
1760 let Some(child_id) = task.links.child_session_id else {
1761 return Err(crate::error::AgentLoopError::tool(format!(
1762 "subagent task {} has no child session link",
1763 task.id
1764 )));
1765 };
1766 let text = task_message_text(&message.content);
1767 store.send_message(child_id, &text).await
1768 }
1769
1770 async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1771 let Some(store) = context.platform_store.as_ref() else {
1772 return Err(crate::error::AgentLoopError::tool(
1773 "subagent task cancellation requires platform_store context",
1774 ));
1775 };
1776 let Some(child_id) = task.links.child_session_id else {
1777 return Err(crate::error::AgentLoopError::tool(format!(
1778 "subagent task {} has no child session link",
1779 task.id
1780 )));
1781 };
1782 store
1784 .send_message(
1785 child_id,
1786 "Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
1787 )
1788 .await
1789 }
1790
1791 async fn reconcile(
1796 &self,
1797 task: &SessionTask,
1798 context: &ToolContext,
1799 ) -> crate::error::Result<()> {
1800 if task.state.is_terminal() {
1801 return Ok(());
1802 }
1803 let (Some(store), Some(child_id)) =
1804 (context.platform_store.as_ref(), task.links.child_session_id)
1805 else {
1806 return Ok(());
1807 };
1808 let status = store.wait_for_idle(child_id, Some(0)).await?;
1811 if terminal_subagent_status(&status).is_none() {
1812 return Ok(());
1813 }
1814 settle_subagent_outcome(
1815 store.as_ref(),
1816 context,
1817 child_id,
1818 &status,
1819 Some(&task.id),
1820 None,
1821 )
1822 .await
1823 .map(|_| ())
1824 .map_err(|_| {
1825 crate::error::AgentLoopError::tool("Failed to read subagent result during reconcile")
1826 })
1827 }
1828}
1829
1830inventory::submit! {
1831 TaskExecutorPlugin {
1832 executor: || Arc::new(SubagentTaskExecutor),
1833 }
1834}
1835
1836pub struct DetachedSessionTaskExecutor;
1843
1844#[async_trait]
1845impl TaskExecutor for DetachedSessionTaskExecutor {
1846 fn kind(&self) -> &str {
1847 TASK_KIND_SESSION
1848 }
1849
1850 async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1851 let Some(registry) = context.session_task_registry.as_ref() else {
1852 return Ok(());
1853 };
1854 let summary = match (context.platform_store.as_ref(), task.links.child_session_id) {
1859 (Some(store), Some(peer_id)) => {
1860 store
1861 .send_message(
1862 peer_id,
1863 "Cancellation requested by the session that spawned you. Stop work, wind down, and end your run.",
1864 )
1865 .await?;
1866 "Peer session cancellation requested; tracking settled canceled.".to_string()
1867 }
1868 _ => "Detached session tracking canceled; no peer session link to signal.".to_string(),
1871 };
1872 registry
1873 .update(
1874 task.session_id,
1875 &task.id,
1876 SessionTaskUpdate {
1877 state: Some(SessionTaskState::Canceled),
1878 summary: Some(summary),
1879 ..Default::default()
1880 },
1881 )
1882 .await?;
1883 Ok(())
1884 }
1885}
1886
1887inventory::submit! {
1888 TaskExecutorPlugin {
1889 executor: || Arc::new(DetachedSessionTaskExecutor),
1890 }
1891}
1892
1893#[cfg(test)]
1894mod tests {
1895 use super::*;
1896 use crate::Tool;
1897 use crate::session_task::{TaskMessageDirection, TaskMessagePart, task_result_path};
1898
1899 #[test]
1902 fn capability_features() {
1903 let cap = SubagentCapability;
1904 assert_eq!(cap.features(), vec!["subagents"]);
1905 }
1906
1907 #[test]
1908 fn terminal_subagent_status_maps_only_terminal_wait_states() {
1909 assert_eq!(terminal_subagent_status("idle"), None);
1912 assert_eq!(
1913 terminal_subagent_status("completed"),
1914 Some(crate::session::SubagentStatus::Completed)
1915 );
1916 assert_eq!(
1917 terminal_subagent_status("failed"),
1918 Some(crate::session::SubagentStatus::Failed)
1919 );
1920 assert_eq!(
1921 terminal_subagent_status("cancelled"),
1922 Some(crate::session::SubagentStatus::Cancelled)
1923 );
1924 assert_eq!(
1925 terminal_subagent_status("sealed"),
1926 Some(crate::session::SubagentStatus::Sealed)
1927 );
1928 assert_eq!(
1929 terminal_subagent_task_state(&crate::session::SubagentStatus::Completed),
1930 SessionTaskState::Succeeded
1931 );
1932 assert_eq!(
1934 terminal_subagent_task_state(&crate::session::SubagentStatus::Sealed),
1935 SessionTaskState::Failed
1936 );
1937 assert_eq!(
1938 terminal_subagent_task_state(&crate::session::SubagentStatus::Cancelled),
1939 SessionTaskState::Canceled
1940 );
1941 assert_eq!(
1942 terminal_subagent_task_state(&crate::session::SubagentStatus::MaxIterationsReached),
1943 SessionTaskState::Failed
1944 );
1945 assert_eq!(terminal_subagent_status("waiting_for_tool_results"), None);
1946 assert_eq!(terminal_subagent_status("paused"), None);
1947 }
1948
1949 #[test]
1950 fn subagent_nesting_policy_resolves_platform_org_agent_precedence() {
1951 let platform = crate::traits::SubagentNestingPolicy::default().with_platform_default(4);
1952 assert_eq!(platform.max_subagent_depth(), 4);
1953
1954 let org = platform.with_org_override(Some(3));
1955 assert_eq!(org.max_subagent_depth(), 3);
1956
1957 let agent = org.with_agent_override(Some(1));
1958 assert_eq!(agent.max_subagent_depth(), 1);
1959 }
1960
1961 #[test]
1962 fn subagent_capability_is_high_risk() {
1963 assert_eq!(SubagentCapability.risk_level(), RiskLevel::High);
1964 }
1965
1966 #[test]
1967 fn spawn_agent_subagent_schema_advertises_only_subagent_target() {
1968 let tool = SpawnSubagentAsAgentTool;
1969 let schema = tool.parameters_schema();
1970 assert_eq!(
1971 schema["properties"]["target"]["properties"]["type"]["enum"],
1972 json!(["subagent"])
1973 );
1974 let required = schema["required"].as_array().unwrap();
1975 assert!(required.contains(&json!("target")));
1976 assert!(required.contains(&json!("name")));
1977 assert!(required.contains(&json!("instructions")));
1978 let props = schema["properties"].as_object().unwrap();
1979 assert!(props.contains_key("blueprint"));
1980 assert!(props.contains_key("config"));
1981 assert!(props.contains_key("result_schema"));
1982 assert!(props.contains_key("message_schema"));
1983 assert!(!required.contains(&json!("blueprint")));
1984 assert!(!required.contains(&json!("config")));
1985 assert_eq!(
1986 schema["properties"]["mode"]["enum"],
1987 json!(["background", "foreground"])
1988 );
1989 assert_eq!(
1990 tool.hints().concurrency_class.as_deref(),
1991 Some(SPAWN_AGENT_CONCURRENCY_CLASS),
1992 "spawn_agent calls share one scheduler class so cap admission is serialized"
1993 );
1994 }
1995
1996 use crate::traits::{NoopSubagentSpawnStore, SpawnClaimResult, SubagentSpawnStore};
2001 use std::sync::Arc;
2002
2003 #[tokio::test]
2005 async fn noop_spawn_store_always_claims() {
2006 let store = NoopSubagentSpawnStore;
2007 let parent = crate::typed_id::SessionId::new();
2008 let token = uuid::Uuid::new_v4();
2009
2010 let result = store
2011 .try_claim_spawn(parent, "call-1", token)
2012 .await
2013 .expect("noop should not error");
2014
2015 assert!(
2016 matches!(result, SpawnClaimResult::Claimed { claim_token, .. } if claim_token == token),
2017 "noop store should return Claimed with the supplied token"
2018 );
2019 }
2020
2021 #[tokio::test]
2023 async fn noop_spawn_store_register_and_settle_are_noops() {
2024 let store = NoopSubagentSpawnStore;
2025 let parent = crate::typed_id::SessionId::new();
2026 let child = crate::typed_id::SessionId::new();
2027 let handle_id = uuid::Uuid::new_v4();
2028 let token = uuid::Uuid::new_v4();
2029
2030 store
2031 .register_child_session(handle_id, token, child)
2032 .await
2033 .expect("noop register should not error");
2034
2035 store
2036 .settle_spawn(parent, "call-1", token, "idle", "result text")
2037 .await
2038 .expect("noop settle should not error");
2039 }
2040
2041 #[tokio::test]
2043 async fn arc_spawn_store_delegates() {
2044 let store: Arc<dyn SubagentSpawnStore> = Arc::new(NoopSubagentSpawnStore);
2045 let parent = crate::typed_id::SessionId::new();
2046 let token = uuid::Uuid::new_v4();
2047
2048 let result = store
2049 .try_claim_spawn(parent, "call-arc", token)
2050 .await
2051 .expect("arc delegation should not error");
2052
2053 assert!(matches!(result, SpawnClaimResult::Claimed { .. }));
2054 }
2055
2056 use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
2061 use crate::platform_store::tests::MockPlatformStore;
2062 use crate::session_file::SessionFile;
2063 use crate::session_task::SessionTaskRegistry;
2064 use crate::traits::SessionFileSystem;
2065 use chrono::Utc;
2066 use std::collections::HashMap;
2067 use std::sync::Mutex;
2068
2069 struct MockSessionStore(Arc<MockPlatformStore>);
2071
2072 struct MockSessionCreationAuthority {
2073 root: crate::typed_id::SessionId,
2074 allowed: bool,
2075 }
2076
2077 #[async_trait]
2078 impl crate::traits::SessionCreationAuthority for MockSessionCreationAuthority {
2079 async fn authorize_session_creation(
2080 &self,
2081 _session_id: crate::typed_id::SessionId,
2082 ) -> crate::error::Result<crate::typed_id::SessionId> {
2083 if self.allowed {
2084 Ok(self.root)
2085 } else {
2086 Err(crate::error::AgentLoopError::tool(
2087 "org:sessions:manage is required",
2088 ))
2089 }
2090 }
2091 }
2092
2093 #[async_trait]
2094 impl crate::traits::SessionStore for MockSessionStore {
2095 async fn get_session(
2096 &self,
2097 session_id: crate::typed_id::SessionId,
2098 ) -> crate::error::Result<Option<crate::session::Session>> {
2099 self.0.get_session_by_id(session_id).await
2100 }
2101 }
2102
2103 fn spawn_context(
2104 store: &Arc<MockPlatformStore>,
2105 registry: Option<Arc<InMemorySessionTaskRegistry>>,
2106 ) -> ToolContext {
2107 spawn_context_for_session(store, registry, store.session.id)
2108 }
2109
2110 fn spawn_context_for_session(
2111 store: &Arc<MockPlatformStore>,
2112 registry: Option<Arc<InMemorySessionTaskRegistry>>,
2113 session_id: crate::typed_id::SessionId,
2114 ) -> ToolContext {
2115 let mut context = ToolContext::new(session_id);
2116 context.platform_store = Some(store.clone());
2117 context.session_store = Some(Arc::new(MockSessionStore(store.clone())));
2118 context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2119 root: store.session.id,
2120 allowed: true,
2121 }));
2122 if let Some(registry) = registry {
2123 context.session_task_registry = Some(registry);
2124 }
2125 context
2126 }
2127
2128 async fn spawn(context: &ToolContext, args: Value) -> ToolExecutionResult {
2129 let mut args = args;
2130 if let Some(object) = args.as_object_mut() {
2131 object
2132 .entry("target")
2133 .or_insert_with(|| json!({"type": "subagent"}));
2134 }
2135 SpawnSubagentAsAgentTool
2136 .execute_with_context(args, context)
2137 .await
2138 }
2139
2140 async fn wait_for_task_state(
2143 registry: &InMemorySessionTaskRegistry,
2144 session_id: crate::typed_id::SessionId,
2145 task_id: &str,
2146 state: crate::session_task::SessionTaskState,
2147 ) -> crate::session_task::SessionTask {
2148 for _ in 0..200 {
2149 let task = registry
2150 .get(session_id, task_id)
2151 .await
2152 .expect("registry get")
2153 .expect("task exists");
2154 if task.state == state {
2155 return task;
2156 }
2157 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2158 }
2159 panic!("task {task_id} did not reach {state:?}");
2160 }
2161
2162 #[derive(Default)]
2163 struct MemoryFileStore {
2164 files: Mutex<HashMap<(uuid::Uuid, String), String>>,
2165 }
2166
2167 #[async_trait]
2168 impl SessionFileSystem for MemoryFileStore {
2169 fn is_mount_resolver(&self) -> bool {
2170 false
2171 }
2172
2173 async fn read_file(
2174 &self,
2175 session_id: crate::typed_id::SessionId,
2176 path: &str,
2177 ) -> crate::error::Result<Option<SessionFile>> {
2178 let content = self
2179 .files
2180 .lock()
2181 .unwrap()
2182 .get(&(session_id.uuid(), path.to_string()))
2183 .cloned();
2184 Ok(content.map(|content| SessionFile {
2185 id: uuid::Uuid::new_v4(),
2186 session_id: session_id.uuid(),
2187 path: path.to_string(),
2188 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2189 content: Some(content.clone()),
2190 encoding: "utf-8".to_string(),
2191 is_directory: false,
2192 is_readonly: false,
2193 size_bytes: content.len() as i64,
2194 created_at: Utc::now(),
2195 updated_at: Utc::now(),
2196 }))
2197 }
2198
2199 async fn write_file(
2200 &self,
2201 session_id: crate::typed_id::SessionId,
2202 path: &str,
2203 content: &str,
2204 _encoding: &str,
2205 ) -> crate::error::Result<SessionFile> {
2206 self.files
2207 .lock()
2208 .unwrap()
2209 .insert((session_id.uuid(), path.to_string()), content.to_string());
2210 Ok(SessionFile {
2211 id: uuid::Uuid::new_v4(),
2212 session_id: session_id.uuid(),
2213 path: path.to_string(),
2214 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2215 content: Some(content.to_string()),
2216 encoding: "utf-8".to_string(),
2217 is_directory: false,
2218 is_readonly: false,
2219 size_bytes: content.len() as i64,
2220 created_at: Utc::now(),
2221 updated_at: Utc::now(),
2222 })
2223 }
2224
2225 async fn delete_file(
2226 &self,
2227 session_id: crate::typed_id::SessionId,
2228 path: &str,
2229 _recursive: bool,
2230 ) -> crate::error::Result<bool> {
2231 Ok(self
2232 .files
2233 .lock()
2234 .unwrap()
2235 .remove(&(session_id.uuid(), path.to_string()))
2236 .is_some())
2237 }
2238
2239 async fn list_directory(
2240 &self,
2241 _session_id: crate::typed_id::SessionId,
2242 _path: &str,
2243 ) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
2244 Ok(vec![])
2245 }
2246
2247 async fn stat_file(
2248 &self,
2249 session_id: crate::typed_id::SessionId,
2250 path: &str,
2251 ) -> crate::error::Result<Option<crate::session_file::FileStat>> {
2252 let content = self
2253 .files
2254 .lock()
2255 .unwrap()
2256 .get(&(session_id.uuid(), path.to_string()))
2257 .cloned();
2258 Ok(content.map(|content| crate::session_file::FileStat {
2259 path: path.to_string(),
2260 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2261 is_directory: false,
2262 is_readonly: false,
2263 size_bytes: content.len() as i64,
2264 created_at: Utc::now(),
2265 updated_at: Utc::now(),
2266 }))
2267 }
2268
2269 async fn grep_files(
2270 &self,
2271 _session_id: crate::typed_id::SessionId,
2272 _pattern: &str,
2273 _path_pattern: Option<&str>,
2274 ) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
2275 Ok(vec![])
2276 }
2277
2278 async fn create_directory(
2279 &self,
2280 session_id: crate::typed_id::SessionId,
2281 path: &str,
2282 ) -> crate::error::Result<crate::session_file::FileInfo> {
2283 Ok(crate::session_file::FileInfo {
2284 id: uuid::Uuid::new_v4(),
2285 session_id: session_id.uuid(),
2286 path: path.to_string(),
2287 name: path.rsplit('/').next().unwrap_or(path).to_string(),
2288 is_directory: true,
2289 is_readonly: false,
2290 size_bytes: 0,
2291 created_at: Utc::now(),
2292 updated_at: Utc::now(),
2293 })
2294 }
2295 }
2296
2297 #[tokio::test]
2298 async fn spawn_agent_subagent_rejects_invalid_mode() {
2299 let context = ToolContext::new(crate::typed_id::SessionId::new());
2300 let result = spawn(
2301 &context,
2302 json!({"name": "Runner", "instructions": "go", "mode": "asap"}),
2303 )
2304 .await;
2305 let ToolExecutionResult::ToolError(msg) = result else {
2306 panic!("expected ToolError, got {result:?}");
2307 };
2308 assert!(msg.contains("Invalid mode"), "got: {msg}");
2309 }
2310
2311 #[tokio::test]
2312 async fn spawn_agent_subagent_rejects_other_target_types() {
2313 let context = ToolContext::new(crate::typed_id::SessionId::new());
2314 let result = SpawnSubagentAsAgentTool
2315 .execute_with_context(
2316 json!({
2317 "name": "Runner",
2318 "instructions": "go",
2319 "target": {"type": "external_a2a"}
2320 }),
2321 &context,
2322 )
2323 .await;
2324 let ToolExecutionResult::ToolError(msg) = result else {
2325 panic!("expected ToolError, got {result:?}");
2326 };
2327 assert!(msg.contains("subagent"), "got: {msg}");
2328 }
2329
2330 #[tokio::test]
2331 async fn spawn_agent_subagent_creates_subagent_task() {
2332 let store = Arc::new(MockPlatformStore::new());
2333 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2334 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2335 let context = spawn_context(&store, Some(registry.clone()));
2336
2337 let result = SpawnSubagentAsAgentTool
2338 .execute_with_context(
2339 json!({
2340 "name": "Runner",
2341 "instructions": "go",
2342 "target": {"type": "subagent"},
2343 "mode": "foreground"
2344 }),
2345 &context,
2346 )
2347 .await;
2348 let ToolExecutionResult::Success(value) = result else {
2349 panic!("expected success, got {result:?}");
2350 };
2351 let task_id = value["task_id"].as_str().expect("task_id");
2352 let task = registry
2353 .get(context.session_id, task_id)
2354 .await
2355 .unwrap()
2356 .unwrap();
2357 assert_eq!(task.kind, TASK_KIND_SUBAGENT);
2358 assert_eq!(task.spec["mode"], "foreground");
2359 assert!(task.links.child_session_id.is_some());
2360 }
2361
2362 #[tokio::test]
2363 async fn detached_spawn_creates_peer_session_task_with_goal_and_lineage() {
2364 let store = Arc::new(MockPlatformStore::new());
2365 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2366 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2367 let context = spawn_context(&store, Some(registry.clone()));
2368
2369 let result = spawn(
2370 &context,
2371 json!({
2372 "name": "Research Peer",
2373 "goal": "Investigate latency",
2374 "instructions": "go",
2375 "lifetime": "detached",
2376 "seed": "workspace",
2377 "mode": "foreground"
2378 }),
2379 )
2380 .await;
2381 let ToolExecutionResult::Success(value) = result else {
2382 panic!("expected success, got {result:?}");
2383 };
2384 let child_id: crate::typed_id::SessionId = value["subagent_id"]
2385 .as_str()
2386 .expect("subagent_id")
2387 .parse()
2388 .expect("valid session id");
2389 let child = store
2390 .get_session_by_id(child_id)
2391 .await
2392 .unwrap()
2393 .expect("child session");
2394 assert_eq!(child.parent_session_id, None);
2395 assert_eq!(child.forked_from_session_id, Some(context.session_id));
2396 assert_eq!(child.title.as_deref(), Some("Research Peer"));
2397 assert_eq!(child.goal.as_deref(), Some("Investigate latency"));
2398 assert_eq!(
2399 store
2400 .created_session_budget_roots
2401 .lock()
2402 .unwrap()
2403 .as_slice(),
2404 &[Some(store.session.id)]
2405 );
2406
2407 let task_id = value["task_id"].as_str().expect("task_id");
2408 let task = registry
2409 .get(context.session_id, task_id)
2410 .await
2411 .unwrap()
2412 .expect("task");
2413 assert_eq!(task.kind, TASK_KIND_SESSION);
2414 assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
2415 assert_eq!(task.links.child_session_id, Some(child_id));
2416 assert_eq!(task.spec["lifetime"], "detached");
2417 assert_eq!(task.spec["seed"], "workspace");
2418 }
2419
2420 #[tokio::test]
2421 async fn detached_spawn_requires_session_creation_authority_before_creation() {
2422 let store = Arc::new(MockPlatformStore::new());
2423 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2424 let mut context = spawn_context(&store, Some(registry));
2425 context.session_creation_authority = None;
2426
2427 let result = spawn(
2428 &context,
2429 json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2430 )
2431 .await;
2432 let ToolExecutionResult::ToolError(message) = result else {
2433 panic!("expected authority ToolError, got {result:?}");
2434 };
2435 assert!(message.contains("session-creation authority"));
2436 assert!(
2437 store
2438 .created_session_budget_roots
2439 .lock()
2440 .unwrap()
2441 .is_empty()
2442 );
2443 }
2444
2445 #[tokio::test]
2446 async fn detached_spawn_reports_permission_denial_before_creation() {
2447 let store = Arc::new(MockPlatformStore::new());
2448 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2449 let mut context = spawn_context(&store, Some(registry));
2450 context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2451 root: store.session.id,
2452 allowed: false,
2453 }));
2454
2455 let result = spawn(
2456 &context,
2457 json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2458 )
2459 .await;
2460 let ToolExecutionResult::ToolError(message) = result else {
2461 panic!("expected permission ToolError, got {result:?}");
2462 };
2463 assert!(message.contains("not authorized"));
2464 assert!(message.contains("org:sessions:manage"));
2465 assert!(
2466 store
2467 .created_session_budget_roots
2468 .lock()
2469 .unwrap()
2470 .is_empty()
2471 );
2472 }
2473
2474 #[tokio::test]
2475 async fn detached_spawn_bypasses_subagent_depth_guard() {
2476 let store = Arc::new(MockPlatformStore::new());
2477 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2478 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2479 crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2480 );
2481
2482 let linked = spawn(
2483 &context,
2484 json!({"name": "Linked", "instructions": "go", "mode": "foreground"}),
2485 )
2486 .await;
2487 assert!(matches!(linked, ToolExecutionResult::ToolError(_)));
2488
2489 let detached = spawn(
2490 &context,
2491 json!({
2492 "name": "Detached",
2493 "instructions": "go",
2494 "mode": "foreground",
2495 "lifetime": "detached"
2496 }),
2497 )
2498 .await;
2499 assert!(
2500 matches!(detached, ToolExecutionResult::Success(_)),
2501 "detached spawn should bypass linked depth guard, got {detached:?}"
2502 );
2503 }
2504
2505 fn session_task_under(
2509 root: crate::typed_id::SessionId,
2510 kind: &str,
2511 state: SessionTaskState,
2512 ) -> CreateSessionTask {
2513 CreateSessionTask {
2514 session_id: root,
2515 id: None,
2516 kind: kind.to_string(),
2517 display_name: "t".to_string(),
2518 spec: json!({}),
2519 state,
2520 links: TaskLinks {
2521 child_session_id: Some(crate::typed_id::SessionId::new()),
2522 ..Default::default()
2523 },
2524 wake_policy: TaskWakePolicy::Silent,
2525 }
2526 }
2527
2528 #[tokio::test]
2529 async fn detached_task_counts_ignore_subagent_and_terminal_active() {
2530 let store = Arc::new(MockPlatformStore::new());
2531 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2532 let root = store.session.id;
2533
2534 registry
2535 .create(session_task_under(
2536 root,
2537 TASK_KIND_SESSION,
2538 SessionTaskState::Running,
2539 ))
2540 .await
2541 .unwrap();
2542 registry
2543 .create(session_task_under(
2544 root,
2545 TASK_KIND_SESSION,
2546 SessionTaskState::Running,
2547 ))
2548 .await
2549 .unwrap();
2550 registry
2552 .create(session_task_under(
2553 root,
2554 TASK_KIND_SESSION,
2555 SessionTaskState::Canceled,
2556 ))
2557 .await
2558 .unwrap();
2559 registry
2561 .create(session_task_under(
2562 root,
2563 TASK_KIND_SUBAGENT,
2564 SessionTaskState::Running,
2565 ))
2566 .await
2567 .unwrap();
2568
2569 let counts = descendant_detached_task_counts(registry.as_ref(), root, 100, 100)
2570 .await
2571 .unwrap();
2572 assert_eq!(
2573 counts.active, 2,
2574 "only non-terminal session tasks are active"
2575 );
2576 assert_eq!(
2577 counts.total, 3,
2578 "terminal session task counts toward total; subagent task excluded"
2579 );
2580 }
2581
2582 #[tokio::test]
2583 async fn detached_spawn_rejected_at_cap_and_allowed_under_cap() {
2584 let store = Arc::new(MockPlatformStore::new());
2585 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2586 let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2587 crate::traits::SubagentNestingPolicy::default()
2588 .with_agent_detached_task_caps_override(Some(1), Some(4)),
2589 );
2590
2591 let ok = spawn(
2593 &context,
2594 json!({"name": "D0", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2595 )
2596 .await;
2597 assert!(
2598 matches!(ok, ToolExecutionResult::Success(_)),
2599 "detached spawn under cap should succeed, got {ok:?}"
2600 );
2601
2602 let refused = spawn(
2605 &context,
2606 json!({"name": "D1", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2607 )
2608 .await;
2609 let ToolExecutionResult::ToolError(msg) = refused else {
2610 panic!("expected detached active cap ToolError, got {refused:?}");
2611 };
2612 assert!(
2613 msg.contains("max_active_detached_tasks is 1"),
2614 "cap error should name the limit, got: {msg}"
2615 );
2616 }
2617
2618 #[tokio::test]
2619 async fn detached_cap_does_not_affect_linked_subagent_spawn() {
2620 let store = Arc::new(MockPlatformStore::new());
2623 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2624 let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2625 crate::traits::SubagentNestingPolicy::default()
2626 .with_agent_detached_task_caps_override(Some(1), Some(4)),
2627 );
2628 let root = store.session.id;
2629
2630 registry
2632 .create(session_task_under(
2633 root,
2634 TASK_KIND_SESSION,
2635 SessionTaskState::Running,
2636 ))
2637 .await
2638 .unwrap();
2639
2640 let refused = spawn(
2642 &context,
2643 json!({"name": "D", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2644 )
2645 .await;
2646 assert!(matches!(refused, ToolExecutionResult::ToolError(_)));
2647
2648 let linked = spawn(
2650 &context,
2651 json!({"name": "L", "instructions": "go", "mode": "background"}),
2652 )
2653 .await;
2654 assert!(
2655 matches!(linked, ToolExecutionResult::Success(_)),
2656 "linked subagent spawn must not be blocked by the detached cap, got {linked:?}"
2657 );
2658 }
2659
2660 #[tokio::test]
2661 async fn detached_session_task_cancel_requests_peer_cancellation() {
2662 let store = Arc::new(MockPlatformStore::new());
2665 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2666 let context = spawn_context(&store, Some(registry.clone()));
2667 let child_id = crate::typed_id::SessionId::new();
2668 let task = registry
2669 .create(CreateSessionTask {
2670 session_id: context.session_id,
2671 id: None,
2672 kind: TASK_KIND_SESSION.to_string(),
2673 display_name: "Peer".to_string(),
2674 spec: json!({}),
2675 state: SessionTaskState::Running,
2676 links: TaskLinks {
2677 child_session_id: Some(child_id),
2678 ..Default::default()
2679 },
2680 wake_policy: TaskWakePolicy::Silent,
2681 })
2682 .await
2683 .unwrap();
2684
2685 DetachedSessionTaskExecutor
2686 .cancel(&task, &context)
2687 .await
2688 .unwrap();
2689
2690 let sent = store.sent_messages.lock().unwrap().clone();
2692 assert_eq!(
2693 sent.len(),
2694 1,
2695 "exactly one cooperative-cancel message expected, got {sent:?}"
2696 );
2697 assert_eq!(sent[0].0, child_id, "cancel must target the peer session");
2698 assert!(
2699 sent[0].1.contains("Cancellation requested"),
2700 "cancel message should ask the peer to stop, got {:?}",
2701 sent[0].1
2702 );
2703
2704 let updated = registry
2706 .get(context.session_id, &task.id)
2707 .await
2708 .unwrap()
2709 .expect("task should remain present");
2710 assert_eq!(updated.state, SessionTaskState::Canceled);
2711 assert_eq!(updated.links.child_session_id, Some(child_id));
2712 assert_eq!(
2713 updated.summary.as_deref(),
2714 Some("Peer session cancellation requested; tracking settled canceled.")
2715 );
2716 }
2717
2718 #[tokio::test]
2719 async fn detached_session_task_cancel_without_peer_link_still_settles() {
2720 let store = Arc::new(MockPlatformStore::new());
2723 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2724 let context = spawn_context(&store, Some(registry.clone()));
2725 let task = registry
2726 .create(CreateSessionTask {
2727 session_id: context.session_id,
2728 id: None,
2729 kind: TASK_KIND_SESSION.to_string(),
2730 display_name: "Peer".to_string(),
2731 spec: json!({}),
2732 state: SessionTaskState::Running,
2733 links: TaskLinks::default(),
2734 wake_policy: TaskWakePolicy::Silent,
2735 })
2736 .await
2737 .unwrap();
2738
2739 DetachedSessionTaskExecutor
2740 .cancel(&task, &context)
2741 .await
2742 .unwrap();
2743
2744 assert!(store.sent_messages.lock().unwrap().is_empty());
2745 let updated = registry
2746 .get(context.session_id, &task.id)
2747 .await
2748 .unwrap()
2749 .expect("task should remain present");
2750 assert_eq!(updated.state, SessionTaskState::Canceled);
2751 assert_eq!(
2752 updated.summary.as_deref(),
2753 Some("Detached session tracking canceled; no peer session link to signal.")
2754 );
2755 }
2756
2757 #[tokio::test]
2758 async fn spawn_agent_subagent_allows_depth_two_and_rejects_depth_three_by_default() {
2759 let store = Arc::new(MockPlatformStore::new());
2760 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2761 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2762 let root_context = spawn_context(&store, Some(registry.clone()));
2763
2764 let first = spawn(
2765 &root_context,
2766 json!({"name": "B", "instructions": "go", "mode": "background"}),
2767 )
2768 .await;
2769 let ToolExecutionResult::Success(first_value) = first else {
2770 panic!("expected first spawn success, got {first:?}");
2771 };
2772 let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2773 .as_str()
2774 .expect("subagent_id")
2775 .parse()
2776 .expect("valid session id");
2777
2778 let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id);
2779 let second = spawn(
2780 &b_context,
2781 json!({"name": "C", "instructions": "go", "mode": "background"}),
2782 )
2783 .await;
2784 let ToolExecutionResult::Success(second_value) = second else {
2785 panic!("expected second spawn success, got {second:?}");
2786 };
2787 let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2788 .as_str()
2789 .expect("subagent_id")
2790 .parse()
2791 .expect("valid session id");
2792
2793 let c_context = spawn_context_for_session(&store, Some(registry), c_id);
2794 let third = spawn(
2795 &c_context,
2796 json!({"name": "D", "instructions": "go", "mode": "background"}),
2797 )
2798 .await;
2799 let ToolExecutionResult::ToolError(message) = third else {
2800 panic!("expected depth cap ToolError, got {third:?}");
2801 };
2802 assert!(
2803 message.contains("max_subagent_depth is 2"),
2804 "got: {message}"
2805 );
2806 assert!(message.contains("depth 3"), "got: {message}");
2807 }
2808
2809 #[tokio::test]
2810 async fn spawn_agent_subagent_depth_zero_restores_hard_block() {
2811 let store = Arc::new(MockPlatformStore::new());
2812 let mut context = spawn_context(&store, None).with_subagent_nesting_policy(
2813 crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2814 );
2815 context.session_task_registry = Some(Arc::new(InMemorySessionTaskRegistry::default()));
2816
2817 let result = spawn(
2818 &context,
2819 json!({"name": "Blocked", "instructions": "go", "mode": "background"}),
2820 )
2821 .await;
2822 let ToolExecutionResult::ToolError(message) = result else {
2823 panic!("expected depth cap ToolError, got {result:?}");
2824 };
2825 assert!(
2826 message.contains("max_subagent_depth is 0"),
2827 "got: {message}"
2828 );
2829 assert!(message.contains("depth 1"), "got: {message}");
2830 }
2831
2832 #[tokio::test]
2833 async fn spawn_agent_subagent_rejects_when_active_descendant_cap_is_full() {
2834 let store = Arc::new(MockPlatformStore::new());
2835 *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2836 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2837 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2838 crate::traits::SubagentNestingPolicy::default()
2839 .with_agent_task_caps_override(Some(1), Some(200)),
2840 );
2841
2842 let first = spawn(
2843 &context,
2844 json!({"name": "First", "instructions": "go", "mode": "background"}),
2845 )
2846 .await;
2847 assert!(
2848 matches!(first, ToolExecutionResult::Success(_)),
2849 "expected first spawn success, got {first:?}"
2850 );
2851
2852 let second = spawn(
2853 &context,
2854 json!({"name": "Second", "instructions": "go", "mode": "background"}),
2855 )
2856 .await;
2857 let ToolExecutionResult::ToolError(message) = second else {
2858 panic!("expected active cap ToolError, got {second:?}");
2859 };
2860 assert!(
2861 message.contains("max_active_descendant_tasks is 1"),
2862 "got: {message}"
2863 );
2864 assert!(
2865 message.contains("2 non-terminal descendant tasks"),
2866 "got: {message}"
2867 );
2868 }
2869
2870 #[tokio::test]
2871 async fn spawn_agent_subagent_counts_grandchildren_for_active_descendant_cap() {
2872 let store = Arc::new(MockPlatformStore::new());
2873 *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2874 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2875 let policy = crate::traits::SubagentNestingPolicy::default()
2876 .with_agent_override(Some(4))
2877 .with_agent_task_caps_override(Some(2), Some(200));
2878 let root_context =
2879 spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(policy);
2880
2881 let first = spawn(
2882 &root_context,
2883 json!({"name": "B", "instructions": "go", "mode": "background"}),
2884 )
2885 .await;
2886 let ToolExecutionResult::Success(first_value) = first else {
2887 panic!("expected first spawn success, got {first:?}");
2888 };
2889 let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2890 .as_str()
2891 .expect("subagent_id")
2892 .parse()
2893 .expect("valid session id");
2894
2895 let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id)
2896 .with_subagent_nesting_policy(policy);
2897 let second = spawn(
2898 &b_context,
2899 json!({"name": "C", "instructions": "go", "mode": "background"}),
2900 )
2901 .await;
2902 let ToolExecutionResult::Success(second_value) = second else {
2903 panic!("expected second spawn success, got {second:?}");
2904 };
2905 let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2906 .as_str()
2907 .expect("subagent_id")
2908 .parse()
2909 .expect("valid session id");
2910
2911 let c_context = spawn_context_for_session(&store, Some(registry), c_id)
2912 .with_subagent_nesting_policy(policy);
2913 let third = spawn(
2914 &c_context,
2915 json!({"name": "D", "instructions": "go", "mode": "background"}),
2916 )
2917 .await;
2918 let ToolExecutionResult::ToolError(message) = third else {
2919 panic!("expected active cap ToolError, got {third:?}");
2920 };
2921 assert!(
2922 message.contains("max_active_descendant_tasks is 2"),
2923 "got: {message}"
2924 );
2925 assert!(message.contains("root session"), "got: {message}");
2926 }
2927
2928 #[tokio::test]
2929 async fn spawn_agent_subagent_total_descendant_cap_counts_terminal_tasks() {
2930 let store = Arc::new(MockPlatformStore::new());
2931 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2932 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2933 let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2934 crate::traits::SubagentNestingPolicy::default()
2935 .with_agent_task_caps_override(Some(16), Some(1)),
2936 );
2937
2938 let first = spawn(
2939 &context,
2940 json!({"name": "First", "instructions": "go", "mode": "foreground"}),
2941 )
2942 .await;
2943 assert!(
2944 matches!(first, ToolExecutionResult::Success(_)),
2945 "expected first spawn success, got {first:?}"
2946 );
2947
2948 let second = spawn(
2949 &context,
2950 json!({"name": "Second", "instructions": "go", "mode": "foreground"}),
2951 )
2952 .await;
2953 let ToolExecutionResult::ToolError(message) = second else {
2954 panic!("expected total cap ToolError, got {second:?}");
2955 };
2956 assert!(
2957 message.contains("max_total_descendant_tasks is 1"),
2958 "got: {message}"
2959 );
2960 assert!(
2961 message.contains("2 descendant task records"),
2962 "got: {message}"
2963 );
2964 }
2965
2966 #[test]
2967 fn subagents_config_validates_descendant_task_caps() {
2968 let capability = SubagentCapability;
2969 assert!(
2970 capability
2971 .validate_config(&json!({
2972 "max_active_descendant_tasks": 16,
2973 "max_total_descendant_tasks": 200
2974 }))
2975 .is_ok()
2976 );
2977 assert_eq!(
2978 capability
2979 .validate_config(&json!({"max_active_descendant_tasks": 1025}))
2980 .unwrap_err(),
2981 "max_active_descendant_tasks must be <= 1024"
2982 );
2983 assert_eq!(
2984 capability
2985 .validate_config(&json!({"max_total_descendant_tasks": 10001}))
2986 .unwrap_err(),
2987 "max_total_descendant_tasks must be <= 10000"
2988 );
2989 }
2990
2991 #[tokio::test]
2992 async fn spawn_agent_subagent_stores_result_schema_on_task() {
2993 let store = Arc::new(MockPlatformStore::new());
2994 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2995 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2996 let context = spawn_context(&store, Some(registry.clone()));
2997
2998 let result = SpawnSubagentAsAgentTool
2999 .execute_with_context(
3000 json!({
3001 "name": "Runner",
3002 "instructions": "go",
3003 "target": {"type": "subagent"},
3004 "mode": "foreground",
3005 "result_schema": {
3006 "type": "object",
3007 "properties": {"answer": {"type": "string"}},
3008 "required": ["answer"],
3009 "additionalProperties": false
3010 }
3011 }),
3012 &context,
3013 )
3014 .await;
3015 let ToolExecutionResult::Success(value) = result else {
3016 panic!("expected success, got {result:?}");
3017 };
3018 let task_id = value["task_id"].as_str().expect("task_id");
3019 let task = registry
3020 .get(context.session_id, task_id)
3021 .await
3022 .unwrap()
3023 .unwrap();
3024 assert_eq!(task.spec["result_schema"]["required"], json!(["answer"]));
3025 assert_eq!(task.state, SessionTaskState::Failed);
3026 assert_eq!(
3027 task.error.as_ref().map(|e| e.kind.as_str()),
3028 Some("no_result")
3029 );
3030 }
3031
3032 #[tokio::test]
3033 async fn report_result_writes_result_file_and_updates_task() {
3034 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3035 let file_store = Arc::new(MemoryFileStore::default());
3036 let parent_session_id = crate::typed_id::SessionId::new();
3037 let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3038 let child_session_id = crate::typed_id::SessionId::new();
3039 let task = registry
3040 .create(CreateSessionTask {
3041 session_id: parent_session_id,
3042 id: None,
3043 kind: TASK_KIND_SUBAGENT.to_string(),
3044 display_name: "Runner".to_string(),
3045 spec: json!({
3046 "result_schema": {
3047 "type": "object",
3048 "properties": {"answer": {"type": "string"}},
3049 "required": ["answer"],
3050 "additionalProperties": false
3051 }
3052 }),
3053 state: SessionTaskState::Running,
3054 links: TaskLinks {
3055 child_session_id: Some(child_session_id),
3056 ..Default::default()
3057 },
3058 wake_policy: TaskWakePolicy::Silent,
3059 })
3060 .await
3061 .unwrap();
3062
3063 let tool = ReportResultTool::new(
3064 parent_session_id,
3065 parent_workspace_id,
3066 child_session_id,
3067 task.id.clone(),
3068 task.spec["result_schema"].clone(),
3069 )
3070 .with_file_store(file_store.clone());
3071 let mut context = ToolContext::new(child_session_id);
3072 context.session_task_registry = Some(registry.clone());
3073
3074 let result = tool
3075 .execute_with_context(json!({"answer": "done"}), &context)
3076 .await;
3077 let ToolExecutionResult::Success(value) = result else {
3078 panic!("expected success, got {result:?}");
3079 };
3080 assert_eq!(value["result_path"], task_result_path(&task.id));
3081
3082 let task = registry
3083 .get(parent_session_id, &task.id)
3084 .await
3085 .unwrap()
3086 .unwrap();
3087 let result_path = task.result_path.as_deref().expect("result_path");
3088 let file = file_store
3089 .read_file(
3090 SessionId::from_uuid(parent_workspace_id.uuid()),
3091 result_path,
3092 )
3093 .await
3094 .unwrap()
3095 .expect("result file");
3096 assert_eq!(
3097 serde_json::from_str::<Value>(file.content.as_deref().unwrap()).unwrap(),
3098 json!({"answer": "done"})
3099 );
3100 }
3101
3102 #[tokio::test]
3103 async fn report_result_rejects_terminal_task_without_overwriting_result() {
3104 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3105 let file_store = Arc::new(MemoryFileStore::default());
3106 let parent_session_id = crate::typed_id::SessionId::new();
3107 let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3108 let child_session_id = crate::typed_id::SessionId::new();
3109 let task = registry
3110 .create(CreateSessionTask {
3111 session_id: parent_session_id,
3112 id: None,
3113 kind: TASK_KIND_SUBAGENT.to_string(),
3114 display_name: "Runner".to_string(),
3115 spec: json!({
3116 "result_schema": {
3117 "type": "object",
3118 "properties": {"answer": {"type": "string"}},
3119 "required": ["answer"],
3120 "additionalProperties": false
3121 }
3122 }),
3123 state: SessionTaskState::Succeeded,
3124 links: TaskLinks {
3125 child_session_id: Some(child_session_id),
3126 ..Default::default()
3127 },
3128 wake_policy: TaskWakePolicy::Silent,
3129 })
3130 .await
3131 .unwrap();
3132 let existing_path = task_result_path(&task.id);
3133 registry
3134 .update(
3135 parent_session_id,
3136 &task.id,
3137 SessionTaskUpdate {
3138 result_path: Some(existing_path.clone()),
3139 summary: Some("original".to_string()),
3140 ..Default::default()
3141 },
3142 )
3143 .await
3144 .unwrap();
3145 file_store
3146 .write_file(
3147 SessionId::from_uuid(parent_workspace_id.uuid()),
3148 &existing_path,
3149 "{\n \"answer\": \"original\"\n}",
3150 "utf-8",
3151 )
3152 .await
3153 .unwrap();
3154
3155 let tool = ReportResultTool::new(
3156 parent_session_id,
3157 parent_workspace_id,
3158 child_session_id,
3159 task.id.clone(),
3160 task.spec["result_schema"].clone(),
3161 )
3162 .with_file_store(file_store.clone());
3163 let mut context = ToolContext::new(child_session_id);
3164 context.session_task_registry = Some(registry.clone());
3165
3166 let result = tool
3167 .execute_with_context(json!({"answer": "tampered"}), &context)
3168 .await;
3169 let ToolExecutionResult::ToolError(message) = result else {
3170 panic!("expected terminal rejection, got {result:?}");
3171 };
3172 assert!(message.contains("terminal"), "got: {message}");
3173
3174 let file = file_store
3175 .read_file(
3176 SessionId::from_uuid(parent_workspace_id.uuid()),
3177 &existing_path,
3178 )
3179 .await
3180 .unwrap()
3181 .expect("result file");
3182 assert!(
3183 file.content.as_deref().unwrap().contains("original"),
3184 "file was overwritten: {file:?}"
3185 );
3186 }
3187
3188 #[tokio::test]
3189 async fn report_result_rejects_invalid_result_schema_payload() {
3190 let tool = ReportResultTool::new(
3191 crate::typed_id::SessionId::new(),
3192 crate::typed_id::WorkspaceId::from_uuid(uuid::Uuid::new_v4()),
3193 crate::typed_id::SessionId::new(),
3194 "task_test".to_string(),
3195 json!({
3196 "type": "object",
3197 "properties": {"answer": {"type": "string"}},
3198 "required": ["answer"],
3199 "additionalProperties": false
3200 }),
3201 );
3202 let result = tool
3203 .execute_with_context(json!({"extra": true}), &ToolContext::new(SessionId::new()))
3204 .await;
3205 let ToolExecutionResult::ToolError(message) = result else {
3206 panic!("expected validation error, got {result:?}");
3207 };
3208 assert!(
3209 message.contains("answer") && message.contains("required"),
3210 "got: {message}"
3211 );
3212 assert!(
3213 message.contains("extra")
3214 && (message.contains("additional") || message.contains("not allowed")),
3215 "got: {message}"
3216 );
3217 }
3218
3219 #[tokio::test]
3220 async fn spawn_agent_subagent_stores_message_schema_and_wakes_on_activity() {
3221 let store = Arc::new(MockPlatformStore::new());
3222 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3223 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3224 let context = spawn_context(&store, Some(registry.clone()));
3225
3226 let result = SpawnSubagentAsAgentTool
3227 .execute_with_context(
3228 json!({
3229 "name": "Runner",
3230 "instructions": "go",
3231 "target": {"type": "subagent"},
3232 "message_schema": {
3233 "type": "object",
3234 "properties": {"step": {"type": "string"}},
3235 "required": ["step"],
3236 "additionalProperties": false
3237 }
3238 }),
3239 &context,
3240 )
3241 .await;
3242 let ToolExecutionResult::Success(value) = result else {
3243 panic!("expected success, got {result:?}");
3244 };
3245 let task_id = value["task_id"].as_str().expect("task_id");
3246 let task = registry
3247 .get(context.session_id, task_id)
3248 .await
3249 .unwrap()
3250 .unwrap();
3251 assert_eq!(task.spec["message_schema"]["required"], json!(["step"]));
3252 assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
3253 }
3254
3255 #[tokio::test]
3256 async fn report_task_progress_posts_structured_outbound_message() {
3257 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3258 let parent_session_id = crate::typed_id::SessionId::new();
3259 let task = registry
3260 .create(CreateSessionTask {
3261 session_id: parent_session_id,
3262 id: None,
3263 kind: TASK_KIND_SUBAGENT.to_string(),
3264 display_name: "Runner".to_string(),
3265 spec: json!({
3266 "message_schema": {
3267 "type": "object",
3268 "properties": {"step": {"type": "string"}},
3269 "required": ["step"],
3270 "additionalProperties": false
3271 }
3272 }),
3273 state: SessionTaskState::Running,
3274 links: TaskLinks::default(),
3275 wake_policy: TaskWakePolicy::OnActivity,
3276 })
3277 .await
3278 .unwrap();
3279
3280 let tool = ReportTaskProgressTool::new(
3281 parent_session_id,
3282 task.id.clone(),
3283 task.attempt,
3284 task.spec["message_schema"].clone(),
3285 );
3286 assert_eq!(tool.name(), "report_task_progress");
3287 let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3288 context.session_task_registry = Some(registry.clone());
3289
3290 let result = tool
3291 .execute_with_context(json!({"step": "tests-running"}), &context)
3292 .await;
3293 let ToolExecutionResult::Success(value) = result else {
3294 panic!("expected success, got {result:?}");
3295 };
3296 assert_eq!(value["status"], "posted");
3297
3298 let messages = registry
3299 .list_messages(parent_session_id, &task.id, None, None)
3300 .await
3301 .unwrap();
3302 assert_eq!(messages.len(), 1);
3303 assert_eq!(messages[0].direction, TaskMessageDirection::Outbound);
3304 assert_eq!(
3305 messages[0].content,
3306 vec![TaskMessagePart::Data {
3307 data: json!({"step": "tests-running"})
3308 }]
3309 );
3310 }
3311
3312 #[tokio::test]
3313 async fn report_task_progress_rejects_stale_task_attempt() {
3314 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3315 let parent_session_id = crate::typed_id::SessionId::new();
3316 let task = registry
3317 .create(CreateSessionTask {
3318 session_id: parent_session_id,
3319 id: None,
3320 kind: TASK_KIND_SUBAGENT.to_string(),
3321 display_name: "Runner".to_string(),
3322 spec: json!({"message_schema": {"type": "object"}}),
3323 state: SessionTaskState::Running,
3324 links: TaskLinks::default(),
3325 wake_policy: TaskWakePolicy::OnActivity,
3326 })
3327 .await
3328 .unwrap();
3329 let tool = ReportTaskProgressTool::new(
3330 parent_session_id,
3331 task.id.clone(),
3332 task.attempt,
3333 task.spec["message_schema"].clone(),
3334 );
3335
3336 registry
3338 .update(
3339 parent_session_id,
3340 &task.id,
3341 SessionTaskUpdate {
3342 state: Some(SessionTaskState::Failed),
3343 increment_attempt: true,
3344 ..Default::default()
3345 },
3346 )
3347 .await
3348 .unwrap();
3349
3350 let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3351 context.session_task_registry = Some(registry.clone());
3352 let result = tool
3353 .execute_with_context(json!({"step": "late"}), &context)
3354 .await;
3355 assert!(
3356 matches!(result, ToolExecutionResult::InternalError(_)),
3357 "stale progress must be rejected, got {result:?}"
3358 );
3359 let messages = registry
3360 .list_messages(parent_session_id, &task.id, None, None)
3361 .await
3362 .unwrap();
3363 assert!(
3364 messages.is_empty(),
3365 "stale progress must not append messages"
3366 );
3367 }
3368
3369 #[tokio::test]
3370 async fn report_task_progress_rejects_invalid_message_schema_payload() {
3371 let tool = ReportTaskProgressTool::new(
3372 crate::typed_id::SessionId::new(),
3373 "task_test".to_string(),
3374 1,
3375 json!({
3376 "type": "object",
3377 "properties": {"step": {"type": "string"}},
3378 "required": ["step"],
3379 "additionalProperties": false
3380 }),
3381 );
3382 let result = tool
3383 .execute_with_context(
3384 json!({"step": 42, "extra": true}),
3385 &ToolContext::new(SessionId::new()),
3386 )
3387 .await;
3388 let ToolExecutionResult::ToolError(message) = result else {
3389 panic!("expected validation error, got {result:?}");
3390 };
3391 assert!(
3392 message.contains("step") && message.contains("string"),
3393 "got: {message}"
3394 );
3395 assert!(
3396 message.contains("extra")
3397 && (message.contains("additional") || message.contains("not allowed")),
3398 "got: {message}"
3399 );
3400 }
3401
3402 #[test]
3403 fn subagent_and_channel_progress_tools_have_distinct_names() {
3404 use crate::progress_reporting::{
3409 REPORT_PROGRESS_TOOL_NAME, ReportProgressTool as ChannelReportProgressTool,
3410 };
3411 use crate::tools::ToolRegistry;
3412
3413 let subagent = ReportTaskProgressTool::new(
3414 crate::typed_id::SessionId::new(),
3415 "task_test".to_string(),
3416 1,
3417 json!({"type": "object"}),
3418 );
3419 assert_eq!(subagent.name(), "report_task_progress");
3420 assert_eq!(REPORT_PROGRESS_TOOL_NAME, "report_progress");
3421 assert_ne!(subagent.name(), REPORT_PROGRESS_TOOL_NAME);
3422
3423 let mut registry = ToolRegistry::new();
3424 registry.register(ChannelReportProgressTool);
3425 registry.register(subagent);
3426 assert!(
3427 registry.has("report_progress"),
3428 "channel report_progress tool must survive"
3429 );
3430 assert!(
3431 registry.has("report_task_progress"),
3432 "subagent report_task_progress tool must survive"
3433 );
3434 }
3435
3436 #[tokio::test]
3437 async fn explicit_background_without_registry_errors() {
3438 let context = ToolContext::new(crate::typed_id::SessionId::new());
3439 let result = spawn(
3440 &context,
3441 json!({"name": "Runner", "instructions": "go", "mode": "background"}),
3442 )
3443 .await;
3444 let ToolExecutionResult::ToolError(msg) = result else {
3445 panic!("expected ToolError, got {result:?}");
3446 };
3447 assert!(
3448 msg.contains("task registry") && msg.contains("foreground"),
3449 "got: {msg}"
3450 );
3451 }
3452
3453 #[tokio::test]
3454 async fn default_mode_without_registry_degrades_to_foreground() {
3455 let store = Arc::new(MockPlatformStore::new());
3456 let context = spawn_context(&store, None);
3457 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3458 let ToolExecutionResult::Success(value) = result else {
3459 panic!("expected success, got {result:?}");
3460 };
3461 assert_eq!(value["status"], "idle");
3463 assert_eq!(value["result"], "Hi!");
3464 assert!(value.get("mode").is_none());
3465 }
3466
3467 #[tokio::test]
3468 async fn background_spawn_returns_immediately_and_settles_task() {
3469 let store = Arc::new(MockPlatformStore::new());
3470 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3471 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3472 let context = spawn_context(&store, Some(registry.clone()));
3473
3474 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3475 let ToolExecutionResult::Success(value) = result else {
3476 panic!("expected success, got {result:?}");
3477 };
3478 assert_eq!(value["status"], "running");
3479 assert_eq!(value["mode"], "background");
3480 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3481
3482 let task = wait_for_task_state(
3483 ®istry,
3484 context.session_id,
3485 &task_id,
3486 SessionTaskState::Succeeded,
3487 )
3488 .await;
3489 assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
3491 assert_eq!(task.spec["mode"], "background");
3492 assert_eq!(task.summary.as_deref(), Some("Hi!"));
3494 }
3495
3496 #[tokio::test]
3497 async fn background_spawn_rejects_when_session_active_run_limit_reached() {
3498 let store = Arc::new(MockPlatformStore::new());
3499 *store.wait_for_idle_status.lock().unwrap() = "paused".to_string();
3500 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3501 let context = spawn_context(&store, Some(registry));
3502
3503 for index in 0..crate::tools::MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3504 let result = spawn(
3505 &context,
3506 json!({
3507 "name": format!("Runner {index}"),
3508 "instructions": "go",
3509 }),
3510 )
3511 .await;
3512 let ToolExecutionResult::Success(value) = result else {
3513 panic!("background spawn below the session limit should start: {result:?}");
3514 };
3515 assert_eq!(value["status"], "running");
3516 }
3517
3518 let result = spawn(
3519 &context,
3520 json!({
3521 "name": "Runner over limit",
3522 "instructions": "go",
3523 }),
3524 )
3525 .await;
3526 let ToolExecutionResult::ToolError(message) = result else {
3527 panic!("background spawn should reject once the session limit is reached: {result:?}");
3528 };
3529 assert!(message.contains("active background runs per session"));
3530 }
3531
3532 #[tokio::test]
3533 async fn background_settles_bare_idle_as_completed() {
3534 let store = Arc::new(MockPlatformStore::new());
3537 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3538 let context = spawn_context(&store, Some(registry.clone()));
3539
3540 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3541 let ToolExecutionResult::Success(value) = result else {
3542 panic!("expected success, got {result:?}");
3543 };
3544 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3545 wait_for_task_state(
3546 ®istry,
3547 context.session_id,
3548 &task_id,
3549 SessionTaskState::Succeeded,
3550 )
3551 .await;
3552 }
3553
3554 #[tokio::test]
3555 async fn background_failed_child_settles_task_failed() {
3556 let store = Arc::new(MockPlatformStore::new());
3557 *store.wait_for_idle_status.lock().unwrap() = "failed".to_string();
3558 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3559 let context = spawn_context(&store, Some(registry.clone()));
3560
3561 let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3562 let ToolExecutionResult::Success(value) = result else {
3563 panic!("expected success, got {result:?}");
3564 };
3565 let task_id = value["task_id"].as_str().expect("task_id").to_string();
3566 let task = wait_for_task_state(
3567 ®istry,
3568 context.session_id,
3569 &task_id,
3570 SessionTaskState::Failed,
3571 )
3572 .await;
3573 assert_eq!(task.error.as_ref().map(|e| e.kind.as_str()), Some("failed"));
3574 }
3575
3576 #[tokio::test]
3577 async fn explicit_foreground_blocks_and_returns_result() {
3578 let store = Arc::new(MockPlatformStore::new());
3579 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3580 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3581 let context = spawn_context(&store, Some(registry.clone()));
3582
3583 let result = spawn(
3584 &context,
3585 json!({"name": "Runner", "instructions": "go", "mode": "foreground"}),
3586 )
3587 .await;
3588 let ToolExecutionResult::Success(value) = result else {
3589 panic!("expected success, got {result:?}");
3590 };
3591 assert_eq!(value["status"], "completed");
3592 assert_eq!(value["result"], "Hi!");
3593 let task_id = value["task_id"].as_str().expect("task_id");
3595 let task = registry
3596 .get(context.session_id, task_id)
3597 .await
3598 .unwrap()
3599 .unwrap();
3600 assert_eq!(task.state, SessionTaskState::Succeeded);
3601 assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
3602 }
3603
3604 #[tokio::test]
3605 async fn reconcile_settles_finished_child() {
3606 let store = Arc::new(MockPlatformStore::new());
3607 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3608 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3609 let context = spawn_context(&store, Some(registry.clone()));
3610
3611 let child_id = crate::typed_id::SessionId::new();
3612 let task = registry
3613 .create(CreateSessionTask {
3614 session_id: context.session_id,
3615 id: None,
3616 kind: TASK_KIND_SUBAGENT.to_string(),
3617 display_name: "Runner".to_string(),
3618 spec: json!({"mode": "background"}),
3619 state: SessionTaskState::Running,
3620 links: TaskLinks {
3621 child_session_id: Some(child_id),
3622 ..Default::default()
3623 },
3624 wake_policy: TaskWakePolicy::OnTerminal,
3625 })
3626 .await
3627 .unwrap();
3628
3629 SubagentTaskExecutor
3630 .reconcile(&task, &context)
3631 .await
3632 .expect("reconcile succeeds");
3633
3634 let task = registry
3635 .get(context.session_id, &task.id)
3636 .await
3637 .unwrap()
3638 .unwrap();
3639 assert_eq!(task.state, SessionTaskState::Succeeded);
3640 assert_eq!(task.summary.as_deref(), Some("Hi!"));
3641 }
3642
3643 #[tokio::test]
3644 async fn reconcile_is_noop_while_child_still_working() {
3645 let store = Arc::new(MockPlatformStore::new());
3646 *store.wait_for_idle_status.lock().unwrap() = "timeout (last status: Active)".to_string();
3647 let registry = Arc::new(InMemorySessionTaskRegistry::default());
3648 let context = spawn_context(&store, Some(registry.clone()));
3649
3650 let task = registry
3651 .create(CreateSessionTask {
3652 session_id: context.session_id,
3653 id: None,
3654 kind: TASK_KIND_SUBAGENT.to_string(),
3655 display_name: "Runner".to_string(),
3656 spec: json!({"mode": "background"}),
3657 state: SessionTaskState::Running,
3658 links: TaskLinks {
3659 child_session_id: Some(crate::typed_id::SessionId::new()),
3660 ..Default::default()
3661 },
3662 wake_policy: TaskWakePolicy::OnTerminal,
3663 })
3664 .await
3665 .unwrap();
3666
3667 SubagentTaskExecutor
3668 .reconcile(&task, &context)
3669 .await
3670 .expect("reconcile succeeds");
3671
3672 let task = registry
3673 .get(context.session_id, &task.id)
3674 .await
3675 .unwrap()
3676 .unwrap();
3677 assert_eq!(task.state, SessionTaskState::Running);
3678 }
3679}