Skip to main content

everruns_core/capabilities/
a2a_delegation.rs

1// Outbound A2A agent delegation.
2//
3// Decision: V1 is outbound-only and stores configured external agents in the
4// capability config. Run state is persisted as session storage KV entries
5// (key = "agent_run:{run_id}") so agents and UI have a unified local/remote
6// delegation handle. Listing derives from `list_keys` by prefix — no index
7// key, so concurrent spawns cannot race. The resource registry is unused for
8// runs (retired as part of the session-tasks dual-write cleanup).
9
10use super::delegation_result::{
11    normalize_result_schema, schema_validation_errors, write_task_result_value,
12};
13use super::{
14    Capability, CapabilityLocalization, CapabilityStatus, RiskLevel, SESSION_TASKS_CAPABILITY_ID,
15    SpawnMode, SystemPromptContext,
16};
17use crate::network_access::NetworkAccessList;
18use crate::session_task::{
19    CreateSessionTask, NewTaskMessage, SessionTask, SessionTaskState, SessionTaskUpdate,
20    TASK_KIND_EXTERNAL_AGENT, TaskError, TaskExecutor, TaskExecutorPlugin, TaskInputRequest,
21    TaskLinks, TaskMessage, TaskWakePolicy, task_message_text,
22};
23use crate::tool_types::ToolHints;
24use crate::tools::{Tool, ToolExecutionResult};
25use crate::traits::{SessionStorageStore, ToolContext};
26use crate::{Result, validate_safe_url};
27use a2a::{
28    AgentCard, CancelTaskRequest, GetTaskRequest, Message, Part, PartContent, Role,
29    SendMessageConfiguration, SendMessageRequest, SendMessageResponse, Task, TaskState,
30};
31use a2a_client::A2AClientFactory;
32use a2a_client::agent_card::AgentCardResolver;
33use a2a_client::middleware::CallInterceptor;
34use a2a_client::transport::ServiceParams;
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37use serde_json::{Value, json};
38use std::collections::BTreeMap;
39use std::sync::Arc;
40use std::time::Duration;
41use tokio::time::{Instant, sleep};
42use url::Url;
43
44// The capability-ID constant lives ungated in `capabilities::mod` so session
45// attachment logic (ard_attachment) can reference it even in builds that gate
46// out the A2A delegation implementation. See the `a2a` feature.
47pub use super::A2A_AGENT_DELEGATION_CAPABILITY_ID;
48const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 300;
49const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
50
51/// Error prefix returned by `wait_for_run` when the attempt fence reveals the
52/// executor was superseded (reaper re-attached the task elsewhere). The
53/// background monitor exits without writing failure state on this error.
54const SUPERSEDED_ERROR_PREFIX: &str = "Superseded external agent poll for";
55const MAX_RESULT_CHARS: usize = 8_192;
56
57/// Outbound A2A delegation capability.
58pub struct A2aAgentDelegationCapability;
59
60#[async_trait]
61impl Capability for A2aAgentDelegationCapability {
62    fn id(&self) -> &str {
63        A2A_AGENT_DELEGATION_CAPABILITY_ID
64    }
65
66    fn name(&self) -> &str {
67        "A2A Agent Delegation"
68    }
69
70    fn description(&self) -> &str {
71        "Delegate work to configured external agents over the A2A protocol."
72    }
73
74    fn status(&self) -> CapabilityStatus {
75        CapabilityStatus::Available
76    }
77
78    fn icon(&self) -> Option<&str> {
79        Some("send")
80    }
81
82    fn category(&self) -> Option<&str> {
83        Some("Orchestration")
84    }
85
86    fn features(&self) -> Vec<&'static str> {
87        vec!["agent_runs"]
88    }
89
90    fn config_schema(&self) -> Option<Value> {
91        Some(json!({
92            "type": "object",
93            "properties": {
94                "agents": {
95                    "type": "array",
96                    "title": "External agents",
97                    "description": "External A2A agents available for delegation.",
98                    "items": {
99                        "type": "object",
100                        "properties": {
101                            "id": {
102                                "type": "string",
103                                "title": "Agent ID",
104                                "description": "Stable ID used in spawn_agent target.external_agent_id."
105                            },
106                            "name": {
107                                "type": "string",
108                                "title": "Name",
109                                "description": "Human-readable name of the external agent."
110                            },
111                            "description": {
112                                "type": "string",
113                                "title": "Description",
114                                "description": "Optional description of what the external agent does."
115                            },
116                            "base_url": {
117                                "type": "string",
118                                "title": "Base URL",
119                                "description": "Base URL for AgentCard discovery. The client fetches /.well-known/agent-card.json."
120                            },
121                            "agent_card": {
122                                "type": "object",
123                                "title": "Agent card",
124                                "description": "Optional cached/inline AgentCard. If omitted, base_url discovery is used."
125                            },
126                            "headers": {
127                                "type": "object",
128                                "title": "Headers",
129                                "additionalProperties": { "type": "string" },
130                                "description": "Non-secret static headers to send to the A2A endpoint."
131                            },
132                            "preferred_binding": {
133                                "type": "string",
134                                "title": "Preferred transport",
135                                "description": "Optional transport preference.",
136                                "oneOf": [
137                                    { "const": "JSONRPC", "title": "JSON-RPC" },
138                                    { "const": "HTTP+JSON", "title": "HTTP+JSON" }
139                                ]
140                            },
141                            "poll_interval_ms": {
142                                "type": "integer",
143                                "title": "Poll interval (ms)",
144                                "description": "Polling interval for remote task status, in milliseconds.",
145                                "minimum": 100,
146                                "maximum": 60000
147                            },
148                            "allow_local_urls": {
149                                "type": "boolean",
150                                "title": "Allow local URLs",
151                                "description": "Testing/dev escape hatch for localhost A2A agents. Keep false in production.",
152                                "default": false
153                            }
154                        },
155                        "required": ["id", "name"],
156                        "additionalProperties": false
157                    },
158                    "default": []
159                }
160            },
161            "additionalProperties": false
162        }))
163    }
164
165    fn validate_config(&self, config: &Value) -> std::result::Result<(), String> {
166        let parsed = A2aDelegationConfig::from_value(config)
167            .map_err(|e| format!("invalid a2a_agent_delegation config: {e}"))?;
168        for agent in parsed.agents {
169            agent.validate()?;
170        }
171        Ok(())
172    }
173
174    fn localizations(&self) -> Vec<CapabilityLocalization> {
175        vec![
176            CapabilityLocalization {
177                locale: "en",
178                name: None,
179                description: None,
180                config_description: Some(
181                    "Defines the external A2A agents this agent may delegate work to and \
182                     how to reach them.",
183                ),
184                config_overlay: None,
185            },
186            CapabilityLocalization {
187                locale: "uk",
188                name: Some("Делегування агентам A2A"),
189                description: Some(
190                    "Делегує роботу налаштованим зовнішнім агентам за протоколом A2A.",
191                ),
192                config_description: Some(
193                    "Визначає зовнішніх агентів A2A, яким цей агент може делегувати роботу, та параметри підключення до них.",
194                ),
195                config_overlay: Some(json!({
196                    "properties": {
197                        "agents": {
198                            "title": "Зовнішні агенти",
199                            "description": "Зовнішні агенти A2A, доступні для делегування.",
200                            "items": {
201                                "properties": {
202                                    "id": {
203                                        "title": "Ідентифікатор агента",
204                                        "description": "Стабільний ідентифікатор, що використовується у spawn_agent (target.external_agent_id)."
205                                    },
206                                    "name": {
207                                        "title": "Назва",
208                                        "description": "Зрозуміла людині назва зовнішнього агента."
209                                    },
210                                    "description": {
211                                        "title": "Опис",
212                                        "description": "Необов'язковий опис того, що робить зовнішній агент."
213                                    },
214                                    "base_url": {
215                                        "title": "Базовий URL",
216                                        "description": "Базовий URL для виявлення AgentCard. Клієнт завантажує /.well-known/agent-card.json."
217                                    },
218                                    "agent_card": {
219                                        "title": "AgentCard",
220                                        "description": "Необов'язковий кешований або вбудований AgentCard. Якщо не задано, використовується виявлення через base_url."
221                                    },
222                                    "headers": {
223                                        "title": "Заголовки",
224                                        "description": "Несекретні статичні заголовки, що надсилаються на кінцеву точку A2A."
225                                    },
226                                    "preferred_binding": {
227                                        "title": "Бажаний транспорт",
228                                        "description": "Необов'язкове налаштування транспорту.",
229                                        "enum_labels": {
230                                            "JSONRPC": "JSON-RPC",
231                                            "HTTP+JSON": "HTTP+JSON"
232                                        }
233                                    },
234                                    "poll_interval_ms": {
235                                        "title": "Інтервал опитування (мс)",
236                                        "description": "Інтервал опитування стану віддаленої задачі в мілісекундах."
237                                    },
238                                    "allow_local_urls": {
239                                        "title": "Дозволити локальні URL",
240                                        "description": "Обхідний шлях для тестування та розробки з локальними агентами A2A. У продакшені тримайте вимкненим."
241                                    }
242                                }
243                            }
244                        }
245                    }
246                })),
247            },
248        ]
249    }
250
251    fn tools_with_config(&self, config: &Value) -> Vec<Box<dyn Tool>> {
252        let config = A2aDelegationConfig::from_value(config).unwrap_or_default();
253        vec![Box::new(SpawnAgentTool::new(config))]
254    }
255
256    fn tools(&self) -> Vec<Box<dyn Tool>> {
257        self.tools_with_config(&Value::Null)
258    }
259
260    fn dependencies(&self) -> Vec<&'static str> {
261        vec![SESSION_TASKS_CAPABILITY_ID]
262    }
263
264    fn risk_level(&self) -> RiskLevel {
265        RiskLevel::High
266    }
267
268    async fn system_prompt_contribution_with_config(
269        &self,
270        _ctx: &SystemPromptContext,
271        config: &Value,
272    ) -> Option<String> {
273        let config = A2aDelegationConfig::from_value(config).unwrap_or_default();
274        let agents = config
275            .agents
276            .iter()
277            .map(|agent| {
278                format!(
279                    "- {} ({}) — {}",
280                    agent.name,
281                    agent.id,
282                    agent.description.as_deref().unwrap_or("External A2A agent")
283                )
284            })
285            .collect::<Vec<_>>();
286
287        Some(format!(
288            "<capability id=\"{}\">\n\
289Delegate work to configured external A2A agents with spawn_agent.\n\
290Use mode=\"background\" for long-running work and wait_task (from session_tasks) later for results; use mode=\"foreground\" when blocked on the result.\n\
291Use message_task for follow-up input or input_required tasks; use cancel_task to stop a remote task.\n\
292Available external agents:\n{}\n\
293</capability>",
294            self.id(),
295            if agents.is_empty() {
296                "- none configured".to_string()
297            } else {
298                agents.join("\n")
299            }
300        ))
301    }
302}
303
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305struct A2aDelegationConfig {
306    #[serde(default)]
307    agents: Vec<ExternalA2aAgentConfig>,
308}
309
310impl A2aDelegationConfig {
311    fn from_value(value: &Value) -> serde_json::Result<Self> {
312        if value.is_null() {
313            Ok(Self::default())
314        } else {
315            serde_json::from_value(value.clone())
316        }
317    }
318
319    fn agent(&self, id: &str) -> Option<&ExternalA2aAgentConfig> {
320        self.agents.iter().find(|agent| agent.id == id)
321    }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
325struct ExternalA2aAgentConfig {
326    id: String,
327    name: String,
328    #[serde(default)]
329    description: Option<String>,
330    #[serde(default)]
331    base_url: Option<String>,
332    #[serde(default)]
333    agent_card: Option<AgentCard>,
334    #[serde(default)]
335    headers: BTreeMap<String, String>,
336    #[serde(default)]
337    preferred_binding: Option<String>,
338    #[serde(default)]
339    poll_interval_ms: Option<u64>,
340    #[serde(default)]
341    allow_local_urls: bool,
342}
343
344impl ExternalA2aAgentConfig {
345    fn validate(&self) -> std::result::Result<(), String> {
346        if self.id.trim().is_empty() {
347            return Err("A2A agent id cannot be empty".to_string());
348        }
349        if self.name.trim().is_empty() {
350            return Err(format!("A2A agent {} name cannot be empty", self.id));
351        }
352        if self.base_url.is_none() && self.agent_card.is_none() {
353            return Err(format!(
354                "A2A agent {} requires base_url or agent_card",
355                self.id
356            ));
357        }
358        if let Some(binding) = &self.preferred_binding
359            && binding != "JSONRPC"
360            && binding != "HTTP+JSON"
361        {
362            return Err(format!(
363                "A2A agent {} preferred_binding must be JSONRPC or HTTP+JSON",
364                self.id
365            ));
366        }
367        if let Some(interval) = self.poll_interval_ms
368            && !(100..=60_000).contains(&interval)
369        {
370            return Err(format!(
371                "A2A agent {} poll_interval_ms must be between 100 and 60000",
372                self.id
373            ));
374        }
375        if let Some(base_url) = &self.base_url {
376            if self.allow_local_urls {
377                validate_http_url(base_url)
378                    .map_err(|e| format!("A2A agent {} has invalid base_url: {e}", self.id))?;
379            } else {
380                validate_safe_url(base_url)
381                    .map_err(|e| format!("A2A agent {} has unsafe base_url: {e}", self.id))?;
382            }
383        }
384        if let Some(card) = &self.agent_card {
385            self.validate_card(card)?;
386        }
387        Ok(())
388    }
389
390    fn validate_card(&self, card: &AgentCard) -> std::result::Result<(), String> {
391        for iface in &card.supported_interfaces {
392            if self.allow_local_urls {
393                validate_http_url(&iface.url)
394                    .map_err(|e| format!("A2A agent {} has invalid interface URL: {e}", self.id))?;
395            } else {
396                validate_safe_url(&iface.url)
397                    .map_err(|e| format!("A2A agent {} has unsafe interface URL: {e}", self.id))?;
398            }
399        }
400        Ok(())
401    }
402
403    async fn resolve_card(&self) -> std::result::Result<AgentCard, String> {
404        self.validate()?;
405        if let Some(card) = &self.agent_card {
406            return Ok(card.clone());
407        }
408        let base_url = self
409            .base_url
410            .as_deref()
411            .ok_or_else(|| format!("A2A agent {} has no base_url", self.id))?;
412        let card = AgentCardResolver::new(None)
413            .resolve(base_url)
414            .await
415            .map_err(|e| format!("Failed to resolve A2A AgentCard: {e}"))?;
416        self.validate_card(&card)?;
417        Ok(card)
418    }
419}
420
421fn validate_http_url(raw_url: &str) -> std::result::Result<(), String> {
422    let url = Url::parse(raw_url).map_err(|e| e.to_string())?;
423    match url.scheme() {
424        "http" | "https" => {}
425        other => return Err(format!("disallowed scheme {other}; expected http or https")),
426    }
427    if url.host_str().is_none() {
428        return Err("URL must have a hostname".to_string());
429    }
430    Ok(())
431}
432
433#[derive(Clone)]
434struct StaticHeaderInterceptor {
435    headers: Vec<(String, String)>,
436}
437
438#[async_trait]
439impl CallInterceptor for StaticHeaderInterceptor {
440    async fn before(
441        &self,
442        _method: &str,
443        params: &mut ServiceParams,
444    ) -> std::result::Result<(), a2a::A2AError> {
445        for (name, value) in &self.headers {
446            params.entry(name.clone()).or_default().push(value.clone());
447        }
448        Ok(())
449    }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
453#[serde(rename_all = "snake_case")]
454enum AgentRunStatus {
455    Submitted,
456    Working,
457    InputRequired,
458    AuthRequired,
459    Completed,
460    Failed,
461    Canceled,
462    Rejected,
463}
464
465impl AgentRunStatus {
466    fn is_terminal(&self) -> bool {
467        matches!(
468            self,
469            Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
470        )
471    }
472}
473
474impl std::fmt::Display for AgentRunStatus {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        let value = match self {
477            Self::Submitted => "submitted",
478            Self::Working => "working",
479            Self::InputRequired => "input_required",
480            Self::AuthRequired => "auth_required",
481            Self::Completed => "completed",
482            Self::Failed => "failed",
483            Self::Canceled => "canceled",
484            Self::Rejected => "rejected",
485        };
486        write!(f, "{value}")
487    }
488}
489
490impl From<&TaskState> for AgentRunStatus {
491    fn from(state: &TaskState) -> Self {
492        match state {
493            TaskState::Submitted | TaskState::Unspecified => Self::Submitted,
494            TaskState::Working => Self::Working,
495            TaskState::InputRequired => Self::InputRequired,
496            TaskState::AuthRequired => Self::AuthRequired,
497            TaskState::Completed => Self::Completed,
498            TaskState::Failed => Self::Failed,
499            TaskState::Canceled => Self::Canceled,
500            TaskState::Rejected => Self::Rejected,
501        }
502    }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506struct AgentRunRecord {
507    run_id: String,
508    kind: String,
509    external_agent_id: String,
510    external_agent_name: String,
511    #[serde(alias = "task")]
512    instructions: String,
513    #[serde(deserialize_with = "deserialize_agent_run_mode")]
514    mode: SpawnMode,
515    status: AgentRunStatus,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    remote_task_id: Option<String>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    remote_context_id: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    result: Option<String>,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    result_path: Option<String>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    error: Option<String>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    error_kind: Option<String>,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    result_schema: Option<Value>,
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    structured_result: Option<Value>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    last_remote_task_snapshot: Option<Value>,
534    #[serde(default)]
535    wake_on_completion: bool,
536    /// Session task mirroring this run (specs/session-tasks.md). Absent on
537    /// records that predate the task registry.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    task_id: Option<String>,
540    /// Snapshot of the agent config used to create the run, so the task
541    /// executor can rebuild the A2A client without the capability config.
542    /// Headers are non-secret by config-schema contract, so persisting them
543    /// in resource metadata is safe. Absent on old records.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    agent_config: Option<ExternalA2aAgentConfig>,
546    /// Merged network policy captured at spawn time. Re-attach runs outside
547    /// ActAtom, so it must restore this before rebuilding outbound clients.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    network_access: Option<NetworkAccessList>,
550}
551
552fn deserialize_agent_run_mode<'de, D>(deserializer: D) -> std::result::Result<SpawnMode, D::Error>
553where
554    D: serde::Deserializer<'de>,
555{
556    let mode = String::deserialize(deserializer)?;
557    match mode.as_str() {
558        // Legacy durable A2A foreground runs were stored as "wait" before the
559        // shared spawn-agent vocabulary was unified on "foreground".
560        "wait" => Ok(SpawnMode::Foreground),
561        _ => SpawnMode::parse(&mode).ok_or_else(|| {
562            serde::de::Error::unknown_variant(&mode, &["background", "foreground", "wait"])
563        }),
564    }
565}
566
567impl AgentRunRecord {
568    fn new(
569        run_id: String,
570        agent: &ExternalA2aAgentConfig,
571        instructions: String,
572        mode: SpawnMode,
573        wake_on_completion: bool,
574        result_schema: Option<Value>,
575    ) -> Self {
576        Self {
577            run_id,
578            kind: "external_a2a".to_string(),
579            external_agent_id: agent.id.clone(),
580            external_agent_name: agent.name.clone(),
581            instructions,
582            mode,
583            status: AgentRunStatus::Submitted,
584            remote_task_id: None,
585            remote_context_id: None,
586            result: None,
587            result_path: None,
588            error: None,
589            error_kind: None,
590            result_schema,
591            structured_result: None,
592            last_remote_task_snapshot: None,
593            wake_on_completion,
594            task_id: None,
595            agent_config: Some(agent.clone()),
596            network_access: None,
597        }
598    }
599
600    fn public_json(&self) -> Value {
601        json!({
602            "agent_run_id": self.run_id,
603            "kind": self.kind,
604            "external_agent_id": self.external_agent_id,
605            "external_agent_name": self.external_agent_name,
606            "instructions": self.instructions,
607            "mode": self.mode,
608            "status": self.status,
609            "remote_task_id": self.remote_task_id,
610            "remote_context_id": self.remote_context_id,
611            "result": self.result,
612            "result_path": self.result_path,
613            "error": self.error,
614            "wake_on_completion": self.wake_on_completion,
615            "task_id": self.task_id,
616        })
617    }
618}
619
620fn run_id() -> String {
621    format!("agrun_{}", uuid::Uuid::now_v7().simple())
622}
623
624fn require_storage(
625    context: &ToolContext,
626) -> std::result::Result<&Arc<dyn SessionStorageStore>, ToolExecutionResult> {
627    context.storage_store.as_ref().ok_or_else(|| {
628        ToolExecutionResult::tool_error("Agent delegation tools require storage_store context")
629    })
630}
631
632/// Prefix shared by all agent run KV keys. Single source of truth: `run_key`
633/// builds keys from it, and `session_storage` reserves it from the user-facing
634/// `kv_store` tool (see `is_internal_session_kv_key`) so session/tool actors
635/// cannot forge or read A2A run records.
636pub(crate) use super::AGENT_RUN_KEY_PREFIX;
637
638/// KV key for a specific agent run record.
639fn run_key(run_id: &str) -> String {
640    format!("{AGENT_RUN_KEY_PREFIX}{run_id}")
641}
642
643/// List run_ids by prefix-filtering the session's KV keys (test helper).
644#[cfg(test)]
645async fn list_run_ids(
646    storage: &dyn SessionStorageStore,
647    session_id: crate::typed_id::SessionId,
648) -> Vec<String> {
649    storage
650        .list_keys(session_id)
651        .await
652        .unwrap_or_default()
653        .into_iter()
654        .filter_map(|info| {
655            info.key
656                .strip_prefix(AGENT_RUN_KEY_PREFIX)
657                .map(ToString::to_string)
658        })
659        .collect()
660}
661
662use super::util::require_str_trimmed as require_str;
663
664async fn save_run(context: &ToolContext, record: &AgentRunRecord) -> Result<()> {
665    mirror_run_to_task(context, record).await;
666    let Some(storage) = &context.storage_store else {
667        return Ok(());
668    };
669    let serialized = serde_json::to_string(record).map_err(|e| {
670        crate::error::AgentLoopError::store(format!("failed to serialize agent run: {e}"))
671    })?;
672    storage
673        .set_value(context.session_id, &run_key(&record.run_id), &serialized)
674        .await?;
675    Ok(())
676}
677
678/// A2A run status → session task state (specs/session-tasks.md). Rejection is
679/// an `error.kind` on `failed`, not a state.
680fn task_state_for(status: &AgentRunStatus) -> SessionTaskState {
681    match status {
682        AgentRunStatus::Submitted => SessionTaskState::Queued,
683        AgentRunStatus::Working => SessionTaskState::Running,
684        AgentRunStatus::InputRequired | AgentRunStatus::AuthRequired => {
685            SessionTaskState::AwaitingInput
686        }
687        AgentRunStatus::Completed => SessionTaskState::Succeeded,
688        AgentRunStatus::Failed => SessionTaskState::Failed,
689        AgentRunStatus::Canceled => SessionTaskState::Canceled,
690        AgentRunStatus::Rejected => SessionTaskState::Failed,
691    }
692}
693
694/// Mirror a run snapshot into the session task registry (best-effort; no-op
695/// when the registry is absent or the record predates task creation).
696async fn mirror_run_to_task(context: &ToolContext, record: &AgentRunRecord) {
697    let Some(registry) = &context.session_task_registry else {
698        return;
699    };
700    let Some(task_id) = &record.task_id else {
701        return;
702    };
703    let state = task_state_for(&record.status);
704
705    // Generate an input request only on the TRANSITION into awaiting_input so
706    // repeated polling does not churn the request id.
707    let input_request = if state == SessionTaskState::AwaitingInput {
708        let already_awaiting = registry
709            .get(context.session_id, task_id)
710            .await
711            .ok()
712            .flatten()
713            .is_some_and(|task| task.state == SessionTaskState::AwaitingInput);
714        if already_awaiting {
715            None
716        } else {
717            Some(TaskInputRequest {
718                id: format!("inreq_{}", uuid::Uuid::now_v7().simple()),
719                prompt: record
720                    .result
721                    .clone()
722                    .unwrap_or_else(|| "External agent requires additional input".to_string()),
723                expected: None,
724            })
725        }
726    } else {
727        None
728    };
729
730    let error = match record.status {
731        AgentRunStatus::Failed => Some(TaskError {
732            kind: record
733                .error_kind
734                .clone()
735                .unwrap_or_else(|| "remote_failed".to_string()),
736            message: record
737                .error
738                .clone()
739                .unwrap_or_else(|| "External agent run failed".to_string()),
740        }),
741        AgentRunStatus::Rejected => Some(TaskError {
742            kind: "rejected".to_string(),
743            message: record
744                .error
745                .clone()
746                .unwrap_or_else(|| "External agent rejected the task".to_string()),
747        }),
748        _ => None,
749    };
750
751    let _ = registry
752        .update(
753            context.session_id,
754            task_id,
755            SessionTaskUpdate {
756                state: Some(state),
757                input_request,
758                summary: record.result.clone(),
759                result_path: record.result_path.clone(),
760                error,
761                links: record
762                    .remote_task_id
763                    .clone()
764                    .map(|remote_task_id| TaskLinks {
765                        remote_task_id: Some(remote_task_id),
766                        ..Default::default()
767                    }),
768                ..Default::default()
769            },
770        )
771        .await;
772}
773
774/// Post the completion summary on the task's outbound message channel
775/// (best-effort). The legacy wake-up session message is sent separately.
776async fn post_task_completion_message(context: &ToolContext, record: &AgentRunRecord) {
777    let (Some(registry), Some(task_id)) = (&context.session_task_registry, &record.task_id) else {
778        return;
779    };
780    let summary = record
781        .result
782        .as_deref()
783        .or(record.error.as_deref())
784        .unwrap_or("No result text returned");
785    let _ = registry
786        .record_message(
787            context.session_id,
788            task_id,
789            NewTaskMessage::outbound_text(format!(
790                "External agent run {}: {summary}",
791                record.status
792            )),
793        )
794        .await;
795}
796
797async fn load_run(
798    context: &ToolContext,
799    run_id: &str,
800) -> std::result::Result<AgentRunRecord, ToolExecutionResult> {
801    let storage = require_storage(context)?;
802    let Some(serialized) = storage
803        .get_value(context.session_id, &run_key(run_id))
804        .await
805        .map_err(ToolExecutionResult::internal_error)?
806    else {
807        return Err(ToolExecutionResult::tool_error(format!(
808            "No agent run found with id: {run_id}"
809        )));
810    };
811    serde_json::from_str(&serialized).map_err(|e| {
812        ToolExecutionResult::internal_error_msg(format!("Invalid agent run record: {e}"))
813    })
814}
815
816fn task_text(task: &Task) -> Option<String> {
817    task.artifacts
818        .as_ref()
819        .into_iter()
820        .flatten()
821        .flat_map(|artifact| artifact.parts.iter())
822        .find_map(|part| part.as_text().map(ToString::to_string))
823        .or_else(|| {
824            task.status
825                .message
826                .as_ref()
827                .and_then(|message| message.text().map(ToString::to_string))
828        })
829}
830
831fn first_data_artifact(task: &Task) -> Option<&Value> {
832    task.artifacts
833        .as_ref()?
834        .iter()
835        .flat_map(|artifact| artifact.parts.iter())
836        .find_map(|part| match &part.content {
837            PartContent::Data(value) => Some(value),
838            _ => None,
839        })
840}
841
842fn message_text(message: &Message) -> Option<String> {
843    message.text().map(ToString::to_string)
844}
845
846fn truncate_text(value: String) -> String {
847    let mut chars = value.chars();
848    let truncated = chars.by_ref().take(MAX_RESULT_CHARS).collect::<String>();
849    if chars.next().is_some() {
850        format!("{truncated}\n[truncated]")
851    } else {
852        truncated
853    }
854}
855
856fn bounded_task_snapshot(task: &Task) -> Value {
857    json!({
858        "id": task.id,
859        "context_id": task.context_id,
860        "state": task.status.state,
861        "text": task_text(task).map(truncate_text),
862    })
863}
864
865fn set_error(record: &mut AgentRunRecord, error: String) {
866    record.error = Some(truncate_text(error));
867}
868
869fn apply_task(record: &mut AgentRunRecord, task: &Task) {
870    record.status = AgentRunStatus::from(&task.status.state);
871    record.remote_task_id = Some(task.id.clone());
872    record.remote_context_id = Some(task.context_id.clone());
873    record.result = task_text(task)
874        .map(truncate_text)
875        .or_else(|| record.result.clone());
876    record.structured_result = first_data_artifact(task).cloned();
877    record.last_remote_task_snapshot = Some(bounded_task_snapshot(task));
878}
879
880async fn write_result_artifact(context: &ToolContext, record: &mut AgentRunRecord) -> Result<()> {
881    if let Some(schema) = record.result_schema.clone() {
882        if record.status != AgentRunStatus::Completed {
883            return Ok(());
884        }
885        let Some(value) = record.structured_result.clone() else {
886            record.status = AgentRunStatus::Failed;
887            record.error_kind = Some("no_result".to_string());
888            set_error(
889                record,
890                "External agent completed without a structured result artifact".to_string(),
891            );
892            record.result = None;
893            record.result_path = None;
894            return Ok(());
895        };
896        let errors = schema_validation_errors(&schema, &value);
897        if !errors.is_empty() {
898            record.status = AgentRunStatus::Failed;
899            record.error_kind = Some("schema_mismatch".to_string());
900            set_error(
901                record,
902                format!(
903                    "External agent result did not match result_schema: {}",
904                    errors.join("; ")
905                ),
906            );
907            record.result = None;
908            record.result_path = None;
909            return Ok(());
910        }
911        let Some(task_id) = record.task_id.as_deref() else {
912            record.status = AgentRunStatus::Failed;
913            record.error_kind = Some("result_write_failed".to_string());
914            set_error(
915                record,
916                "Structured result has no local session task".to_string(),
917            );
918            return Ok(());
919        };
920        let Some(result_path) = write_task_result_value(context, task_id, &value).await? else {
921            record.status = AgentRunStatus::Failed;
922            record.error_kind = Some("result_write_failed".to_string());
923            set_error(
924                record,
925                "Structured result could not be persisted".to_string(),
926            );
927            return Ok(());
928        };
929        record.result_path = Some(result_path);
930        record.result = Some(value.to_string());
931        return Ok(());
932    }
933
934    let Some(file_store) = &context.file_store else {
935        return Ok(());
936    };
937    let dir = format!("/.agent-runs/{}", record.run_id);
938    let path = format!("{dir}/result.json");
939    let _ = file_store
940        .create_directory(context.session_id, "/.agent-runs")
941        .await;
942    let _ = file_store.create_directory(context.session_id, &dir).await;
943    record.result_path = Some(path.clone());
944    let body = serde_json::to_string_pretty(&record.public_json())
945        .unwrap_or_else(|_| record.public_json().to_string());
946    file_store
947        .write_file(context.session_id, &path, &body, "utf-8")
948        .await?;
949    Ok(())
950}
951
952async fn wake_parent(context: &ToolContext, record: &AgentRunRecord) -> Result<()> {
953    let Some(platform_store) = &context.platform_store else {
954        return Ok(());
955    };
956    let summary = record
957        .result
958        .as_deref()
959        .or(record.error.as_deref())
960        .unwrap_or("No result text returned");
961    let message = format!(
962        "External agent run completed.\n- run_id: {}\n- agent: {}\n- status: {}\n- result_path: {}\n- summary: {}",
963        record.run_id,
964        record.external_agent_name,
965        record.status,
966        record.result_path.as_deref().unwrap_or("(not persisted)"),
967        summary
968    );
969    platform_store
970        .send_message(context.session_id, &message)
971        .await
972}
973
974/// Pre-discovery ACL gate: must run BEFORE `resolve_card` so that AgentCard
975/// discovery itself (which performs an outbound HTTP fetch against `base_url`)
976/// cannot be used to probe disallowed/internal hosts. Only checks `base_url`
977/// when it will actually be used for discovery — when an inline `agent_card`
978/// is supplied, `resolve_card` never touches `base_url`, so a stale or unused
979/// configured URL must not cause spurious failures.
980fn enforce_network_access_pre_resolve(
981    agent: &ExternalA2aAgentConfig,
982    context: &ToolContext,
983) -> std::result::Result<(), String> {
984    let Some(acl) = context.network_access.as_ref() else {
985        return Ok(());
986    };
987    if agent.agent_card.is_some() {
988        return Ok(());
989    }
990    if let Some(base_url) = &agent.base_url
991        && !acl.is_url_allowed(base_url)
992    {
993        return Err(format!(
994            "A2A base URL blocked by network access policy: {base_url}"
995        ));
996    }
997    Ok(())
998}
999
1000/// Post-discovery ACL gate: every interface URL surfaced by the resolved
1001/// AgentCard must also be permitted by the runtime ACL before the A2A client
1002/// is built. This catches the case where an attacker can influence the card
1003/// (signed/unsigned) to point interface URLs at internal hosts.
1004fn enforce_network_access_post_resolve(
1005    card: &AgentCard,
1006    context: &ToolContext,
1007) -> std::result::Result<(), String> {
1008    let Some(acl) = context.network_access.as_ref() else {
1009        return Ok(());
1010    };
1011    for iface in &card.supported_interfaces {
1012        if !acl.is_url_allowed(&iface.url) {
1013            return Err(format!(
1014                "A2A interface URL blocked by network access policy: {}",
1015                iface.url
1016            ));
1017        }
1018    }
1019    Ok(())
1020}
1021
1022async fn build_client(
1023    agent: &ExternalA2aAgentConfig,
1024    context: &ToolContext,
1025) -> std::result::Result<a2a_client::A2AClient<Box<dyn a2a_client::Transport>>, String> {
1026    enforce_network_access_pre_resolve(agent, context)?;
1027    let card = agent.resolve_card().await?;
1028    enforce_network_access_post_resolve(&card, context)?;
1029    let mut builder = A2AClientFactory::builder();
1030    if let Some(binding) = &agent.preferred_binding {
1031        builder = builder.preferred_bindings(vec![binding.clone()]);
1032    }
1033    let headers = agent
1034        .headers
1035        .iter()
1036        .map(|(name, value)| (name.clone(), value.clone()))
1037        .collect::<Vec<_>>();
1038    if !headers.is_empty() {
1039        builder = builder.with_interceptor(Arc::new(StaticHeaderInterceptor { headers }));
1040    }
1041    builder
1042        .build()
1043        .create_from_card(&card)
1044        .await
1045        .map_err(|e| format!("Failed to create A2A client: {e}"))
1046}
1047
1048fn send_request(
1049    text: &str,
1050    remote_task_id: Option<String>,
1051    remote_context_id: Option<String>,
1052    return_immediately: bool,
1053) -> SendMessageRequest {
1054    let mut message = Message::new(Role::User, vec![Part::text(text)]);
1055    message.task_id = remote_task_id;
1056    message.context_id = remote_context_id;
1057    SendMessageRequest {
1058        message,
1059        configuration: Some(SendMessageConfiguration {
1060            accepted_output_modes: Some(vec![
1061                "text/plain".to_string(),
1062                "application/json".to_string(),
1063            ]),
1064            task_push_notification_config: None,
1065            history_length: None,
1066            return_immediately: Some(return_immediately),
1067        }),
1068        metadata: None,
1069        tenant: None,
1070    }
1071}
1072
1073async fn submit_run(
1074    context: &ToolContext,
1075    agent: &ExternalA2aAgentConfig,
1076    record: &mut AgentRunRecord,
1077    text: &str,
1078    remote_task_id: Option<String>,
1079    remote_context_id: Option<String>,
1080) -> std::result::Result<(), String> {
1081    let client = build_client(agent, context).await?;
1082    let response = client
1083        .send_message(&send_request(text, remote_task_id, remote_context_id, true))
1084        .await
1085        .map_err(|e| format!("A2A send_message failed: {e}"))?;
1086    match response {
1087        SendMessageResponse::Task(task) => apply_task(record, &task),
1088        SendMessageResponse::Message(message) => {
1089            record.status = AgentRunStatus::Completed;
1090            record.result = message_text(&message).map(truncate_text);
1091        }
1092    }
1093    if record.status.is_terminal() {
1094        write_result_artifact(context, record)
1095            .await
1096            .map_err(|e| e.to_string())?;
1097    }
1098    save_run(context, record).await.map_err(|e| e.to_string())
1099}
1100
1101/// Poll a remote A2A task until it reaches a terminal state or the deadline
1102/// expires. Sends a registry heartbeat on every poll iteration when
1103/// `heartbeat_attempt` is provided, so the reaper knows the worker is alive
1104/// and stale writes from a superseded executor are rejected.
1105/// Terminal outcome of a poll loop, replacing the previous stringly-typed
1106/// control flow (timeout/supersede were detected via `error.starts_with(...)`).
1107/// `Err(String)` is still used for genuine I/O/transport failures; the
1108/// non-error terminal states (completed, timed out, superseded) are typed.
1109enum WaitOutcome {
1110    /// The run reached a terminal status (or had no remote task to poll).
1111    /// Boxed: `AgentRunRecord` is ~900 bytes and dwarfs the other variants;
1112    /// boxing keeps the enum small (clippy `large_enum_variant`).
1113    Completed(Box<AgentRunRecord>),
1114    /// The poll deadline elapsed before the run finished.
1115    TimedOut { run_id: String, timeout_secs: u64 },
1116    /// The attempt fence revealed a newer executor owns this task.
1117    Superseded {
1118        run_id: String,
1119        attempt: i32,
1120        by_attempt: i32,
1121    },
1122}
1123
1124impl WaitOutcome {
1125    /// User-facing timeout message. Kept byte-identical to the legacy string so
1126    /// `timeout_or_error_result` surfaces the same text downstream.
1127    fn timed_out_message(run_id: &str, timeout_secs: u64) -> String {
1128        format!("Timed out waiting for external agent run {run_id} after {timeout_secs}s")
1129    }
1130
1131    /// Diagnostic supersede message. Logged only; never surfaced to callers.
1132    fn superseded_message(run_id: &str, attempt: i32, by_attempt: i32) -> String {
1133        format!(
1134            "{SUPERSEDED_ERROR_PREFIX} run {run_id} (attempt {attempt} superseded by {by_attempt})"
1135        )
1136    }
1137}
1138
1139async fn wait_for_run(
1140    context: &ToolContext,
1141    agent: &ExternalA2aAgentConfig,
1142    mut record: AgentRunRecord,
1143    timeout_secs: u64,
1144    // When Some, write a heartbeat on every poll with this attempt fence so
1145    // a superseded executor's stale writes are rejected.
1146    heartbeat_attempt: Option<i32>,
1147) -> std::result::Result<WaitOutcome, String> {
1148    if record.status.is_terminal() {
1149        write_result_artifact(context, &mut record)
1150            .await
1151            .map_err(|e| e.to_string())?;
1152        save_run(context, &record)
1153            .await
1154            .map_err(|e| e.to_string())?;
1155        return Ok(WaitOutcome::Completed(Box::new(record)));
1156    }
1157    let Some(remote_task_id) = record.remote_task_id.clone() else {
1158        return Ok(WaitOutcome::Completed(Box::new(record)));
1159    };
1160    let client = build_client(agent, context).await?;
1161    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
1162    let poll_interval = Duration::from_millis(
1163        agent
1164            .poll_interval_ms
1165            .unwrap_or(DEFAULT_POLL_INTERVAL_MS)
1166            .max(100),
1167    );
1168
1169    while Instant::now() < deadline {
1170        // Heartbeat through the registry so the reaper sees a live worker.
1171        // A fence miss (returned attempt differs from ours) means the reaper
1172        // superseded this executor — stop polling immediately so we never
1173        // write failure state over the new attempt's work.
1174        if let (Some(attempt), Some(registry), Some(task_id)) = (
1175            heartbeat_attempt,
1176            &context.session_task_registry,
1177            record.task_id.as_deref(),
1178        ) {
1179            let heartbeat = registry
1180                .update(
1181                    context.session_id,
1182                    task_id,
1183                    SessionTaskUpdate {
1184                        heartbeat_at: Some(chrono::Utc::now()),
1185                        expected_attempt: Some(attempt),
1186                        ..Default::default()
1187                    },
1188                )
1189                .await;
1190            if let Ok(Some(task)) = heartbeat
1191                && task.attempt != attempt
1192            {
1193                return Ok(WaitOutcome::Superseded {
1194                    run_id: record.run_id.clone(),
1195                    attempt,
1196                    by_attempt: task.attempt,
1197                });
1198            }
1199        }
1200
1201        let task = client
1202            .get_task(&GetTaskRequest {
1203                id: remote_task_id.clone(),
1204                history_length: Some(10),
1205                tenant: None,
1206            })
1207            .await
1208            .map_err(|e| format!("A2A get_task failed: {e}"))?;
1209        apply_task(&mut record, &task);
1210        if record.status.is_terminal() {
1211            write_result_artifact(context, &mut record)
1212                .await
1213                .map_err(|e| e.to_string())?;
1214            save_run(context, &record)
1215                .await
1216                .map_err(|e| e.to_string())?;
1217            return Ok(WaitOutcome::Completed(Box::new(record)));
1218        }
1219        save_run(context, &record)
1220            .await
1221            .map_err(|e| e.to_string())?;
1222        sleep(poll_interval).await;
1223    }
1224
1225    Ok(WaitOutcome::TimedOut {
1226        run_id: record.run_id.clone(),
1227        timeout_secs,
1228    })
1229}
1230
1231/// Render a timeout outcome as a (successful) tool result reporting
1232/// `timed_out: true`. Separated from the error path now that timeout is a
1233/// typed `WaitOutcome` variant rather than a string-prefix sniff.
1234async fn timed_out_result(
1235    context: &ToolContext,
1236    run_id: &str,
1237    message: String,
1238) -> ToolExecutionResult {
1239    match load_run(context, run_id).await {
1240        Ok(record) => ToolExecutionResult::success(json!({
1241            "agent_run_id": record.run_id,
1242            "status": record.status,
1243            "timed_out": true,
1244            "message": truncate_text(message),
1245            "remote_task_id": record.remote_task_id,
1246            "remote_context_id": record.remote_context_id,
1247        })),
1248        Err(e) => e,
1249    }
1250}
1251
1252/// Persist a Failed run record with `message`, notify the parent, and wake it
1253/// when appropriate. Shared by the background monitor's timeout and error
1254/// paths, which previously both flowed through a single `Err(String)` arm.
1255async fn persist_failed_run(
1256    context: &ToolContext,
1257    run_id: &str,
1258    fallback_record: AgentRunRecord,
1259    message: String,
1260) {
1261    let mut failed = load_run(context, run_id).await.unwrap_or(fallback_record);
1262    failed.status = AgentRunStatus::Failed;
1263    set_error(&mut failed, message);
1264    let _ = write_result_artifact(context, &mut failed).await;
1265    let _ = save_run(context, &failed).await;
1266    post_task_completion_message(context, &failed).await;
1267    // Legacy wake: only when no registry is present (registry-level
1268    // wake_policy handles it otherwise via post_task_completion_message).
1269    if failed.wake_on_completion && context.session_task_registry.is_none() {
1270        let _ = wake_parent(context, &failed).await;
1271    }
1272}
1273
1274/// Background poll loop for an A2A run. `heartbeat_attempt` is the task
1275/// attempt number captured at spawn/re-attach time; heartbeats carry this so
1276/// the fence rejects writes from a previously superseded attempt.
1277async fn background_monitor(
1278    context: ToolContext,
1279    agent: ExternalA2aAgentConfig,
1280    record: AgentRunRecord,
1281    timeout_secs: u64,
1282    heartbeat_attempt: Option<i32>,
1283) {
1284    let run_id = record.run_id.clone();
1285    let fallback_record = record.clone();
1286    let record = match wait_for_run(&context, &agent, record, timeout_secs, heartbeat_attempt).await
1287    {
1288        Ok(WaitOutcome::Completed(record)) => *record,
1289        // Superseded by a newer attempt (reaper re-attached the task to
1290        // another executor): exit silently — the new owner reports state;
1291        // writing failure here would overwrite its work.
1292        Ok(WaitOutcome::Superseded { .. }) => {
1293            tracing::info!(run_id = %run_id, "A2A background monitor superseded; exiting");
1294            return;
1295        }
1296        // Timeout and genuine errors both persist a Failed run record. Build
1297        // the user-facing message from the typed outcome so the persisted text
1298        // stays identical to the legacy string.
1299        Ok(WaitOutcome::TimedOut {
1300            run_id: timed_out_run_id,
1301            timeout_secs,
1302        }) => {
1303            let message = WaitOutcome::timed_out_message(&timed_out_run_id, timeout_secs);
1304            persist_failed_run(&context, &run_id, fallback_record, message).await;
1305            return;
1306        }
1307        Err(error) => {
1308            persist_failed_run(&context, &run_id, fallback_record, error).await;
1309            return;
1310        }
1311    };
1312    post_task_completion_message(&context, &record).await;
1313    // Registry-level wake_policy handles the wake when a task registry is
1314    // present (post_task_completion_message records an outbound message which
1315    // triggers the waker). Fall back to the legacy synthetic session message
1316    // only when no registry is wired (older execution contexts).
1317    if record.wake_on_completion && context.session_task_registry.is_none() {
1318        let _ = wake_parent(&context, &record).await;
1319    }
1320    let _ = save_run(&context, &record).await;
1321}
1322
1323#[derive(Clone)]
1324pub struct SpawnAgentTool {
1325    config: A2aDelegationConfig,
1326}
1327
1328impl SpawnAgentTool {
1329    fn new(config: A2aDelegationConfig) -> Self {
1330        Self { config }
1331    }
1332}
1333
1334#[async_trait]
1335impl Tool for SpawnAgentTool {
1336    fn narrate(
1337        &self,
1338        tool_call: &crate::tool_types::ToolCall,
1339        phase: crate::tool_narration::ToolNarrationPhase,
1340        locale: Option<&str>,
1341        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1342    ) -> Option<String> {
1343        Some(crate::tool_narration::narrate_subagent_spawn(
1344            &tool_call.arguments,
1345            phase,
1346            locale,
1347        ))
1348    }
1349
1350    fn name(&self) -> &str {
1351        "spawn_agent"
1352    }
1353
1354    fn display_name(&self) -> Option<&str> {
1355        Some("Spawn Agent")
1356    }
1357
1358    fn description(&self) -> &str {
1359        "Delegate a task to a configured external A2A agent in foreground or background mode."
1360    }
1361
1362    fn parameters_schema(&self) -> Value {
1363        json!({
1364            "type": "object",
1365            "properties": {
1366                "instructions": {"type": "string", "description": "Instructions to send to the external agent."},
1367                "target": {
1368                    "type": "object",
1369                    "properties": {
1370                        "type": {"type": "string", "enum": ["external_a2a"]},
1371                        "id": {"type": "string", "description": "Configured external A2A agent id."},
1372                        "external_agent_id": {"type": "string", "description": "Deprecated provider-specific spelling; prefer target.id."}
1373                    },
1374                    "required": ["type"],
1375                    "additionalProperties": false
1376                },
1377                "mode": {"type": "string", "enum": ["background", "foreground"], "default": "foreground"},
1378                "wait_timeout_secs": {"type": "integer", "minimum": 1, "maximum": 86400},
1379                "wake_on_completion": {"type": "boolean", "default": true},
1380                "result_schema": {"type": "object", "description": "JSON Schema for a required structured result artifact from the external agent."},
1381                "message_schema": {"type": "object", "description": "Not supported for external A2A targets; supplied values fail explicitly."}
1382            },
1383            "required": ["instructions", "target"],
1384            "additionalProperties": false
1385        })
1386    }
1387
1388    fn hints(&self) -> ToolHints {
1389        ToolHints::default()
1390            .with_long_running(true)
1391            .with_open_world(true)
1392    }
1393
1394    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1395        ToolExecutionResult::tool_error("spawn_agent requires session context")
1396    }
1397
1398    async fn execute_with_context(
1399        &self,
1400        arguments: Value,
1401        context: &ToolContext,
1402    ) -> ToolExecutionResult {
1403        if let Err(e) = require_storage(context) {
1404            return e;
1405        }
1406        let instructions = match require_str(&arguments, "instructions") {
1407            Ok(instructions) => instructions.to_string(),
1408            Err(e) => return e,
1409        };
1410        let target = arguments.get("target").unwrap_or(&Value::Null);
1411        if target.get("type").and_then(Value::as_str) != Some("external_a2a") {
1412            return ToolExecutionResult::tool_error(
1413                "spawn_agent currently supports target.type = external_a2a",
1414            );
1415        }
1416        if arguments
1417            .get("lifetime")
1418            .and_then(Value::as_str)
1419            .is_some_and(|value| value == "detached")
1420        {
1421            return ToolExecutionResult::tool_error(
1422                "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1423            );
1424        }
1425        let external_agent_id = match target
1426            .get("id")
1427            .or_else(|| target.get("external_agent_id"))
1428            .and_then(Value::as_str)
1429            .map(str::trim)
1430            .filter(|s| !s.is_empty())
1431        {
1432            Some(id) => id,
1433            None => {
1434                return ToolExecutionResult::tool_error("Missing required parameter: target.id");
1435            }
1436        };
1437        let Some(agent) = self.config.agent(external_agent_id).cloned() else {
1438            return ToolExecutionResult::tool_error(format!(
1439                "Unknown external A2A agent: {external_agent_id}"
1440            ));
1441        };
1442        let mode = match arguments.get("mode").and_then(Value::as_str) {
1443            None => SpawnMode::Foreground,
1444            Some(value) => match SpawnMode::parse(value) {
1445                Some(mode) => mode,
1446                None => {
1447                    return ToolExecutionResult::tool_error(format!(
1448                        "Invalid mode: {value}. Expected background, foreground"
1449                    ));
1450                }
1451            },
1452        };
1453        let timeout_secs = arguments
1454            .get("wait_timeout_secs")
1455            .and_then(Value::as_u64)
1456            .unwrap_or(DEFAULT_WAIT_TIMEOUT_SECS);
1457        let wake_on_completion = arguments
1458            .get("wake_on_completion")
1459            .and_then(Value::as_bool)
1460            .unwrap_or(true);
1461        let result_schema = match normalize_result_schema(&arguments) {
1462            Ok(schema) => schema,
1463            Err(error) => return error,
1464        };
1465        if arguments
1466            .get("message_schema")
1467            .is_some_and(|schema| !schema.is_null())
1468        {
1469            return ToolExecutionResult::tool_error(
1470                "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1471            );
1472        }
1473        if result_schema.is_some()
1474            && (context.session_task_registry.is_none() || context.file_store.is_none())
1475        {
1476            return ToolExecutionResult::tool_error(
1477                "result_schema for external_a2a requires session_task_registry and file_store context.",
1478            );
1479        }
1480        let run_id = run_id();
1481        let mut record = AgentRunRecord::new(
1482            run_id.clone(),
1483            &agent,
1484            instructions.clone(),
1485            mode,
1486            wake_on_completion,
1487            result_schema.clone(),
1488        );
1489        record.network_access = context.network_access.clone();
1490        // Create the session task tracking this run (specs/session-tasks.md).
1491        // Background runs must be task-backed before any remote work starts so
1492        // wait_task/message_task/cancel_task have a usable control handle.
1493        // run_id is stored in spec so load_run_for_task can do a direct key lookup.
1494        match &context.session_task_registry {
1495            Some(task_registry) => {
1496                match task_registry
1497                    .create(CreateSessionTask {
1498                        session_id: context.session_id,
1499                        id: None,
1500                        kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
1501                        display_name: agent.name.clone(),
1502                        spec: json!({
1503                            "run_id": &run_id,
1504                            "external_agent_id": agent.id,
1505                            "instructions": &instructions,
1506                            "mode": &mode,
1507                            "result_schema": result_schema,
1508                        }),
1509                        state: SessionTaskState::Queued,
1510                        links: TaskLinks::default(),
1511                        wake_policy: match mode {
1512                            SpawnMode::Background => TaskWakePolicy::OnTerminal,
1513                            SpawnMode::Foreground => TaskWakePolicy::Silent,
1514                        },
1515                    })
1516                    .await
1517                {
1518                    Ok(created) => record.task_id = Some(created.id),
1519                    Err(e) if mode == SpawnMode::Background || result_schema.is_some() => {
1520                        // Background runs must be task-backed before remote work
1521                        // starts; surface this as a user-facing tool error (per
1522                        // the capability contract) rather than an internal error,
1523                        // and do not launch the run.
1524                        return ToolExecutionResult::tool_error(format!(
1525                            "Background spawn_agent could not create its session task, so the run \
1526                             was not started: {e}"
1527                        ));
1528                    }
1529                    Err(_) => {}
1530                }
1531            }
1532            None if mode == SpawnMode::Background => {
1533                return ToolExecutionResult::tool_error(
1534                    "Background spawn_agent requires session_task_registry context so the run can be controlled with wait_task/message_task/cancel_task",
1535                );
1536            }
1537            None => {}
1538        }
1539        if let Err(e) = save_run(context, &record).await {
1540            return ToolExecutionResult::internal_error(e);
1541        }
1542        if let Err(error) =
1543            submit_run(context, &agent, &mut record, &instructions, None, None).await
1544        {
1545            record.status = AgentRunStatus::Failed;
1546            set_error(&mut record, error);
1547            let _ = save_run(context, &record).await;
1548            return ToolExecutionResult::success(record.public_json());
1549        }
1550        match mode {
1551            SpawnMode::Background => {
1552                let context = context.clone();
1553                // Capture the attempt at spawn time (1 for a fresh spawn).
1554                // The heartbeat loop uses this to fence stale writes from any
1555                // future superseded attempt.
1556                let heartbeat_attempt = record.task_id.is_some().then_some(1i32);
1557                let background_record = record.clone();
1558                tokio::spawn(async move {
1559                    background_monitor(
1560                        context,
1561                        agent,
1562                        background_record,
1563                        timeout_secs,
1564                        heartbeat_attempt,
1565                    )
1566                    .await;
1567                });
1568                ToolExecutionResult::success(record.public_json())
1569            }
1570            SpawnMode::Foreground => {
1571                // Foreground wait: the tool executor owns the call stack so no
1572                // separate heartbeat thread is needed; pass None.
1573                match wait_for_run(context, &agent, record, timeout_secs, None).await {
1574                    Ok(WaitOutcome::Completed(record)) => {
1575                        ToolExecutionResult::success(record.public_json())
1576                    }
1577                    Ok(WaitOutcome::TimedOut {
1578                        run_id: timed_out_run_id,
1579                        timeout_secs,
1580                    }) => {
1581                        let message =
1582                            WaitOutcome::timed_out_message(&timed_out_run_id, timeout_secs);
1583                        timed_out_result(context, &run_id, message).await
1584                    }
1585                    // Foreground waits pass `heartbeat_attempt: None`, so the
1586                    // fence never fires and Superseded is unreachable here.
1587                    // Surface it as a tool error rather than panicking if that
1588                    // invariant ever changes.
1589                    Ok(WaitOutcome::Superseded {
1590                        run_id: superseded_run_id,
1591                        attempt,
1592                        by_attempt,
1593                    }) => ToolExecutionResult::tool_error(WaitOutcome::superseded_message(
1594                        &superseded_run_id,
1595                        attempt,
1596                        by_attempt,
1597                    )),
1598                    Err(error) => ToolExecutionResult::tool_error(error),
1599                }
1600            }
1601        }
1602    }
1603
1604    fn requires_context(&self) -> bool {
1605        true
1606    }
1607}
1608
1609// ============================================================================
1610// Task executor: external_agent
1611// ============================================================================
1612
1613/// Locate the agent run mirrored by a session task.
1614/// The run_id is stored in the task's spec so we can do a direct KV lookup.
1615fn reattach_network_access(
1616    record: &AgentRunRecord,
1617    context: &ToolContext,
1618) -> Option<NetworkAccessList> {
1619    record
1620        .network_access
1621        .clone()
1622        .or_else(|| context.network_access.clone())
1623}
1624
1625async fn load_run_for_task(
1626    context: &ToolContext,
1627    task: &SessionTask,
1628) -> std::result::Result<AgentRunRecord, String> {
1629    let Some(storage) = &context.storage_store else {
1630        return Err("external agent tasks require storage_store context".to_string());
1631    };
1632    // Direct lookup via run_id stored in task spec (set at spawn time).
1633    if let Some(run_id) = task.spec.get("run_id").and_then(Value::as_str)
1634        && let Ok(Some(serialized)) = storage
1635            .get_value(context.session_id, &run_key(run_id))
1636            .await
1637    {
1638        return serde_json::from_str::<AgentRunRecord>(&serialized)
1639            .map_err(|e| format!("invalid agent run record for task {}: {e}", task.id));
1640    }
1641    Err(format!("No agent run found for task {}", task.id))
1642}
1643
1644/// Agent config snapshot stored on the run, required to rebuild the A2A
1645/// client outside the capability's configured tool instances.
1646fn agent_snapshot(record: &AgentRunRecord) -> std::result::Result<ExternalA2aAgentConfig, String> {
1647    record.agent_config.clone().ok_or_else(|| {
1648        format!(
1649            "Agent run {} has no stored agent config snapshot (created before task support); use message_task/cancel_task instead",
1650            record.run_id
1651        )
1652    })
1653}
1654
1655/// Control plane for `external_agent` tasks. Rebuilds the A2A client from the
1656/// agent config snapshot persisted on the run record.
1657pub struct ExternalAgentTaskExecutor;
1658
1659#[async_trait]
1660impl TaskExecutor for ExternalAgentTaskExecutor {
1661    fn kind(&self) -> &str {
1662        TASK_KIND_EXTERNAL_AGENT
1663    }
1664
1665    fn can_reattach(&self) -> bool {
1666        true
1667    }
1668
1669    /// Re-attach to a running external A2A task after worker loss.
1670    ///
1671    /// Loads the persisted `AgentRunRecord` from session storage, then:
1672    /// - If already terminal: mirrors the terminal state to the registry and
1673    ///   returns (idempotent reconcile).
1674    /// - If `remote_task_id` or `agent_config` is absent: returns an error so
1675    ///   the reaper falls back to failing the task as orphaned.
1676    /// - Otherwise: rebuilds the A2A client from the stored config snapshot and
1677    ///   resumes the background poll loop with `heartbeat_attempt = task.attempt`
1678    ///   (the NEW attempt number after the reaper bumped it) so stale writes from
1679    ///   the superseded executor are rejected.
1680    async fn start(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1681        let record = load_run_for_task(context, task)
1682            .await
1683            .map_err(crate::error::AgentLoopError::tool)?;
1684        let context = context
1685            .clone()
1686            .with_network_access(reattach_network_access(&record, context));
1687
1688        // If the run is already terminal, just mirror and return.
1689        if record.status.is_terminal() {
1690            mirror_run_to_task(&context, &record).await;
1691            return Ok(());
1692        }
1693
1694        // Missing remote_task_id means we never sent to the remote agent —
1695        // there is nothing to poll; caller will fail this as orphaned.
1696        if record.remote_task_id.is_none() {
1697            return Err(crate::error::AgentLoopError::tool(format!(
1698                "external_agent task {} has no remote_task_id; cannot re-attach",
1699                task.id
1700            )));
1701        }
1702
1703        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1704
1705        // Resume the background poll loop. Use the NEW attempt (bumped by the
1706        // reaper) for heartbeating so the superseded executor's stale writes
1707        // are rejected by the fence.
1708        let heartbeat_attempt = Some(task.attempt);
1709        tokio::spawn(async move {
1710            background_monitor(
1711                context,
1712                agent,
1713                record,
1714                DEFAULT_WAIT_TIMEOUT_SECS,
1715                heartbeat_attempt,
1716            )
1717            .await;
1718        });
1719        Ok(())
1720    }
1721
1722    async fn deliver(
1723        &self,
1724        task: &SessionTask,
1725        message: &TaskMessage,
1726        context: &ToolContext,
1727    ) -> crate::error::Result<()> {
1728        let mut record = load_run_for_task(context, task)
1729            .await
1730            .map_err(crate::error::AgentLoopError::tool)?;
1731        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1732        let text = task_message_text(&message.content);
1733        let remote_task_id = record.remote_task_id.clone();
1734        let remote_context_id = record.remote_context_id.clone();
1735        // On send error the run state stays unchanged — return the error and
1736        // let the caller decide; the registry already holds the message.
1737        submit_run(
1738            context,
1739            &agent,
1740            &mut record,
1741            &text,
1742            remote_task_id,
1743            remote_context_id,
1744        )
1745        .await
1746        .map_err(crate::error::AgentLoopError::tool)
1747    }
1748
1749    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1750        let mut record = load_run_for_task(context, task)
1751            .await
1752            .map_err(crate::error::AgentLoopError::tool)?;
1753        if record.status.is_terminal() {
1754            return Ok(());
1755        }
1756        let Some(remote_task_id) = record.remote_task_id.clone() else {
1757            // Never reached the remote agent; cancel locally.
1758            record.status = AgentRunStatus::Canceled;
1759            save_run(context, &record).await?;
1760            return Ok(());
1761        };
1762        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1763        let client = build_client(&agent, context)
1764            .await
1765            .map_err(crate::error::AgentLoopError::tool)?;
1766        let remote = client
1767            .cancel_task(&CancelTaskRequest {
1768                id: remote_task_id,
1769                metadata: None,
1770                tenant: None,
1771            })
1772            .await
1773            .map_err(|e| {
1774                crate::error::AgentLoopError::tool(format!("A2A cancel_task failed: {e}"))
1775            })?;
1776        apply_task(&mut record, &remote);
1777        save_run(context, &record).await?;
1778        Ok(())
1779    }
1780
1781    async fn reconcile(
1782        &self,
1783        task: &SessionTask,
1784        context: &ToolContext,
1785    ) -> crate::error::Result<()> {
1786        let mut record = load_run_for_task(context, task)
1787            .await
1788            .map_err(crate::error::AgentLoopError::tool)?;
1789        if record.status.is_terminal() {
1790            return Ok(());
1791        }
1792        let Some(remote_task_id) = record.remote_task_id.clone() else {
1793            return Ok(());
1794        };
1795        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1796        let client = build_client(&agent, context)
1797            .await
1798            .map_err(crate::error::AgentLoopError::tool)?;
1799        let remote = client
1800            .get_task(&GetTaskRequest {
1801                id: remote_task_id,
1802                history_length: Some(10),
1803                tenant: None,
1804            })
1805            .await
1806            .map_err(|e| crate::error::AgentLoopError::tool(format!("A2A get_task failed: {e}")))?;
1807        apply_task(&mut record, &remote);
1808        if record.status.is_terminal() {
1809            let _ = write_result_artifact(context, &mut record).await;
1810        }
1811        save_run(context, &record).await?;
1812        Ok(())
1813    }
1814}
1815
1816inventory::submit! {
1817    TaskExecutorPlugin {
1818        executor: || Arc::new(ExternalAgentTaskExecutor),
1819    }
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824    use super::*;
1825    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
1826    use crate::session_task::SessionTaskRegistry;
1827    use crate::traits::SessionFileSystem;
1828    use crate::typed_id::SessionId;
1829    use a2a::StreamResponse;
1830    use a2a::{AgentCapabilities, AgentInterface, Artifact, TaskStatus, TaskStatusUpdateEvent};
1831    use a2a_server::agent_card::agent_card_router;
1832    use a2a_server::{
1833        DefaultRequestHandler, InMemoryTaskStore, StaticAgentCard, jsonrpc::jsonrpc_router,
1834    };
1835    use axum::Router;
1836    use futures::stream;
1837    use std::collections::{BTreeMap, HashMap};
1838    use std::sync::Mutex;
1839    use tokio::net::TcpListener;
1840    #[derive(Default)]
1841    struct TestStorageStore {
1842        values: Mutex<HashMap<String, String>>,
1843    }
1844
1845    #[async_trait]
1846    impl crate::traits::SessionStorageStore for TestStorageStore {
1847        async fn set_value(&self, _session_id: SessionId, key: &str, value: &str) -> Result<()> {
1848            self.values
1849                .lock()
1850                .unwrap()
1851                .insert(key.to_string(), value.to_string());
1852            Ok(())
1853        }
1854
1855        async fn get_value(&self, _session_id: SessionId, key: &str) -> Result<Option<String>> {
1856            Ok(self.values.lock().unwrap().get(key).cloned())
1857        }
1858
1859        async fn delete_value(&self, _session_id: SessionId, key: &str) -> Result<bool> {
1860            Ok(self.values.lock().unwrap().remove(key).is_some())
1861        }
1862
1863        async fn list_keys(&self, _session_id: SessionId) -> Result<Vec<crate::KeyInfo>> {
1864            let now = chrono::Utc::now();
1865            Ok(self
1866                .values
1867                .lock()
1868                .unwrap()
1869                .keys()
1870                .map(|key| crate::KeyInfo {
1871                    key: key.clone(),
1872                    created_at: now,
1873                    updated_at: now,
1874                })
1875                .collect())
1876        }
1877
1878        async fn set_secret(
1879            &self,
1880            _session_id: SessionId,
1881            _name: &str,
1882            _value: &str,
1883        ) -> Result<()> {
1884            Ok(())
1885        }
1886
1887        async fn get_secret(&self, _session_id: SessionId, _name: &str) -> Result<Option<String>> {
1888            Ok(None)
1889        }
1890
1891        async fn delete_secret(&self, _session_id: SessionId, _name: &str) -> Result<bool> {
1892            Ok(false)
1893        }
1894
1895        async fn list_secrets(&self, _session_id: SessionId) -> Result<Vec<crate::SecretInfo>> {
1896            Ok(Vec::new())
1897        }
1898    }
1899
1900    #[derive(Default)]
1901    struct TestFileStore {
1902        files: Mutex<HashMap<String, String>>,
1903    }
1904
1905    #[async_trait]
1906    impl SessionFileSystem for TestFileStore {
1907        fn is_mount_resolver(&self) -> bool {
1908            false
1909        }
1910
1911        async fn read_file(
1912            &self,
1913            session_id: SessionId,
1914            path: &str,
1915        ) -> Result<Option<SessionFile>> {
1916            Ok(self
1917                .files
1918                .lock()
1919                .unwrap()
1920                .get(path)
1921                .map(|content| SessionFile {
1922                    id: uuid::Uuid::new_v4(),
1923                    session_id: session_id.uuid(),
1924                    path: path.to_string(),
1925                    name: FileInfo::name_from_path(path),
1926                    content: Some(content.clone()),
1927                    encoding: "text".to_string(),
1928                    is_directory: false,
1929                    is_readonly: false,
1930                    size_bytes: content.len() as i64,
1931                    created_at: chrono::Utc::now(),
1932                    updated_at: chrono::Utc::now(),
1933                }))
1934        }
1935
1936        async fn write_file(
1937            &self,
1938            session_id: SessionId,
1939            path: &str,
1940            content: &str,
1941            _encoding: &str,
1942        ) -> Result<SessionFile> {
1943            self.files
1944                .lock()
1945                .unwrap()
1946                .insert(path.to_string(), content.to_string());
1947            Ok(SessionFile {
1948                id: uuid::Uuid::new_v4(),
1949                session_id: session_id.uuid(),
1950                path: path.to_string(),
1951                name: FileInfo::name_from_path(path),
1952                content: Some(content.to_string()),
1953                encoding: "text".to_string(),
1954                is_directory: false,
1955                is_readonly: false,
1956                size_bytes: content.len() as i64,
1957                created_at: chrono::Utc::now(),
1958                updated_at: chrono::Utc::now(),
1959            })
1960        }
1961
1962        async fn delete_file(
1963            &self,
1964            _session_id: SessionId,
1965            path: &str,
1966            _recursive: bool,
1967        ) -> Result<bool> {
1968            Ok(self.files.lock().unwrap().remove(path).is_some())
1969        }
1970
1971        async fn list_directory(
1972            &self,
1973            _session_id: SessionId,
1974            _path: &str,
1975        ) -> Result<Vec<FileInfo>> {
1976            Ok(vec![])
1977        }
1978
1979        async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
1980            Ok(None)
1981        }
1982
1983        async fn grep_files(
1984            &self,
1985            _session_id: SessionId,
1986            _pattern: &str,
1987            _path_pattern: Option<&str>,
1988        ) -> Result<Vec<GrepMatch>> {
1989            Ok(vec![])
1990        }
1991
1992        async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1993            Ok(FileInfo {
1994                id: uuid::Uuid::new_v4(),
1995                session_id: session_id.uuid(),
1996                path: path.to_string(),
1997                name: FileInfo::name_from_path(path),
1998                is_directory: true,
1999                is_readonly: false,
2000                size_bytes: 0,
2001                created_at: chrono::Utc::now(),
2002                updated_at: chrono::Utc::now(),
2003            })
2004        }
2005    }
2006
2007    struct EchoA2aExecutor;
2008
2009    impl a2a_server::AgentExecutor for EchoA2aExecutor {
2010        fn execute(
2011            &self,
2012            ctx: a2a_server::ExecutorContext,
2013        ) -> futures::stream::BoxStream<'static, std::result::Result<StreamResponse, a2a::A2AError>>
2014        {
2015            let task_id = ctx.task_id.clone();
2016            let context_id = ctx.context_id.clone();
2017            let text = ctx
2018                .message
2019                .as_ref()
2020                .and_then(Message::text)
2021                .unwrap_or_default()
2022                .to_string();
2023            let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
2024                task_id: task_id.clone(),
2025                context_id: context_id.clone(),
2026                status: TaskStatus {
2027                    state: TaskState::Working,
2028                    message: None,
2029                    timestamp: None,
2030                },
2031                metadata: None,
2032            });
2033            let completed = StreamResponse::Task(Task {
2034                id: task_id,
2035                context_id,
2036                status: TaskStatus {
2037                    state: TaskState::Completed,
2038                    message: None,
2039                    timestamp: None,
2040                },
2041                artifacts: Some(vec![Artifact {
2042                    artifact_id: a2a::new_artifact_id(),
2043                    name: Some("echo".to_string()),
2044                    description: None,
2045                    parts: vec![
2046                        Part::text(format!("echo: {text}")),
2047                        Part::data(json!({"echo": text})),
2048                    ],
2049                    metadata: None,
2050                    extensions: None,
2051                }]),
2052                history: ctx.stored_task.and_then(|task| task.history),
2053                metadata: None,
2054            });
2055            Box::pin(stream::iter(vec![Ok(working), Ok(completed)]))
2056        }
2057
2058        fn cancel(
2059            &self,
2060            ctx: a2a_server::ExecutorContext,
2061        ) -> futures::stream::BoxStream<'static, std::result::Result<StreamResponse, a2a::A2AError>>
2062        {
2063            let canceled = StreamResponse::Task(Task {
2064                id: ctx.task_id,
2065                context_id: ctx.context_id,
2066                status: TaskStatus {
2067                    state: TaskState::Canceled,
2068                    message: None,
2069                    timestamp: None,
2070                },
2071                artifacts: None,
2072                history: None,
2073                metadata: None,
2074            });
2075            Box::pin(stream::once(async move { Ok(canceled) }))
2076        }
2077    }
2078
2079    async fn spawn_real_a2a_agent() -> String {
2080        crate::telemetry::install_crypto_provider();
2081        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2082        let addr = listener.local_addr().unwrap();
2083        let base_url = format!("http://{addr}");
2084        let card = AgentCard {
2085            name: "Echo A2A Agent".to_string(),
2086            description: "Real A2A test agent".to_string(),
2087            version: "1.0.0".to_string(),
2088            supported_interfaces: vec![AgentInterface::new(
2089                format!("{base_url}/jsonrpc"),
2090                "JSONRPC",
2091            )],
2092            capabilities: AgentCapabilities {
2093                streaming: Some(true),
2094                push_notifications: Some(false),
2095                extensions: None,
2096                extended_agent_card: None,
2097            },
2098            default_input_modes: vec!["text/plain".to_string()],
2099            default_output_modes: vec!["text/plain".to_string()],
2100            skills: vec![],
2101            provider: None,
2102            documentation_url: None,
2103            icon_url: None,
2104            security_schemes: None,
2105            security_requirements: None,
2106            signatures: None,
2107        };
2108        let handler = Arc::new(DefaultRequestHandler::new(
2109            EchoA2aExecutor,
2110            InMemoryTaskStore::default(),
2111        ));
2112        let app = Router::new()
2113            .merge(agent_card_router(Arc::new(StaticAgentCard::new(card))))
2114            .nest("/jsonrpc", jsonrpc_router(handler));
2115        tokio::spawn(async move {
2116            axum::serve(listener, app).await.unwrap();
2117        });
2118        base_url
2119    }
2120
2121    fn configured_capability(base_url: String) -> A2aDelegationConfig {
2122        A2aDelegationConfig {
2123            agents: vec![ExternalA2aAgentConfig {
2124                id: "echo".to_string(),
2125                name: "Echo".to_string(),
2126                description: Some("Echo test agent".to_string()),
2127                base_url: Some(base_url),
2128                agent_card: None,
2129                headers: BTreeMap::new(),
2130                preferred_binding: Some("JSONRPC".to_string()),
2131                poll_interval_ms: Some(100),
2132                allow_local_urls: true,
2133            }],
2134        }
2135    }
2136
2137    fn context(
2138        storage_store: Arc<TestStorageStore>,
2139        file_store: Arc<TestFileStore>,
2140    ) -> ToolContext {
2141        ToolContext::with_stores(SessionId::new(), file_store, storage_store)
2142    }
2143
2144    #[tokio::test]
2145    async fn spawn_agent_foreground_calls_real_a2a_agent_with_target_id_alias() {
2146        let base_url = spawn_real_a2a_agent().await;
2147        let config = configured_capability(base_url);
2148        let tool = SpawnAgentTool::new(config);
2149        let storage_store = Arc::new(TestStorageStore::default());
2150        let file_store = Arc::new(TestFileStore::default());
2151        let ctx = context(storage_store, file_store);
2152
2153        let result = tool
2154            .execute_with_context(
2155                json!({
2156                    "instructions": "hello",
2157                    "target": {"type": "external_a2a", "id": "echo"},
2158                    "mode": "foreground",
2159                    "wait_timeout_secs": 5
2160                }),
2161                &ctx,
2162            )
2163            .await;
2164
2165        let ToolExecutionResult::Success(value) = result else {
2166            panic!("expected success: {result:?}");
2167        };
2168        assert_eq!(value["status"], "completed");
2169        assert_eq!(value["result"], "echo: hello");
2170        assert!(value["result_path"].as_str().is_some());
2171    }
2172
2173    #[tokio::test]
2174    async fn spawn_agent_rejects_legacy_wait_mode() {
2175        let tool = SpawnAgentTool::new(configured_capability("http://127.0.0.1:1".to_string()));
2176        let ctx = context(
2177            Arc::new(TestStorageStore::default()),
2178            Arc::new(TestFileStore::default()),
2179        );
2180        let result = tool
2181            .execute_with_context(
2182                json!({
2183                    "instructions": "never sent",
2184                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2185                    "mode": "wait"
2186                }),
2187                &ctx,
2188            )
2189            .await;
2190        let ToolExecutionResult::ToolError(message) = result else {
2191            panic!("expected legacy mode rejection: {result:?}");
2192        };
2193        assert!(message.contains("background, foreground"));
2194    }
2195
2196    #[tokio::test]
2197    async fn spawn_agent_foreground_validates_a2a_data_artifact_and_writes_task_result() {
2198        let tool = SpawnAgentTool::new(configured_capability(spawn_real_a2a_agent().await));
2199        let storage_store = Arc::new(TestStorageStore::default());
2200        let file_store = Arc::new(TestFileStore::default());
2201        let registry = Arc::new(InMemRegistry::default());
2202        let ctx =
2203            context(storage_store, file_store.clone()).with_session_task_registry(registry.clone());
2204
2205        let result = tool
2206            .execute_with_context(
2207                json!({
2208                    "instructions": "structured",
2209                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2210                    "mode": "foreground",
2211                    "wait_timeout_secs": 5,
2212                    "result_schema": {
2213                        "type": "object",
2214                        "properties": {"echo": {"type": "string"}},
2215                        "required": ["echo"],
2216                        "additionalProperties": false
2217                    }
2218                }),
2219                &ctx,
2220            )
2221            .await;
2222
2223        let ToolExecutionResult::Success(value) = result else {
2224            panic!("expected success: {result:?}");
2225        };
2226        assert_eq!(value["status"], "completed");
2227        let task_id = value["task_id"].as_str().expect("task_id");
2228        let expected_path = crate::session_task::task_result_path(task_id);
2229        assert_eq!(value["result_path"], expected_path);
2230        let content = file_store
2231            .files
2232            .lock()
2233            .unwrap()
2234            .get(&expected_path)
2235            .cloned()
2236            .expect("result file");
2237        assert_eq!(
2238            serde_json::from_str::<Value>(&content).unwrap(),
2239            json!({"echo": "structured"})
2240        );
2241        let task = registry
2242            .get(ctx.session_id, task_id)
2243            .await
2244            .unwrap()
2245            .unwrap();
2246        assert_eq!(task.state, SessionTaskState::Succeeded);
2247        assert_eq!(task.result_path.as_deref(), Some(expected_path.as_str()));
2248    }
2249
2250    #[tokio::test]
2251    async fn spawn_agent_foreground_marks_a2a_schema_mismatch_failed() {
2252        let tool = SpawnAgentTool::new(configured_capability(spawn_real_a2a_agent().await));
2253        let storage_store = Arc::new(TestStorageStore::default());
2254        let file_store = Arc::new(TestFileStore::default());
2255        let registry = Arc::new(InMemRegistry::default());
2256        let ctx = context(storage_store, file_store).with_session_task_registry(registry.clone());
2257
2258        let result = tool
2259            .execute_with_context(
2260                json!({
2261                    "instructions": "structured",
2262                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2263                    "mode": "foreground",
2264                    "wait_timeout_secs": 5,
2265                    "result_schema": {
2266                        "type": "object",
2267                        "properties": {"echo": {"type": "integer"}},
2268                        "required": ["echo"]
2269                    }
2270                }),
2271                &ctx,
2272            )
2273            .await;
2274
2275        let ToolExecutionResult::Success(value) = result else {
2276            panic!("expected terminal run result: {result:?}");
2277        };
2278        assert_eq!(value["status"], "failed");
2279        let task_id = value["task_id"].as_str().expect("task_id");
2280        let task = registry
2281            .get(ctx.session_id, task_id)
2282            .await
2283            .unwrap()
2284            .unwrap();
2285        assert_eq!(task.state, SessionTaskState::Failed);
2286        assert_eq!(
2287            task.error.as_ref().map(|error| error.kind.as_str()),
2288            Some("schema_mismatch")
2289        );
2290        assert!(task.result_path.is_none());
2291    }
2292
2293    #[tokio::test]
2294    async fn spawn_agent_rejects_message_schema_for_external_a2a() {
2295        let tool = SpawnAgentTool::new(configured_capability("http://127.0.0.1:1".to_string()));
2296        let ctx = context(
2297            Arc::new(TestStorageStore::default()),
2298            Arc::new(TestFileStore::default()),
2299        );
2300        let result = tool
2301            .execute_with_context(
2302                json!({
2303                    "instructions": "never sent",
2304                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2305                    "message_schema": {"type": "object"}
2306                }),
2307                &ctx,
2308            )
2309            .await;
2310        let ToolExecutionResult::ToolError(message) = result else {
2311            panic!("expected explicit rejection: {result:?}");
2312        };
2313        assert!(message.contains("message_schema is not supported for external_a2a"));
2314    }
2315
2316    #[test]
2317    fn uk_localization_and_schema_one_of_match_validation() {
2318        let cap = A2aAgentDelegationCapability;
2319        assert_eq!(cap.localized_name(Some("uk-UA")), "Делегування агентам A2A");
2320        assert!(
2321            cap.localized_description(Some("uk-UA"))
2322                .contains("Делегує роботу")
2323        );
2324        assert!(cap.describe_schema(Some("uk")).is_some());
2325        assert!(cap.describe_schema(None).is_some());
2326
2327        // preferred_binding oneOf consts must be exactly the values
2328        // validate_config accepts.
2329        let schema = cap.config_schema().expect("config schema");
2330        let consts: Vec<&str> =
2331            schema["properties"]["agents"]["items"]["properties"]["preferred_binding"]["oneOf"]
2332                .as_array()
2333                .expect("oneOf")
2334                .iter()
2335                .map(|v| v["const"].as_str().expect("const"))
2336                .collect();
2337        assert_eq!(consts, vec!["JSONRPC", "HTTP+JSON"]);
2338        for binding in consts {
2339            let config = json!({
2340                "agents": [{
2341                    "id": "echo",
2342                    "name": "Echo",
2343                    "base_url": "https://agent.example.com",
2344                    "preferred_binding": binding
2345                }]
2346            });
2347            cap.validate_config(&config)
2348                .unwrap_or_else(|e| panic!("{binding} should validate: {e}"));
2349        }
2350        assert!(
2351            cap.validate_config(&json!({
2352                "agents": [{
2353                    "id": "echo",
2354                    "name": "Echo",
2355                    "base_url": "https://agent.example.com",
2356                    "preferred_binding": "SMTP"
2357                }]
2358            }))
2359            .is_err()
2360        );
2361
2362        let tool_schema = SpawnAgentTool::new(A2aDelegationConfig::default()).parameters_schema();
2363        assert_eq!(
2364            tool_schema["properties"]["mode"]["enum"],
2365            json!(["background", "foreground"])
2366        );
2367        assert!(tool_schema["properties"]["target"]["properties"]["id"].is_object());
2368    }
2369
2370    #[test]
2371    fn validates_local_urls_only_with_escape_hatch() {
2372        let mut config = configured_capability("http://127.0.0.1:1".to_string());
2373        config.agents[0].allow_local_urls = false;
2374        assert!(config.agents[0].validate().is_err());
2375        config.agents[0].allow_local_urls = true;
2376        assert!(config.agents[0].validate().is_ok());
2377    }
2378
2379    #[test]
2380    fn reattach_network_access_prefers_persisted_run_policy() {
2381        use crate::SessionId;
2382
2383        let config = ExternalA2aAgentConfig {
2384            id: "echo".to_string(),
2385            name: "Echo".to_string(),
2386            description: None,
2387            base_url: Some("https://allowed.example.com/a2a".to_string()),
2388            agent_card: None,
2389            headers: BTreeMap::new(),
2390            preferred_binding: None,
2391            poll_interval_ms: None,
2392            allow_local_urls: false,
2393        };
2394        let mut record = AgentRunRecord::new(
2395            "run-policy".to_string(),
2396            &config,
2397            "instructions".to_string(),
2398            SpawnMode::Background,
2399            false,
2400            None,
2401        );
2402        let persisted_policy =
2403            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]);
2404        record.network_access = Some(persisted_policy.clone());
2405
2406        let reaper_context = ToolContext::new(SessionId::new()).with_network_access(None);
2407        assert_eq!(
2408            reattach_network_access(&record, &reaper_context),
2409            Some(persisted_policy.clone())
2410        );
2411
2412        let fallback_policy =
2413            NetworkAccessList::allow_only(vec!["fallback.example.com".to_string()]);
2414        let fallback_context =
2415            ToolContext::new(SessionId::new()).with_network_access(Some(fallback_policy.clone()));
2416        record.network_access = None;
2417        assert_eq!(
2418            reattach_network_access(&record, &fallback_context),
2419            Some(fallback_policy)
2420        );
2421    }
2422
2423    #[test]
2424    fn enforce_network_access_blocks_disallowed_base_url() {
2425        use crate::SessionId;
2426        use crate::network_access::NetworkAccessList;
2427
2428        let agent = ExternalA2aAgentConfig {
2429            id: "a".to_string(),
2430            name: "a".to_string(),
2431            description: None,
2432            base_url: Some("https://blocked.example.com".to_string()),
2433            agent_card: None,
2434            headers: BTreeMap::new(),
2435            preferred_binding: None,
2436            poll_interval_ms: None,
2437            allow_local_urls: false,
2438        };
2439        let card = AgentCard {
2440            name: "a".to_string(),
2441            description: "a".to_string(),
2442            version: "1".to_string(),
2443            supported_interfaces: vec![],
2444            capabilities: AgentCapabilities {
2445                streaming: None,
2446                push_notifications: None,
2447                extensions: None,
2448                extended_agent_card: None,
2449            },
2450            default_input_modes: vec![],
2451            default_output_modes: vec![],
2452            skills: vec![],
2453            provider: None,
2454            documentation_url: None,
2455            icon_url: None,
2456            security_schemes: None,
2457            security_requirements: None,
2458            signatures: None,
2459        };
2460
2461        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2462            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2463        ));
2464        let err = enforce_network_access_pre_resolve(&agent, &ctx).unwrap_err();
2465        assert!(
2466            err.contains("blocked.example.com"),
2467            "unexpected error: {err}"
2468        );
2469
2470        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2471            NetworkAccessList::allow_only(vec!["blocked.example.com".to_string()]),
2472        ));
2473        enforce_network_access_pre_resolve(&agent, &ctx).unwrap();
2474        enforce_network_access_post_resolve(&card, &ctx).unwrap();
2475    }
2476
2477    #[test]
2478    fn enforce_network_access_blocks_disallowed_interface_url() {
2479        use crate::SessionId;
2480        use crate::network_access::NetworkAccessList;
2481
2482        let card = AgentCard {
2483            name: "a".to_string(),
2484            description: "a".to_string(),
2485            version: "1".to_string(),
2486            supported_interfaces: vec![AgentInterface::new(
2487                "https://probe.internal/api".to_string(),
2488                "JSONRPC",
2489            )],
2490            capabilities: AgentCapabilities {
2491                streaming: None,
2492                push_notifications: None,
2493                extensions: None,
2494                extended_agent_card: None,
2495            },
2496            default_input_modes: vec![],
2497            default_output_modes: vec![],
2498            skills: vec![],
2499            provider: None,
2500            documentation_url: None,
2501            icon_url: None,
2502            security_schemes: None,
2503            security_requirements: None,
2504            signatures: None,
2505        };
2506        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2507            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2508        ));
2509        let err = enforce_network_access_post_resolve(&card, &ctx).unwrap_err();
2510        assert!(err.contains("probe.internal"), "unexpected error: {err}");
2511    }
2512
2513    #[test]
2514    fn enforce_network_access_pre_resolve_skips_when_inline_card_present() {
2515        use crate::SessionId;
2516        use crate::network_access::NetworkAccessList;
2517
2518        // base_url not on the allowlist, but agent_card is supplied inline so
2519        // resolve_card never performs discovery against base_url. The pre-resolve
2520        // gate must skip the base_url check to avoid spurious failures.
2521        let inline_card = AgentCard {
2522            name: "a".to_string(),
2523            description: "a".to_string(),
2524            version: "1".to_string(),
2525            supported_interfaces: vec![AgentInterface::new(
2526                "https://allowed.example.com/api".to_string(),
2527                "JSONRPC",
2528            )],
2529            capabilities: AgentCapabilities {
2530                streaming: None,
2531                push_notifications: None,
2532                extensions: None,
2533                extended_agent_card: None,
2534            },
2535            default_input_modes: vec![],
2536            default_output_modes: vec![],
2537            skills: vec![],
2538            provider: None,
2539            documentation_url: None,
2540            icon_url: None,
2541            security_schemes: None,
2542            security_requirements: None,
2543            signatures: None,
2544        };
2545        let agent = ExternalA2aAgentConfig {
2546            id: "a".to_string(),
2547            name: "a".to_string(),
2548            description: None,
2549            base_url: Some("https://stale.unused.example.com".to_string()),
2550            agent_card: Some(inline_card),
2551            headers: BTreeMap::new(),
2552            preferred_binding: None,
2553            poll_interval_ms: None,
2554            allow_local_urls: false,
2555        };
2556        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2557            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2558        ));
2559        enforce_network_access_pre_resolve(&agent, &ctx).unwrap();
2560    }
2561
2562    #[test]
2563    fn local_url_escape_hatch_still_rejects_bad_url_shape() {
2564        let mut config = configured_capability("file:///tmp/agent".to_string());
2565        config.agents[0].allow_local_urls = true;
2566        assert!(config.agents[0].validate().is_err());
2567
2568        let mut config = configured_capability("http://127.0.0.1:1".to_string());
2569        config.agents[0].allow_local_urls = true;
2570        config.agents[0].preferred_binding = Some("SMTP".to_string());
2571        assert!(config.agents[0].validate().is_err());
2572
2573        config.agents[0].preferred_binding = Some("JSONRPC".to_string());
2574        config.agents[0].poll_interval_ms = Some(99);
2575        assert!(config.agents[0].validate().is_err());
2576    }
2577
2578    // -------------------------------------------------------------------------
2579    // ExternalAgentTaskExecutor::start() tests
2580    // -------------------------------------------------------------------------
2581
2582    /// Minimal in-memory task registry for executor tests.
2583    #[derive(Default, Clone)]
2584    struct InMemRegistry {
2585        tasks: Arc<Mutex<HashMap<String, crate::session_task::SessionTask>>>,
2586    }
2587
2588    #[async_trait]
2589    impl crate::session_task::SessionTaskRegistry for InMemRegistry {
2590        async fn create(
2591            &self,
2592            input: crate::session_task::CreateSessionTask,
2593        ) -> crate::error::Result<crate::session_task::SessionTask> {
2594            let task = crate::session_task::new_session_task(input, chrono::Utc::now());
2595            self.tasks
2596                .lock()
2597                .unwrap()
2598                .insert(task.id.clone(), task.clone());
2599            Ok(task)
2600        }
2601
2602        async fn get(
2603            &self,
2604            _session_id: crate::typed_id::SessionId,
2605            task_id: &str,
2606        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2607            Ok(self.tasks.lock().unwrap().get(task_id).cloned())
2608        }
2609
2610        async fn list(
2611            &self,
2612            _session_id: crate::typed_id::SessionId,
2613            _filter: Option<&crate::session_task::SessionTaskFilter>,
2614        ) -> crate::error::Result<Vec<crate::session_task::SessionTask>> {
2615            Ok(self.tasks.lock().unwrap().values().cloned().collect())
2616        }
2617
2618        async fn update(
2619            &self,
2620            _session_id: crate::typed_id::SessionId,
2621            task_id: &str,
2622            update: crate::session_task::SessionTaskUpdate,
2623        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2624            let mut tasks = self.tasks.lock().unwrap();
2625            let Some(task) = tasks.get_mut(task_id) else {
2626                return Ok(None);
2627            };
2628            crate::session_task::apply_task_update(task, update, chrono::Utc::now());
2629            Ok(Some(task.clone()))
2630        }
2631
2632        async fn request_cancel(
2633            &self,
2634            _session_id: crate::typed_id::SessionId,
2635            _task_id: &str,
2636        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2637            Ok(None)
2638        }
2639
2640        async fn record_message(
2641            &self,
2642            _session_id: crate::typed_id::SessionId,
2643            _task_id: &str,
2644            _message: crate::session_task::NewTaskMessage,
2645        ) -> crate::error::Result<crate::session_task::TaskMessage> {
2646            Err(crate::error::AgentLoopError::tool("not implemented"))
2647        }
2648
2649        async fn list_messages(
2650            &self,
2651            _session_id: crate::typed_id::SessionId,
2652            _task_id: &str,
2653            _limit: Option<u32>,
2654            _after_id: Option<&str>,
2655        ) -> crate::error::Result<Vec<crate::session_task::TaskMessage>> {
2656            Ok(vec![])
2657        }
2658    }
2659
2660    #[test]
2661    fn a2a_delegation_depends_on_session_tasks() {
2662        let cap = A2aAgentDelegationCapability;
2663
2664        assert_eq!(cap.dependencies(), vec![SESSION_TASKS_CAPABILITY_ID]);
2665    }
2666
2667    #[tokio::test]
2668    async fn background_spawn_requires_task_registry_before_remote_work() {
2669        let config = configured_capability("http://127.0.0.1:1".to_string());
2670        let spawn = SpawnAgentTool::new(config);
2671        let storage_store = Arc::new(TestStorageStore::default());
2672        let file_store = Arc::new(TestFileStore::default());
2673        let ctx = context(storage_store.clone(), file_store);
2674
2675        let result = spawn
2676            .execute_with_context(
2677                json!({
2678                    "instructions": "background",
2679                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2680                    "mode": "background"
2681                }),
2682                &ctx,
2683            )
2684            .await;
2685
2686        let ToolExecutionResult::ToolError(message) = result else {
2687            panic!("background spawn should reject missing task registry");
2688        };
2689        assert!(
2690            message.contains("requires session_task_registry"),
2691            "unexpected error: {message}"
2692        );
2693        assert!(
2694            storage_store.values.lock().unwrap().is_empty(),
2695            "background spawn must not persist or launch remote work without task tracking"
2696        );
2697    }
2698
2699    /// Parity check for the retired `wait_agent`: a background `spawn_agent`
2700    /// run is observable end-to-end through the generic `wait_task` tool.
2701    /// The background poll loop mirrors each remote snapshot onto the session
2702    /// task via `save_run`, so `wait_task` converges to the terminal state.
2703    #[tokio::test]
2704    async fn background_spawn_is_waitable_via_generic_wait_task() {
2705        use crate::capabilities::session_tasks::WaitTaskTool;
2706
2707        let base_url = spawn_real_a2a_agent().await;
2708        let config = configured_capability(base_url);
2709        let spawn = SpawnAgentTool::new(config);
2710
2711        // A context WITH a task registry so the background run creates and
2712        // mirrors a session task (the registry is what wait_task reads).
2713        let storage_store = Arc::new(TestStorageStore::default());
2714        let file_store = Arc::new(TestFileStore::default());
2715        let registry = Arc::new(InMemRegistry::default());
2716        let ctx = ToolContext::with_stores(SessionId::new(), file_store, storage_store)
2717            .with_session_task_registry(registry.clone());
2718
2719        let result = spawn
2720            .execute_with_context(
2721                json!({
2722                    "instructions": "background",
2723                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2724                    "mode": "background",
2725                    "wait_timeout_secs": 5,
2726                    "wake_on_completion": false
2727                }),
2728                &ctx,
2729            )
2730            .await;
2731        let ToolExecutionResult::Success(value) = result else {
2732            panic!("expected spawn success: {result:?}");
2733        };
2734        let task_id = value["task_id"]
2735            .as_str()
2736            .expect("background spawn returns a task_id when a registry is present");
2737
2738        let waited = WaitTaskTool
2739            .execute_with_context(json!({"task_id": task_id, "timeout_seconds": 5}), &ctx)
2740            .await;
2741        let ToolExecutionResult::Success(value) = waited else {
2742            panic!("expected wait_task success: {waited:?}");
2743        };
2744        assert_eq!(
2745            value["timed_out"], false,
2746            "wait_task should observe a terminal state, got {value:?}"
2747        );
2748        assert_eq!(value["task"]["state"], "succeeded");
2749    }
2750
2751    /// Build a SessionTask snapshot for testing (not persisted in any store).
2752    fn fake_task_with_spec(
2753        session_id: crate::typed_id::SessionId,
2754        run_id: &str,
2755        attempt: i32,
2756    ) -> crate::session_task::SessionTask {
2757        let now = chrono::Utc::now();
2758        crate::session_task::SessionTask {
2759            id: format!("task_{run_id}"),
2760            session_id,
2761            root_session_id: None,
2762            kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2763            display_name: "Test external agent".to_string(),
2764            spec: json!({ "run_id": run_id }),
2765            state: SessionTaskState::Running,
2766            state_detail: None,
2767            progress: None,
2768            links: TaskLinks::default(),
2769            wake_policy: TaskWakePolicy::Silent,
2770            input_request: None,
2771            cancel_requested_at: None,
2772            summary: None,
2773            result_path: None,
2774            artifacts: vec![],
2775            error: None,
2776            attempt,
2777            worker_id: None,
2778            heartbeat_at: None,
2779            started_at: None,
2780            finished_at: None,
2781            created_at: now,
2782            updated_at: now,
2783        }
2784    }
2785
2786    /// start() with a terminal run record should mirror state and return Ok.
2787    #[tokio::test]
2788    async fn external_agent_executor_start_mirrors_terminal_run() {
2789        let storage = Arc::new(TestStorageStore::default());
2790        let registry = Arc::new(InMemRegistry::default());
2791        let session_id = crate::typed_id::SessionId::new();
2792
2793        let run_id = "run-terminal".to_string();
2794        let config = ExternalA2aAgentConfig {
2795            id: "echo".to_string(),
2796            name: "Echo".to_string(),
2797            description: None,
2798            base_url: None,
2799            agent_card: None,
2800            headers: BTreeMap::new(),
2801            preferred_binding: None,
2802            poll_interval_ms: None,
2803            allow_local_urls: false,
2804        };
2805        let task_id = format!("task_{run_id}");
2806
2807        // Create a completed run record.
2808        let mut record = AgentRunRecord::new(
2809            run_id.clone(),
2810            &config,
2811            "instructions".to_string(),
2812            SpawnMode::Background,
2813            false,
2814            None,
2815        );
2816        record.status = AgentRunStatus::Completed;
2817        record.result = Some("done".to_string());
2818        record.remote_task_id = Some("remote-xyz".to_string());
2819        record.task_id = Some(task_id.clone());
2820
2821        // Persist the run record.
2822        let serialized = serde_json::to_string(&record).unwrap();
2823        storage
2824            .set_value(session_id, &run_key(&run_id), &serialized)
2825            .await
2826            .unwrap();
2827
2828        // Create the session task in the registry so mirror_run_to_task can update it.
2829        registry
2830            .create(crate::session_task::CreateSessionTask {
2831                session_id,
2832                id: Some(task_id.clone()),
2833                kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2834                display_name: "Echo".to_string(),
2835                spec: json!({ "run_id": &run_id }),
2836                state: SessionTaskState::Running,
2837                links: TaskLinks::default(),
2838                wake_policy: TaskWakePolicy::Silent,
2839            })
2840            .await
2841            .unwrap();
2842
2843        let ctx = ToolContext::new(session_id)
2844            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>)
2845            .with_session_task_registry(registry.clone());
2846
2847        let task = fake_task_with_spec(session_id, &run_id, 2);
2848        let executor = ExternalAgentTaskExecutor;
2849        executor
2850            .start(&task, &ctx)
2851            .await
2852            .expect("start should succeed");
2853
2854        // The registry task should now reflect the terminal state.
2855        let updated = registry.get(session_id, &task_id).await.unwrap().unwrap();
2856        assert_eq!(
2857            updated.state,
2858            SessionTaskState::Succeeded,
2859            "terminal run should mirror to succeeded"
2860        );
2861    }
2862
2863    /// A heartbeat fence miss (task attempt moved past ours) must abort the
2864    /// poll loop with the superseded error before any remote call or write.
2865    #[tokio::test]
2866    async fn wait_for_run_exits_superseded_on_fence_miss() {
2867        let storage = Arc::new(TestStorageStore::default());
2868        let registry = Arc::new(InMemRegistry::default());
2869        let session_id = crate::typed_id::SessionId::new();
2870
2871        let run_id = "run-superseded".to_string();
2872        // Inline card so build_client performs no network discovery; the
2873        // heartbeat fence check fires before any remote get_task call.
2874        let inline_card = AgentCard {
2875            name: "Echo".to_string(),
2876            description: "Echo".to_string(),
2877            version: "1".to_string(),
2878            supported_interfaces: vec![AgentInterface::new(
2879                "https://agent.example.com/api".to_string(),
2880                "JSONRPC",
2881            )],
2882            capabilities: AgentCapabilities {
2883                streaming: None,
2884                push_notifications: None,
2885                extensions: None,
2886                extended_agent_card: None,
2887            },
2888            default_input_modes: vec![],
2889            default_output_modes: vec![],
2890            skills: vec![],
2891            provider: None,
2892            documentation_url: None,
2893            icon_url: None,
2894            security_schemes: None,
2895            security_requirements: None,
2896            signatures: None,
2897        };
2898        let config = ExternalA2aAgentConfig {
2899            id: "echo".to_string(),
2900            name: "Echo".to_string(),
2901            description: None,
2902            base_url: Some("https://agent.example.com".to_string()),
2903            agent_card: Some(inline_card),
2904            headers: BTreeMap::new(),
2905            preferred_binding: None,
2906            poll_interval_ms: None,
2907            allow_local_urls: false,
2908        };
2909        let task_id = format!("task_{run_id}");
2910
2911        let mut record = AgentRunRecord::new(
2912            run_id.clone(),
2913            &config,
2914            "instructions".to_string(),
2915            SpawnMode::Background,
2916            false,
2917            None,
2918        );
2919        record.remote_task_id = Some("remote-xyz".to_string());
2920        record.task_id = Some(task_id.clone());
2921
2922        // Task in the registry already at attempt 2 (reaper superseded us).
2923        registry
2924            .create(crate::session_task::CreateSessionTask {
2925                session_id,
2926                id: Some(task_id.clone()),
2927                kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2928                display_name: "Echo".to_string(),
2929                spec: json!({ "run_id": &run_id }),
2930                state: SessionTaskState::Running,
2931                links: TaskLinks::default(),
2932                wake_policy: TaskWakePolicy::Silent,
2933            })
2934            .await
2935            .unwrap();
2936        registry
2937            .update(
2938                session_id,
2939                &task_id,
2940                crate::session_task::SessionTaskUpdate {
2941                    increment_attempt: true,
2942                    ..Default::default()
2943                },
2944            )
2945            .await
2946            .unwrap();
2947
2948        let ctx = ToolContext::new(session_id)
2949            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>)
2950            .with_session_task_registry(registry.clone());
2951
2952        // Poll as the old executor (attempt 1): the first heartbeat reveals
2953        // the supersession and the loop exits before any remote call.
2954        let outcome = wait_for_run(&ctx, &config, record, 30, Some(1))
2955            .await
2956            .expect("superseded poll must not be a transport error");
2957        // EVE-645: supersession is now a typed WaitOutcome variant rather than
2958        // a string-prefix sniff on the error.
2959        let WaitOutcome::Superseded {
2960            run_id: superseded_run_id,
2961            attempt,
2962            by_attempt,
2963        } = outcome
2964        else {
2965            panic!("expected Superseded outcome");
2966        };
2967        assert_eq!(superseded_run_id, run_id);
2968        assert_eq!(attempt, 1);
2969        assert_eq!(by_attempt, 2);
2970        // Diagnostic string still carries the legacy prefix for log greps.
2971        assert!(
2972            WaitOutcome::superseded_message(&superseded_run_id, attempt, by_attempt)
2973                .starts_with(SUPERSEDED_ERROR_PREFIX)
2974        );
2975    }
2976
2977    // EVE-645: timeout is selected via the typed WaitOutcome::TimedOut variant
2978    // and its message stays byte-identical to the legacy string.
2979    #[test]
2980    fn wait_outcome_timed_out_message_is_stable() {
2981        let msg = WaitOutcome::timed_out_message("run-123", 30);
2982        assert_eq!(
2983            msg,
2984            "Timed out waiting for external agent run run-123 after 30s"
2985        );
2986    }
2987
2988    /// start() with a run that has no remote_task_id should return an error.
2989    #[tokio::test]
2990    async fn external_agent_executor_start_errors_on_missing_remote_task_id() {
2991        let storage = Arc::new(TestStorageStore::default());
2992        let session_id = crate::typed_id::SessionId::new();
2993
2994        let run_id = "run-no-remote".to_string();
2995        let config = ExternalA2aAgentConfig {
2996            id: "echo".to_string(),
2997            name: "Echo".to_string(),
2998            description: None,
2999            base_url: None,
3000            agent_card: None,
3001            headers: BTreeMap::new(),
3002            preferred_binding: None,
3003            poll_interval_ms: None,
3004            allow_local_urls: false,
3005        };
3006
3007        // Create a run record without a remote_task_id (never reached the remote agent).
3008        let record = AgentRunRecord::new(
3009            run_id.clone(),
3010            &config,
3011            "instructions".to_string(),
3012            SpawnMode::Background,
3013            false,
3014            None,
3015        );
3016        assert!(record.remote_task_id.is_none());
3017
3018        let serialized = serde_json::to_string(&record).unwrap();
3019        storage
3020            .set_value(session_id, &run_key(&run_id), &serialized)
3021            .await
3022            .unwrap();
3023
3024        let ctx = ToolContext::new(session_id)
3025            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>);
3026
3027        let task = fake_task_with_spec(session_id, &run_id, 2);
3028        let executor = ExternalAgentTaskExecutor;
3029        let result = executor.start(&task, &ctx).await;
3030        assert!(
3031            result.is_err(),
3032            "start() should error when remote_task_id is absent"
3033        );
3034        let err = result.unwrap_err();
3035        assert!(
3036            err.to_string().contains("remote_task_id"),
3037            "error should mention remote_task_id: {err}"
3038        );
3039    }
3040
3041    /// Legacy records written before the task-to-instructions rename and the
3042    /// A2A wait-to-foreground mode rename should still load from durable
3043    /// session storage.
3044    #[test]
3045    fn agent_run_record_accepts_legacy_task_field() {
3046        let legacy = json!({
3047            "run_id": "legacy-run",
3048            "kind": "external_a2a",
3049            "external_agent_id": "echo",
3050            "external_agent_name": "Echo",
3051            "task": "legacy instructions",
3052            "mode": "wait",
3053            "status": "submitted"
3054        });
3055
3056        let record: AgentRunRecord = serde_json::from_value(legacy).unwrap();
3057        assert_eq!(record.instructions, "legacy instructions");
3058        assert_eq!(record.mode, SpawnMode::Foreground);
3059
3060        let serialized = serde_json::to_value(&record).unwrap();
3061        assert_eq!(serialized["instructions"], "legacy instructions");
3062        assert_eq!(serialized["mode"], "foreground");
3063        assert!(serialized.get("task").is_none());
3064    }
3065
3066    /// Roundtrip: save_run → load_run → load_run_for_task all resolve consistently.
3067    #[tokio::test]
3068    async fn agent_run_storage_roundtrip() {
3069        let storage_store = Arc::new(TestStorageStore::default());
3070        let file_store = Arc::new(TestFileStore::default());
3071        let ctx = context(storage_store, file_store);
3072
3073        let run_id = "test-run-roundtrip".to_string();
3074        let task_id = "task-abc".to_string();
3075        let config = ExternalA2aAgentConfig {
3076            id: "echo".to_string(),
3077            name: "Echo".to_string(),
3078            description: None,
3079            base_url: Some("http://localhost:1".to_string()),
3080            agent_card: None,
3081            headers: BTreeMap::new(),
3082            preferred_binding: None,
3083            poll_interval_ms: None,
3084            allow_local_urls: true,
3085        };
3086        let mut record = AgentRunRecord::new(
3087            run_id.clone(),
3088            &config,
3089            "do something".to_string(),
3090            SpawnMode::Foreground,
3091            false,
3092            None,
3093        );
3094        record.task_id = Some(task_id.clone());
3095
3096        // save_run then load_run should round-trip the record.
3097        save_run(&ctx, &record).await.expect("save_run failed");
3098        let loaded = load_run(&ctx, &run_id).await.expect("load_run failed");
3099        assert_eq!(loaded.run_id, run_id);
3100        assert_eq!(loaded.task_id.as_deref(), Some(task_id.as_str()));
3101
3102        // load_run_for_task with run_id in spec should resolve the same record.
3103        let now = chrono::Utc::now();
3104        let fake_task = SessionTask {
3105            id: task_id.clone(),
3106            session_id: ctx.session_id,
3107            root_session_id: None,
3108            kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
3109            display_name: "Echo".to_string(),
3110            spec: json!({ "run_id": &run_id }),
3111            state: SessionTaskState::Running,
3112            state_detail: None,
3113            progress: None,
3114            links: TaskLinks::default(),
3115            wake_policy: TaskWakePolicy::Silent,
3116            input_request: None,
3117            cancel_requested_at: None,
3118            summary: None,
3119            result_path: None,
3120            artifacts: vec![],
3121            error: None,
3122            attempt: 1,
3123            worker_id: None,
3124            heartbeat_at: None,
3125            started_at: None,
3126            finished_at: None,
3127            created_at: now,
3128            updated_at: now,
3129        };
3130        let from_task = load_run_for_task(&ctx, &fake_task)
3131            .await
3132            .expect("load_run_for_task failed");
3133        assert_eq!(from_task.run_id, run_id);
3134
3135        // The prefix-derived listing should include the run_id.
3136        let storage = ctx.storage_store.as_ref().unwrap();
3137        let index = list_run_ids(storage.as_ref(), ctx.session_id).await;
3138        assert!(
3139            index.contains(&run_id),
3140            "run_id not found in index: {index:?}"
3141        );
3142    }
3143}