Skip to main content

recursive/tools/
a2a.rs

1//! `a2a_call` tool — invoke a remote A2A (Agent2Agent v1.0) agent.
2//!
3//! A2A is an open protocol (Linux Foundation / originally Google) that lets
4//! agents built by different vendors delegate tasks to each other via HTTP+JSON.
5//!
6//! # Protocol overview
7//!
8//! 1. Client sends `POST {base_url}/message:send` with a `SendMessageRequest`.
9//! 2. Server returns either a direct `Message` (synchronous) or a `Task` (async).
10//! 3. If a `Task` is returned, the client polls `GET {base_url}/tasks/{id}` until
11//!    the task reaches a terminal state (`COMPLETED`, `FAILED`, etc.).
12//! 4. Artifacts from the completed task contain the final output.
13//!
14//! Reference: <https://a2a-protocol.org/v1.0.0/specification/>
15
16use async_trait::async_trait;
17use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20use std::time::{Duration, Instant};
21
22use crate::error::{Error, Result};
23use crate::llm::ToolSpec;
24use crate::tools::{Tool, ToolSideEffect};
25
26// ---------------------------------------------------------------------------
27// A2A data model (minimal subset for MVP)
28// ---------------------------------------------------------------------------
29
30/// A text, file, or data part inside a Message or Artifact.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Part {
33    /// Plain text content. Other part types (file, data) are ignored for now.
34    #[serde(default)]
35    pub text: Option<String>,
36}
37
38/// An artifact produced by the remote agent.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Artifact {
41    #[serde(default)]
42    pub parts: Vec<Part>,
43}
44
45/// Task lifecycle state values from the A2A v1.0 spec.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub enum TaskState {
48    #[serde(rename = "TASK_STATE_SUBMITTED")]
49    Submitted,
50    #[serde(rename = "TASK_STATE_WORKING")]
51    Working,
52    #[serde(rename = "TASK_STATE_COMPLETED")]
53    Completed,
54    #[serde(rename = "TASK_STATE_FAILED")]
55    Failed,
56    #[serde(rename = "TASK_STATE_CANCELED")]
57    Canceled,
58    #[serde(rename = "TASK_STATE_REJECTED")]
59    Rejected,
60    /// Catch-all for unknown states (forward compatibility).
61    #[serde(other)]
62    Unknown,
63}
64
65impl TaskState {
66    fn is_terminal(&self) -> bool {
67        matches!(
68            self,
69            Self::Completed | Self::Failed | Self::Canceled | Self::Rejected | Self::Unknown
70        )
71    }
72
73    fn as_str(&self) -> &'static str {
74        match self {
75            Self::Submitted => "TASK_STATE_SUBMITTED",
76            Self::Working => "TASK_STATE_WORKING",
77            Self::Completed => "TASK_STATE_COMPLETED",
78            Self::Failed => "TASK_STATE_FAILED",
79            Self::Canceled => "TASK_STATE_CANCELED",
80            Self::Rejected => "TASK_STATE_REJECTED",
81            Self::Unknown => "TASK_STATE_UNKNOWN",
82        }
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct TaskStatus {
88    pub state: TaskState,
89}
90
91/// A task returned by `POST /message:send` or `GET /tasks/{id}`.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Task {
94    pub id: String,
95    pub status: TaskStatus,
96    #[serde(default)]
97    pub artifacts: Vec<Artifact>,
98}
99
100/// A direct message response (no task tracking needed).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct MessageResponse {
103    #[serde(default)]
104    pub parts: Vec<Part>,
105}
106
107/// Top-level response from `POST /message:send`.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(untagged)]
110pub enum SendMessageResponse {
111    /// Agent returned a Task (async processing).
112    HasTask { task: Task },
113    /// Agent returned a direct Message.
114    HasMessage { message: MessageResponse },
115}
116
117// ---------------------------------------------------------------------------
118// Helper: extract text from a list of Parts
119// ---------------------------------------------------------------------------
120
121fn parts_to_text(parts: &[Part]) -> String {
122    parts
123        .iter()
124        .filter_map(|p| p.text.as_deref())
125        .collect::<Vec<_>>()
126        .join("\n")
127}
128
129fn artifacts_to_text(artifacts: &[Artifact]) -> String {
130    artifacts
131        .iter()
132        .flat_map(|a| a.parts.iter())
133        .filter_map(|p| p.text.as_deref())
134        .collect::<Vec<_>>()
135        .join("\n")
136}
137
138// ---------------------------------------------------------------------------
139// A2aCallTool
140// ---------------------------------------------------------------------------
141
142/// Tool that calls a remote A2A v1.0 agent and returns its text output.
143pub struct A2aCallTool;
144
145impl A2aCallTool {
146    pub fn new() -> Self {
147        Self
148    }
149
150    /// Build a `reqwest::Client` with sane timeouts.
151    fn build_client() -> std::result::Result<reqwest::Client, reqwest::Error> {
152        reqwest::Client::builder()
153            .timeout(Duration::from_secs(30))
154            .connect_timeout(Duration::from_secs(10))
155            .build()
156    }
157}
158
159impl Default for A2aCallTool {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165#[async_trait]
166impl Tool for A2aCallTool {
167    fn spec(&self) -> ToolSpec {
168        ToolSpec {
169            name: "a2a_call".into(),
170            description: "Invoke a remote A2A (Agent2Agent v1.0) agent and return its response. \
171                           Supports both polling mode (default) and real-time SSE streaming mode. \
172                           In polling mode the tool sends a message and polls the task until done. \
173                           In streaming mode it connects to the server's SSE stream and accumulates \
174                           artifact text as it arrives."
175                .into(),
176            parameters: json!({
177                "type": "object",
178                "properties": {
179                    "url": {
180                        "type": "string",
181                        "description": "Base URL of the A2A server (e.g. 'https://agent.example.com'). \
182                                        Polling: posts to {url}/message:send and polls {url}/tasks/{id}. \
183                                        Streaming: posts to {url}/message:stream."
184                    },
185                    "prompt": {
186                        "type": "string",
187                        "description": "The text message to send to the remote agent."
188                    },
189                    "authorization": {
190                        "type": "string",
191                        "description": "Optional Authorization header value (e.g. 'Bearer <token>'). \
192                                        Omit for public agents."
193                    },
194                    "timeout_secs": {
195                        "type": "integer",
196                        "description": "Maximum seconds to wait for task completion (default: 60, max: 300). \
197                                        In polling mode: polls every 2 seconds. \
198                                        In streaming mode: max time to keep the SSE connection open.",
199                        "default": 60
200                    },
201                    "streaming": {
202                        "type": "boolean",
203                        "description": "If true, use SSE streaming mode (POST {url}/message:stream). \
204                                        The server must support A2A streaming. Default: false.",
205                        "default": false
206                    },
207                    "async_mode": {
208                        "type": "boolean",
209                        "description": "If true, submit the task and return immediately with the task ID \
210                                        without waiting for completion. Use a2a_task_check later to \
211                                        retrieve the result (composable with schedule_wakeup). Default: false.",
212                        "default": false
213                    }
214                },
215                "required": ["url", "prompt"]
216            }),
217        }
218    }
219
220    fn side_effect_class(&self) -> ToolSideEffect {
221        ToolSideEffect::External
222    }
223
224    async fn execute(&self, arguments: Value) -> Result<String> {
225        let url = arguments["url"]
226            .as_str()
227            .ok_or_else(|| Error::BadToolArgs {
228                name: "a2a_call".into(),
229                message: "missing required parameter: url".into(),
230            })?;
231        let prompt = arguments["prompt"]
232            .as_str()
233            .ok_or_else(|| Error::BadToolArgs {
234                name: "a2a_call".into(),
235                message: "missing required parameter: prompt".into(),
236            })?;
237        let authorization = arguments["authorization"].as_str();
238        let timeout_secs = arguments["timeout_secs"]
239            .as_u64()
240            .unwrap_or(60)
241            .clamp(1, 300);
242        let use_streaming = arguments["streaming"].as_bool().unwrap_or(false);
243        let use_async = arguments["async_mode"].as_bool().unwrap_or(false);
244
245        // Normalise base URL — strip trailing slash.
246        let base = url.trim_end_matches('/');
247
248        let client = Self::build_client().map_err(|e| Error::Tool {
249            name: "a2a_call".into(),
250            message: format!("failed to build HTTP client: {e}"),
251        })?;
252
253        if use_streaming {
254            return execute_streaming(base, prompt, authorization, timeout_secs, &client).await;
255        }
256
257        // ── Step 1: POST /message:send ────────────────────────────────────
258        let message_id = uuid::Uuid::new_v4().to_string();
259        let send_url = format!("{base}/message:send");
260
261        let mut req = client
262            .post(&send_url)
263            .header(CONTENT_TYPE, "application/a2a+json")
264            .json(&json!({
265                "message": {
266                    "role": "ROLE_USER",
267                    "parts": [{"text": prompt}],
268                    "messageId": message_id
269                }
270            }));
271
272        if let Some(auth) = authorization {
273            req = req.header(AUTHORIZATION, auth);
274        }
275
276        let resp = req.send().await.map_err(|e| Error::Tool {
277            name: "a2a_call".into(),
278            message: format!("network error calling {send_url}: {e}"),
279        })?;
280
281        let status = resp.status();
282        if !status.is_success() {
283            let body = resp.text().await.unwrap_or_default();
284            return Ok(format!("ERROR: HTTP {status} from {send_url}: {body}"));
285        }
286
287        let body_text = resp.text().await.map_err(|e| Error::Tool {
288            name: "a2a_call".into(),
289            message: format!("failed to read response body: {e}"),
290        })?;
291
292        let send_resp: SendMessageResponse =
293            serde_json::from_str(&body_text).map_err(|e| Error::Tool {
294                name: "a2a_call".into(),
295                message: format!("invalid A2A response: {e}\nbody: {body_text}"),
296            })?;
297
298        // ── Step 2: handle synchronous message response ───────────────────
299        match send_resp {
300            SendMessageResponse::HasMessage { message } => {
301                let text = parts_to_text(&message.parts);
302                return Ok(if text.is_empty() {
303                    "(empty response from agent)".to_string()
304                } else {
305                    text
306                });
307            }
308            SendMessageResponse::HasTask { mut task } => {
309                // ── Async mode: return task ID + background poll script ──────
310                if use_async {
311                    let task_id = &task.id;
312                    let state = task.status.state.as_str();
313                    // Build a shell one-liner the agent can pass to run_background.
314                    // When the task reaches a terminal state the script exits, and
315                    // run_event_loop detects the completed background job and starts
316                    // a new turn with the output as its goal.
317                    let poll_cmd = format!(
318                        "end=$(($(date +%s)+{timeout_secs})); \
319while [ $(date +%s) -lt $end ]; do \
320  r=$(curl -sf '{base}/tasks/{task_id}' 2>/dev/null || echo '{{}}'); \
321  s=$(echo \"$r\" | python3 -c \"import sys,json;print(json.load(sys.stdin).get('status',{{}}).get('state','UNKNOWN'))\" 2>/dev/null || echo UNKNOWN); \
322  case \"$s\" in TASK_STATE_COMPLETED|TASK_STATE_FAILED|TASK_STATE_CANCELED|TASK_STATE_REJECTED) \
323    echo \"A2A task {task_id} finished: $s\"; echo \"$r\"; exit 0;; \
324  esac; sleep 2; done; \
325echo \"A2A task {task_id} timed out after {timeout_secs}s\""
326                    );
327                    return Ok(format!(
328                        "TASK_ID: {task_id}\nSTATE: {state}\n\n\
329To poll in background and auto-trigger a new turn on completion, \
330call run_background with:\n  command: {poll_cmd}\n  name: a2a-{task_id}\n\n\
331When the background job completes, a new turn will start automatically with the result."
332                    ));
333                }
334
335                // ── Step 3: poll until terminal state ─────────────────────
336                let deadline = Instant::now() + Duration::from_secs(timeout_secs);
337                let task_url = format!("{base}/tasks/{}", task.id);
338
339                while !task.status.state.is_terminal() {
340                    if Instant::now() >= deadline {
341                        return Ok(format!(
342                            "ERROR: task {} timed out after {}s (last state: {})",
343                            task.id,
344                            timeout_secs,
345                            task.status.state.as_str()
346                        ));
347                    }
348
349                    tokio::time::sleep(Duration::from_secs(2)).await;
350
351                    let mut poll_req = client.get(&task_url);
352                    if let Some(auth) = authorization {
353                        poll_req = poll_req.header(AUTHORIZATION, auth);
354                    }
355
356                    let poll_resp = poll_req.send().await.map_err(|e| Error::Tool {
357                        name: "a2a_call".into(),
358                        message: format!("network error polling {task_url}: {e}"),
359                    })?;
360
361                    let poll_status = poll_resp.status();
362                    if !poll_status.is_success() {
363                        let err_body = poll_resp.text().await.unwrap_or_default();
364                        return Ok(format!(
365                            "ERROR: HTTP {poll_status} polling {task_url}: {err_body}"
366                        ));
367                    }
368
369                    let poll_body = poll_resp.text().await.map_err(|e| Error::Tool {
370                        name: "a2a_call".into(),
371                        message: format!("failed to read poll response: {e}"),
372                    })?;
373
374                    let updated: Task =
375                        serde_json::from_str(&poll_body).map_err(|e| Error::Tool {
376                            name: "a2a_call".into(),
377                            message: format!("invalid task poll response: {e}\nbody: {poll_body}"),
378                        })?;
379
380                    task = updated;
381                }
382
383                // ── Step 4: extract result ─────────────────────────────────
384                match task.status.state {
385                    TaskState::Completed => {
386                        let text = artifacts_to_text(&task.artifacts);
387                        Ok(if text.is_empty() {
388                            "(task completed with no artifact text)".to_string()
389                        } else {
390                            text
391                        })
392                    }
393                    other => Ok(format!(
394                        "ERROR: task {} ended with state {}",
395                        task.id,
396                        other.as_str()
397                    )),
398                }
399            }
400        }
401    }
402}
403
404// ---------------------------------------------------------------------------
405// SSE streaming implementation
406// ---------------------------------------------------------------------------
407
408/// Parse one SSE event block (multiple `key: value` lines separated by `\n`)
409/// and extract any artifact text and terminal task state from it.
410fn parse_sse_event(event_block: &str) -> (Option<String>, Option<TaskState>) {
411    let mut data_lines: Vec<&str> = Vec::new();
412    for line in event_block.lines() {
413        if let Some(data) = line.strip_prefix("data:") {
414            data_lines.push(data.trim());
415        }
416    }
417    let data = data_lines.join("");
418    if data.is_empty() {
419        return (None, None);
420    }
421
422    let Ok(v): std::result::Result<Value, _> = serde_json::from_str(&data) else {
423        return (None, None);
424    };
425
426    // Extract artifact text — handle both A2A REST and JSON-RPC response shapes.
427    let mut text: Option<String> = None;
428    let mut state: Option<TaskState> = None;
429
430    // Shape 1: {"type":"TaskArtifactUpdateEvent","task":{...}}
431    // Shape 2: {"result":{"kind":"artifact-update","artifact":{...}}}
432    if let Some(artifacts) = v
433        .pointer("/task/artifacts")
434        .or_else(|| v.pointer("/result/artifact/parts"))
435        .and_then(|a| a.as_array())
436    {
437        let extracted: String = artifacts
438            .iter()
439            .flat_map(|a| a["parts"].as_array().into_iter().flatten())
440            .filter_map(|p| p["text"].as_str())
441            .collect::<Vec<_>>()
442            .join("");
443        if !extracted.is_empty() {
444            text = Some(extracted);
445        }
446    } else if let Some(part_text) = v
447        .pointer("/result/artifact/parts/0/text")
448        .and_then(|v| v.as_str())
449    {
450        text = Some(part_text.to_string());
451    }
452
453    // Extract state — handle both shapes.
454    let raw_state = v
455        .pointer("/task/status/state")
456        .or_else(|| v.pointer("/result/status/state"))
457        .and_then(|s| s.as_str());
458
459    if let Some(s) = raw_state {
460        if let Ok(ts) = serde_json::from_value::<TaskState>(Value::String(s.to_string())) {
461            state = Some(ts);
462        }
463    }
464
465    (text, state)
466}
467
468/// Execute `a2a_call` in SSE streaming mode.
469///
470/// POSTs to `{base}/message:stream` with `Accept: text/event-stream` and reads
471/// the SSE stream until a terminal task state is received or the timeout expires.
472async fn execute_streaming(
473    base: &str,
474    prompt: &str,
475    authorization: Option<&str>,
476    timeout_secs: u64,
477    client: &reqwest::Client,
478) -> Result<String> {
479    let stream_url = format!("{base}/message:stream");
480    let message_id = uuid::Uuid::new_v4().to_string();
481    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
482
483    let mut req = client
484        .post(&stream_url)
485        .header(CONTENT_TYPE, "application/a2a+json")
486        .header(reqwest::header::ACCEPT, "text/event-stream")
487        .json(&json!({
488            "message": {
489                "role": "ROLE_USER",
490                "parts": [{"text": prompt}],
491                "messageId": message_id
492            }
493        }));
494
495    if let Some(auth) = authorization {
496        req = req.header(AUTHORIZATION, auth);
497    }
498
499    let resp = req.send().await.map_err(|e| Error::Tool {
500        name: "a2a_call".into(),
501        message: format!("network error calling {stream_url}: {e}"),
502    })?;
503
504    let status = resp.status();
505    if !status.is_success() {
506        let body = resp.text().await.unwrap_or_default();
507        return Ok(format!("ERROR: HTTP {status} from {stream_url}: {body}"));
508    }
509
510    // Read SSE events chunk by chunk.
511    let mut buffer = String::new();
512    let mut accumulated_text = String::new();
513    let mut final_state: Option<TaskState> = None;
514    let mut resp = resp;
515
516    loop {
517        if Instant::now() >= deadline {
518            let suffix = if accumulated_text.is_empty() {
519                "(no text received before timeout)".to_string()
520            } else {
521                accumulated_text.clone()
522            };
523            return Ok(format!(
524                "ERROR: SSE stream timed out after {timeout_secs}s. Partial output:\n{suffix}"
525            ));
526        }
527
528        let chunk = tokio::time::timeout(Duration::from_secs(5), resp.chunk())
529            .await
530            .map_err(|_| Error::Tool {
531                name: "a2a_call".into(),
532                message: "SSE chunk read timed out (5s idle)".into(),
533            })?
534            .map_err(|e| Error::Tool {
535                name: "a2a_call".into(),
536                message: format!("SSE read error: {e}"),
537            })?;
538
539        let Some(bytes) = chunk else {
540            // Connection closed by server.
541            break;
542        };
543
544        if let Ok(text_chunk) = std::str::from_utf8(&bytes) {
545            buffer.push_str(text_chunk);
546        }
547
548        // Process complete SSE event blocks (separated by \n\n or \r\n\r\n).
549        while let Some(pos) = buffer.find("\n\n").or_else(|| buffer.find("\r\n\r\n")) {
550            let sep_len = if buffer[pos..].starts_with("\r\n\r\n") {
551                4
552            } else {
553                2
554            };
555            let event_block = buffer[..pos].to_string();
556            buffer.drain(..pos + sep_len);
557
558            let (text, state) = parse_sse_event(&event_block);
559            if let Some(t) = text {
560                accumulated_text.push_str(&t);
561            }
562            if let Some(s) = state {
563                if s.is_terminal() {
564                    final_state = Some(s);
565                    break;
566                }
567            }
568        }
569
570        if final_state.is_some() {
571            break;
572        }
573    }
574
575    match final_state {
576        Some(TaskState::Completed) | None => {
577            if accumulated_text.is_empty() {
578                Ok("(task completed with no artifact text)".to_string())
579            } else {
580                Ok(accumulated_text)
581            }
582        }
583        Some(other) => Ok(format!(
584            "ERROR: task ended with state {} (partial output: {})",
585            other.as_str(),
586            accumulated_text
587        )),
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Agent Card data model
593// ---------------------------------------------------------------------------
594
595/// Capability flags from the A2A Agent Card.
596#[derive(Debug, Clone, Serialize, Deserialize, Default)]
597pub struct AgentCapabilities {
598    /// Whether the agent supports streaming (SSE) via `sendSubscribe`.
599    #[serde(default)]
600    pub streaming: bool,
601    /// Whether the agent supports push-notification callbacks.
602    #[serde(rename = "pushNotifications", default)]
603    pub push_notifications: bool,
604}
605
606/// One skill entry in the Agent Card.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct AgentSkill {
609    pub id: String,
610    #[serde(default)]
611    pub name: String,
612    #[serde(default)]
613    pub description: String,
614}
615
616/// Authentication scheme described in the Agent Card.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct AgentAuthentication {
619    pub schemes: Vec<String>,
620}
621
622/// Subset of the A2A v1.0 Agent Card returned by `GET /.well-known/agent.json`.
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct AgentCard {
625    #[serde(default)]
626    pub name: String,
627    #[serde(default)]
628    pub description: String,
629    #[serde(default)]
630    pub capabilities: AgentCapabilities,
631    #[serde(default)]
632    pub skills: Vec<AgentSkill>,
633    pub authentication: Option<AgentAuthentication>,
634}
635
636impl AgentCard {
637    /// Return a concise human-readable summary of this Agent Card.
638    pub fn summary(&self) -> String {
639        let mut lines: Vec<String> = Vec::new();
640        let name = if self.name.is_empty() {
641            "<unnamed>"
642        } else {
643            &self.name
644        };
645        lines.push(format!("Agent: {name}"));
646        if !self.description.is_empty() {
647            lines.push(format!("Description: {}", self.description));
648        }
649        lines.push(format!(
650            "Streaming: {}",
651            if self.capabilities.streaming {
652                "supported"
653            } else {
654                "not supported"
655            }
656        ));
657        lines.push(format!(
658            "Push notifications: {}",
659            if self.capabilities.push_notifications {
660                "supported"
661            } else {
662                "not supported"
663            }
664        ));
665        if let Some(auth) = &self.authentication {
666            lines.push(format!("Auth: {}", auth.schemes.join(", ")));
667        } else {
668            lines.push("Auth: none".into());
669        }
670        if !self.skills.is_empty() {
671            lines.push("Skills:".into());
672            for skill in &self.skills {
673                let desc = if skill.description.is_empty() {
674                    String::new()
675                } else {
676                    format!(": {}", skill.description)
677                };
678                lines.push(format!("  - {}{}", skill.name, desc));
679            }
680        }
681        lines.join("\n")
682    }
683}
684
685// ---------------------------------------------------------------------------
686// A2aCardTool
687// ---------------------------------------------------------------------------
688
689/// Tool that fetches and summarises a remote A2A Agent Card
690/// (`GET /.well-known/agent.json`).
691pub struct A2aCardTool;
692
693impl A2aCardTool {
694    pub fn new() -> Self {
695        Self
696    }
697}
698
699impl Default for A2aCardTool {
700    fn default() -> Self {
701        Self::new()
702    }
703}
704
705#[async_trait]
706impl Tool for A2aCardTool {
707    fn spec(&self) -> ToolSpec {
708        ToolSpec {
709            name: "a2a_card".into(),
710            description: "Fetch and display the Agent Card of a remote A2A v1.0 agent. \
711                           The card describes the agent's capabilities (streaming, push \
712                           notifications), authentication scheme, and available skills. \
713                           Call this before a2a_call to discover what the agent supports."
714                .into(),
715            parameters: json!({
716                "type": "object",
717                "properties": {
718                    "url": {
719                        "type": "string",
720                        "description": "Base URL of the A2A server (e.g. 'https://agent.example.com'). \
721                                        The tool fetches {url}/.well-known/agent.json."
722                    },
723                    "authorization": {
724                        "type": "string",
725                        "description": "Optional Authorization header value (e.g. 'Bearer <token>')."
726                    }
727                },
728                "required": ["url"]
729            }),
730        }
731    }
732
733    fn side_effect_class(&self) -> ToolSideEffect {
734        ToolSideEffect::External
735    }
736
737    async fn execute(&self, arguments: Value) -> Result<String> {
738        let url = arguments["url"]
739            .as_str()
740            .ok_or_else(|| Error::BadToolArgs {
741                name: "a2a_card".into(),
742                message: "missing required parameter: url".into(),
743            })?;
744        let authorization = arguments["authorization"].as_str();
745
746        let base = url.trim_end_matches('/');
747        let card_url = format!("{base}/.well-known/agent.json");
748
749        let client = A2aCallTool::build_client().map_err(|e| Error::Tool {
750            name: "a2a_card".into(),
751            message: format!("failed to build HTTP client: {e}"),
752        })?;
753
754        let mut req = client.get(&card_url);
755        if let Some(auth) = authorization {
756            req = req.header(AUTHORIZATION, auth);
757        }
758
759        let resp = req.send().await.map_err(|e| Error::Tool {
760            name: "a2a_card".into(),
761            message: format!("network error fetching {card_url}: {e}"),
762        })?;
763
764        let status = resp.status();
765        if !status.is_success() {
766            let body = resp.text().await.unwrap_or_default();
767            return Ok(format!("ERROR: HTTP {status} from {card_url}: {body}"));
768        }
769
770        let body_text = resp.text().await.map_err(|e| Error::Tool {
771            name: "a2a_card".into(),
772            message: format!("failed to read response body: {e}"),
773        })?;
774
775        let card: AgentCard = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
776            name: "a2a_card".into(),
777            message: format!("invalid Agent Card JSON: {e}\nbody: {body_text}"),
778        })?;
779
780        Ok(card.summary())
781    }
782}
783
784// ---------------------------------------------------------------------------
785// A2aTaskCheckTool
786// ---------------------------------------------------------------------------
787
788/// Tool that checks the current state and artifacts of an A2A task.
789///
790/// Designed to be used together with `a2a_call` in `async_mode: true`, allowing
791/// the agent to submit a long-running task and check on it later, optionally
792/// combined with `schedule_wakeup` for periodic polling.
793pub struct A2aTaskCheckTool;
794
795impl A2aTaskCheckTool {
796    pub fn new() -> Self {
797        Self
798    }
799}
800
801impl Default for A2aTaskCheckTool {
802    fn default() -> Self {
803        Self::new()
804    }
805}
806
807#[async_trait]
808impl Tool for A2aTaskCheckTool {
809    fn spec(&self) -> ToolSpec {
810        ToolSpec {
811            name: "a2a_task_check".into(),
812            description: "Check the current status and artifacts of a previously submitted A2A task. \
813                           Useful for one-off manual inspection. The preferred pattern for automatic \
814                           async polling is: use a2a_call with async_mode=true (which provides a \
815                           ready-to-run shell command), pass that command to run_background, and let \
816                           run_event_loop auto-trigger a new turn when the background job completes."
817                .into(),
818            parameters: json!({
819                "type": "object",
820                "properties": {
821                    "url": {
822                        "type": "string",
823                        "description": "Base URL of the A2A server (same url used in a2a_call)."
824                    },
825                    "task_id": {
826                        "type": "string",
827                        "description": "Task ID returned by a2a_call in async_mode (the value after 'TASK_ID: ')."
828                    },
829                    "authorization": {
830                        "type": "string",
831                        "description": "Optional Authorization header value (e.g. 'Bearer <token>')."
832                    }
833                },
834                "required": ["url", "task_id"]
835            }),
836        }
837    }
838
839    fn side_effect_class(&self) -> ToolSideEffect {
840        ToolSideEffect::External
841    }
842
843    async fn execute(&self, arguments: Value) -> Result<String> {
844        let url = arguments["url"]
845            .as_str()
846            .ok_or_else(|| Error::BadToolArgs {
847                name: "a2a_task_check".into(),
848                message: "missing required parameter: url".into(),
849            })?;
850        let task_id = arguments["task_id"]
851            .as_str()
852            .ok_or_else(|| Error::BadToolArgs {
853                name: "a2a_task_check".into(),
854                message: "missing required parameter: task_id".into(),
855            })?;
856        let authorization = arguments["authorization"].as_str();
857
858        let base = url.trim_end_matches('/');
859        let task_url = format!("{base}/tasks/{task_id}");
860
861        let client = A2aCallTool::build_client().map_err(|e| Error::Tool {
862            name: "a2a_task_check".into(),
863            message: format!("failed to build HTTP client: {e}"),
864        })?;
865
866        let mut req = client.get(&task_url);
867        if let Some(auth) = authorization {
868            req = req.header(AUTHORIZATION, auth);
869        }
870
871        let resp = req.send().await.map_err(|e| Error::Tool {
872            name: "a2a_task_check".into(),
873            message: format!("network error fetching {task_url}: {e}"),
874        })?;
875
876        let status = resp.status();
877        if !status.is_success() {
878            let body = resp.text().await.unwrap_or_default();
879            return Ok(format!("ERROR: HTTP {status} from {task_url}: {body}"));
880        }
881
882        let body_text = resp.text().await.map_err(|e| Error::Tool {
883            name: "a2a_task_check".into(),
884            message: format!("failed to read response body: {e}"),
885        })?;
886
887        let task: Task = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
888            name: "a2a_task_check".into(),
889            message: format!("invalid task JSON: {e}\nbody: {body_text}"),
890        })?;
891
892        let state_str = task.status.state.as_str();
893        let artifact_text = artifacts_to_text(&task.artifacts);
894
895        if task.status.state.is_terminal() {
896            if artifact_text.is_empty() {
897                Ok(format!("STATE: {state_str}\n(no artifact text)"))
898            } else {
899                Ok(format!("STATE: {state_str}\n{artifact_text}"))
900            }
901        } else {
902            Ok(format!(
903                "STATE: {state_str}\n(task not yet complete; check again later)"
904            ))
905        }
906    }
907}
908
909// ---------------------------------------------------------------------------
910// Tests
911// ---------------------------------------------------------------------------
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use std::io::{Read, Write};
917    use std::net::TcpListener;
918    use std::sync::{Arc, Mutex};
919
920    /// Spawn a one-shot mock HTTP server on an ephemeral port.
921    /// `response_body` is the raw HTTP response (including headers) the server sends.
922    fn spawn_mock_server(response_body: &'static str) -> std::net::SocketAddr {
923        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
924        let addr = listener.local_addr().unwrap();
925        std::thread::spawn(move || {
926            if let Ok((mut stream, _)) = listener.accept() {
927                let mut buf = [0u8; 8192];
928                let _ = stream.read(&mut buf);
929                let _ = stream.write_all(response_body.as_bytes());
930                let _ = stream.flush();
931            }
932        });
933        addr
934    }
935
936    /// Spawn a mock server that returns different responses for each request.
937    /// Requests are served in order; extras after the list result in 500.
938    fn spawn_sequential_mock_server(responses: Vec<&'static str>) -> std::net::SocketAddr {
939        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
940        let addr = listener.local_addr().unwrap();
941        let responses = Arc::new(Mutex::new(responses.into_iter()));
942        std::thread::spawn(move || {
943            for _ in 0..100 {
944                if let Ok((mut stream, _)) = listener.accept() {
945                    let mut buf = [0u8; 8192];
946                    let _ = stream.read(&mut buf);
947                    let response = responses
948                        .lock()
949                        .unwrap()
950                        .next()
951                        .unwrap_or("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 5\r\nConnection: close\r\n\r\nerror");
952                    let _ = stream.write_all(response.as_bytes());
953                    let _ = stream.flush();
954                }
955            }
956        });
957        // Give the server a moment to start
958        std::thread::sleep(std::time::Duration::from_millis(20));
959        addr
960    }
961
962    fn make_json_response(body: &str) -> String {
963        format!(
964            "HTTP/1.1 200 OK\r\nContent-Type: application/a2a+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
965            body.len(),
966            body
967        )
968    }
969
970    // ── Unit tests for data model helpers ─────────────────────────────────
971
972    #[test]
973    fn parts_to_text_concatenates_text_parts() {
974        let parts = vec![
975            Part {
976                text: Some("Hello".into()),
977            },
978            Part { text: None },
979            Part {
980                text: Some(" World".into()),
981            },
982        ];
983        assert_eq!(parts_to_text(&parts), "Hello\n World");
984    }
985
986    #[test]
987    fn task_state_is_terminal() {
988        assert!(TaskState::Completed.is_terminal());
989        assert!(TaskState::Failed.is_terminal());
990        assert!(TaskState::Canceled.is_terminal());
991        assert!(TaskState::Rejected.is_terminal());
992        assert!(!TaskState::Submitted.is_terminal());
993        assert!(!TaskState::Working.is_terminal());
994    }
995
996    #[test]
997    fn missing_prompt_returns_bad_tool_args_error() {
998        let rt = tokio::runtime::Runtime::new().unwrap();
999        let tool = A2aCallTool::new();
1000        let err = rt
1001            .block_on(tool.execute(json!({"url": "http://localhost"})))
1002            .unwrap_err();
1003        assert!(matches!(err, Error::BadToolArgs { .. }));
1004    }
1005
1006    #[test]
1007    fn missing_url_returns_bad_tool_args_error() {
1008        let rt = tokio::runtime::Runtime::new().unwrap();
1009        let tool = A2aCallTool::new();
1010        let err = rt
1011            .block_on(tool.execute(json!({"prompt": "hi"})))
1012            .unwrap_err();
1013        assert!(matches!(err, Error::BadToolArgs { .. }));
1014    }
1015
1016    #[tokio::test]
1017    async fn immediate_message_response_returns_text() {
1018        let body = r#"{"message":{"parts":[{"text":"The weather is sunny."}]}}"#;
1019        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1020        let addr = spawn_mock_server(raw_resp);
1021        tokio::time::sleep(Duration::from_millis(30)).await;
1022
1023        let tool = A2aCallTool::new();
1024        let result = tool
1025            .execute(json!({
1026                "url": format!("http://{addr}"),
1027                "prompt": "What is the weather?"
1028            }))
1029            .await
1030            .unwrap();
1031
1032        assert_eq!(result, "The weather is sunny.");
1033    }
1034
1035    #[tokio::test]
1036    async fn completed_task_response_returns_artifact_text() {
1037        let body = r#"{"task":{"id":"t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Task done!"}]}]}}"#;
1038        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1039        let addr = spawn_mock_server(raw_resp);
1040        tokio::time::sleep(Duration::from_millis(30)).await;
1041
1042        let tool = A2aCallTool::new();
1043        let result = tool
1044            .execute(json!({
1045                "url": format!("http://{addr}"),
1046                "prompt": "Do the task"
1047            }))
1048            .await
1049            .unwrap();
1050
1051        assert_eq!(result, "Task done!");
1052    }
1053
1054    #[tokio::test]
1055    async fn working_task_polled_to_completion() {
1056        // First response: WORKING task
1057        let working_resp = make_json_response(
1058            r#"{"task":{"id":"t2","status":{"state":"TASK_STATE_WORKING"},"artifacts":[]}}"#,
1059        );
1060        // Second response (GET /tasks/t2): COMPLETED task
1061        let completed_resp = make_json_response(
1062            r#"{"id":"t2","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Polling done"}]}]}"#,
1063        );
1064        let working_resp: &'static str = Box::leak(working_resp.into_boxed_str());
1065        let completed_resp: &'static str = Box::leak(completed_resp.into_boxed_str());
1066
1067        let addr = spawn_sequential_mock_server(vec![working_resp, completed_resp]);
1068
1069        let tool = A2aCallTool::new();
1070        let result = tool
1071            .execute(json!({
1072                "url": format!("http://{addr}"),
1073                "prompt": "Do the task",
1074                "timeout_secs": 30
1075            }))
1076            .await
1077            .unwrap();
1078
1079        assert_eq!(result, "Polling done");
1080    }
1081
1082    #[tokio::test]
1083    async fn failed_task_returns_error_string() {
1084        let body = r#"{"task":{"id":"t3","status":{"state":"TASK_STATE_FAILED"},"artifacts":[]}}"#;
1085        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1086        let addr = spawn_mock_server(raw_resp);
1087        tokio::time::sleep(Duration::from_millis(30)).await;
1088
1089        let tool = A2aCallTool::new();
1090        let result = tool
1091            .execute(json!({
1092                "url": format!("http://{addr}"),
1093                "prompt": "Do the task"
1094            }))
1095            .await
1096            .unwrap();
1097
1098        assert!(
1099            result.starts_with("ERROR: task t3 ended with state TASK_STATE_FAILED"),
1100            "got: {result}"
1101        );
1102    }
1103
1104    #[tokio::test]
1105    async fn http_error_returns_error_string() {
1106        let raw_resp = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndown";
1107        let addr = spawn_mock_server(raw_resp);
1108        tokio::time::sleep(Duration::from_millis(30)).await;
1109
1110        let tool = A2aCallTool::new();
1111        let result = tool
1112            .execute(json!({
1113                "url": format!("http://{addr}"),
1114                "prompt": "ping"
1115            }))
1116            .await
1117            .unwrap();
1118
1119        assert!(result.starts_with("ERROR: HTTP"), "got: {result}");
1120    }
1121
1122    // ── A2aCardTool tests ─────────────────────────────────────────────────
1123
1124    #[test]
1125    fn agent_card_summary_full() {
1126        let card = AgentCard {
1127            name: "Weather Bot".into(),
1128            description: "Provides forecasts.".into(),
1129            capabilities: AgentCapabilities {
1130                streaming: true,
1131                push_notifications: false,
1132            },
1133            skills: vec![AgentSkill {
1134                id: "s1".into(),
1135                name: "get_weather".into(),
1136                description: "Get current weather.".into(),
1137            }],
1138            authentication: Some(AgentAuthentication {
1139                schemes: vec!["Bearer".into()],
1140            }),
1141        };
1142        let summary = card.summary();
1143        assert!(summary.contains("Agent: Weather Bot"), "{summary}");
1144        assert!(summary.contains("Streaming: supported"), "{summary}");
1145        assert!(
1146            summary.contains("Push notifications: not supported"),
1147            "{summary}"
1148        );
1149        assert!(summary.contains("Auth: Bearer"), "{summary}");
1150        assert!(summary.contains("get_weather"), "{summary}");
1151    }
1152
1153    #[test]
1154    fn agent_card_summary_empty_name_uses_unnamed() {
1155        let card = AgentCard {
1156            name: String::new(),
1157            description: String::new(),
1158            capabilities: AgentCapabilities::default(),
1159            skills: vec![],
1160            authentication: None,
1161        };
1162        let summary = card.summary();
1163        assert!(summary.contains("Agent: <unnamed>"), "{summary}");
1164        assert!(summary.contains("Auth: none"), "{summary}");
1165    }
1166
1167    #[tokio::test]
1168    async fn a2a_card_parses_valid_agent_card() {
1169        let body = r#"{
1170            "name": "Echo Agent",
1171            "description": "Echoes your message.",
1172            "capabilities": {"streaming": false, "pushNotifications": false},
1173            "skills": [{"id": "echo", "name": "echo", "description": "Echo text."}]
1174        }"#;
1175        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1176        let addr = spawn_mock_server(raw_resp);
1177        tokio::time::sleep(Duration::from_millis(30)).await;
1178
1179        let tool = A2aCardTool::new();
1180        let result = tool
1181            .execute(json!({"url": format!("http://{addr}")}))
1182            .await
1183            .unwrap();
1184
1185        assert!(result.contains("Echo Agent"), "{result}");
1186        assert!(result.contains("Streaming: not supported"), "{result}");
1187        assert!(result.contains("echo"), "{result}");
1188    }
1189
1190    #[tokio::test]
1191    async fn a2a_card_returns_error_on_http_404() {
1192        let raw_resp =
1193            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found";
1194        let addr = spawn_mock_server(raw_resp);
1195        tokio::time::sleep(Duration::from_millis(30)).await;
1196
1197        let tool = A2aCardTool::new();
1198        let result = tool
1199            .execute(json!({"url": format!("http://{addr}")}))
1200            .await
1201            .unwrap();
1202
1203        assert!(result.starts_with("ERROR: HTTP 404"), "{result}");
1204    }
1205
1206    // ── SSE streaming tests ────────────────────────────────────────────────
1207
1208    #[test]
1209    fn parse_sse_event_extracts_artifact_text() {
1210        let event = r#"data: {"type":"TaskArtifactUpdateEvent","task":{"id":"t1","status":{"state":"TASK_STATE_WORKING"},"artifacts":[{"parts":[{"text":"Hello world"}],"index":0}]}}"#;
1211        let (text, state) = parse_sse_event(event);
1212        assert_eq!(text.as_deref(), Some("Hello world"));
1213        assert!(state.is_none() || matches!(state, Some(TaskState::Working)));
1214    }
1215
1216    #[test]
1217    fn parse_sse_event_extracts_terminal_state() {
1218        let event = r#"data: {"type":"TaskStatusUpdateEvent","task":{"id":"t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[]}}"#;
1219        let (text, state) = parse_sse_event(event);
1220        assert!(text.is_none() || text.as_deref() == Some(""));
1221        assert!(matches!(state, Some(TaskState::Completed)));
1222    }
1223
1224    #[test]
1225    fn parse_sse_event_ignores_unknown_data() {
1226        let event = "data: {\"not_a2a\": true}";
1227        let (text, state) = parse_sse_event(event);
1228        assert!(text.is_none());
1229        assert!(state.is_none());
1230    }
1231
1232    #[tokio::test]
1233    async fn streaming_mode_accumulates_artifact_text() {
1234        // Build a minimal SSE response with two artifact events and a COMPLETED status.
1235        let body_parts = [
1236            "data: {\"type\":\"TaskStatusUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[]}}\n\n",
1237            "data: {\"type\":\"TaskArtifactUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[{\"parts\":[{\"text\":\"Hello \"}],\"index\":0}]}}\n\n",
1238            "data: {\"type\":\"TaskArtifactUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[{\"parts\":[{\"text\":\"world!\"}],\"index\":0}]}}\n\n",
1239            "data: {\"type\":\"TaskStatusUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_COMPLETED\"},\"artifacts\":[]}}\n\n",
1240        ].concat();
1241        let sse_body = body_parts.as_str();
1242        let raw_resp = format!(
1243            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1244            sse_body.len(),
1245            sse_body
1246        );
1247        let raw_resp: &'static str = Box::leak(raw_resp.into_boxed_str());
1248        let addr = spawn_mock_server(raw_resp);
1249        tokio::time::sleep(Duration::from_millis(30)).await;
1250
1251        let tool = A2aCallTool::new();
1252        let result = tool
1253            .execute(json!({
1254                "url": format!("http://{addr}"),
1255                "prompt": "say hello",
1256                "streaming": true,
1257                "timeout_secs": 10
1258            }))
1259            .await
1260            .unwrap();
1261
1262        assert_eq!(result, "Hello world!", "got: {result}");
1263    }
1264
1265    // ── Goal 179: async_mode + a2a_task_check tests ───────────────────────
1266
1267    #[tokio::test]
1268    async fn async_mode_returns_task_id_immediately() {
1269        let body = r#"{"task":{"id":"async-t1","status":{"state":"TASK_STATE_SUBMITTED"},"artifacts":[]}}"#;
1270        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1271        let addr = spawn_mock_server(raw_resp);
1272        tokio::time::sleep(Duration::from_millis(30)).await;
1273
1274        let tool = A2aCallTool::new();
1275        let result = tool
1276            .execute(json!({
1277                "url": format!("http://{addr}"),
1278                "prompt": "do something slow",
1279                "async_mode": true
1280            }))
1281            .await
1282            .unwrap();
1283
1284        assert!(result.contains("TASK_ID: async-t1"), "got: {result}");
1285        assert!(result.contains("TASK_STATE_SUBMITTED"), "got: {result}");
1286        assert!(result.contains("run_background"), "got: {result}");
1287        assert!(result.contains("curl"), "got: {result}");
1288    }
1289
1290    #[tokio::test]
1291    async fn a2a_task_check_completed_task() {
1292        let body = r#"{"id":"check-t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Done!"}]}]}"#;
1293        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1294        let addr = spawn_mock_server(raw_resp);
1295        tokio::time::sleep(Duration::from_millis(30)).await;
1296
1297        let tool = A2aTaskCheckTool::new();
1298        let result = tool
1299            .execute(json!({
1300                "url": format!("http://{addr}"),
1301                "task_id": "check-t1"
1302            }))
1303            .await
1304            .unwrap();
1305
1306        assert!(result.contains("TASK_STATE_COMPLETED"), "got: {result}");
1307        assert!(result.contains("Done!"), "got: {result}");
1308    }
1309
1310    #[tokio::test]
1311    async fn a2a_task_check_working_task_hints_to_retry() {
1312        let body = r#"{"id":"check-t2","status":{"state":"TASK_STATE_WORKING"},"artifacts":[]}"#;
1313        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1314        let addr = spawn_mock_server(raw_resp);
1315        tokio::time::sleep(Duration::from_millis(30)).await;
1316
1317        let tool = A2aTaskCheckTool::new();
1318        let result = tool
1319            .execute(json!({
1320                "url": format!("http://{addr}"),
1321                "task_id": "check-t2"
1322            }))
1323            .await
1324            .unwrap();
1325
1326        assert!(result.contains("TASK_STATE_WORKING"), "got: {result}");
1327        assert!(result.contains("check again later"), "got: {result}");
1328    }
1329
1330    #[tokio::test]
1331    async fn a2a_task_check_http_error_returns_error_string() {
1332        let raw_resp =
1333            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found";
1334        let addr = spawn_mock_server(raw_resp);
1335        tokio::time::sleep(Duration::from_millis(30)).await;
1336
1337        let tool = A2aTaskCheckTool::new();
1338        let result = tool
1339            .execute(json!({
1340                "url": format!("http://{addr}"),
1341                "task_id": "missing-task"
1342            }))
1343            .await
1344            .unwrap();
1345
1346        assert!(result.starts_with("ERROR: HTTP 404"), "got: {result}");
1347    }
1348}