Skip to main content

everruns_core/capabilities/
agent_handoff.rs

1// Agent handoff capability.
2//
3// Decision: handoff starts a real configured Agent in a child session instead
4// of using a code-defined blueprint. The source agent gets only handoff tools;
5// the target agent owns its own tools, capabilities, data, and runtime prompt.
6// Credentials are never accepted as tool arguments or config. Required
7// provider connections are resolved server-side before the child session starts.
8
9use 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        // Detached handoffs create the same lifecycle-independent peer shape
1019        // as detached subagents, so they share the host authorization and
1020        // origin-root budget linkage invariant.
1021        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    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
1420
1421    #[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    /// Regression for the confused-deputy issue this PR fixes: the child
1897    /// session must be created with the *target's* harness, not the
1898    /// parent session's harness. If a future refactor reintroduces
1899    /// `parent_session.harness_id` here, the child would inherit the
1900    /// parent's mounts/capabilities while gaining the target's tools.
1901    #[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        // Sanity: the target harness must differ from the parent's,
1909        // otherwise the assertion below cannot distinguish them.
1910        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                // Handoff tasks use the dedicated `agent_handoff` kind and carry
1966                // target_id/external_agent_id in spec.
1967                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}