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 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 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 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 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 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 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
1593fn 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 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
1628fn 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
1639pub 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 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 record.status.is_terminal() {
1674 mirror_run_to_task(&context, &record).await;
1675 return Ok(());
1676 }
1677
1678 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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 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 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 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 #[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 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 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 let outcome = wait_for_run(&ctx, &config, record, 30, Some(1))
2939 .await
2940 .expect("superseded poll must not be a transport error");
2941 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 assert!(
2956 WaitOutcome::superseded_message(&superseded_run_id, attempt, by_attempt)
2957 .starts_with(SUPERSEDED_ERROR_PREFIX)
2958 );
2959 }
2960
2961 #[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 #[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 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 #[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 #[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(&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 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 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}