1use super::delegation_result::{
10 MESSAGE_SCHEMA_SPEC_KEY, RESULT_SCHEMA_SPEC_KEY, normalize_message_schema,
11 normalize_result_schema, required_result_is_missing, result_value_for_task,
12};
13use super::util::{get_platform_store, require_str_nonblank as require_str};
14use super::{
15 Capability, CapabilityLocalization, CapabilityStatus, RiskLevel, SpawnMode, SystemPromptContext,
16};
17use crate::config_layer::{AgentConfigOverlay, normalize_initial_file_path};
18use crate::platform_store::PlatformCreateSessionRequest;
19use crate::session::{SessionSeedMode, SubagentStatus};
20use crate::session_task::{
21 CreateSessionTask, SessionTask, SessionTaskState, SessionTaskUpdate, TASK_KIND_AGENT_HANDOFF,
22 TASK_KIND_SESSION, TaskError, TaskExecutor, TaskExecutorPlugin, TaskLinks, TaskMessage,
23 TaskWakePolicy, task_message_text,
24};
25use crate::tool_types::ToolHints;
26use crate::tools::{Tool, ToolExecutionResult};
27use crate::traits::ToolContext;
28use crate::typed_id::{AgentId, HarnessId};
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use std::sync::Arc;
33
34pub const AGENT_HANDOFF_CAPABILITY_ID: &str = "agent_handoff";
35const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 300;
36const BACKGROUND_WAIT_SLICE_SECS: u64 = 300;
37const BACKGROUND_MAX_WAIT_SECS: u64 = 6 * 60 * 60;
38const BACKGROUND_HEARTBEAT_INTERVAL_SECS: u64 = 15;
39const BACKGROUND_POLL_BACKOFF_SECS: u64 = 5;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum HandoffLifetime {
43 Linked,
44 Detached,
45}
46
47impl HandoffLifetime {
48 fn parse(arguments: &Value) -> Result<Self, String> {
49 match arguments.get("lifetime").and_then(Value::as_str) {
50 None | Some("linked") => Ok(Self::Linked),
51 Some("detached") => Ok(Self::Detached),
52 Some(other) => Err(format!(
53 "Invalid lifetime: {other}. Expected 'linked' or 'detached'."
54 )),
55 }
56 }
57
58 fn as_str(self) -> &'static str {
59 match self {
60 Self::Linked => "linked",
61 Self::Detached => "detached",
62 }
63 }
64}
65
66fn parse_seed(arguments: &Value) -> Result<SessionSeedMode, String> {
67 match arguments.get("seed").and_then(Value::as_str) {
68 None | Some("fresh") => Ok(SessionSeedMode::Fresh),
69 Some("fork") => Ok(SessionSeedMode::Fork),
70 Some("workspace") => Ok(SessionSeedMode::Workspace),
71 Some(other) => Err(format!(
72 "Invalid seed: {other}. Expected 'fresh', 'fork', or 'workspace'."
73 )),
74 }
75}
76
77fn terminal_handoff_status(wait_status: &str) -> Option<SubagentStatus> {
78 match wait_status {
79 "idle" | "completed" => Some(SubagentStatus::Completed),
80 "error" | "failed" => Some(SubagentStatus::Failed),
81 "cancelled" => Some(SubagentStatus::Cancelled),
82 "max_iterations_reached" => Some(SubagentStatus::MaxIterationsReached),
83 "sealed" => Some(SubagentStatus::Sealed),
84 _ => None,
85 }
86}
87
88pub struct AgentHandoffCapability;
89
90#[async_trait]
91impl Capability for AgentHandoffCapability {
92 fn id(&self) -> &str {
93 AGENT_HANDOFF_CAPABILITY_ID
94 }
95
96 fn name(&self) -> &str {
97 "Agent Handoff"
98 }
99
100 fn description(&self) -> &str {
101 "Delegate work to configured first-party agents through an authenticated handoff gate."
102 }
103
104 fn status(&self) -> CapabilityStatus {
105 CapabilityStatus::Available
106 }
107
108 fn icon(&self) -> Option<&str> {
109 Some("user-round-check")
110 }
111
112 fn category(&self) -> Option<&str> {
113 Some("Orchestration")
114 }
115
116 fn features(&self) -> Vec<&'static str> {
117 vec!["agent_handoffs"]
118 }
119
120 fn risk_level(&self) -> RiskLevel {
121 RiskLevel::High
122 }
123
124 fn config_schema(&self) -> Option<Value> {
125 Some(json!({
126 "type": "object",
127 "properties": {
128 "targets": {
129 "type": "array",
130 "title": "Handoff targets",
131 "description": "Configured agents this agent may hand work off to.",
132 "items": {
133 "type": "object",
134 "properties": {
135 "id": {
136 "type": "string",
137 "title": "Target ID",
138 "description": "Stable target key used as spawn_agent target.id."
139 },
140 "name": {
141 "type": "string",
142 "title": "Name",
143 "description": "Human-readable name of the handoff target."
144 },
145 "description": {
146 "type": "string",
147 "title": "Description",
148 "description": "Optional description of what the target agent does."
149 },
150 "agent_id": {
151 "type": "string",
152 "title": "Agent ID",
153 "description": "Public id of the configured target agent."
154 },
155 "harness_id": {
156 "type": "string",
157 "title": "Harness ID",
158 "description": "Public id of the configured target harness."
159 },
160 "required_connections": {
161 "type": "array",
162 "title": "Required connections",
163 "items": { "type": "string" },
164 "description": "Provider connections required before handoff starts."
165 },
166 "required_scopes": {
167 "type": "array",
168 "title": "Required scopes",
169 "items": { "type": "string" },
170 "description": "Non-secret scope labels recorded for audit and resource metadata."
171 }
172 },
173 "required": ["id", "name", "agent_id", "harness_id"],
174 "additionalProperties": false
175 },
176 "default": []
177 }
178 },
179 "additionalProperties": false
180 }))
181 }
182
183 fn validate_config(&self, config: &Value) -> std::result::Result<(), String> {
184 let parsed = AgentHandoffConfig::from_value(config)
185 .map_err(|e| format!("invalid agent_handoff config: {e}"))?;
186 parsed.validate()
187 }
188
189 fn localizations(&self) -> Vec<CapabilityLocalization> {
190 vec![
191 CapabilityLocalization {
192 locale: "en",
193 name: None,
194 description: None,
195 config_description: Some(
196 "Defines the configured agents this agent may hand work off to and \
197 the connections each handoff requires.",
198 ),
199 config_overlay: None,
200 },
201 CapabilityLocalization {
202 locale: "uk",
203 name: Some("Передання роботи агентам"),
204 description: Some(
205 "Делегує роботу налаштованим власним агентам через автентифікований \
206 шлюз передання.",
207 ),
208 config_description: Some(
209 "Визначає налаштованих агентів, яким цей агент може передавати роботу, та підключення, потрібні для кожного передання.",
210 ),
211 config_overlay: Some(json!({
212 "properties": {
213 "targets": {
214 "title": "Цілі передання",
215 "description": "Налаштовані агенти, яким цей агент може передавати роботу.",
216 "items": {
217 "properties": {
218 "id": {
219 "title": "Ідентифікатор цілі",
220 "description": "Стабільний ключ цілі, що використовується як spawn_agent target.id."
221 },
222 "name": {
223 "title": "Назва",
224 "description": "Зрозуміла людині назва цілі передання."
225 },
226 "description": {
227 "title": "Опис",
228 "description": "Необов'язковий опис того, що робить цільовий агент."
229 },
230 "agent_id": {
231 "title": "Ідентифікатор агента",
232 "description": "Публічний ідентифікатор налаштованого цільового агента."
233 },
234 "harness_id": {
235 "title": "Ідентифікатор harness",
236 "description": "Публічний ідентифікатор налаштованого цільового harness."
237 },
238 "required_connections": {
239 "title": "Обов'язкові підключення",
240 "description": "Підключення до провайдерів, потрібні перед початком передання."
241 },
242 "required_scopes": {
243 "title": "Обов'язкові scope",
244 "description": "Несекретні мітки scope, що записуються для аудиту та метаданих ресурсів."
245 }
246 }
247 }
248 }
249 }
250 })),
251 },
252 ]
253 }
254
255 fn tools_with_config(&self, config: &Value) -> Vec<Box<dyn Tool>> {
256 let _ = AgentHandoffConfig::from_value(config).unwrap_or_default();
257 vec![]
258 }
259
260 fn tools(&self) -> Vec<Box<dyn Tool>> {
261 self.tools_with_config(&Value::Null)
262 }
263
264 async fn system_prompt_contribution_with_config(
265 &self,
266 _ctx: &SystemPromptContext,
267 config: &Value,
268 ) -> Option<String> {
269 let config = AgentHandoffConfig::from_value(config).unwrap_or_default();
270 let targets = config
271 .targets
272 .iter()
273 .map(|target| {
274 format!(
275 "- {} ({}) — {}",
276 target.name,
277 target.id,
278 target
279 .description
280 .as_deref()
281 .unwrap_or("Configured handoff target")
282 )
283 })
284 .collect::<Vec<_>>();
285
286 Some(format!(
287 "<capability id=\"{}\">\n\
288Use spawn_agent with target.type=\"agent\" to delegate work to configured first-party agents.\n\
289Never ask the user to paste provider tokens into chat or pass credentials in tool arguments.\n\
290If a required provider connection is missing, spawn_agent will return a connection_required result and the client should collect credentials through the Connections flow.\n\
291Available handoff targets:\n{}\n\
292</capability>",
293 self.id(),
294 if targets.is_empty() {
295 "- none configured".to_string()
296 } else {
297 targets.join("\n")
298 }
299 ))
300 }
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304struct AgentHandoffConfig {
305 #[serde(default)]
306 targets: Vec<AgentHandoffTargetConfig>,
307}
308
309impl AgentHandoffConfig {
310 fn from_value(value: &Value) -> serde_json::Result<Self> {
311 if value.is_null() {
312 Ok(Self::default())
313 } else {
314 serde_json::from_value(value.clone())
315 }
316 }
317
318 fn validate(&self) -> std::result::Result<(), String> {
319 let mut seen = std::collections::HashSet::new();
320 for target in &self.targets {
321 target.validate()?;
322 if !seen.insert(target.id.as_str()) {
323 return Err(format!("Duplicate handoff target id: {}", target.id));
324 }
325 }
326 Ok(())
327 }
328
329 fn target(&self, id: &str) -> Option<&AgentHandoffTargetConfig> {
330 self.targets.iter().find(|target| target.id == id)
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335struct AgentHandoffTargetConfig {
336 id: String,
337 name: String,
338 #[serde(default)]
339 description: Option<String>,
340 agent_id: AgentId,
341 harness_id: HarnessId,
342 #[serde(default)]
343 required_connections: Vec<String>,
344 #[serde(default)]
345 required_scopes: Vec<String>,
346}
347
348impl AgentHandoffTargetConfig {
349 fn validate(&self) -> std::result::Result<(), String> {
350 if self.id.trim().is_empty() {
351 return Err("Agent handoff target id cannot be empty".to_string());
352 }
353 if self.name.trim().is_empty() {
354 return Err(format!(
355 "Agent handoff target {} name cannot be empty",
356 self.id
357 ));
358 }
359 for provider in &self.required_connections {
360 if provider.trim().is_empty() {
361 return Err(format!(
362 "Agent handoff target {} has an empty required connection",
363 self.id
364 ));
365 }
366 }
367 for scope in &self.required_scopes {
368 if scope.trim().is_empty() {
369 return Err(format!(
370 "Agent handoff target {} has an empty required scope",
371 self.id
372 ));
373 }
374 }
375 Ok(())
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum SpawnAgentHandoffMode {
381 Spawn(SpawnMode),
382 Invite,
383}
384
385impl SpawnAgentHandoffMode {
386 fn parse(value: Option<&str>, context: &ToolContext) -> std::result::Result<Self, String> {
387 let explicit = match value.map(str::trim).filter(|s| !s.is_empty()) {
388 None => None,
389 Some(value) => match SpawnMode::parse(value) {
390 Some(mode) => Some(Self::Spawn(mode)),
391 None => match value {
392 "invite" => Some(Self::Invite),
393 other => {
394 return Err(format!(
395 "Invalid mode: \"{other}\". Valid modes: background, foreground, invite."
396 ));
397 }
398 },
399 },
400 };
401 let has_registry = context.session_task_registry.is_some();
402 match explicit {
403 Some(Self::Spawn(SpawnMode::Background)) if !has_registry => Err(
404 "Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead."
405 .to_string(),
406 ),
407 Some(mode) => Ok(mode),
408 None if has_registry => Ok(Self::Spawn(SpawnMode::Background)),
409 None => Ok(Self::Spawn(SpawnMode::Foreground)),
410 }
411 }
412
413 fn as_str(self) -> &'static str {
414 match self {
415 Self::Spawn(mode) => mode.as_str(),
416 Self::Invite => "invite",
417 }
418 }
419
420 fn is_invite(self) -> bool {
421 self == Self::Invite
422 }
423}
424
425fn capability_conflict_message(
426 host: &AgentConfigOverlay,
427 guest: &AgentConfigOverlay,
428) -> Option<String> {
429 guest.capabilities.iter().find_map(|guest_cap| {
430 host.capabilities
431 .iter()
432 .find(|host_cap| host_cap.capability_id() == guest_cap.capability_id())
433 .and_then(|host_cap| {
434 (host_cap.config != guest_cap.config).then(|| {
435 format!(
436 "capability `{}` has different host and guest configuration",
437 guest_cap.capability_id()
438 )
439 })
440 })
441 })
442}
443
444fn initial_file_conflict_message(
445 host: &AgentConfigOverlay,
446 guest: &AgentConfigOverlay,
447) -> Option<String> {
448 guest.initial_files.iter().find_map(|guest_file| {
449 let guest_path = normalize_initial_file_path(&guest_file.path);
450 host.initial_files
451 .iter()
452 .find(|host_file| normalize_initial_file_path(&host_file.path) == guest_path)
453 .and_then(|host_file| {
454 (host_file != guest_file)
455 .then(|| format!("mount `{guest_path}` has different host and guest contents"))
456 })
457 })
458}
459
460fn mcp_conflict_message(host: &AgentConfigOverlay, guest: &AgentConfigOverlay) -> Option<String> {
461 guest.mcp_servers.iter().find_map(|(name, guest_server)| {
462 host.mcp_servers.get(name).and_then(|host_server| {
463 (host_server != guest_server)
464 .then(|| format!("MCP server `{name}` has different host and guest configuration"))
465 })
466 })
467}
468
469fn invite_conflict_message(
470 host: &AgentConfigOverlay,
471 guest: &AgentConfigOverlay,
472) -> Option<String> {
473 capability_conflict_message(host, guest)
474 .or_else(|| initial_file_conflict_message(host, guest))
475 .or_else(|| mcp_conflict_message(host, guest))
476}
477
478async fn harness_chain_overlay(
479 store: &dyn crate::platform_store::PlatformStore,
480 harness_id: HarnessId,
481) -> Result<AgentConfigOverlay, ToolExecutionResult> {
482 let chain = store
483 .get_harness_chain(harness_id)
484 .await
485 .map_err(ToolExecutionResult::internal_error)?;
486 if chain.is_empty() {
487 return Err(ToolExecutionResult::tool_error(format!(
488 "Harness not found: {harness_id}"
489 )));
490 }
491 Ok(AgentConfigOverlay::fold(
492 chain.iter().map(AgentConfigOverlay::from),
493 ))
494}
495
496async fn invite_mode_overlays(
497 store: &dyn crate::platform_store::PlatformStore,
498 parent_session: &crate::session::Session,
499 target: &AgentHandoffTargetConfig,
500) -> Result<(AgentConfigOverlay, AgentConfigOverlay), ToolExecutionResult> {
501 let mut host_layers = vec![harness_chain_overlay(store, parent_session.harness_id).await?];
502 if let Some(agent_id) = parent_session.agent_id {
503 let host_agent = store
504 .get_agent_by_id(agent_id)
505 .await
506 .map_err(ToolExecutionResult::internal_error)?
507 .ok_or_else(|| {
508 ToolExecutionResult::tool_error(format!("Host agent not found: {agent_id}"))
509 })?;
510 host_layers.push(AgentConfigOverlay::from(&host_agent));
511 }
512 host_layers.push(AgentConfigOverlay::from(parent_session));
513
514 let target_agent = store
515 .get_agent_by_id(target.agent_id)
516 .await
517 .map_err(ToolExecutionResult::internal_error)?
518 .ok_or_else(|| {
519 ToolExecutionResult::tool_error(format!("Target agent not found: {}", target.agent_id))
520 })?;
521
522 Ok((
523 AgentConfigOverlay::fold(host_layers),
524 AgentConfigOverlay::fold([
525 harness_chain_overlay(store, target.harness_id).await?,
526 AgentConfigOverlay::from(&target_agent),
527 ]),
528 ))
529}
530
531fn child_task(task: &str, public_context: Option<&Value>) -> String {
532 let Some(public_context) = public_context else {
533 return task.to_string();
534 };
535 format!(
536 "{task}\n\n<public_handoff_context>\n{}\n</public_handoff_context>",
537 serde_json::to_string_pretty(public_context).unwrap_or_else(|_| "{}".to_string())
538 )
539}
540
541fn last_agent_message(messages: &[crate::platform_store::PlatformMessage]) -> Option<String> {
542 messages
543 .iter()
544 .rfind(|message| message.role == "agent" || message.role == "assistant")
545 .map(|message| message.content.clone())
546}
547
548async fn finish_handoff_task(
549 context: &ToolContext,
550 task_id: Option<&str>,
551 state: SessionTaskState,
552 summary: Option<String>,
553 error: Option<TaskError>,
554 expected_attempt: Option<i32>,
555) {
556 let (Some(registry), Some(task_id)) = (context.session_task_registry.as_ref(), task_id) else {
557 return;
558 };
559 let _ = registry
560 .update(
561 context.session_id,
562 task_id,
563 SessionTaskUpdate {
564 state: Some(state),
565 summary,
566 error,
567 expected_attempt,
568 ..Default::default()
569 },
570 )
571 .await;
572}
573
574async fn finalize_handoff_task(
575 context: &ToolContext,
576 task_id: Option<&str>,
577 mut state: SessionTaskState,
578 mut summary: Option<String>,
579 mut error: Option<TaskError>,
580 expected_attempt: Option<i32>,
581) {
582 if state == SessionTaskState::Succeeded && required_result_is_missing(context, task_id).await {
583 state = SessionTaskState::Failed;
584 summary =
585 Some("Agent handoff completed without reporting a structured result.".to_string());
586 error = Some(TaskError {
587 kind: "no_result".to_string(),
588 message:
589 "Agent handoff completed without calling report_result for its result_schema task."
590 .to_string(),
591 });
592 }
593 finish_handoff_task(context, task_id, state, summary, error, expected_attempt).await;
594}
595
596fn handoff_task_state(status: &SubagentStatus) -> SessionTaskState {
597 match status {
598 SubagentStatus::Completed => SessionTaskState::Succeeded,
599 SubagentStatus::Cancelled => SessionTaskState::Canceled,
600 SubagentStatus::Failed | SubagentStatus::MaxIterationsReached | SubagentStatus::Sealed => {
601 SessionTaskState::Failed
602 }
603 SubagentStatus::Running | SubagentStatus::Spawning => SessionTaskState::Running,
604 }
605}
606
607fn handoff_error(status: &str, state: SessionTaskState) -> Option<TaskError> {
608 (state == SessionTaskState::Failed).then(|| TaskError {
609 kind: "handoff_failed".to_string(),
610 message: format!("Handoff ended with status: {status}"),
611 })
612}
613
614async fn handoff_result(
615 store: &dyn crate::platform_store::PlatformStore,
616 child_session_id: crate::typed_id::SessionId,
617 status: &str,
618) -> Result<String, ToolExecutionResult> {
619 let messages = store
620 .get_messages(child_session_id, Some(5))
621 .await
622 .map_err(ToolExecutionResult::internal_error)?;
623 Ok(last_agent_message(&messages)
624 .unwrap_or_else(|| format!("Handoff completed with status: {status}")))
625}
626
627fn spawn_handoff_background_watcher(
628 context: &ToolContext,
629 child_session_id: crate::typed_id::SessionId,
630 first_message: String,
631 task_id: String,
632 task_attempt: i32,
633) {
634 let context = context.clone();
635 tokio::spawn(async move {
636 let Some(store) = context.platform_store.clone() else {
637 return;
638 };
639
640 if let Err(error) = store.send_message(child_session_id, &first_message).await {
641 finish_handoff_task(
642 &context,
643 Some(&task_id),
644 SessionTaskState::Failed,
645 None,
646 Some(TaskError {
647 kind: "handoff_failed".to_string(),
648 message: error.to_string(),
649 }),
650 Some(task_attempt),
651 )
652 .await;
653 return;
654 }
655
656 let heartbeat = async {
657 let Some(registry) = context.session_task_registry.clone() else {
658 return std::future::pending::<()>().await;
659 };
660 loop {
661 tokio::time::sleep(std::time::Duration::from_secs(
662 BACKGROUND_HEARTBEAT_INTERVAL_SECS,
663 ))
664 .await;
665 let _ = registry
666 .update(
667 context.session_id,
668 &task_id,
669 SessionTaskUpdate {
670 heartbeat_at: Some(chrono::Utc::now()),
671 expected_attempt: Some(task_attempt),
672 ..Default::default()
673 },
674 )
675 .await;
676 }
677 };
678
679 let wait_and_settle = async {
680 let started = tokio::time::Instant::now();
681 loop {
682 let status = match store
683 .wait_for_idle(child_session_id, Some(BACKGROUND_WAIT_SLICE_SECS))
684 .await
685 {
686 Ok(status) => status,
687 Err(error) => {
688 finish_handoff_task(
689 &context,
690 Some(&task_id),
691 SessionTaskState::Failed,
692 None,
693 Some(TaskError {
694 kind: "handoff_failed".to_string(),
695 message: error.to_string(),
696 }),
697 Some(task_attempt),
698 )
699 .await;
700 return;
701 }
702 };
703
704 if let Some(terminal) = terminal_handoff_status(&status) {
705 let state = handoff_task_state(&terminal);
706 let result = handoff_result(store.as_ref(), child_session_id, &status)
707 .await
708 .ok();
709 let error = handoff_error(&status, state);
710 finalize_handoff_task(
711 &context,
712 Some(&task_id),
713 state,
714 result,
715 error,
716 Some(task_attempt),
717 )
718 .await;
719 return;
720 }
721
722 if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
723 finish_handoff_task(
724 &context,
725 Some(&task_id),
726 SessionTaskState::Failed,
727 None,
728 Some(TaskError {
729 kind: "timeout".to_string(),
730 message: format!(
731 "Background agent handoff did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {status})"
732 ),
733 }),
734 Some(task_attempt),
735 )
736 .await;
737 return;
738 }
739
740 if let Some(registry) = context.session_task_registry.as_ref() {
741 let _ = registry
742 .update(
743 context.session_id,
744 &task_id,
745 SessionTaskUpdate {
746 state_detail: Some(format!(
747 "waiting for agent handoff ({}s elapsed, last status: {status})",
748 started.elapsed().as_secs()
749 )),
750 expected_attempt: Some(task_attempt),
751 ..Default::default()
752 },
753 )
754 .await;
755 }
756 if !status.starts_with("timeout") {
757 tokio::time::sleep(std::time::Duration::from_secs(
758 BACKGROUND_POLL_BACKOFF_SECS,
759 ))
760 .await;
761 }
762 }
763 };
764
765 tokio::select! {
766 () = wait_and_settle => {}
767 () = heartbeat => {}
768 }
769 });
770}
771
772async fn require_connections(
773 context: &ToolContext,
774 target: &AgentHandoffTargetConfig,
775) -> Result<(), ToolExecutionResult> {
776 if target.required_connections.is_empty() {
777 return Ok(());
778 }
779
780 let Some(resolver) = &context.connection_resolver else {
781 return Err(ToolExecutionResult::internal_error_msg(
782 "Agent handoff connection resolution is not available in this execution context.",
783 ));
784 };
785
786 for provider in &target.required_connections {
787 match resolver
788 .get_connection_token(context.session_id, provider)
789 .await
790 {
791 Ok(Some(_token)) => {}
792 Ok(None) => return Err(ToolExecutionResult::connection_required(provider.clone())),
793 Err(error) => return Err(ToolExecutionResult::internal_error(error)),
794 }
795 }
796 Ok(())
797}
798
799pub struct SpawnAgentHandoffTool {
800 config: AgentHandoffConfig,
801}
802
803impl SpawnAgentHandoffTool {
804 pub fn new(config: &Value) -> Self {
805 Self {
806 config: AgentHandoffConfig::from_value(config).unwrap_or_default(),
807 }
808 }
809}
810
811#[async_trait]
812impl Tool for SpawnAgentHandoffTool {
813 fn narrate(
814 &self,
815 tool_call: &crate::tool_types::ToolCall,
816 phase: crate::tool_narration::ToolNarrationPhase,
817 locale: Option<&str>,
818 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
819 ) -> Option<String> {
820 Some(crate::tool_narration::narrate_subagent_spawn(
821 &tool_call.arguments,
822 phase,
823 locale,
824 ))
825 }
826
827 fn name(&self) -> &str {
828 "spawn_agent"
829 }
830
831 fn display_name(&self) -> Option<&str> {
832 Some("Spawn Agent")
833 }
834
835 fn description(&self) -> &str {
836 "Delegate work to a configured first-party target agent. Set target.type to \"agent\" and target.id to a configured handoff target id. Runs in the background by default when task tracking is available; set mode to \"foreground\" to block for the result or \"invite\" to add the target as a member of the current session."
837 }
838
839 fn parameters_schema(&self) -> Value {
840 json!({
841 "type": "object",
842 "properties": {
843 "name": {
844 "type": "string",
845 "description": "Human-readable name for this delegated run."
846 },
847 "instructions": {
848 "type": "string",
849 "description": "Instructions for the target agent. Do not include credentials or bearer tokens."
850 },
851 "target": {
852 "type": "object",
853 "properties": {
854 "type": {
855 "type": "string",
856 "enum": ["agent"],
857 "description": "Delegation target type. Use \"agent\" for a configured first-party Agent handoff."
858 },
859 "id": {
860 "type": "string",
861 "description": "Configured handoff target id."
862 }
863 },
864 "required": ["type", "id"],
865 "additionalProperties": false
866 },
867 "mode": {
868 "type": "string",
869 "enum": ["background", "foreground", "invite"],
870 "description": "Execution mode. \"background\" (default when task tracking is available) returns immediately with a task_id; \"foreground\" blocks until the handoff completes; \"invite\" adds the target agent as a member participant in this session."
871 },
872 "public_context": {
873 "type": "object",
874 "description": "Non-secret structured context to include with the instructions."
875 },
876 "result_schema": {
877 "type": "object",
878 "description": "JSON Schema for the child agent's final structured result. The child must call report_result before the task can succeed."
879 },
880 "message_schema": {
881 "type": "object",
882 "description": "JSON Schema for structured progress messages. The child receives report_task_progress."
883 }
884 },
885 "required": ["name", "instructions", "target"],
886 "additionalProperties": false
887 })
888 }
889
890 fn hints(&self) -> ToolHints {
891 ToolHints::default().with_long_running(true)
892 }
893
894 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
895 ToolExecutionResult::tool_error(
896 "spawn_agent requires context. This tool must be executed with session context.",
897 )
898 }
899
900 async fn execute_with_context(
901 &self,
902 arguments: Value,
903 context: &ToolContext,
904 ) -> ToolExecutionResult {
905 let store = match get_platform_store(context) {
906 Ok(store) => store,
907 Err(error) => return error,
908 };
909
910 let target = arguments.get("target").unwrap_or(&Value::Null);
911 if target.get("type").and_then(Value::as_str) != Some("agent") {
912 return ToolExecutionResult::tool_error(
913 "spawn_agent target.type must be \"agent\" for the agent_handoff capability",
914 );
915 }
916 let target_id = match target
917 .get("id")
918 .and_then(Value::as_str)
919 .map(str::trim)
920 .filter(|s| !s.is_empty())
921 {
922 Some(id) => id,
923 None => {
924 return ToolExecutionResult::tool_error("Missing required parameter: target.id");
925 }
926 };
927 let name = match require_str(&arguments, "name") {
928 Ok(value) => value.trim().to_string(),
929 Err(error) => return error,
930 };
931 let instructions = match require_str(&arguments, "instructions") {
932 Ok(value) => value,
933 Err(error) => return error,
934 };
935 let goal = arguments
936 .get("goal")
937 .and_then(Value::as_str)
938 .map(str::trim)
939 .filter(|value| !value.is_empty())
940 .map(str::to_string);
941 let lifetime = match HandoffLifetime::parse(&arguments) {
942 Ok(value) => value,
943 Err(error) => return ToolExecutionResult::tool_error(error),
944 };
945 let seed = match parse_seed(&arguments) {
946 Ok(value) => value,
947 Err(error) => return ToolExecutionResult::tool_error(error),
948 };
949 let mode = match SpawnAgentHandoffMode::parse(
950 arguments.get("mode").and_then(Value::as_str),
951 context,
952 ) {
953 Ok(mode) => mode,
954 Err(error) => return ToolExecutionResult::tool_error(error),
955 };
956 let result_schema = match normalize_result_schema(&arguments) {
957 Ok(schema) => schema,
958 Err(error) => return error,
959 };
960 let message_schema = match normalize_message_schema(&arguments) {
961 Ok(schema) => schema,
962 Err(error) => return error,
963 };
964 if mode.is_invite() && (result_schema.is_some() || message_schema.is_some()) {
965 return ToolExecutionResult::tool_error(
966 "result_schema and message_schema require a child task and are not valid for invite-mode agent handoffs.",
967 );
968 }
969 if (result_schema.is_some() || message_schema.is_some())
970 && context.session_task_registry.is_none()
971 {
972 return ToolExecutionResult::tool_error(
973 "result_schema and message_schema require session_task_registry context for agent handoffs.",
974 );
975 }
976 if lifetime == HandoffLifetime::Detached && mode.is_invite() {
977 return ToolExecutionResult::tool_error(
978 "lifetime=\"detached\" is only valid for agent handoffs that create a new session; invite mode joins the current session.",
979 );
980 }
981
982 let Some(target) = self.config.target(target_id) else {
983 return ToolExecutionResult::tool_error(format!(
984 "Unknown handoff target: \"{target_id}\". Check configured targets."
985 ));
986 };
987
988 if let Err(error) = require_connections(context, target).await {
989 return error;
990 }
991
992 let parent_session = match store.get_session_by_id(context.session_id).await {
993 Ok(Some(session)) => session,
994 Ok(None) => return ToolExecutionResult::tool_error("Current session not found"),
995 Err(error) => return ToolExecutionResult::internal_error(error),
996 };
997
998 if lifetime == HandoffLifetime::Linked && parent_session.parent_session_id.is_some() {
999 return ToolExecutionResult::tool_error(
1000 "Agent handoffs cannot be started from child sessions.",
1001 );
1002 }
1003
1004 if mode.is_invite() {
1005 let (host_overlay, guest_overlay) =
1006 match invite_mode_overlays(store, &parent_session, target).await {
1007 Ok(overlays) => overlays,
1008 Err(error) => return error,
1009 };
1010 if let Some(conflict) = invite_conflict_message(&host_overlay, &guest_overlay) {
1011 return ToolExecutionResult::tool_error(format!(
1012 "Invite-mode handoff cannot join target \"{}\": {conflict}. Use background or foreground mode for targets that need their own environment.",
1013 target.id
1014 ));
1015 }
1016
1017 let participant = match store
1018 .add_agent_session_participant(context.session_id, target.agent_id)
1019 .await
1020 {
1021 Ok(participant) => participant,
1022 Err(error) => return ToolExecutionResult::internal_error(error),
1023 };
1024
1025 return ToolExecutionResult::success(json!({
1026 "participant_id": participant.id,
1027 "target": target.id,
1028 "target_agent_id": target.agent_id,
1029 "name": name,
1030 "status": "joined",
1031 "mode": "invite",
1032 "message": "Target agent joined this session and can respond when addressed.",
1033 }));
1034 }
1035
1036 let budget_root_session_id = if lifetime == HandoffLifetime::Detached {
1040 let Some(authority) = context.session_creation_authority.as_ref() else {
1041 return ToolExecutionResult::tool_error(
1042 "Detached handoff requires session-creation authority.",
1043 );
1044 };
1045 match authority
1046 .authorize_session_creation(context.session_id)
1047 .await
1048 {
1049 Ok(root_session_id) => Some(root_session_id),
1050 Err(error) => {
1051 return ToolExecutionResult::tool_error(format!(
1052 "Detached handoff is not authorized to create a session: {error}"
1053 ));
1054 }
1055 }
1056 } else {
1057 None
1058 };
1059
1060 let child_session = match store
1061 .create_session_with_options(PlatformCreateSessionRequest {
1062 harness_id: target.harness_id,
1063 agent_id: Some(target.agent_id),
1064 title: Some(name.clone()),
1065 goal,
1066 locale: parent_session.locale.clone(),
1067 blueprint_id: None,
1068 blueprint_config: None,
1069 parent_session_id: (lifetime == HandoffLifetime::Linked)
1070 .then_some(context.session_id),
1071 forked_from_session_id: (lifetime == HandoffLifetime::Detached)
1072 .then_some(context.session_id),
1073 budget_root_session_id,
1074 seed,
1075 })
1076 .await
1077 {
1078 Ok(session) => session,
1079 Err(error) => return ToolExecutionResult::internal_error(error),
1080 };
1081
1082 let handoff_task = child_task(instructions, arguments.get("public_context"));
1083 let mut task_id = None;
1084 let mut task_attempt = 1;
1085 if let Some(task_registry) = &context.session_task_registry {
1086 let mut task_spec = json!({
1087 "target_id": &target.id,
1088 "external_agent_id": target.agent_id,
1089 "instructions": instructions,
1090 "mode": mode.as_str(),
1091 "lifetime": lifetime.as_str(),
1092 "seed": seed.as_str(),
1093 });
1094 if let Some(spec) = task_spec.as_object_mut() {
1095 if let Some(schema) = &result_schema {
1096 spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1097 }
1098 if let Some(schema) = &message_schema {
1099 spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1100 }
1101 }
1102 match task_registry
1103 .create(CreateSessionTask {
1104 session_id: context.session_id,
1105 id: None,
1106 kind: match lifetime {
1107 HandoffLifetime::Linked => TASK_KIND_AGENT_HANDOFF,
1108 HandoffLifetime::Detached => TASK_KIND_SESSION,
1109 }
1110 .to_string(),
1111 display_name: name.clone(),
1112 spec: task_spec,
1113 state: SessionTaskState::Running,
1114 links: TaskLinks {
1115 child_session_id: Some(child_session.id),
1116 ..Default::default()
1117 },
1118 wake_policy: match (lifetime, mode, message_schema.is_some()) {
1119 (HandoffLifetime::Detached, _, _) => TaskWakePolicy::Silent,
1120 (
1121 HandoffLifetime::Linked,
1122 SpawnAgentHandoffMode::Spawn(SpawnMode::Background),
1123 true,
1124 ) => TaskWakePolicy::OnActivity,
1125 (
1126 HandoffLifetime::Linked,
1127 SpawnAgentHandoffMode::Spawn(SpawnMode::Background),
1128 false,
1129 ) => TaskWakePolicy::OnTerminal,
1130 (
1131 HandoffLifetime::Linked,
1132 SpawnAgentHandoffMode::Spawn(SpawnMode::Foreground),
1133 _,
1134 ) => TaskWakePolicy::Silent,
1135 (HandoffLifetime::Linked, SpawnAgentHandoffMode::Invite, _) => {
1136 unreachable!("invite mode returns before child-session task creation")
1137 }
1138 },
1139 })
1140 .await
1141 {
1142 Ok(task) => {
1143 task_attempt = task.attempt;
1144 task_id = Some(task.id);
1145 }
1146 Err(error)
1147 if mode == SpawnAgentHandoffMode::Spawn(SpawnMode::Background)
1148 || result_schema.is_some()
1149 || message_schema.is_some() =>
1150 {
1151 return ToolExecutionResult::tool_error(format!(
1152 "Background spawn_agent could not create its session task, so the handoff was not started: {error}"
1153 ));
1154 }
1155 Err(_) => {}
1156 }
1157 }
1158
1159 match mode {
1160 SpawnAgentHandoffMode::Spawn(SpawnMode::Background) => {
1161 let Some(task_id) = task_id else {
1162 return ToolExecutionResult::tool_error(
1163 "Background spawn_agent requires session_task_registry context so the handoff can be controlled with wait_task/message_task/cancel_task",
1164 );
1165 };
1166 spawn_handoff_background_watcher(
1167 context,
1168 child_session.id,
1169 handoff_task,
1170 task_id.clone(),
1171 task_attempt,
1172 );
1173 ToolExecutionResult::success(json!({
1174 "task_id": task_id,
1175 "handoff_id": child_session.id.to_string(),
1176 "target": target.id,
1177 "target_agent_id": target.agent_id,
1178 "name": name,
1179 "status": "running",
1180 "mode": "background",
1181 }))
1182 }
1183 SpawnAgentHandoffMode::Spawn(SpawnMode::Foreground) => {
1184 if let Err(error) = store.send_message(child_session.id, &handoff_task).await {
1185 finish_handoff_task(
1186 context,
1187 task_id.as_deref(),
1188 SessionTaskState::Failed,
1189 None,
1190 Some(TaskError {
1191 kind: "handoff_failed".to_string(),
1192 message: error.to_string(),
1193 }),
1194 None,
1195 )
1196 .await;
1197 return ToolExecutionResult::internal_error(error);
1198 }
1199
1200 let status = match store
1201 .wait_for_idle(child_session.id, Some(DEFAULT_WAIT_TIMEOUT_SECS))
1202 .await
1203 {
1204 Ok(status) => status,
1205 Err(error) => {
1206 finish_handoff_task(
1207 context,
1208 task_id.as_deref(),
1209 SessionTaskState::Failed,
1210 None,
1211 Some(TaskError {
1212 kind: "handoff_failed".to_string(),
1213 message: error.to_string(),
1214 }),
1215 None,
1216 )
1217 .await;
1218 return ToolExecutionResult::success(json!({
1219 "task_id": task_id,
1220 "handoff_id": child_session.id.to_string(),
1221 "target": target.id,
1222 "target_agent_id": target.agent_id,
1223 "name": name,
1224 "status": "failed",
1225 "error": error.to_string(),
1226 "mode": "foreground",
1227 }));
1228 }
1229 };
1230
1231 let result = match handoff_result(store, child_session.id, &status).await {
1232 Ok(result) => result,
1233 Err(error) => return error,
1234 };
1235 if let Some(terminal) = terminal_handoff_status(&status) {
1236 let state = handoff_task_state(&terminal);
1237 let error = handoff_error(&status, state);
1238 finalize_handoff_task(
1239 context,
1240 task_id.as_deref(),
1241 state,
1242 Some(result.clone()),
1243 error,
1244 None,
1245 )
1246 .await;
1247 }
1248
1249 let result_value = result_value_for_task(context, task_id.as_deref())
1250 .await
1251 .unwrap_or_else(|| json!(result));
1252
1253 ToolExecutionResult::success(json!({
1254 "task_id": task_id,
1255 "handoff_id": child_session.id.to_string(),
1256 "target": target.id,
1257 "target_agent_id": target.agent_id,
1258 "name": name,
1259 "status": status,
1260 "result": result_value,
1261 "mode": "foreground",
1262 }))
1263 }
1264 SpawnAgentHandoffMode::Invite => {
1265 unreachable!("invite mode returns before child-session execution")
1266 }
1267 }
1268 }
1269
1270 fn requires_context(&self) -> bool {
1271 true
1272 }
1273}
1274
1275pub struct AgentHandoffTaskExecutor;
1276
1277#[async_trait]
1278impl TaskExecutor for AgentHandoffTaskExecutor {
1279 fn kind(&self) -> &str {
1280 TASK_KIND_AGENT_HANDOFF
1281 }
1282
1283 async fn deliver(
1284 &self,
1285 task: &SessionTask,
1286 message: &TaskMessage,
1287 context: &ToolContext,
1288 ) -> crate::error::Result<()> {
1289 let Some(store) = context.platform_store.as_ref() else {
1290 return Err(crate::error::AgentLoopError::tool(
1291 "agent handoff task delivery requires platform_store context",
1292 ));
1293 };
1294 let Some(child_id) = task.links.child_session_id else {
1295 return Err(crate::error::AgentLoopError::tool(format!(
1296 "agent handoff task {} has no child session link",
1297 task.id
1298 )));
1299 };
1300 let text = task_message_text(&message.content);
1301 store.send_message(child_id, &text).await
1302 }
1303
1304 async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1305 let Some(store) = context.platform_store.as_ref() else {
1306 return Err(crate::error::AgentLoopError::tool(
1307 "agent handoff task cancellation requires platform_store context",
1308 ));
1309 };
1310 let Some(child_id) = task.links.child_session_id else {
1311 return Err(crate::error::AgentLoopError::tool(format!(
1312 "agent handoff task {} has no child session link",
1313 task.id
1314 )));
1315 };
1316 store
1317 .send_message(
1318 child_id,
1319 "Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
1320 )
1321 .await
1322 }
1323
1324 async fn reconcile(
1325 &self,
1326 task: &SessionTask,
1327 context: &ToolContext,
1328 ) -> crate::error::Result<()> {
1329 if task.state.is_terminal() {
1330 return Ok(());
1331 }
1332 let (Some(store), Some(child_id)) =
1333 (context.platform_store.as_ref(), task.links.child_session_id)
1334 else {
1335 return Ok(());
1336 };
1337 let status = store.wait_for_idle(child_id, Some(0)).await?;
1338 let Some(terminal) = terminal_handoff_status(&status) else {
1339 return Ok(());
1340 };
1341 let state = handoff_task_state(&terminal);
1342 let result = handoff_result(store.as_ref(), child_id, &status).await.ok();
1343 let error = handoff_error(&status, state);
1344 finalize_handoff_task(context, Some(&task.id), state, result, error, None).await;
1345 Ok(())
1346 }
1347}
1348
1349inventory::submit! {
1350 TaskExecutorPlugin {
1351 executor: || Arc::new(AgentHandoffTaskExecutor),
1352 }
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357 use super::*;
1358 use crate::Result;
1359 use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
1360 use crate::platform_store::tests::MockPlatformStore;
1361 use crate::session_task::{CreateSessionTask, SessionTaskRegistry, TaskLinks, TaskMessagePart};
1362 use crate::tools::{Tool, ToolExecutionResult};
1363 use crate::traits::UserConnectionResolver;
1364 use crate::typed_id::SessionId;
1365 use std::collections::HashSet;
1366 use std::sync::Arc;
1367 use uuid::Uuid;
1368
1369 fn target_config(
1370 agent_id: AgentId,
1371 harness_id: HarnessId,
1372 required_connections: Vec<&str>,
1373 ) -> Value {
1374 json!({
1375 "targets": [
1376 {
1377 "id": "aws_operator",
1378 "name": "AWS Operator",
1379 "description": "Manage fake AWS infrastructure",
1380 "agent_id": agent_id,
1381 "harness_id": harness_id,
1382 "required_connections": required_connections,
1383 "required_scopes": ["fake_aws:rds:create"]
1384 }
1385 ]
1386 })
1387 }
1388
1389 fn spawn_agent_tool(config: &Value) -> Box<dyn Tool> {
1390 Box::new(SpawnAgentHandoffTool::new(config))
1391 }
1392
1393 struct TestConnectionResolver {
1394 providers: HashSet<String>,
1395 }
1396
1397 #[async_trait]
1398 impl UserConnectionResolver for TestConnectionResolver {
1399 async fn get_connection_token(
1400 &self,
1401 _session_id: SessionId,
1402 provider: &str,
1403 ) -> Result<Option<String>> {
1404 Ok(self
1405 .providers
1406 .contains(provider)
1407 .then(|| "server-side-secret-token".to_string()))
1408 }
1409
1410 async fn get_connection_user(
1411 &self,
1412 _session_id: SessionId,
1413 _provider: &str,
1414 ) -> Result<Option<Uuid>> {
1415 Ok(None)
1416 }
1417
1418 async fn get_connection_token_for_user(
1419 &self,
1420 _user_id: Uuid,
1421 _provider: &str,
1422 ) -> Result<Option<String>> {
1423 Ok(None)
1424 }
1425 }
1426
1427 fn context(
1428 store: Arc<MockPlatformStore>,
1429 resolver: Option<Arc<dyn UserConnectionResolver>>,
1430 ) -> ToolContext {
1431 let mut context = ToolContext::new(store.session.id);
1432 context.platform_store = Some(store);
1433 context.connection_resolver = resolver;
1434 context
1435 }
1436
1437 #[test]
1440 fn config_schema_exposes_targets_array() {
1441 let cap = AgentHandoffCapability;
1442 let schema = cap.config_schema().expect("config schema");
1443 assert_eq!(schema["properties"]["targets"]["type"], "array");
1444 }
1445
1446 #[test]
1447 fn capability_no_longer_contributes_legacy_handoff_tools() {
1448 let cap = AgentHandoffCapability;
1449 assert!(cap.tools_with_config(&json!({})).is_empty());
1450 }
1451
1452 #[test]
1453 fn uk_localization_resolves() {
1454 let cap = AgentHandoffCapability;
1455 assert_eq!(
1456 cap.localized_name(Some("uk-UA")),
1457 "Передання роботи агентам"
1458 );
1459 assert!(
1460 cap.localized_description(Some("uk-UA"))
1461 .contains("Делегує роботу")
1462 );
1463 assert!(cap.describe_schema(Some("uk")).is_some());
1464 assert!(cap.describe_schema(None).is_some());
1465 }
1466
1467 #[test]
1468 fn terminal_handoff_status_maps_only_terminal_wait_states() {
1469 assert_eq!(
1470 terminal_handoff_status("idle"),
1471 Some(SubagentStatus::Completed)
1472 );
1473 assert_eq!(
1474 terminal_handoff_status("error"),
1475 Some(SubagentStatus::Failed)
1476 );
1477 assert_eq!(
1478 terminal_handoff_status("max_iterations_reached"),
1479 Some(SubagentStatus::MaxIterationsReached)
1480 );
1481 assert_eq!(terminal_handoff_status("waiting_for_tool_results"), None);
1482 assert_eq!(terminal_handoff_status("paused"), None);
1483 }
1484
1485 #[test]
1486 fn validate_config_rejects_duplicate_targets() {
1487 let agent_id = AgentId::new();
1488 let harness_id = HarnessId::new();
1489 let config = json!({
1490 "targets": [
1491 { "id": "dup", "name": "One", "agent_id": agent_id, "harness_id": harness_id },
1492 { "id": "dup", "name": "Two", "agent_id": AgentId::new(), "harness_id": HarnessId::new() }
1493 ]
1494 });
1495
1496 let error = AgentHandoffCapability
1497 .validate_config(&config)
1498 .expect_err("duplicate targets should fail");
1499 assert!(error.contains("Duplicate handoff target id"));
1500 }
1501
1502 #[test]
1503 fn spawn_agent_schema_advertises_only_agent_target() {
1504 let tool = SpawnAgentHandoffTool::new(&json!({}));
1505 let schema = tool.parameters_schema();
1506 assert_eq!(
1507 schema["properties"]["target"]["properties"]["type"]["enum"],
1508 json!(["agent"])
1509 );
1510 assert_eq!(
1511 schema["properties"]["target"]["required"],
1512 json!(["type", "id"])
1513 );
1514 assert_eq!(
1515 schema["properties"]["mode"]["enum"],
1516 json!(["background", "foreground", "invite"])
1517 );
1518 assert_eq!(
1519 schema["required"],
1520 json!(["name", "instructions", "target"])
1521 );
1522 }
1523
1524 #[tokio::test]
1525 async fn spawn_agent_handoff_requires_configured_connection() {
1526 let store = Arc::new(MockPlatformStore::new());
1527 let config = target_config(
1528 store.agent.public_id,
1529 store.session.harness_id,
1530 vec!["fake_aws"],
1531 );
1532 let tool = spawn_agent_tool(&config);
1533 let resolver = Arc::new(TestConnectionResolver {
1534 providers: HashSet::new(),
1535 });
1536 let context = context(store, Some(resolver));
1537
1538 let result = tool
1539 .execute_with_context(
1540 json!({
1541 "name": "AWS Operator",
1542 "instructions": "Create an RDS database named app-db",
1543 "target": { "type": "agent", "id": "aws_operator" },
1544 "mode": "foreground"
1545 }),
1546 &context,
1547 )
1548 .await;
1549
1550 assert!(matches!(
1551 result,
1552 ToolExecutionResult::ConnectionRequired { provider } if provider == "fake_aws"
1553 ));
1554 }
1555
1556 #[tokio::test]
1557 async fn spawn_agent_handoff_rejects_other_target_types() {
1558 let store = Arc::new(MockPlatformStore::new());
1559 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1560 let tool = spawn_agent_tool(&config);
1561 let context = context(store, None);
1562
1563 let result = tool
1564 .execute_with_context(
1565 json!({
1566 "name": "Wrong Target",
1567 "instructions": "Do work",
1568 "target": { "type": "subagent" }
1569 }),
1570 &context,
1571 )
1572 .await;
1573
1574 assert!(
1575 matches!(result, ToolExecutionResult::ToolError(message) if message.contains("target.type must be \"agent\""))
1576 );
1577 }
1578
1579 #[tokio::test]
1580 async fn spawn_agent_handoff_creates_agent_handoff_task() {
1581 let store = Arc::new(MockPlatformStore::new());
1582 let resolver = Arc::new(TestConnectionResolver {
1583 providers: HashSet::from(["fake_aws".to_string()]),
1584 });
1585 let config = target_config(
1586 store.agent.public_id,
1587 store.session.harness_id,
1588 vec!["fake_aws"],
1589 );
1590 let tool = spawn_agent_tool(&config);
1591 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1592 let mut context = context(store.clone(), Some(resolver));
1593 context.session_task_registry = Some(registry.clone());
1594
1595 let result = tool
1596 .execute_with_context(
1597 json!({
1598 "name": "AWS Operator Run",
1599 "instructions": "Create an RDS database named app-db",
1600 "target": { "type": "agent", "id": "aws_operator" },
1601 "mode": "foreground",
1602 "public_context": { "region": "us-east-1" }
1603 }),
1604 &context,
1605 )
1606 .await;
1607
1608 let ToolExecutionResult::Success(value) = result else {
1609 panic!("expected success, got {result:?}");
1610 };
1611 let task_id = value["task_id"].as_str().expect("task_id");
1612 let task = registry
1613 .get(store.session.id, task_id)
1614 .await
1615 .expect("task lookup")
1616 .expect("task");
1617 assert_eq!(value["mode"], "foreground");
1618 assert_eq!(value["target"], "aws_operator");
1619 assert_eq!(task.kind, TASK_KIND_AGENT_HANDOFF);
1620 assert_eq!(task.display_name, "AWS Operator Run");
1621 assert_eq!(task.state, SessionTaskState::Succeeded);
1622 assert_eq!(task.spec["target_id"], "aws_operator");
1623 assert_eq!(task.spec["mode"], "foreground");
1624 assert!(task.links.child_session_id.is_some());
1625 }
1626
1627 #[tokio::test]
1628 async fn schema_bound_handoff_requires_report_result() {
1629 let store = Arc::new(MockPlatformStore::new());
1630 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
1631 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1632 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1633 let mut context = context(store.clone(), None);
1634 context.session_task_registry = Some(registry.clone());
1635
1636 let result = spawn_agent_tool(&config)
1637 .execute_with_context(
1638 json!({
1639 "name": "Structured handoff",
1640 "instructions": "Return structured data",
1641 "target": {"type": "agent", "id": "aws_operator"},
1642 "mode": "foreground",
1643 "result_schema": {
1644 "type": "object",
1645 "properties": {"answer": {"type": "string"}},
1646 "required": ["answer"]
1647 }
1648 }),
1649 &context,
1650 )
1651 .await;
1652
1653 let ToolExecutionResult::Success(value) = result else {
1654 panic!("expected terminal handoff result, got {result:?}");
1655 };
1656 let task_id = value["task_id"].as_str().expect("task_id");
1657 let task = registry
1658 .get(store.session.id, task_id)
1659 .await
1660 .unwrap()
1661 .unwrap();
1662 assert_eq!(
1663 task.spec[RESULT_SCHEMA_SPEC_KEY]["required"],
1664 json!(["answer"])
1665 );
1666 assert_eq!(task.state, SessionTaskState::Failed);
1667 assert_eq!(
1668 task.error.as_ref().map(|error| error.kind.as_str()),
1669 Some("no_result")
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn handoff_message_schema_is_task_backed_and_wakes_on_activity() {
1675 let store = Arc::new(MockPlatformStore::new());
1676 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1677 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1678 let mut context = context(store.clone(), None);
1679 context.session_task_registry = Some(registry.clone());
1680
1681 let result = spawn_agent_tool(&config)
1682 .execute_with_context(
1683 json!({
1684 "name": "Progress handoff",
1685 "instructions": "Report progress",
1686 "target": {"type": "agent", "id": "aws_operator"},
1687 "mode": "background",
1688 "message_schema": {
1689 "type": "object",
1690 "properties": {"step": {"type": "string"}},
1691 "required": ["step"]
1692 }
1693 }),
1694 &context,
1695 )
1696 .await;
1697
1698 let ToolExecutionResult::Success(value) = result else {
1699 panic!("expected background handoff, got {result:?}");
1700 };
1701 let task_id = value["task_id"].as_str().expect("task_id");
1702 let task = registry
1703 .get(store.session.id, task_id)
1704 .await
1705 .unwrap()
1706 .unwrap();
1707 assert_eq!(
1708 task.spec[MESSAGE_SCHEMA_SPEC_KEY]["required"],
1709 json!(["step"])
1710 );
1711 assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
1712 }
1713
1714 #[tokio::test]
1715 async fn spawn_agent_handoff_background_returns_task_handle() {
1716 let store = Arc::new(MockPlatformStore::new());
1717 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1718 let tool = spawn_agent_tool(&config);
1719 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1720 let mut context = context(store.clone(), None);
1721 context.session_task_registry = Some(registry.clone());
1722
1723 let result = tool
1724 .execute_with_context(
1725 json!({
1726 "name": "AWS Operator Background",
1727 "instructions": "List RDS databases",
1728 "target": { "type": "agent", "id": "aws_operator" },
1729 "mode": "background"
1730 }),
1731 &context,
1732 )
1733 .await;
1734
1735 let ToolExecutionResult::Success(value) = result else {
1736 panic!("expected success, got {result:?}");
1737 };
1738 let task_id = value["task_id"].as_str().expect("task_id");
1739 assert_eq!(value["status"], "running");
1740 assert_eq!(value["mode"], "background");
1741
1742 let mut task = registry
1743 .get(store.session.id, task_id)
1744 .await
1745 .expect("task lookup")
1746 .expect("task");
1747 assert_eq!(task.kind, TASK_KIND_AGENT_HANDOFF);
1748 assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
1749 assert_eq!(task.spec["mode"], "background");
1750
1751 for _ in 0..20 {
1752 if task.state.is_terminal() {
1753 break;
1754 }
1755 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1756 task = registry
1757 .get(store.session.id, task_id)
1758 .await
1759 .expect("task lookup")
1760 .expect("task");
1761 }
1762
1763 assert_eq!(task.state, SessionTaskState::Succeeded);
1764 assert_eq!(task.summary.as_deref(), Some("Hi!"));
1765 }
1766
1767 #[tokio::test]
1768 async fn spawn_agent_handoff_invite_adds_member_participant() {
1769 let store = Arc::new(MockPlatformStore::new());
1770 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1771 let tool = spawn_agent_tool(&config);
1772 let context = context(store.clone(), None);
1773
1774 let result = tool
1775 .execute_with_context(
1776 json!({
1777 "name": "AWS Operator Invite",
1778 "instructions": "Join this incident session",
1779 "target": { "type": "agent", "id": "aws_operator" },
1780 "mode": "invite"
1781 }),
1782 &context,
1783 )
1784 .await;
1785
1786 let ToolExecutionResult::Success(value) = result else {
1787 panic!("expected success, got {result:?}");
1788 };
1789 assert_eq!(value["mode"], "invite");
1790 assert_eq!(value["status"], "joined");
1791 assert_eq!(value["target"], "aws_operator");
1792 assert!(value["participant_id"].as_str().is_some());
1793
1794 assert!(
1795 store
1796 .created_session_harness_ids
1797 .lock()
1798 .expect("recorder lock")
1799 .is_empty(),
1800 "invite mode must not create a child session"
1801 );
1802 let participants = store
1803 .joined_participants
1804 .lock()
1805 .expect("participants lock")
1806 .clone();
1807 assert_eq!(participants.len(), 1);
1808 assert_eq!(participants[0].agent_id, Some(store.agent.public_id));
1809 assert_eq!(
1810 participants[0].role,
1811 crate::session::SessionParticipantRole::Member
1812 );
1813 }
1814
1815 #[tokio::test]
1816 async fn spawn_agent_handoff_invite_rejects_inherited_harness_capability_conflict() {
1817 let mut store_value = MockPlatformStore::new();
1818 let parent_harness_id = HarnessId::new();
1819 let child_harness_id = HarnessId::new();
1820 let mut parent_harness = store_value.harness.clone();
1821 parent_harness.id = parent_harness_id;
1822 parent_harness.parent_harness_id = None;
1823 parent_harness.capabilities = vec![crate::AgentCapabilityConfig::with_config(
1824 "web_fetch",
1825 json!({"max_bytes": 1024}),
1826 )];
1827 let mut child_harness = store_value.harness.clone();
1828 child_harness.id = child_harness_id;
1829 child_harness.parent_harness_id = Some(parent_harness_id);
1830 child_harness.capabilities = vec![];
1831 store_value.session.harness_id = child_harness_id;
1832 store_value.agent.capabilities = vec![crate::AgentCapabilityConfig::with_config(
1833 "web_fetch",
1834 json!({"max_bytes": 2048}),
1835 )];
1836 {
1837 let mut harnesses = store_value.extra_harnesses.lock().unwrap();
1838 harnesses.insert(parent_harness_id, parent_harness);
1839 harnesses.insert(child_harness_id, child_harness);
1840 }
1841 let store = Arc::new(store_value);
1842 let config = target_config(store.agent.public_id, child_harness_id, vec![]);
1843 let tool = spawn_agent_tool(&config);
1844 let context = context(store.clone(), None);
1845
1846 let result = tool
1847 .execute_with_context(
1848 json!({
1849 "name": "AWS Operator Invite",
1850 "instructions": "Join this incident session",
1851 "target": { "type": "agent", "id": "aws_operator" },
1852 "mode": "invite"
1853 }),
1854 &context,
1855 )
1856 .await;
1857
1858 assert!(matches!(result, ToolExecutionResult::ToolError(message)
1859 if message.contains("Invite-mode handoff cannot join target")
1860 && message.contains("capability `web_fetch`")
1861 && message.contains("Use background or foreground mode")));
1862 assert!(
1863 store
1864 .joined_participants
1865 .lock()
1866 .expect("participants lock")
1867 .is_empty(),
1868 "conflicting inherited harness invite must not join the participant"
1869 );
1870 }
1871
1872 #[tokio::test]
1873 async fn spawn_agent_handoff_invite_rejects_capability_conflict() {
1874 let mut store_value = MockPlatformStore::new();
1875 store_value.session.capabilities = vec![crate::AgentCapabilityConfig::with_config(
1876 "web_fetch",
1877 json!({"max_bytes": 1024}),
1878 )];
1879 store_value.agent.capabilities = vec![crate::AgentCapabilityConfig::with_config(
1880 "web_fetch",
1881 json!({"max_bytes": 2048}),
1882 )];
1883 let store = Arc::new(store_value);
1884 let config = target_config(store.agent.public_id, store.session.harness_id, vec![]);
1885 let tool = spawn_agent_tool(&config);
1886 let context = context(store.clone(), None);
1887
1888 let result = tool
1889 .execute_with_context(
1890 json!({
1891 "name": "AWS Operator Invite",
1892 "instructions": "Join this incident session",
1893 "target": { "type": "agent", "id": "aws_operator" },
1894 "mode": "invite"
1895 }),
1896 &context,
1897 )
1898 .await;
1899
1900 assert!(matches!(result, ToolExecutionResult::ToolError(message)
1901 if message.contains("Invite-mode handoff cannot join target")
1902 && message.contains("capability `web_fetch`")
1903 && message.contains("Use background or foreground mode")));
1904 assert!(
1905 store
1906 .joined_participants
1907 .lock()
1908 .expect("participants lock")
1909 .is_empty(),
1910 "conflicting invite must not join the participant"
1911 );
1912 }
1913
1914 #[tokio::test]
1920 async fn spawn_agent_handoff_uses_target_harness_not_parent() {
1921 let store = Arc::new(MockPlatformStore::new());
1922 let resolver = Arc::new(TestConnectionResolver {
1923 providers: HashSet::from(["fake_aws".to_string()]),
1924 });
1925 let target_harness_id = HarnessId::new();
1926 assert_ne!(store.session.harness_id, target_harness_id);
1929
1930 let config = target_config(store.agent.public_id, target_harness_id, vec!["fake_aws"]);
1931 let tool = spawn_agent_tool(&config);
1932 let context = context(store.clone(), Some(resolver));
1933
1934 let result = tool
1935 .execute_with_context(
1936 json!({
1937 "name": "AWS Operator Run",
1938 "instructions": "Create an RDS database named app-db",
1939 "target": { "type": "agent", "id": "aws_operator" },
1940 "mode": "foreground"
1941 }),
1942 &context,
1943 )
1944 .await;
1945 assert!(result.is_success(), "expected success, got {result:?}");
1946
1947 let recorded = store
1948 .created_session_harness_ids
1949 .lock()
1950 .expect("recorder lock")
1951 .clone();
1952 assert_eq!(
1953 recorded.len(),
1954 1,
1955 "expected exactly one child create_session call, got {recorded:?}"
1956 );
1957 assert_eq!(
1958 recorded[0], target_harness_id,
1959 "child session must inherit the target harness, not the parent's",
1960 );
1961 assert_ne!(
1962 recorded[0], store.session.harness_id,
1963 "child session must NOT inherit the parent harness (confused-deputy regression)",
1964 );
1965 }
1966
1967 #[tokio::test]
1968 async fn agent_handoff_task_executor_delivers_followup() {
1969 let parent_id = SessionId::new();
1970 let child_id = SessionId::new();
1971 let mut store_value = MockPlatformStore::new();
1972 store_value.session.id = child_id;
1973 store_value.session.parent_session_id = Some(parent_id);
1974 let store = Arc::new(store_value);
1975
1976 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1977 let task = registry
1978 .create(CreateSessionTask {
1979 session_id: parent_id,
1980 id: None,
1981 kind: TASK_KIND_AGENT_HANDOFF.to_string(),
1982 display_name: "AWS Operator".to_string(),
1983 spec: json!({ "target_id": "aws", "external_agent_id": "agent_aws" }),
1986 state: SessionTaskState::Running,
1987 links: TaskLinks {
1988 child_session_id: Some(child_id),
1989 ..Default::default()
1990 },
1991 wake_policy: TaskWakePolicy::Silent,
1992 })
1993 .await
1994 .expect("create task");
1995 let message = TaskMessage {
1996 id: "msg_1".to_string(),
1997 task_id: task.id.clone(),
1998 direction: crate::session_task::TaskMessageDirection::Inbound,
1999 content: vec![TaskMessagePart::text("List RDS databases")],
2000 in_reply_to: None,
2001 created_at: chrono::Utc::now(),
2002 };
2003
2004 let mut ctx = ToolContext::new(parent_id);
2005 ctx.platform_store = Some(store);
2006 ctx.session_task_registry = Some(registry);
2007
2008 AgentHandoffTaskExecutor
2009 .deliver(&task, &message, &ctx)
2010 .await
2011 .expect("follow-up delivered");
2012 }
2013
2014 #[tokio::test]
2015 async fn agent_handoff_task_executor_reconciles_terminal_child() {
2016 let parent_id = SessionId::new();
2017 let child_id = SessionId::new();
2018 let store = Arc::new(MockPlatformStore::new());
2019 *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2020
2021 let registry = Arc::new(InMemorySessionTaskRegistry::default());
2022 let task = registry
2023 .create(CreateSessionTask {
2024 session_id: parent_id,
2025 id: None,
2026 kind: TASK_KIND_AGENT_HANDOFF.to_string(),
2027 display_name: "AWS Operator".to_string(),
2028 spec: json!({ "target_id": "aws", "external_agent_id": "agent_aws" }),
2029 state: SessionTaskState::Running,
2030 links: TaskLinks {
2031 child_session_id: Some(child_id),
2032 ..Default::default()
2033 },
2034 wake_policy: TaskWakePolicy::Silent,
2035 })
2036 .await
2037 .expect("create task");
2038
2039 let mut ctx = ToolContext::new(parent_id);
2040 ctx.platform_store = Some(store);
2041 ctx.session_task_registry = Some(registry.clone());
2042
2043 AgentHandoffTaskExecutor
2044 .reconcile(&task, &ctx)
2045 .await
2046 .expect("reconcile succeeds");
2047 let task = registry
2048 .get(parent_id, &task.id)
2049 .await
2050 .expect("task lookup")
2051 .expect("task");
2052 assert_eq!(task.state, SessionTaskState::Succeeded);
2053 assert_eq!(task.summary.as_deref(), Some("Hi!"));
2054 }
2055}