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