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