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 name(&self) -> &str {
1337        "spawn_agent"
1338    }
1339
1340    fn display_name(&self) -> Option<&str> {
1341        Some("Spawn Agent")
1342    }
1343
1344    fn description(&self) -> &str {
1345        "Delegate a task to a configured external A2A agent in foreground or background mode."
1346    }
1347
1348    fn parameters_schema(&self) -> Value {
1349        json!({
1350            "type": "object",
1351            "properties": {
1352                "instructions": {"type": "string", "description": "Instructions to send to the external agent."},
1353                "target": {
1354                    "type": "object",
1355                    "properties": {
1356                        "type": {"type": "string", "enum": ["external_a2a"]},
1357                        "id": {"type": "string", "description": "Configured external A2A agent id."},
1358                        "external_agent_id": {"type": "string", "description": "Deprecated provider-specific spelling; prefer target.id."}
1359                    },
1360                    "required": ["type"],
1361                    "additionalProperties": false
1362                },
1363                "mode": {"type": "string", "enum": ["background", "foreground"], "default": "foreground"},
1364                "wait_timeout_secs": {"type": "integer", "minimum": 1, "maximum": 86400},
1365                "wake_on_completion": {"type": "boolean", "default": true},
1366                "result_schema": {"type": "object", "description": "JSON Schema for a required structured result artifact from the external agent."},
1367                "message_schema": {"type": "object", "description": "Not supported for external A2A targets; supplied values fail explicitly."}
1368            },
1369            "required": ["instructions", "target"],
1370            "additionalProperties": false
1371        })
1372    }
1373
1374    fn hints(&self) -> ToolHints {
1375        ToolHints::default()
1376            .with_long_running(true)
1377            .with_open_world(true)
1378    }
1379
1380    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1381        ToolExecutionResult::tool_error("spawn_agent requires session context")
1382    }
1383
1384    async fn execute_with_context(
1385        &self,
1386        arguments: Value,
1387        context: &ToolContext,
1388    ) -> ToolExecutionResult {
1389        if let Err(e) = require_storage(context) {
1390            return e;
1391        }
1392        let instructions = match require_str(&arguments, "instructions") {
1393            Ok(instructions) => instructions.to_string(),
1394            Err(e) => return e,
1395        };
1396        let target = arguments.get("target").unwrap_or(&Value::Null);
1397        if target.get("type").and_then(Value::as_str) != Some("external_a2a") {
1398            return ToolExecutionResult::tool_error(
1399                "spawn_agent currently supports target.type = external_a2a",
1400            );
1401        }
1402        if arguments
1403            .get("lifetime")
1404            .and_then(Value::as_str)
1405            .is_some_and(|value| value == "detached")
1406        {
1407            return ToolExecutionResult::tool_error(
1408                "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1409            );
1410        }
1411        let external_agent_id = match target
1412            .get("id")
1413            .or_else(|| target.get("external_agent_id"))
1414            .and_then(Value::as_str)
1415            .map(str::trim)
1416            .filter(|s| !s.is_empty())
1417        {
1418            Some(id) => id,
1419            None => {
1420                return ToolExecutionResult::tool_error("Missing required parameter: target.id");
1421            }
1422        };
1423        let Some(agent) = self.config.agent(external_agent_id).cloned() else {
1424            return ToolExecutionResult::tool_error(format!(
1425                "Unknown external A2A agent: {external_agent_id}"
1426            ));
1427        };
1428        let mode = match arguments.get("mode").and_then(Value::as_str) {
1429            None => SpawnMode::Foreground,
1430            Some(value) if let Some(mode) = SpawnMode::parse(value) => mode,
1431            Some(other) => {
1432                return ToolExecutionResult::tool_error(format!(
1433                    "Invalid mode: {other}. Expected background, foreground"
1434                ));
1435            }
1436        };
1437        let timeout_secs = arguments
1438            .get("wait_timeout_secs")
1439            .and_then(Value::as_u64)
1440            .unwrap_or(DEFAULT_WAIT_TIMEOUT_SECS);
1441        let wake_on_completion = arguments
1442            .get("wake_on_completion")
1443            .and_then(Value::as_bool)
1444            .unwrap_or(true);
1445        let result_schema = match normalize_result_schema(&arguments) {
1446            Ok(schema) => schema,
1447            Err(error) => return error,
1448        };
1449        if arguments
1450            .get("message_schema")
1451            .is_some_and(|schema| !schema.is_null())
1452        {
1453            return ToolExecutionResult::tool_error(
1454                "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1455            );
1456        }
1457        if result_schema.is_some()
1458            && (context.session_task_registry.is_none() || context.file_store.is_none())
1459        {
1460            return ToolExecutionResult::tool_error(
1461                "result_schema for external_a2a requires session_task_registry and file_store context.",
1462            );
1463        }
1464        let run_id = run_id();
1465        let mut record = AgentRunRecord::new(
1466            run_id.clone(),
1467            &agent,
1468            instructions.clone(),
1469            mode,
1470            wake_on_completion,
1471            result_schema.clone(),
1472        );
1473        record.network_access = context.network_access.clone();
1474        // Create the session task tracking this run (specs/session-tasks.md).
1475        // Background runs must be task-backed before any remote work starts so
1476        // wait_task/message_task/cancel_task have a usable control handle.
1477        // run_id is stored in spec so load_run_for_task can do a direct key lookup.
1478        match &context.session_task_registry {
1479            Some(task_registry) => {
1480                match task_registry
1481                    .create(CreateSessionTask {
1482                        session_id: context.session_id,
1483                        id: None,
1484                        kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
1485                        display_name: agent.name.clone(),
1486                        spec: json!({
1487                            "run_id": &run_id,
1488                            "external_agent_id": agent.id,
1489                            "instructions": &instructions,
1490                            "mode": &mode,
1491                            "result_schema": result_schema,
1492                        }),
1493                        state: SessionTaskState::Queued,
1494                        links: TaskLinks::default(),
1495                        wake_policy: match mode {
1496                            SpawnMode::Background => TaskWakePolicy::OnTerminal,
1497                            SpawnMode::Foreground => TaskWakePolicy::Silent,
1498                        },
1499                    })
1500                    .await
1501                {
1502                    Ok(created) => record.task_id = Some(created.id),
1503                    Err(e) if mode == SpawnMode::Background || result_schema.is_some() => {
1504                        // Background runs must be task-backed before remote work
1505                        // starts; surface this as a user-facing tool error (per
1506                        // the capability contract) rather than an internal error,
1507                        // and do not launch the run.
1508                        return ToolExecutionResult::tool_error(format!(
1509                            "Background spawn_agent could not create its session task, so the run \
1510                             was not started: {e}"
1511                        ));
1512                    }
1513                    Err(_) => {}
1514                }
1515            }
1516            None if mode == SpawnMode::Background => {
1517                return ToolExecutionResult::tool_error(
1518                    "Background spawn_agent requires session_task_registry context so the run can be controlled with wait_task/message_task/cancel_task",
1519                );
1520            }
1521            None => {}
1522        }
1523        if let Err(e) = save_run(context, &record).await {
1524            return ToolExecutionResult::internal_error(e);
1525        }
1526        if let Err(error) =
1527            submit_run(context, &agent, &mut record, &instructions, None, None).await
1528        {
1529            record.status = AgentRunStatus::Failed;
1530            set_error(&mut record, error);
1531            let _ = save_run(context, &record).await;
1532            return ToolExecutionResult::success(record.public_json());
1533        }
1534        match mode {
1535            SpawnMode::Background => {
1536                let context = context.clone();
1537                // Capture the attempt at spawn time (1 for a fresh spawn).
1538                // The heartbeat loop uses this to fence stale writes from any
1539                // future superseded attempt.
1540                let heartbeat_attempt = record.task_id.is_some().then_some(1i32);
1541                let background_record = record.clone();
1542                tokio::spawn(async move {
1543                    background_monitor(
1544                        context,
1545                        agent,
1546                        background_record,
1547                        timeout_secs,
1548                        heartbeat_attempt,
1549                    )
1550                    .await;
1551                });
1552                ToolExecutionResult::success(record.public_json())
1553            }
1554            SpawnMode::Foreground => {
1555                // Foreground wait: the tool executor owns the call stack so no
1556                // separate heartbeat thread is needed; pass None.
1557                match wait_for_run(context, &agent, record, timeout_secs, None).await {
1558                    Ok(WaitOutcome::Completed(record)) => {
1559                        ToolExecutionResult::success(record.public_json())
1560                    }
1561                    Ok(WaitOutcome::TimedOut {
1562                        run_id: timed_out_run_id,
1563                        timeout_secs,
1564                    }) => {
1565                        let message =
1566                            WaitOutcome::timed_out_message(&timed_out_run_id, timeout_secs);
1567                        timed_out_result(context, &run_id, message).await
1568                    }
1569                    // Foreground waits pass `heartbeat_attempt: None`, so the
1570                    // fence never fires and Superseded is unreachable here.
1571                    // Surface it as a tool error rather than panicking if that
1572                    // invariant ever changes.
1573                    Ok(WaitOutcome::Superseded {
1574                        run_id: superseded_run_id,
1575                        attempt,
1576                        by_attempt,
1577                    }) => ToolExecutionResult::tool_error(WaitOutcome::superseded_message(
1578                        &superseded_run_id,
1579                        attempt,
1580                        by_attempt,
1581                    )),
1582                    Err(error) => ToolExecutionResult::tool_error(error),
1583                }
1584            }
1585        }
1586    }
1587
1588    fn requires_context(&self) -> bool {
1589        true
1590    }
1591}
1592
1593// ============================================================================
1594// Task executor: external_agent
1595// ============================================================================
1596
1597/// Locate the agent run mirrored by a session task.
1598/// The run_id is stored in the task's spec so we can do a direct KV lookup.
1599fn reattach_network_access(
1600    record: &AgentRunRecord,
1601    context: &ToolContext,
1602) -> Option<NetworkAccessList> {
1603    record
1604        .network_access
1605        .clone()
1606        .or_else(|| context.network_access.clone())
1607}
1608
1609async fn load_run_for_task(
1610    context: &ToolContext,
1611    task: &SessionTask,
1612) -> std::result::Result<AgentRunRecord, String> {
1613    let Some(storage) = &context.storage_store else {
1614        return Err("external agent tasks require storage_store context".to_string());
1615    };
1616    // Direct lookup via run_id stored in task spec (set at spawn time).
1617    if let Some(run_id) = task.spec.get("run_id").and_then(Value::as_str)
1618        && let Ok(Some(serialized)) = storage
1619            .get_value(context.session_id, &run_key(run_id))
1620            .await
1621    {
1622        return serde_json::from_str::<AgentRunRecord>(&serialized)
1623            .map_err(|e| format!("invalid agent run record for task {}: {e}", task.id));
1624    }
1625    Err(format!("No agent run found for task {}", task.id))
1626}
1627
1628/// Agent config snapshot stored on the run, required to rebuild the A2A
1629/// client outside the capability's configured tool instances.
1630fn agent_snapshot(record: &AgentRunRecord) -> std::result::Result<ExternalA2aAgentConfig, String> {
1631    record.agent_config.clone().ok_or_else(|| {
1632        format!(
1633            "Agent run {} has no stored agent config snapshot (created before task support); use message_task/cancel_task instead",
1634            record.run_id
1635        )
1636    })
1637}
1638
1639/// Control plane for `external_agent` tasks. Rebuilds the A2A client from the
1640/// agent config snapshot persisted on the run record.
1641pub struct ExternalAgentTaskExecutor;
1642
1643#[async_trait]
1644impl TaskExecutor for ExternalAgentTaskExecutor {
1645    fn kind(&self) -> &str {
1646        TASK_KIND_EXTERNAL_AGENT
1647    }
1648
1649    fn can_reattach(&self) -> bool {
1650        true
1651    }
1652
1653    /// Re-attach to a running external A2A task after worker loss.
1654    ///
1655    /// Loads the persisted `AgentRunRecord` from session storage, then:
1656    /// - If already terminal: mirrors the terminal state to the registry and
1657    ///   returns (idempotent reconcile).
1658    /// - If `remote_task_id` or `agent_config` is absent: returns an error so
1659    ///   the reaper falls back to failing the task as orphaned.
1660    /// - Otherwise: rebuilds the A2A client from the stored config snapshot and
1661    ///   resumes the background poll loop with `heartbeat_attempt = task.attempt`
1662    ///   (the NEW attempt number after the reaper bumped it) so stale writes from
1663    ///   the superseded executor are rejected.
1664    async fn start(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1665        let record = load_run_for_task(context, task)
1666            .await
1667            .map_err(crate::error::AgentLoopError::tool)?;
1668        let context = context
1669            .clone()
1670            .with_network_access(reattach_network_access(&record, context));
1671
1672        // If the run is already terminal, just mirror and return.
1673        if record.status.is_terminal() {
1674            mirror_run_to_task(&context, &record).await;
1675            return Ok(());
1676        }
1677
1678        // Missing remote_task_id means we never sent to the remote agent —
1679        // there is nothing to poll; caller will fail this as orphaned.
1680        if record.remote_task_id.is_none() {
1681            return Err(crate::error::AgentLoopError::tool(format!(
1682                "external_agent task {} has no remote_task_id; cannot re-attach",
1683                task.id
1684            )));
1685        }
1686
1687        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1688
1689        // Resume the background poll loop. Use the NEW attempt (bumped by the
1690        // reaper) for heartbeating so the superseded executor's stale writes
1691        // are rejected by the fence.
1692        let heartbeat_attempt = Some(task.attempt);
1693        tokio::spawn(async move {
1694            background_monitor(
1695                context,
1696                agent,
1697                record,
1698                DEFAULT_WAIT_TIMEOUT_SECS,
1699                heartbeat_attempt,
1700            )
1701            .await;
1702        });
1703        Ok(())
1704    }
1705
1706    async fn deliver(
1707        &self,
1708        task: &SessionTask,
1709        message: &TaskMessage,
1710        context: &ToolContext,
1711    ) -> crate::error::Result<()> {
1712        let mut record = load_run_for_task(context, task)
1713            .await
1714            .map_err(crate::error::AgentLoopError::tool)?;
1715        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1716        let text = task_message_text(&message.content);
1717        let remote_task_id = record.remote_task_id.clone();
1718        let remote_context_id = record.remote_context_id.clone();
1719        // On send error the run state stays unchanged — return the error and
1720        // let the caller decide; the registry already holds the message.
1721        submit_run(
1722            context,
1723            &agent,
1724            &mut record,
1725            &text,
1726            remote_task_id,
1727            remote_context_id,
1728        )
1729        .await
1730        .map_err(crate::error::AgentLoopError::tool)
1731    }
1732
1733    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1734        let mut record = load_run_for_task(context, task)
1735            .await
1736            .map_err(crate::error::AgentLoopError::tool)?;
1737        if record.status.is_terminal() {
1738            return Ok(());
1739        }
1740        let Some(remote_task_id) = record.remote_task_id.clone() else {
1741            // Never reached the remote agent; cancel locally.
1742            record.status = AgentRunStatus::Canceled;
1743            save_run(context, &record).await?;
1744            return Ok(());
1745        };
1746        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1747        let client = build_client(&agent, context)
1748            .await
1749            .map_err(crate::error::AgentLoopError::tool)?;
1750        let remote = client
1751            .cancel_task(&CancelTaskRequest {
1752                id: remote_task_id,
1753                metadata: None,
1754                tenant: None,
1755            })
1756            .await
1757            .map_err(|e| {
1758                crate::error::AgentLoopError::tool(format!("A2A cancel_task failed: {e}"))
1759            })?;
1760        apply_task(&mut record, &remote);
1761        save_run(context, &record).await?;
1762        Ok(())
1763    }
1764
1765    async fn reconcile(
1766        &self,
1767        task: &SessionTask,
1768        context: &ToolContext,
1769    ) -> crate::error::Result<()> {
1770        let mut record = load_run_for_task(context, task)
1771            .await
1772            .map_err(crate::error::AgentLoopError::tool)?;
1773        if record.status.is_terminal() {
1774            return Ok(());
1775        }
1776        let Some(remote_task_id) = record.remote_task_id.clone() else {
1777            return Ok(());
1778        };
1779        let agent = agent_snapshot(&record).map_err(crate::error::AgentLoopError::tool)?;
1780        let client = build_client(&agent, context)
1781            .await
1782            .map_err(crate::error::AgentLoopError::tool)?;
1783        let remote = client
1784            .get_task(&GetTaskRequest {
1785                id: remote_task_id,
1786                history_length: Some(10),
1787                tenant: None,
1788            })
1789            .await
1790            .map_err(|e| crate::error::AgentLoopError::tool(format!("A2A get_task failed: {e}")))?;
1791        apply_task(&mut record, &remote);
1792        if record.status.is_terminal() {
1793            let _ = write_result_artifact(context, &mut record).await;
1794        }
1795        save_run(context, &record).await?;
1796        Ok(())
1797    }
1798}
1799
1800inventory::submit! {
1801    TaskExecutorPlugin {
1802        executor: || Arc::new(ExternalAgentTaskExecutor),
1803    }
1804}
1805
1806#[cfg(test)]
1807mod tests {
1808    use super::*;
1809    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
1810    use crate::session_task::SessionTaskRegistry;
1811    use crate::traits::SessionFileSystem;
1812    use crate::typed_id::SessionId;
1813    use a2a::StreamResponse;
1814    use a2a::{AgentCapabilities, AgentInterface, Artifact, TaskStatus, TaskStatusUpdateEvent};
1815    use a2a_server::agent_card::agent_card_router;
1816    use a2a_server::{
1817        DefaultRequestHandler, InMemoryTaskStore, StaticAgentCard, jsonrpc::jsonrpc_router,
1818    };
1819    use axum::Router;
1820    use futures::stream;
1821    use std::collections::{BTreeMap, HashMap};
1822    use std::sync::Mutex;
1823    use tokio::net::TcpListener;
1824    #[derive(Default)]
1825    struct TestStorageStore {
1826        values: Mutex<HashMap<String, String>>,
1827    }
1828
1829    #[async_trait]
1830    impl crate::traits::SessionStorageStore for TestStorageStore {
1831        async fn set_value(&self, _session_id: SessionId, key: &str, value: &str) -> Result<()> {
1832            self.values
1833                .lock()
1834                .unwrap()
1835                .insert(key.to_string(), value.to_string());
1836            Ok(())
1837        }
1838
1839        async fn get_value(&self, _session_id: SessionId, key: &str) -> Result<Option<String>> {
1840            Ok(self.values.lock().unwrap().get(key).cloned())
1841        }
1842
1843        async fn delete_value(&self, _session_id: SessionId, key: &str) -> Result<bool> {
1844            Ok(self.values.lock().unwrap().remove(key).is_some())
1845        }
1846
1847        async fn list_keys(&self, _session_id: SessionId) -> Result<Vec<crate::KeyInfo>> {
1848            let now = chrono::Utc::now();
1849            Ok(self
1850                .values
1851                .lock()
1852                .unwrap()
1853                .keys()
1854                .map(|key| crate::KeyInfo {
1855                    key: key.clone(),
1856                    created_at: now,
1857                    updated_at: now,
1858                })
1859                .collect())
1860        }
1861
1862        async fn set_secret(
1863            &self,
1864            _session_id: SessionId,
1865            _name: &str,
1866            _value: &str,
1867        ) -> Result<()> {
1868            Ok(())
1869        }
1870
1871        async fn get_secret(&self, _session_id: SessionId, _name: &str) -> Result<Option<String>> {
1872            Ok(None)
1873        }
1874
1875        async fn delete_secret(&self, _session_id: SessionId, _name: &str) -> Result<bool> {
1876            Ok(false)
1877        }
1878
1879        async fn list_secrets(&self, _session_id: SessionId) -> Result<Vec<crate::SecretInfo>> {
1880            Ok(Vec::new())
1881        }
1882    }
1883
1884    #[derive(Default)]
1885    struct TestFileStore {
1886        files: Mutex<HashMap<String, String>>,
1887    }
1888
1889    #[async_trait]
1890    impl SessionFileSystem for TestFileStore {
1891        fn is_mount_resolver(&self) -> bool {
1892            false
1893        }
1894
1895        async fn read_file(
1896            &self,
1897            session_id: SessionId,
1898            path: &str,
1899        ) -> Result<Option<SessionFile>> {
1900            Ok(self
1901                .files
1902                .lock()
1903                .unwrap()
1904                .get(path)
1905                .map(|content| SessionFile {
1906                    id: uuid::Uuid::new_v4(),
1907                    session_id: session_id.uuid(),
1908                    path: path.to_string(),
1909                    name: FileInfo::name_from_path(path),
1910                    content: Some(content.clone()),
1911                    encoding: "text".to_string(),
1912                    is_directory: false,
1913                    is_readonly: false,
1914                    size_bytes: content.len() as i64,
1915                    created_at: chrono::Utc::now(),
1916                    updated_at: chrono::Utc::now(),
1917                }))
1918        }
1919
1920        async fn write_file(
1921            &self,
1922            session_id: SessionId,
1923            path: &str,
1924            content: &str,
1925            _encoding: &str,
1926        ) -> Result<SessionFile> {
1927            self.files
1928                .lock()
1929                .unwrap()
1930                .insert(path.to_string(), content.to_string());
1931            Ok(SessionFile {
1932                id: uuid::Uuid::new_v4(),
1933                session_id: session_id.uuid(),
1934                path: path.to_string(),
1935                name: FileInfo::name_from_path(path),
1936                content: Some(content.to_string()),
1937                encoding: "text".to_string(),
1938                is_directory: false,
1939                is_readonly: false,
1940                size_bytes: content.len() as i64,
1941                created_at: chrono::Utc::now(),
1942                updated_at: chrono::Utc::now(),
1943            })
1944        }
1945
1946        async fn delete_file(
1947            &self,
1948            _session_id: SessionId,
1949            path: &str,
1950            _recursive: bool,
1951        ) -> Result<bool> {
1952            Ok(self.files.lock().unwrap().remove(path).is_some())
1953        }
1954
1955        async fn list_directory(
1956            &self,
1957            _session_id: SessionId,
1958            _path: &str,
1959        ) -> Result<Vec<FileInfo>> {
1960            Ok(vec![])
1961        }
1962
1963        async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
1964            Ok(None)
1965        }
1966
1967        async fn grep_files(
1968            &self,
1969            _session_id: SessionId,
1970            _pattern: &str,
1971            _path_pattern: Option<&str>,
1972        ) -> Result<Vec<GrepMatch>> {
1973            Ok(vec![])
1974        }
1975
1976        async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1977            Ok(FileInfo {
1978                id: uuid::Uuid::new_v4(),
1979                session_id: session_id.uuid(),
1980                path: path.to_string(),
1981                name: FileInfo::name_from_path(path),
1982                is_directory: true,
1983                is_readonly: false,
1984                size_bytes: 0,
1985                created_at: chrono::Utc::now(),
1986                updated_at: chrono::Utc::now(),
1987            })
1988        }
1989    }
1990
1991    struct EchoA2aExecutor;
1992
1993    impl a2a_server::AgentExecutor for EchoA2aExecutor {
1994        fn execute(
1995            &self,
1996            ctx: a2a_server::ExecutorContext,
1997        ) -> futures::stream::BoxStream<'static, std::result::Result<StreamResponse, a2a::A2AError>>
1998        {
1999            let task_id = ctx.task_id.clone();
2000            let context_id = ctx.context_id.clone();
2001            let text = ctx
2002                .message
2003                .as_ref()
2004                .and_then(Message::text)
2005                .unwrap_or_default()
2006                .to_string();
2007            let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
2008                task_id: task_id.clone(),
2009                context_id: context_id.clone(),
2010                status: TaskStatus {
2011                    state: TaskState::Working,
2012                    message: None,
2013                    timestamp: None,
2014                },
2015                metadata: None,
2016            });
2017            let completed = StreamResponse::Task(Task {
2018                id: task_id,
2019                context_id,
2020                status: TaskStatus {
2021                    state: TaskState::Completed,
2022                    message: None,
2023                    timestamp: None,
2024                },
2025                artifacts: Some(vec![Artifact {
2026                    artifact_id: a2a::new_artifact_id(),
2027                    name: Some("echo".to_string()),
2028                    description: None,
2029                    parts: vec![
2030                        Part::text(format!("echo: {text}")),
2031                        Part::data(json!({"echo": text})),
2032                    ],
2033                    metadata: None,
2034                    extensions: None,
2035                }]),
2036                history: ctx.stored_task.and_then(|task| task.history),
2037                metadata: None,
2038            });
2039            Box::pin(stream::iter(vec![Ok(working), Ok(completed)]))
2040        }
2041
2042        fn cancel(
2043            &self,
2044            ctx: a2a_server::ExecutorContext,
2045        ) -> futures::stream::BoxStream<'static, std::result::Result<StreamResponse, a2a::A2AError>>
2046        {
2047            let canceled = StreamResponse::Task(Task {
2048                id: ctx.task_id,
2049                context_id: ctx.context_id,
2050                status: TaskStatus {
2051                    state: TaskState::Canceled,
2052                    message: None,
2053                    timestamp: None,
2054                },
2055                artifacts: None,
2056                history: None,
2057                metadata: None,
2058            });
2059            Box::pin(stream::once(async move { Ok(canceled) }))
2060        }
2061    }
2062
2063    async fn spawn_real_a2a_agent() -> String {
2064        crate::telemetry::install_crypto_provider();
2065        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2066        let addr = listener.local_addr().unwrap();
2067        let base_url = format!("http://{addr}");
2068        let card = AgentCard {
2069            name: "Echo A2A Agent".to_string(),
2070            description: "Real A2A test agent".to_string(),
2071            version: "1.0.0".to_string(),
2072            supported_interfaces: vec![AgentInterface::new(
2073                format!("{base_url}/jsonrpc"),
2074                "JSONRPC",
2075            )],
2076            capabilities: AgentCapabilities {
2077                streaming: Some(true),
2078                push_notifications: Some(false),
2079                extensions: None,
2080                extended_agent_card: None,
2081            },
2082            default_input_modes: vec!["text/plain".to_string()],
2083            default_output_modes: vec!["text/plain".to_string()],
2084            skills: vec![],
2085            provider: None,
2086            documentation_url: None,
2087            icon_url: None,
2088            security_schemes: None,
2089            security_requirements: None,
2090            signatures: None,
2091        };
2092        let handler = Arc::new(DefaultRequestHandler::new(
2093            EchoA2aExecutor,
2094            InMemoryTaskStore::default(),
2095        ));
2096        let app = Router::new()
2097            .merge(agent_card_router(Arc::new(StaticAgentCard::new(card))))
2098            .nest("/jsonrpc", jsonrpc_router(handler));
2099        tokio::spawn(async move {
2100            axum::serve(listener, app).await.unwrap();
2101        });
2102        base_url
2103    }
2104
2105    fn configured_capability(base_url: String) -> A2aDelegationConfig {
2106        A2aDelegationConfig {
2107            agents: vec![ExternalA2aAgentConfig {
2108                id: "echo".to_string(),
2109                name: "Echo".to_string(),
2110                description: Some("Echo test agent".to_string()),
2111                base_url: Some(base_url),
2112                agent_card: None,
2113                headers: BTreeMap::new(),
2114                preferred_binding: Some("JSONRPC".to_string()),
2115                poll_interval_ms: Some(100),
2116                allow_local_urls: true,
2117            }],
2118        }
2119    }
2120
2121    fn context(
2122        storage_store: Arc<TestStorageStore>,
2123        file_store: Arc<TestFileStore>,
2124    ) -> ToolContext {
2125        ToolContext::with_stores(SessionId::new(), file_store, storage_store)
2126    }
2127
2128    #[tokio::test]
2129    async fn spawn_agent_foreground_calls_real_a2a_agent_with_target_id_alias() {
2130        let base_url = spawn_real_a2a_agent().await;
2131        let config = configured_capability(base_url);
2132        let tool = SpawnAgentTool::new(config);
2133        let storage_store = Arc::new(TestStorageStore::default());
2134        let file_store = Arc::new(TestFileStore::default());
2135        let ctx = context(storage_store, file_store);
2136
2137        let result = tool
2138            .execute_with_context(
2139                json!({
2140                    "instructions": "hello",
2141                    "target": {"type": "external_a2a", "id": "echo"},
2142                    "mode": "foreground",
2143                    "wait_timeout_secs": 5
2144                }),
2145                &ctx,
2146            )
2147            .await;
2148
2149        let ToolExecutionResult::Success(value) = result else {
2150            panic!("expected success: {result:?}");
2151        };
2152        assert_eq!(value["status"], "completed");
2153        assert_eq!(value["result"], "echo: hello");
2154        assert!(value["result_path"].as_str().is_some());
2155    }
2156
2157    #[tokio::test]
2158    async fn spawn_agent_rejects_legacy_wait_mode() {
2159        let tool = SpawnAgentTool::new(configured_capability("http://127.0.0.1:1".to_string()));
2160        let ctx = context(
2161            Arc::new(TestStorageStore::default()),
2162            Arc::new(TestFileStore::default()),
2163        );
2164        let result = tool
2165            .execute_with_context(
2166                json!({
2167                    "instructions": "never sent",
2168                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2169                    "mode": "wait"
2170                }),
2171                &ctx,
2172            )
2173            .await;
2174        let ToolExecutionResult::ToolError(message) = result else {
2175            panic!("expected legacy mode rejection: {result:?}");
2176        };
2177        assert!(message.contains("background, foreground"));
2178    }
2179
2180    #[tokio::test]
2181    async fn spawn_agent_foreground_validates_a2a_data_artifact_and_writes_task_result() {
2182        let tool = SpawnAgentTool::new(configured_capability(spawn_real_a2a_agent().await));
2183        let storage_store = Arc::new(TestStorageStore::default());
2184        let file_store = Arc::new(TestFileStore::default());
2185        let registry = Arc::new(InMemRegistry::default());
2186        let ctx =
2187            context(storage_store, file_store.clone()).with_session_task_registry(registry.clone());
2188
2189        let result = tool
2190            .execute_with_context(
2191                json!({
2192                    "instructions": "structured",
2193                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2194                    "mode": "foreground",
2195                    "wait_timeout_secs": 5,
2196                    "result_schema": {
2197                        "type": "object",
2198                        "properties": {"echo": {"type": "string"}},
2199                        "required": ["echo"],
2200                        "additionalProperties": false
2201                    }
2202                }),
2203                &ctx,
2204            )
2205            .await;
2206
2207        let ToolExecutionResult::Success(value) = result else {
2208            panic!("expected success: {result:?}");
2209        };
2210        assert_eq!(value["status"], "completed");
2211        let task_id = value["task_id"].as_str().expect("task_id");
2212        let expected_path = crate::session_task::task_result_path(task_id);
2213        assert_eq!(value["result_path"], expected_path);
2214        let content = file_store
2215            .files
2216            .lock()
2217            .unwrap()
2218            .get(&expected_path)
2219            .cloned()
2220            .expect("result file");
2221        assert_eq!(
2222            serde_json::from_str::<Value>(&content).unwrap(),
2223            json!({"echo": "structured"})
2224        );
2225        let task = registry
2226            .get(ctx.session_id, task_id)
2227            .await
2228            .unwrap()
2229            .unwrap();
2230        assert_eq!(task.state, SessionTaskState::Succeeded);
2231        assert_eq!(task.result_path.as_deref(), Some(expected_path.as_str()));
2232    }
2233
2234    #[tokio::test]
2235    async fn spawn_agent_foreground_marks_a2a_schema_mismatch_failed() {
2236        let tool = SpawnAgentTool::new(configured_capability(spawn_real_a2a_agent().await));
2237        let storage_store = Arc::new(TestStorageStore::default());
2238        let file_store = Arc::new(TestFileStore::default());
2239        let registry = Arc::new(InMemRegistry::default());
2240        let ctx = context(storage_store, file_store).with_session_task_registry(registry.clone());
2241
2242        let result = tool
2243            .execute_with_context(
2244                json!({
2245                    "instructions": "structured",
2246                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2247                    "mode": "foreground",
2248                    "wait_timeout_secs": 5,
2249                    "result_schema": {
2250                        "type": "object",
2251                        "properties": {"echo": {"type": "integer"}},
2252                        "required": ["echo"]
2253                    }
2254                }),
2255                &ctx,
2256            )
2257            .await;
2258
2259        let ToolExecutionResult::Success(value) = result else {
2260            panic!("expected terminal run result: {result:?}");
2261        };
2262        assert_eq!(value["status"], "failed");
2263        let task_id = value["task_id"].as_str().expect("task_id");
2264        let task = registry
2265            .get(ctx.session_id, task_id)
2266            .await
2267            .unwrap()
2268            .unwrap();
2269        assert_eq!(task.state, SessionTaskState::Failed);
2270        assert_eq!(
2271            task.error.as_ref().map(|error| error.kind.as_str()),
2272            Some("schema_mismatch")
2273        );
2274        assert!(task.result_path.is_none());
2275    }
2276
2277    #[tokio::test]
2278    async fn spawn_agent_rejects_message_schema_for_external_a2a() {
2279        let tool = SpawnAgentTool::new(configured_capability("http://127.0.0.1:1".to_string()));
2280        let ctx = context(
2281            Arc::new(TestStorageStore::default()),
2282            Arc::new(TestFileStore::default()),
2283        );
2284        let result = tool
2285            .execute_with_context(
2286                json!({
2287                    "instructions": "never sent",
2288                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2289                    "message_schema": {"type": "object"}
2290                }),
2291                &ctx,
2292            )
2293            .await;
2294        let ToolExecutionResult::ToolError(message) = result else {
2295            panic!("expected explicit rejection: {result:?}");
2296        };
2297        assert!(message.contains("message_schema is not supported for external_a2a"));
2298    }
2299
2300    #[test]
2301    fn uk_localization_and_schema_one_of_match_validation() {
2302        let cap = A2aAgentDelegationCapability;
2303        assert_eq!(cap.localized_name(Some("uk-UA")), "Делегування агентам A2A");
2304        assert!(
2305            cap.localized_description(Some("uk-UA"))
2306                .contains("Делегує роботу")
2307        );
2308        assert!(cap.describe_schema(Some("uk")).is_some());
2309        assert!(cap.describe_schema(None).is_some());
2310
2311        // preferred_binding oneOf consts must be exactly the values
2312        // validate_config accepts.
2313        let schema = cap.config_schema().expect("config schema");
2314        let consts: Vec<&str> =
2315            schema["properties"]["agents"]["items"]["properties"]["preferred_binding"]["oneOf"]
2316                .as_array()
2317                .expect("oneOf")
2318                .iter()
2319                .map(|v| v["const"].as_str().expect("const"))
2320                .collect();
2321        assert_eq!(consts, vec!["JSONRPC", "HTTP+JSON"]);
2322        for binding in consts {
2323            let config = json!({
2324                "agents": [{
2325                    "id": "echo",
2326                    "name": "Echo",
2327                    "base_url": "https://agent.example.com",
2328                    "preferred_binding": binding
2329                }]
2330            });
2331            cap.validate_config(&config)
2332                .unwrap_or_else(|e| panic!("{binding} should validate: {e}"));
2333        }
2334        assert!(
2335            cap.validate_config(&json!({
2336                "agents": [{
2337                    "id": "echo",
2338                    "name": "Echo",
2339                    "base_url": "https://agent.example.com",
2340                    "preferred_binding": "SMTP"
2341                }]
2342            }))
2343            .is_err()
2344        );
2345
2346        let tool_schema = SpawnAgentTool::new(A2aDelegationConfig::default()).parameters_schema();
2347        assert_eq!(
2348            tool_schema["properties"]["mode"]["enum"],
2349            json!(["background", "foreground"])
2350        );
2351        assert!(tool_schema["properties"]["target"]["properties"]["id"].is_object());
2352    }
2353
2354    #[test]
2355    fn validates_local_urls_only_with_escape_hatch() {
2356        let mut config = configured_capability("http://127.0.0.1:1".to_string());
2357        config.agents[0].allow_local_urls = false;
2358        assert!(config.agents[0].validate().is_err());
2359        config.agents[0].allow_local_urls = true;
2360        assert!(config.agents[0].validate().is_ok());
2361    }
2362
2363    #[test]
2364    fn reattach_network_access_prefers_persisted_run_policy() {
2365        use crate::SessionId;
2366
2367        let config = ExternalA2aAgentConfig {
2368            id: "echo".to_string(),
2369            name: "Echo".to_string(),
2370            description: None,
2371            base_url: Some("https://allowed.example.com/a2a".to_string()),
2372            agent_card: None,
2373            headers: BTreeMap::new(),
2374            preferred_binding: None,
2375            poll_interval_ms: None,
2376            allow_local_urls: false,
2377        };
2378        let mut record = AgentRunRecord::new(
2379            "run-policy".to_string(),
2380            &config,
2381            "instructions".to_string(),
2382            SpawnMode::Background,
2383            false,
2384            None,
2385        );
2386        let persisted_policy =
2387            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]);
2388        record.network_access = Some(persisted_policy.clone());
2389
2390        let reaper_context = ToolContext::new(SessionId::new()).with_network_access(None);
2391        assert_eq!(
2392            reattach_network_access(&record, &reaper_context),
2393            Some(persisted_policy.clone())
2394        );
2395
2396        let fallback_policy =
2397            NetworkAccessList::allow_only(vec!["fallback.example.com".to_string()]);
2398        let fallback_context =
2399            ToolContext::new(SessionId::new()).with_network_access(Some(fallback_policy.clone()));
2400        record.network_access = None;
2401        assert_eq!(
2402            reattach_network_access(&record, &fallback_context),
2403            Some(fallback_policy)
2404        );
2405    }
2406
2407    #[test]
2408    fn enforce_network_access_blocks_disallowed_base_url() {
2409        use crate::SessionId;
2410        use crate::network_access::NetworkAccessList;
2411
2412        let agent = ExternalA2aAgentConfig {
2413            id: "a".to_string(),
2414            name: "a".to_string(),
2415            description: None,
2416            base_url: Some("https://blocked.example.com".to_string()),
2417            agent_card: None,
2418            headers: BTreeMap::new(),
2419            preferred_binding: None,
2420            poll_interval_ms: None,
2421            allow_local_urls: false,
2422        };
2423        let card = AgentCard {
2424            name: "a".to_string(),
2425            description: "a".to_string(),
2426            version: "1".to_string(),
2427            supported_interfaces: vec![],
2428            capabilities: AgentCapabilities {
2429                streaming: None,
2430                push_notifications: None,
2431                extensions: None,
2432                extended_agent_card: None,
2433            },
2434            default_input_modes: vec![],
2435            default_output_modes: vec![],
2436            skills: vec![],
2437            provider: None,
2438            documentation_url: None,
2439            icon_url: None,
2440            security_schemes: None,
2441            security_requirements: None,
2442            signatures: None,
2443        };
2444
2445        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2446            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2447        ));
2448        let err = enforce_network_access_pre_resolve(&agent, &ctx).unwrap_err();
2449        assert!(
2450            err.contains("blocked.example.com"),
2451            "unexpected error: {err}"
2452        );
2453
2454        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2455            NetworkAccessList::allow_only(vec!["blocked.example.com".to_string()]),
2456        ));
2457        enforce_network_access_pre_resolve(&agent, &ctx).unwrap();
2458        enforce_network_access_post_resolve(&card, &ctx).unwrap();
2459    }
2460
2461    #[test]
2462    fn enforce_network_access_blocks_disallowed_interface_url() {
2463        use crate::SessionId;
2464        use crate::network_access::NetworkAccessList;
2465
2466        let card = AgentCard {
2467            name: "a".to_string(),
2468            description: "a".to_string(),
2469            version: "1".to_string(),
2470            supported_interfaces: vec![AgentInterface::new(
2471                "https://probe.internal/api".to_string(),
2472                "JSONRPC",
2473            )],
2474            capabilities: AgentCapabilities {
2475                streaming: None,
2476                push_notifications: None,
2477                extensions: None,
2478                extended_agent_card: None,
2479            },
2480            default_input_modes: vec![],
2481            default_output_modes: vec![],
2482            skills: vec![],
2483            provider: None,
2484            documentation_url: None,
2485            icon_url: None,
2486            security_schemes: None,
2487            security_requirements: None,
2488            signatures: None,
2489        };
2490        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2491            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2492        ));
2493        let err = enforce_network_access_post_resolve(&card, &ctx).unwrap_err();
2494        assert!(err.contains("probe.internal"), "unexpected error: {err}");
2495    }
2496
2497    #[test]
2498    fn enforce_network_access_pre_resolve_skips_when_inline_card_present() {
2499        use crate::SessionId;
2500        use crate::network_access::NetworkAccessList;
2501
2502        // base_url not on the allowlist, but agent_card is supplied inline so
2503        // resolve_card never performs discovery against base_url. The pre-resolve
2504        // gate must skip the base_url check to avoid spurious failures.
2505        let inline_card = AgentCard {
2506            name: "a".to_string(),
2507            description: "a".to_string(),
2508            version: "1".to_string(),
2509            supported_interfaces: vec![AgentInterface::new(
2510                "https://allowed.example.com/api".to_string(),
2511                "JSONRPC",
2512            )],
2513            capabilities: AgentCapabilities {
2514                streaming: None,
2515                push_notifications: None,
2516                extensions: None,
2517                extended_agent_card: None,
2518            },
2519            default_input_modes: vec![],
2520            default_output_modes: vec![],
2521            skills: vec![],
2522            provider: None,
2523            documentation_url: None,
2524            icon_url: None,
2525            security_schemes: None,
2526            security_requirements: None,
2527            signatures: None,
2528        };
2529        let agent = ExternalA2aAgentConfig {
2530            id: "a".to_string(),
2531            name: "a".to_string(),
2532            description: None,
2533            base_url: Some("https://stale.unused.example.com".to_string()),
2534            agent_card: Some(inline_card),
2535            headers: BTreeMap::new(),
2536            preferred_binding: None,
2537            poll_interval_ms: None,
2538            allow_local_urls: false,
2539        };
2540        let ctx = ToolContext::new(SessionId::new()).with_network_access(Some(
2541            NetworkAccessList::allow_only(vec!["allowed.example.com".to_string()]),
2542        ));
2543        enforce_network_access_pre_resolve(&agent, &ctx).unwrap();
2544    }
2545
2546    #[test]
2547    fn local_url_escape_hatch_still_rejects_bad_url_shape() {
2548        let mut config = configured_capability("file:///tmp/agent".to_string());
2549        config.agents[0].allow_local_urls = true;
2550        assert!(config.agents[0].validate().is_err());
2551
2552        let mut config = configured_capability("http://127.0.0.1:1".to_string());
2553        config.agents[0].allow_local_urls = true;
2554        config.agents[0].preferred_binding = Some("SMTP".to_string());
2555        assert!(config.agents[0].validate().is_err());
2556
2557        config.agents[0].preferred_binding = Some("JSONRPC".to_string());
2558        config.agents[0].poll_interval_ms = Some(99);
2559        assert!(config.agents[0].validate().is_err());
2560    }
2561
2562    // -------------------------------------------------------------------------
2563    // ExternalAgentTaskExecutor::start() tests
2564    // -------------------------------------------------------------------------
2565
2566    /// Minimal in-memory task registry for executor tests.
2567    #[derive(Default, Clone)]
2568    struct InMemRegistry {
2569        tasks: Arc<Mutex<HashMap<String, crate::session_task::SessionTask>>>,
2570    }
2571
2572    #[async_trait]
2573    impl crate::session_task::SessionTaskRegistry for InMemRegistry {
2574        async fn create(
2575            &self,
2576            input: crate::session_task::CreateSessionTask,
2577        ) -> crate::error::Result<crate::session_task::SessionTask> {
2578            let task = crate::session_task::new_session_task(input, chrono::Utc::now());
2579            self.tasks
2580                .lock()
2581                .unwrap()
2582                .insert(task.id.clone(), task.clone());
2583            Ok(task)
2584        }
2585
2586        async fn get(
2587            &self,
2588            _session_id: crate::typed_id::SessionId,
2589            task_id: &str,
2590        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2591            Ok(self.tasks.lock().unwrap().get(task_id).cloned())
2592        }
2593
2594        async fn list(
2595            &self,
2596            _session_id: crate::typed_id::SessionId,
2597            _filter: Option<&crate::session_task::SessionTaskFilter>,
2598        ) -> crate::error::Result<Vec<crate::session_task::SessionTask>> {
2599            Ok(self.tasks.lock().unwrap().values().cloned().collect())
2600        }
2601
2602        async fn update(
2603            &self,
2604            _session_id: crate::typed_id::SessionId,
2605            task_id: &str,
2606            update: crate::session_task::SessionTaskUpdate,
2607        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2608            let mut tasks = self.tasks.lock().unwrap();
2609            let Some(task) = tasks.get_mut(task_id) else {
2610                return Ok(None);
2611            };
2612            crate::session_task::apply_task_update(task, update, chrono::Utc::now());
2613            Ok(Some(task.clone()))
2614        }
2615
2616        async fn request_cancel(
2617            &self,
2618            _session_id: crate::typed_id::SessionId,
2619            _task_id: &str,
2620        ) -> crate::error::Result<Option<crate::session_task::SessionTask>> {
2621            Ok(None)
2622        }
2623
2624        async fn record_message(
2625            &self,
2626            _session_id: crate::typed_id::SessionId,
2627            _task_id: &str,
2628            _message: crate::session_task::NewTaskMessage,
2629        ) -> crate::error::Result<crate::session_task::TaskMessage> {
2630            Err(crate::error::AgentLoopError::tool("not implemented"))
2631        }
2632
2633        async fn list_messages(
2634            &self,
2635            _session_id: crate::typed_id::SessionId,
2636            _task_id: &str,
2637            _limit: Option<u32>,
2638            _after_id: Option<&str>,
2639        ) -> crate::error::Result<Vec<crate::session_task::TaskMessage>> {
2640            Ok(vec![])
2641        }
2642    }
2643
2644    #[test]
2645    fn a2a_delegation_depends_on_session_tasks() {
2646        let cap = A2aAgentDelegationCapability;
2647
2648        assert_eq!(cap.dependencies(), vec![SESSION_TASKS_CAPABILITY_ID]);
2649    }
2650
2651    #[tokio::test]
2652    async fn background_spawn_requires_task_registry_before_remote_work() {
2653        let config = configured_capability("http://127.0.0.1:1".to_string());
2654        let spawn = SpawnAgentTool::new(config);
2655        let storage_store = Arc::new(TestStorageStore::default());
2656        let file_store = Arc::new(TestFileStore::default());
2657        let ctx = context(storage_store.clone(), file_store);
2658
2659        let result = spawn
2660            .execute_with_context(
2661                json!({
2662                    "instructions": "background",
2663                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2664                    "mode": "background"
2665                }),
2666                &ctx,
2667            )
2668            .await;
2669
2670        let ToolExecutionResult::ToolError(message) = result else {
2671            panic!("background spawn should reject missing task registry");
2672        };
2673        assert!(
2674            message.contains("requires session_task_registry"),
2675            "unexpected error: {message}"
2676        );
2677        assert!(
2678            storage_store.values.lock().unwrap().is_empty(),
2679            "background spawn must not persist or launch remote work without task tracking"
2680        );
2681    }
2682
2683    /// Parity check for the retired `wait_agent`: a background `spawn_agent`
2684    /// run is observable end-to-end through the generic `wait_task` tool.
2685    /// The background poll loop mirrors each remote snapshot onto the session
2686    /// task via `save_run`, so `wait_task` converges to the terminal state.
2687    #[tokio::test]
2688    async fn background_spawn_is_waitable_via_generic_wait_task() {
2689        use crate::capabilities::session_tasks::WaitTaskTool;
2690
2691        let base_url = spawn_real_a2a_agent().await;
2692        let config = configured_capability(base_url);
2693        let spawn = SpawnAgentTool::new(config);
2694
2695        // A context WITH a task registry so the background run creates and
2696        // mirrors a session task (the registry is what wait_task reads).
2697        let storage_store = Arc::new(TestStorageStore::default());
2698        let file_store = Arc::new(TestFileStore::default());
2699        let registry = Arc::new(InMemRegistry::default());
2700        let ctx = ToolContext::with_stores(SessionId::new(), file_store, storage_store)
2701            .with_session_task_registry(registry.clone());
2702
2703        let result = spawn
2704            .execute_with_context(
2705                json!({
2706                    "instructions": "background",
2707                    "target": {"type": "external_a2a", "external_agent_id": "echo"},
2708                    "mode": "background",
2709                    "wait_timeout_secs": 5,
2710                    "wake_on_completion": false
2711                }),
2712                &ctx,
2713            )
2714            .await;
2715        let ToolExecutionResult::Success(value) = result else {
2716            panic!("expected spawn success: {result:?}");
2717        };
2718        let task_id = value["task_id"]
2719            .as_str()
2720            .expect("background spawn returns a task_id when a registry is present");
2721
2722        let waited = WaitTaskTool
2723            .execute_with_context(json!({"task_id": task_id, "timeout_seconds": 5}), &ctx)
2724            .await;
2725        let ToolExecutionResult::Success(value) = waited else {
2726            panic!("expected wait_task success: {waited:?}");
2727        };
2728        assert_eq!(
2729            value["timed_out"], false,
2730            "wait_task should observe a terminal state, got {value:?}"
2731        );
2732        assert_eq!(value["task"]["state"], "succeeded");
2733    }
2734
2735    /// Build a SessionTask snapshot for testing (not persisted in any store).
2736    fn fake_task_with_spec(
2737        session_id: crate::typed_id::SessionId,
2738        run_id: &str,
2739        attempt: i32,
2740    ) -> crate::session_task::SessionTask {
2741        let now = chrono::Utc::now();
2742        crate::session_task::SessionTask {
2743            id: format!("task_{run_id}"),
2744            session_id,
2745            root_session_id: None,
2746            kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2747            display_name: "Test external agent".to_string(),
2748            spec: json!({ "run_id": run_id }),
2749            state: SessionTaskState::Running,
2750            state_detail: None,
2751            progress: None,
2752            links: TaskLinks::default(),
2753            wake_policy: TaskWakePolicy::Silent,
2754            input_request: None,
2755            cancel_requested_at: None,
2756            summary: None,
2757            result_path: None,
2758            artifacts: vec![],
2759            error: None,
2760            attempt,
2761            worker_id: None,
2762            heartbeat_at: None,
2763            started_at: None,
2764            finished_at: None,
2765            created_at: now,
2766            updated_at: now,
2767        }
2768    }
2769
2770    /// start() with a terminal run record should mirror state and return Ok.
2771    #[tokio::test]
2772    async fn external_agent_executor_start_mirrors_terminal_run() {
2773        let storage = Arc::new(TestStorageStore::default());
2774        let registry = Arc::new(InMemRegistry::default());
2775        let session_id = crate::typed_id::SessionId::new();
2776
2777        let run_id = "run-terminal".to_string();
2778        let config = ExternalA2aAgentConfig {
2779            id: "echo".to_string(),
2780            name: "Echo".to_string(),
2781            description: None,
2782            base_url: None,
2783            agent_card: None,
2784            headers: BTreeMap::new(),
2785            preferred_binding: None,
2786            poll_interval_ms: None,
2787            allow_local_urls: false,
2788        };
2789        let task_id = format!("task_{run_id}");
2790
2791        // Create a completed run record.
2792        let mut record = AgentRunRecord::new(
2793            run_id.clone(),
2794            &config,
2795            "instructions".to_string(),
2796            SpawnMode::Background,
2797            false,
2798            None,
2799        );
2800        record.status = AgentRunStatus::Completed;
2801        record.result = Some("done".to_string());
2802        record.remote_task_id = Some("remote-xyz".to_string());
2803        record.task_id = Some(task_id.clone());
2804
2805        // Persist the run record.
2806        let serialized = serde_json::to_string(&record).unwrap();
2807        storage
2808            .set_value(session_id, &run_key(&run_id), &serialized)
2809            .await
2810            .unwrap();
2811
2812        // Create the session task in the registry so mirror_run_to_task can update it.
2813        registry
2814            .create(crate::session_task::CreateSessionTask {
2815                session_id,
2816                id: Some(task_id.clone()),
2817                kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2818                display_name: "Echo".to_string(),
2819                spec: json!({ "run_id": &run_id }),
2820                state: SessionTaskState::Running,
2821                links: TaskLinks::default(),
2822                wake_policy: TaskWakePolicy::Silent,
2823            })
2824            .await
2825            .unwrap();
2826
2827        let ctx = ToolContext::new(session_id)
2828            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>)
2829            .with_session_task_registry(registry.clone());
2830
2831        let task = fake_task_with_spec(session_id, &run_id, 2);
2832        let executor = ExternalAgentTaskExecutor;
2833        executor
2834            .start(&task, &ctx)
2835            .await
2836            .expect("start should succeed");
2837
2838        // The registry task should now reflect the terminal state.
2839        let updated = registry.get(session_id, &task_id).await.unwrap().unwrap();
2840        assert_eq!(
2841            updated.state,
2842            SessionTaskState::Succeeded,
2843            "terminal run should mirror to succeeded"
2844        );
2845    }
2846
2847    /// A heartbeat fence miss (task attempt moved past ours) must abort the
2848    /// poll loop with the superseded error before any remote call or write.
2849    #[tokio::test]
2850    async fn wait_for_run_exits_superseded_on_fence_miss() {
2851        let storage = Arc::new(TestStorageStore::default());
2852        let registry = Arc::new(InMemRegistry::default());
2853        let session_id = crate::typed_id::SessionId::new();
2854
2855        let run_id = "run-superseded".to_string();
2856        // Inline card so build_client performs no network discovery; the
2857        // heartbeat fence check fires before any remote get_task call.
2858        let inline_card = AgentCard {
2859            name: "Echo".to_string(),
2860            description: "Echo".to_string(),
2861            version: "1".to_string(),
2862            supported_interfaces: vec![AgentInterface::new(
2863                "https://agent.example.com/api".to_string(),
2864                "JSONRPC",
2865            )],
2866            capabilities: AgentCapabilities {
2867                streaming: None,
2868                push_notifications: None,
2869                extensions: None,
2870                extended_agent_card: None,
2871            },
2872            default_input_modes: vec![],
2873            default_output_modes: vec![],
2874            skills: vec![],
2875            provider: None,
2876            documentation_url: None,
2877            icon_url: None,
2878            security_schemes: None,
2879            security_requirements: None,
2880            signatures: None,
2881        };
2882        let config = ExternalA2aAgentConfig {
2883            id: "echo".to_string(),
2884            name: "Echo".to_string(),
2885            description: None,
2886            base_url: Some("https://agent.example.com".to_string()),
2887            agent_card: Some(inline_card),
2888            headers: BTreeMap::new(),
2889            preferred_binding: None,
2890            poll_interval_ms: None,
2891            allow_local_urls: false,
2892        };
2893        let task_id = format!("task_{run_id}");
2894
2895        let mut record = AgentRunRecord::new(
2896            run_id.clone(),
2897            &config,
2898            "instructions".to_string(),
2899            SpawnMode::Background,
2900            false,
2901            None,
2902        );
2903        record.remote_task_id = Some("remote-xyz".to_string());
2904        record.task_id = Some(task_id.clone());
2905
2906        // Task in the registry already at attempt 2 (reaper superseded us).
2907        registry
2908            .create(crate::session_task::CreateSessionTask {
2909                session_id,
2910                id: Some(task_id.clone()),
2911                kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
2912                display_name: "Echo".to_string(),
2913                spec: json!({ "run_id": &run_id }),
2914                state: SessionTaskState::Running,
2915                links: TaskLinks::default(),
2916                wake_policy: TaskWakePolicy::Silent,
2917            })
2918            .await
2919            .unwrap();
2920        registry
2921            .update(
2922                session_id,
2923                &task_id,
2924                crate::session_task::SessionTaskUpdate {
2925                    increment_attempt: true,
2926                    ..Default::default()
2927                },
2928            )
2929            .await
2930            .unwrap();
2931
2932        let ctx = ToolContext::new(session_id)
2933            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>)
2934            .with_session_task_registry(registry.clone());
2935
2936        // Poll as the old executor (attempt 1): the first heartbeat reveals
2937        // the supersession and the loop exits before any remote call.
2938        let outcome = wait_for_run(&ctx, &config, record, 30, Some(1))
2939            .await
2940            .expect("superseded poll must not be a transport error");
2941        // EVE-645: supersession is now a typed WaitOutcome variant rather than
2942        // a string-prefix sniff on the error.
2943        let WaitOutcome::Superseded {
2944            run_id: superseded_run_id,
2945            attempt,
2946            by_attempt,
2947        } = outcome
2948        else {
2949            panic!("expected Superseded outcome");
2950        };
2951        assert_eq!(superseded_run_id, run_id);
2952        assert_eq!(attempt, 1);
2953        assert_eq!(by_attempt, 2);
2954        // Diagnostic string still carries the legacy prefix for log greps.
2955        assert!(
2956            WaitOutcome::superseded_message(&superseded_run_id, attempt, by_attempt)
2957                .starts_with(SUPERSEDED_ERROR_PREFIX)
2958        );
2959    }
2960
2961    // EVE-645: timeout is selected via the typed WaitOutcome::TimedOut variant
2962    // and its message stays byte-identical to the legacy string.
2963    #[test]
2964    fn wait_outcome_timed_out_message_is_stable() {
2965        let msg = WaitOutcome::timed_out_message("run-123", 30);
2966        assert_eq!(
2967            msg,
2968            "Timed out waiting for external agent run run-123 after 30s"
2969        );
2970    }
2971
2972    /// start() with a run that has no remote_task_id should return an error.
2973    #[tokio::test]
2974    async fn external_agent_executor_start_errors_on_missing_remote_task_id() {
2975        let storage = Arc::new(TestStorageStore::default());
2976        let session_id = crate::typed_id::SessionId::new();
2977
2978        let run_id = "run-no-remote".to_string();
2979        let config = ExternalA2aAgentConfig {
2980            id: "echo".to_string(),
2981            name: "Echo".to_string(),
2982            description: None,
2983            base_url: None,
2984            agent_card: None,
2985            headers: BTreeMap::new(),
2986            preferred_binding: None,
2987            poll_interval_ms: None,
2988            allow_local_urls: false,
2989        };
2990
2991        // Create a run record without a remote_task_id (never reached the remote agent).
2992        let record = AgentRunRecord::new(
2993            run_id.clone(),
2994            &config,
2995            "instructions".to_string(),
2996            SpawnMode::Background,
2997            false,
2998            None,
2999        );
3000        assert!(record.remote_task_id.is_none());
3001
3002        let serialized = serde_json::to_string(&record).unwrap();
3003        storage
3004            .set_value(session_id, &run_key(&run_id), &serialized)
3005            .await
3006            .unwrap();
3007
3008        let ctx = ToolContext::new(session_id)
3009            .with_storage_store_arc(storage.clone() as Arc<dyn crate::traits::SessionStorageStore>);
3010
3011        let task = fake_task_with_spec(session_id, &run_id, 2);
3012        let executor = ExternalAgentTaskExecutor;
3013        let result = executor.start(&task, &ctx).await;
3014        assert!(
3015            result.is_err(),
3016            "start() should error when remote_task_id is absent"
3017        );
3018        let err = result.unwrap_err();
3019        assert!(
3020            err.to_string().contains("remote_task_id"),
3021            "error should mention remote_task_id: {err}"
3022        );
3023    }
3024
3025    /// Legacy records written before the task-to-instructions rename and the
3026    /// A2A wait-to-foreground mode rename should still load from durable
3027    /// session storage.
3028    #[test]
3029    fn agent_run_record_accepts_legacy_task_field() {
3030        let legacy = json!({
3031            "run_id": "legacy-run",
3032            "kind": "external_a2a",
3033            "external_agent_id": "echo",
3034            "external_agent_name": "Echo",
3035            "task": "legacy instructions",
3036            "mode": "wait",
3037            "status": "submitted"
3038        });
3039
3040        let record: AgentRunRecord = serde_json::from_value(legacy).unwrap();
3041        assert_eq!(record.instructions, "legacy instructions");
3042        assert_eq!(record.mode, SpawnMode::Foreground);
3043
3044        let serialized = serde_json::to_value(&record).unwrap();
3045        assert_eq!(serialized["instructions"], "legacy instructions");
3046        assert_eq!(serialized["mode"], "foreground");
3047        assert!(serialized.get("task").is_none());
3048    }
3049
3050    /// Roundtrip: save_run → load_run → load_run_for_task all resolve consistently.
3051    #[tokio::test]
3052    async fn agent_run_storage_roundtrip() {
3053        let storage_store = Arc::new(TestStorageStore::default());
3054        let file_store = Arc::new(TestFileStore::default());
3055        let ctx = context(storage_store, file_store);
3056
3057        let run_id = "test-run-roundtrip".to_string();
3058        let task_id = "task-abc".to_string();
3059        let config = ExternalA2aAgentConfig {
3060            id: "echo".to_string(),
3061            name: "Echo".to_string(),
3062            description: None,
3063            base_url: Some("http://localhost:1".to_string()),
3064            agent_card: None,
3065            headers: BTreeMap::new(),
3066            preferred_binding: None,
3067            poll_interval_ms: None,
3068            allow_local_urls: true,
3069        };
3070        let mut record = AgentRunRecord::new(
3071            run_id.clone(),
3072            &config,
3073            "do something".to_string(),
3074            SpawnMode::Foreground,
3075            false,
3076            None,
3077        );
3078        record.task_id = Some(task_id.clone());
3079
3080        // save_run then load_run should round-trip the record.
3081        save_run(&ctx, &record).await.expect("save_run failed");
3082        let loaded = load_run(&ctx, &run_id).await.expect("load_run failed");
3083        assert_eq!(loaded.run_id, run_id);
3084        assert_eq!(loaded.task_id.as_deref(), Some(task_id.as_str()));
3085
3086        // load_run_for_task with run_id in spec should resolve the same record.
3087        let now = chrono::Utc::now();
3088        let fake_task = SessionTask {
3089            id: task_id.clone(),
3090            session_id: ctx.session_id,
3091            root_session_id: None,
3092            kind: TASK_KIND_EXTERNAL_AGENT.to_string(),
3093            display_name: "Echo".to_string(),
3094            spec: json!({ "run_id": &run_id }),
3095            state: SessionTaskState::Running,
3096            state_detail: None,
3097            progress: None,
3098            links: TaskLinks::default(),
3099            wake_policy: TaskWakePolicy::Silent,
3100            input_request: None,
3101            cancel_requested_at: None,
3102            summary: None,
3103            result_path: None,
3104            artifacts: vec![],
3105            error: None,
3106            attempt: 1,
3107            worker_id: None,
3108            heartbeat_at: None,
3109            started_at: None,
3110            finished_at: None,
3111            created_at: now,
3112            updated_at: now,
3113        };
3114        let from_task = load_run_for_task(&ctx, &fake_task)
3115            .await
3116            .expect("load_run_for_task failed");
3117        assert_eq!(from_task.run_id, run_id);
3118
3119        // The prefix-derived listing should include the run_id.
3120        let storage = ctx.storage_store.as_ref().unwrap();
3121        let index = list_run_ids(storage.as_ref(), ctx.session_id).await;
3122        assert!(
3123            index.contains(&run_id),
3124            "run_id not found in index: {index:?}"
3125        );
3126    }
3127}