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