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, LOCATION};
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    /// Test-only escape hatch: when `true`, skip the SSRF URL guard so
145    /// mock-server tests can exercise the real network path against
146    /// 127.0.0.1. Production instances (via [`new`]) always guard.
147    ///
148    /// [`new`]: A2aCallTool::new
149    #[cfg(test)]
150    allow_private_urls: bool,
151}
152
153impl A2aCallTool {
154    pub fn new() -> Self {
155        Self {
156            #[cfg(test)]
157            allow_private_urls: false,
158        }
159    }
160
161    /// Test-only constructor: skips the SSRF URL guard. Mock-server
162    /// integration tests bind to 127.0.0.1, which the guard rejects.
163    #[cfg(test)]
164    pub(crate) fn new_unchecked() -> Self {
165        Self {
166            allow_private_urls: true,
167        }
168    }
169
170    /// Build a `reqwest::Client` with sane timeouts.
171    fn build_client() -> std::result::Result<reqwest::Client, reqwest::Error> {
172        reqwest::Client::builder()
173            .timeout(Duration::from_secs(30))
174            .connect_timeout(Duration::from_secs(10))
175            // Do not follow redirects. `validate_url` (url_guard) checks only
176            // the *initial* URL, so reqwest's default policy (follow up to 10
177            // redirects) would let a public A2A server 302-redirect a request
178            // to a private/internal host after the guard passed — an SSRF
179            // bypass (same fix as web_fetch.rs, Goal 372). Under
180            // `Policy::none()` a 3xx comes back as a normal response and each
181            // call site rejects it explicitly instead of issuing a second
182            // request.
183            .redirect(reqwest::redirect::Policy::none())
184            .build()
185    }
186}
187
188impl Default for A2aCallTool {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194#[async_trait]
195impl Tool for A2aCallTool {
196    fn is_deferred(&self) -> bool {
197        true
198    }
199
200    fn spec(&self) -> ToolSpec {
201        ToolSpec {
202            name: "a2a_call".into(),
203            description: "Invoke a remote A2A (Agent2Agent v1.0) agent and return its response. \
204                           Supports both polling mode (default) and real-time SSE streaming mode. \
205                           In polling mode the tool sends a message and polls the task until done. \
206                           In streaming mode it connects to the server's SSE stream and accumulates \
207                           artifact text as it arrives."
208                .into(),
209            parameters: json!({
210                "type": "object",
211                "properties": {
212                    "url": {
213                        "type": "string",
214                        "description": "Base URL of the A2A server (e.g. 'https://agent.example.com'). \
215                                        Polling: posts to {url}/message:send and polls {url}/tasks/{id}. \
216                                        Streaming: posts to {url}/message:stream."
217                    },
218                    "prompt": {
219                        "type": "string",
220                        "description": "The text message to send to the remote agent."
221                    },
222                    "authorization": {
223                        "type": "string",
224                        "description": "Optional Authorization header value (e.g. 'Bearer <token>'). \
225                                        Omit for public agents."
226                    },
227                    "timeout_secs": {
228                        "type": "integer",
229                        "description": "Maximum seconds to wait for task completion (default: 60, max: 300). \
230                                        In polling mode: polls every 2 seconds. \
231                                        In streaming mode: max time to keep the SSE connection open.",
232                        "default": 60
233                    },
234                    "streaming": {
235                        "type": "boolean",
236                        "description": "If true, use SSE streaming mode (POST {url}/message:stream). \
237                                        The server must support A2A streaming. Default: false.",
238                        "default": false
239                    },
240                    "async_mode": {
241                        "type": "boolean",
242                        "description": "If true, submit the task and return immediately with the task ID \
243                                        without waiting for completion. Use a2a_task_check later to \
244                                        retrieve the result (composable with schedule_wakeup). Default: false.",
245                        "default": false
246                    }
247                },
248                "required": ["url", "prompt"]
249            }),
250        }
251    }
252
253    fn side_effect_class(&self) -> ToolSideEffect {
254        ToolSideEffect::External
255    }
256
257    async fn execute(&self, arguments: Value) -> Result<String> {
258        let url = arguments["url"]
259            .as_str()
260            .ok_or_else(|| Error::BadToolArgs {
261                name: "a2a_call".into(),
262                message: "missing required parameter: url".into(),
263            })?;
264
265        // SSRF guard: reject private/internal/loopback targets before any
266        // socket opens. Test instances (`new_unchecked`) skip this so the
267        // protocol tests can reach 127.0.0.1 mock servers.
268        #[cfg(test)]
269        let validated_url = if self.allow_private_urls {
270            url.to_string()
271        } else {
272            crate::tools::url_guard::validate_url(url)?
273        };
274        #[cfg(not(test))]
275        let validated_url = crate::tools::url_guard::validate_url(url)?;
276
277        let prompt = arguments["prompt"]
278            .as_str()
279            .ok_or_else(|| Error::BadToolArgs {
280                name: "a2a_call".into(),
281                message: "missing required parameter: prompt".into(),
282            })?;
283        let authorization = arguments["authorization"].as_str();
284        let timeout_secs = arguments["timeout_secs"]
285            .as_u64()
286            .unwrap_or(60)
287            .clamp(1, 300);
288        let use_streaming = arguments["streaming"].as_bool().unwrap_or(false);
289        let use_async = arguments["async_mode"].as_bool().unwrap_or(false);
290
291        // Normalise base URL — strip trailing slash.
292        let base = validated_url.trim_end_matches('/');
293
294        let client = Self::build_client().map_err(|e| Error::Tool {
295            name: "a2a_call".into(),
296            call_id: None,
297            message: format!("failed to build HTTP client: {e}"),
298        })?;
299
300        if use_streaming {
301            return execute_streaming(base, prompt, authorization, timeout_secs, &client).await;
302        }
303
304        // ── Step 1: POST /message:send ────────────────────────────────────
305        let message_id = uuid::Uuid::new_v4().to_string();
306        let send_url = format!("{base}/message:send");
307
308        let mut req = client
309            .post(&send_url)
310            .header(CONTENT_TYPE, "application/a2a+json")
311            .json(&json!({
312                "message": {
313                    "role": "ROLE_USER",
314                    "parts": [{"text": prompt}],
315                    "messageId": message_id
316                }
317            }));
318
319        if let Some(auth) = authorization {
320            req = req.header(AUTHORIZATION, auth);
321        }
322
323        let resp = req.send().await.map_err(|e| Error::Tool {
324            name: "a2a_call".into(),
325            call_id: None,
326            message: format!("network error calling {send_url}: {e}"),
327        })?;
328
329        let status = resp.status();
330        // With `Policy::none()` a 3xx is a normal response, not an error —
331        // reject it explicitly. Following `Location` would bypass the SSRF
332        // URL guard (the redirect target is not validated).
333        if status.is_redirection() {
334            return Err(redirect_error("a2a_call", &resp, status));
335        }
336        if !status.is_success() {
337            let body = resp.text().await.unwrap_or_default();
338            return Ok(format!("ERROR: HTTP {status} from {send_url}: {body}"));
339        }
340
341        let body_text = resp.text().await.map_err(|e| Error::Tool {
342            name: "a2a_call".into(),
343            call_id: None,
344            message: format!("failed to read response body: {e}"),
345        })?;
346
347        let send_resp: SendMessageResponse =
348            serde_json::from_str(&body_text).map_err(|e| Error::Tool {
349                name: "a2a_call".into(),
350                call_id: None,
351                message: format!("invalid A2A response: {e}\nbody: {body_text}"),
352            })?;
353
354        // ── Step 2: handle synchronous message response ───────────────────
355        match send_resp {
356            SendMessageResponse::HasMessage { message } => {
357                let text = parts_to_text(&message.parts);
358                return Ok(if text.is_empty() {
359                    "(empty response from agent)".to_string()
360                } else {
361                    text
362                });
363            }
364            SendMessageResponse::HasTask { task } => {
365                // ── Async mode: return task ID + background poll script ──────
366                if use_async {
367                    // Security: the server controls `task.id`, and it gets
368                    // embedded into a shell one-liner the model hands to
369                    // `run_background`. Only `[A-Za-z0-9._-]` ids are safe to
370                    // interpolate; anything else (quotes, `;`, backtick, `$()`,
371                    // whitespace) falls back to synchronous polling instead of
372                    // building an injectable script.
373                    if !is_safe_task_id(&task.id) {
374                        let unsafe_id = task.id.clone();
375                        let result = poll_task_to_completion(
376                            &client,
377                            base,
378                            task,
379                            authorization,
380                            timeout_secs,
381                        )
382                        .await?;
383                        return Ok(format!(
384                            "WARNING: A2A server returned unsafe task.id {unsafe_id:?}; \
385async_mode ignored, polling synchronously\n{result}"
386                        ));
387                    }
388
389                    let task_id = &task.id;
390                    let state = task.status.state.as_str();
391                    // Build a shell one-liner the agent can pass to run_background.
392                    // When the task reaches a terminal state the script exits, and
393                    // run_event_loop detects the completed background job and starts
394                    // a new turn with the output as its goal.
395                    let poll_cmd = format!(
396                        "end=$(($(date +%s)+{timeout_secs})); \
397while [ $(date +%s) -lt $end ]; do \
398  r=$(curl -sf '{base}/tasks/{task_id}' 2>/dev/null || echo '{{}}'); \
399  s=$(echo \"$r\" | python3 -c \"import sys,json;print(json.load(sys.stdin).get('status',{{}}).get('state','UNKNOWN'))\" 2>/dev/null || echo UNKNOWN); \
400  case \"$s\" in TASK_STATE_COMPLETED|TASK_STATE_FAILED|TASK_STATE_CANCELED|TASK_STATE_REJECTED) \
401    echo \"A2A task {task_id} finished: $s\"; echo \"$r\"; exit 0;; \
402  esac; sleep 2; done; \
403echo \"A2A task {task_id} timed out after {timeout_secs}s\""
404                    );
405                    return Ok(format!(
406                        "TASK_ID: {task_id}\nSTATE: {state}\n\n\
407To poll in background and auto-trigger a new turn on completion, \
408call run_background with:\n  command: {poll_cmd}\n  name: a2a-{task_id}\n\n\
409When the background job completes, a new turn will start automatically with the result."
410                    ));
411                }
412
413                // ── Step 3 (sync): poll until terminal state ──────────────
414                poll_task_to_completion(&client, base, task, authorization, timeout_secs).await
415            }
416        }
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Redirect + async task.id hardening (Goal 378)
422// ---------------------------------------------------------------------------
423
424/// Build the error surfaced when an A2A server answers with a redirect.
425///
426/// With `Policy::none()` a 3xx arrives as a normal response, not an error, so
427/// it must be rejected explicitly: following `Location` could bypass the SSRF
428/// URL guard by pointing the next request at a private/internal host (Goal 372).
429/// The redirect is never followed.
430fn redirect_error(name: &str, resp: &reqwest::Response, status: reqwest::StatusCode) -> Error {
431    let location = resp
432        .headers()
433        .get(LOCATION)
434        .and_then(|v| v.to_str().ok())
435        .unwrap_or("<none>");
436    Error::Tool {
437        name: name.into(),
438        call_id: None,
439        message: format!(
440            "A2A server returned redirect ({}) to {location}",
441            status.as_u16()
442        ),
443    }
444}
445
446/// Whitelist for A2A `task.id` values that may be embedded into the async-mode
447/// shell one-liner.
448///
449/// The id is server-controlled, so only `[A-Za-z0-9._-]` is allowed — any
450/// shell metacharacter (`'`, `;`, backtick, `$()`, whitespace) is rejected and
451/// the caller falls back to synchronous polling instead of building an
452/// injectable script.
453fn is_safe_task_id(id: &str) -> bool {
454    !id.is_empty()
455        && id
456            .chars()
457            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
458}
459
460/// Poll `task` via `GET {base}/tasks/{id}` until it reaches a terminal state
461/// and return the plain result string.
462///
463/// Shared by the synchronous path and by the `async_mode` fallback when the
464/// server returns a `task.id` that is not safe to embed in the background poll
465/// script.
466async fn poll_task_to_completion(
467    client: &reqwest::Client,
468    base: &str,
469    mut task: Task,
470    authorization: Option<&str>,
471    timeout_secs: u64,
472) -> Result<String> {
473    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
474    let task_url = format!("{base}/tasks/{}", task.id);
475
476    while !task.status.state.is_terminal() {
477        if Instant::now() >= deadline {
478            return Ok(format!(
479                "ERROR: task {} timed out after {}s (last state: {})",
480                task.id,
481                timeout_secs,
482                task.status.state.as_str()
483            ));
484        }
485
486        tokio::time::sleep(Duration::from_secs(2)).await;
487
488        let mut poll_req = client.get(&task_url);
489        if let Some(auth) = authorization {
490            poll_req = poll_req.header(AUTHORIZATION, auth);
491        }
492
493        let poll_resp = poll_req.send().await.map_err(|e| Error::Tool {
494            name: "a2a_call".into(),
495            call_id: None,
496            message: format!("network error polling {task_url}: {e}"),
497        })?;
498
499        let poll_status = poll_resp.status();
500        if poll_status.is_redirection() {
501            return Err(redirect_error("a2a_call", &poll_resp, poll_status));
502        }
503        if !poll_status.is_success() {
504            let err_body = poll_resp.text().await.unwrap_or_default();
505            return Ok(format!(
506                "ERROR: HTTP {poll_status} polling {task_url}: {err_body}"
507            ));
508        }
509
510        let poll_body = poll_resp.text().await.map_err(|e| Error::Tool {
511            name: "a2a_call".into(),
512            call_id: None,
513            message: format!("failed to read poll response: {e}"),
514        })?;
515
516        let updated: Task = serde_json::from_str(&poll_body).map_err(|e| Error::Tool {
517            name: "a2a_call".into(),
518            call_id: None,
519            message: format!("invalid task poll response: {e}\nbody: {poll_body}"),
520        })?;
521
522        task = updated;
523    }
524
525    match task.status.state {
526        TaskState::Completed => {
527            let text = artifacts_to_text(&task.artifacts);
528            Ok(if text.is_empty() {
529                "(task completed with no artifact text)".to_string()
530            } else {
531                text
532            })
533        }
534        other => Ok(format!(
535            "ERROR: task {} ended with state {}",
536            task.id,
537            other.as_str()
538        )),
539    }
540}
541
542// ---------------------------------------------------------------------------
543// SSE streaming implementation
544// ---------------------------------------------------------------------------
545
546/// Parse one SSE event block (multiple `key: value` lines separated by `\n`)
547/// and extract any artifact text and terminal task state from it.
548fn parse_sse_event(event_block: &str) -> (Option<String>, Option<TaskState>) {
549    let mut data_lines: Vec<&str> = Vec::new();
550    for line in event_block.lines() {
551        if let Some(data) = line.strip_prefix("data:") {
552            data_lines.push(data.trim());
553        }
554    }
555    let data = data_lines.join("");
556    if data.is_empty() {
557        return (None, None);
558    }
559
560    let Ok(v): std::result::Result<Value, _> = serde_json::from_str(&data) else {
561        return (None, None);
562    };
563
564    // Extract artifact text — handle both A2A REST and JSON-RPC response shapes.
565    let mut text: Option<String> = None;
566    let mut state: Option<TaskState> = None;
567
568    // Shape 1: {"type":"TaskArtifactUpdateEvent","task":{...}}
569    // Shape 2: {"result":{"kind":"artifact-update","artifact":{...}}}
570    if let Some(artifacts) = v
571        .pointer("/task/artifacts")
572        .or_else(|| v.pointer("/result/artifact/parts"))
573        .and_then(|a| a.as_array())
574    {
575        let extracted: String = artifacts
576            .iter()
577            .flat_map(|a| a["parts"].as_array().into_iter().flatten())
578            .filter_map(|p| p["text"].as_str())
579            .collect::<Vec<_>>()
580            .join("");
581        if !extracted.is_empty() {
582            text = Some(extracted);
583        }
584    } else if let Some(part_text) = v
585        .pointer("/result/artifact/parts/0/text")
586        .and_then(|v| v.as_str())
587    {
588        text = Some(part_text.to_string());
589    }
590
591    // Extract state — handle both shapes.
592    let raw_state = v
593        .pointer("/task/status/state")
594        .or_else(|| v.pointer("/result/status/state"))
595        .and_then(|s| s.as_str());
596
597    if let Some(s) = raw_state {
598        if let Ok(ts) = serde_json::from_value::<TaskState>(Value::String(s.to_string())) {
599            state = Some(ts);
600        }
601    }
602
603    (text, state)
604}
605
606/// Execute `a2a_call` in SSE streaming mode.
607///
608/// POSTs to `{base}/message:stream` with `Accept: text/event-stream` and reads
609/// the SSE stream until a terminal task state is received or the timeout expires.
610async fn execute_streaming(
611    base: &str,
612    prompt: &str,
613    authorization: Option<&str>,
614    timeout_secs: u64,
615    client: &reqwest::Client,
616) -> Result<String> {
617    let stream_url = format!("{base}/message:stream");
618    let message_id = uuid::Uuid::new_v4().to_string();
619    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
620
621    let mut req = client
622        .post(&stream_url)
623        .header(CONTENT_TYPE, "application/a2a+json")
624        .header(reqwest::header::ACCEPT, "text/event-stream")
625        .json(&json!({
626            "message": {
627                "role": "ROLE_USER",
628                "parts": [{"text": prompt}],
629                "messageId": message_id
630            }
631        }));
632
633    if let Some(auth) = authorization {
634        req = req.header(AUTHORIZATION, auth);
635    }
636
637    let resp = req.send().await.map_err(|e| Error::Tool {
638        name: "a2a_call".into(),
639        call_id: None,
640        message: format!("network error calling {stream_url}: {e}"),
641    })?;
642
643    let status = resp.status();
644    // With `Policy::none()` a 3xx is a normal response, not an error —
645    // reject it explicitly. Following `Location` would bypass the SSRF URL
646    // guard (the redirect target is not validated).
647    if status.is_redirection() {
648        return Err(redirect_error("a2a_call", &resp, status));
649    }
650    if !status.is_success() {
651        let body = resp.text().await.unwrap_or_default();
652        return Ok(format!("ERROR: HTTP {status} from {stream_url}: {body}"));
653    }
654
655    // Read SSE events chunk by chunk.
656    let mut buffer = String::new();
657    let mut accumulated_text = String::new();
658    let mut final_state: Option<TaskState> = None;
659    let mut resp = resp;
660
661    loop {
662        if Instant::now() >= deadline {
663            let suffix = if accumulated_text.is_empty() {
664                "(no text received before timeout)".to_string()
665            } else {
666                accumulated_text.clone()
667            };
668            return Ok(format!(
669                "ERROR: SSE stream timed out after {timeout_secs}s. Partial output:\n{suffix}"
670            ));
671        }
672
673        let chunk = tokio::time::timeout(Duration::from_secs(5), resp.chunk())
674            .await
675            .map_err(|_| Error::Tool {
676                name: "a2a_call".into(),
677                call_id: None,
678                message: "SSE chunk read timed out (5s idle)".into(),
679            })?
680            .map_err(|e| Error::Tool {
681                name: "a2a_call".into(),
682                call_id: None,
683                message: format!("SSE read error: {e}"),
684            })?;
685
686        let Some(bytes) = chunk else {
687            // Connection closed by server.
688            break;
689        };
690
691        if let Ok(text_chunk) = std::str::from_utf8(&bytes) {
692            buffer.push_str(text_chunk);
693        }
694
695        // Process complete SSE event blocks (separated by \n\n or \r\n\r\n).
696        while let Some(pos) = buffer.find("\n\n").or_else(|| buffer.find("\r\n\r\n")) {
697            let sep_len = if buffer[pos..].starts_with("\r\n\r\n") {
698                4
699            } else {
700                2
701            };
702            let event_block = buffer[..pos].to_string();
703            buffer.drain(..pos + sep_len);
704
705            let (text, state) = parse_sse_event(&event_block);
706            if let Some(t) = text {
707                accumulated_text.push_str(&t);
708            }
709            if let Some(s) = state {
710                if s.is_terminal() {
711                    final_state = Some(s);
712                    break;
713                }
714            }
715        }
716
717        if final_state.is_some() {
718            break;
719        }
720    }
721
722    match final_state {
723        Some(TaskState::Completed) | None => {
724            if accumulated_text.is_empty() {
725                Ok("(task completed with no artifact text)".to_string())
726            } else {
727                Ok(accumulated_text)
728            }
729        }
730        Some(other) => Ok(format!(
731            "ERROR: task ended with state {} (partial output: {})",
732            other.as_str(),
733            accumulated_text
734        )),
735    }
736}
737
738// ---------------------------------------------------------------------------
739// Agent Card data model
740// ---------------------------------------------------------------------------
741
742/// Capability flags from the A2A Agent Card.
743#[derive(Debug, Clone, Serialize, Deserialize, Default)]
744pub struct AgentCapabilities {
745    /// Whether the agent supports streaming (SSE) via `sendSubscribe`.
746    #[serde(default)]
747    pub streaming: bool,
748    /// Whether the agent supports push-notification callbacks.
749    #[serde(rename = "pushNotifications", default)]
750    pub push_notifications: bool,
751}
752
753/// One skill entry in the Agent Card.
754#[derive(Debug, Clone, Serialize, Deserialize)]
755pub struct AgentSkill {
756    pub id: String,
757    #[serde(default)]
758    pub name: String,
759    #[serde(default)]
760    pub description: String,
761}
762
763/// Authentication scheme described in the Agent Card.
764#[derive(Debug, Clone, Serialize, Deserialize)]
765pub struct AgentAuthentication {
766    pub schemes: Vec<String>,
767}
768
769/// Subset of the A2A v1.0 Agent Card returned by `GET /.well-known/agent.json`.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct AgentCard {
772    #[serde(default)]
773    pub name: String,
774    #[serde(default)]
775    pub description: String,
776    #[serde(default)]
777    pub capabilities: AgentCapabilities,
778    #[serde(default)]
779    pub skills: Vec<AgentSkill>,
780    pub authentication: Option<AgentAuthentication>,
781}
782
783impl AgentCard {
784    /// Return a concise human-readable summary of this Agent Card.
785    pub fn summary(&self) -> String {
786        let mut lines: Vec<String> = Vec::new();
787        let name = if self.name.is_empty() {
788            "<unnamed>"
789        } else {
790            &self.name
791        };
792        lines.push(format!("Agent: {name}"));
793        if !self.description.is_empty() {
794            lines.push(format!("Description: {}", self.description));
795        }
796        lines.push(format!(
797            "Streaming: {}",
798            if self.capabilities.streaming {
799                "supported"
800            } else {
801                "not supported"
802            }
803        ));
804        lines.push(format!(
805            "Push notifications: {}",
806            if self.capabilities.push_notifications {
807                "supported"
808            } else {
809                "not supported"
810            }
811        ));
812        if let Some(auth) = &self.authentication {
813            lines.push(format!("Auth: {}", auth.schemes.join(", ")));
814        } else {
815            lines.push("Auth: none".into());
816        }
817        if !self.skills.is_empty() {
818            lines.push("Skills:".into());
819            for skill in &self.skills {
820                let desc = if skill.description.is_empty() {
821                    String::new()
822                } else {
823                    format!(": {}", skill.description)
824                };
825                lines.push(format!("  - {}{}", skill.name, desc));
826            }
827        }
828        lines.join("\n")
829    }
830}
831
832// ---------------------------------------------------------------------------
833// A2aCardTool
834// ---------------------------------------------------------------------------
835
836/// Tool that fetches and summarises a remote A2A Agent Card
837/// (`GET /.well-known/agent.json`).
838pub struct A2aCardTool {
839    /// Test-only escape hatch: when `true`, skip the SSRF URL guard so
840    /// mock-server tests can exercise the real network path against
841    /// 127.0.0.1. Production instances (via [`new`]) always guard.
842    ///
843    /// [`new`]: A2aCardTool::new
844    #[cfg(test)]
845    allow_private_urls: bool,
846}
847
848impl A2aCardTool {
849    pub fn new() -> Self {
850        Self {
851            #[cfg(test)]
852            allow_private_urls: false,
853        }
854    }
855
856    /// Test-only constructor: skips the SSRF URL guard. Mock-server
857    /// integration tests bind to 127.0.0.1, which the guard rejects.
858    #[cfg(test)]
859    pub(crate) fn new_unchecked() -> Self {
860        Self {
861            allow_private_urls: true,
862        }
863    }
864}
865
866impl Default for A2aCardTool {
867    fn default() -> Self {
868        Self::new()
869    }
870}
871
872#[async_trait]
873impl Tool for A2aCardTool {
874    fn is_deferred(&self) -> bool {
875        true
876    }
877
878    fn spec(&self) -> ToolSpec {
879        ToolSpec {
880            name: "a2a_card".into(),
881            description: "Fetch and display the Agent Card of a remote A2A v1.0 agent. \
882                           The card describes the agent's capabilities (streaming, push \
883                           notifications), authentication scheme, and available skills. \
884                           Call this before a2a_call to discover what the agent supports."
885                .into(),
886            parameters: json!({
887                "type": "object",
888                "properties": {
889                    "url": {
890                        "type": "string",
891                        "description": "Base URL of the A2A server (e.g. 'https://agent.example.com'). \
892                                        The tool fetches {url}/.well-known/agent.json."
893                    },
894                    "authorization": {
895                        "type": "string",
896                        "description": "Optional Authorization header value (e.g. 'Bearer <token>')."
897                    }
898                },
899                "required": ["url"]
900            }),
901        }
902    }
903
904    fn side_effect_class(&self) -> ToolSideEffect {
905        ToolSideEffect::External
906    }
907
908    async fn execute(&self, arguments: Value) -> Result<String> {
909        let url = arguments["url"]
910            .as_str()
911            .ok_or_else(|| Error::BadToolArgs {
912                name: "a2a_card".into(),
913                message: "missing required parameter: url".into(),
914            })?;
915
916        // SSRF guard: reject private/internal/loopback targets before any
917        // socket opens. Test instances (`new_unchecked`) skip this so the
918        // protocol tests can reach 127.0.0.1 mock servers.
919        #[cfg(test)]
920        let validated_url = if self.allow_private_urls {
921            url.to_string()
922        } else {
923            crate::tools::url_guard::validate_url(url)?
924        };
925        #[cfg(not(test))]
926        let validated_url = crate::tools::url_guard::validate_url(url)?;
927
928        let authorization = arguments["authorization"].as_str();
929
930        let base = validated_url.trim_end_matches('/');
931        let card_url = format!("{base}/.well-known/agent.json");
932
933        let client = A2aCallTool::build_client().map_err(|e| Error::Tool {
934            name: "a2a_card".into(),
935            call_id: None,
936            message: format!("failed to build HTTP client: {e}"),
937        })?;
938
939        let mut req = client.get(&card_url);
940        if let Some(auth) = authorization {
941            req = req.header(AUTHORIZATION, auth);
942        }
943
944        let resp = req.send().await.map_err(|e| Error::Tool {
945            name: "a2a_card".into(),
946            call_id: None,
947            message: format!("network error fetching {card_url}: {e}"),
948        })?;
949
950        let status = resp.status();
951        // With `Policy::none()` a 3xx is a normal response, not an error —
952        // reject it explicitly. Following `Location` would bypass the SSRF URL
953        // guard (the redirect target is not validated).
954        if status.is_redirection() {
955            return Err(redirect_error("a2a_card", &resp, status));
956        }
957        if !status.is_success() {
958            let body = resp.text().await.unwrap_or_default();
959            return Ok(format!("ERROR: HTTP {status} from {card_url}: {body}"));
960        }
961
962        let body_text = resp.text().await.map_err(|e| Error::Tool {
963            name: "a2a_card".into(),
964            call_id: None,
965            message: format!("failed to read response body: {e}"),
966        })?;
967
968        let card: AgentCard = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
969            name: "a2a_card".into(),
970            call_id: None,
971            message: format!("invalid Agent Card JSON: {e}\nbody: {body_text}"),
972        })?;
973
974        Ok(card.summary())
975    }
976}
977
978// ---------------------------------------------------------------------------
979// A2aTaskCheckTool
980// ---------------------------------------------------------------------------
981
982/// Tool that checks the current state and artifacts of an A2A task.
983///
984/// Designed to be used together with `a2a_call` in `async_mode: true`, allowing
985/// the agent to submit a long-running task and check on it later, optionally
986/// combined with `schedule_wakeup` for periodic polling.
987pub struct A2aTaskCheckTool {
988    /// Test-only escape hatch: when `true`, skip the SSRF URL guard so
989    /// mock-server tests can exercise the real network path against
990    /// 127.0.0.1. Production instances (via [`new`]) always guard.
991    ///
992    /// [`new`]: A2aTaskCheckTool::new
993    #[cfg(test)]
994    allow_private_urls: bool,
995}
996
997impl A2aTaskCheckTool {
998    pub fn new() -> Self {
999        Self {
1000            #[cfg(test)]
1001            allow_private_urls: false,
1002        }
1003    }
1004
1005    /// Test-only constructor: skips the SSRF URL guard. Mock-server
1006    /// integration tests bind to 127.0.0.1, which the guard rejects.
1007    #[cfg(test)]
1008    pub(crate) fn new_unchecked() -> Self {
1009        Self {
1010            allow_private_urls: true,
1011        }
1012    }
1013}
1014
1015impl Default for A2aTaskCheckTool {
1016    fn default() -> Self {
1017        Self::new()
1018    }
1019}
1020
1021#[async_trait]
1022impl Tool for A2aTaskCheckTool {
1023    fn is_deferred(&self) -> bool {
1024        true
1025    }
1026
1027    fn spec(&self) -> ToolSpec {
1028        ToolSpec {
1029            name: "a2a_task_check".into(),
1030            description: "Check the current status and artifacts of a previously submitted A2A task. \
1031                           Useful for one-off manual inspection. The preferred pattern for automatic \
1032                           async polling is: use a2a_call with async_mode=true (which provides a \
1033                           ready-to-run shell command), pass that command to run_background, and let \
1034                           run_event_loop auto-trigger a new turn when the background job completes."
1035                .into(),
1036            parameters: json!({
1037                "type": "object",
1038                "properties": {
1039                    "url": {
1040                        "type": "string",
1041                        "description": "Base URL of the A2A server (same url used in a2a_call)."
1042                    },
1043                    "task_id": {
1044                        "type": "string",
1045                        "description": "Task ID returned by a2a_call in async_mode (the value after 'TASK_ID: ')."
1046                    },
1047                    "authorization": {
1048                        "type": "string",
1049                        "description": "Optional Authorization header value (e.g. 'Bearer <token>')."
1050                    }
1051                },
1052                "required": ["url", "task_id"]
1053            }),
1054        }
1055    }
1056
1057    fn side_effect_class(&self) -> ToolSideEffect {
1058        ToolSideEffect::External
1059    }
1060
1061    async fn execute(&self, arguments: Value) -> Result<String> {
1062        let url = arguments["url"]
1063            .as_str()
1064            .ok_or_else(|| Error::BadToolArgs {
1065                name: "a2a_task_check".into(),
1066                message: "missing required parameter: url".into(),
1067            })?;
1068
1069        // SSRF guard: reject private/internal/loopback targets before any
1070        // socket opens. Test instances (`new_unchecked`) skip this so the
1071        // protocol tests can reach 127.0.0.1 mock servers.
1072        #[cfg(test)]
1073        let validated_url = if self.allow_private_urls {
1074            url.to_string()
1075        } else {
1076            crate::tools::url_guard::validate_url(url)?
1077        };
1078        #[cfg(not(test))]
1079        let validated_url = crate::tools::url_guard::validate_url(url)?;
1080
1081        let task_id = arguments["task_id"]
1082            .as_str()
1083            .ok_or_else(|| Error::BadToolArgs {
1084                name: "a2a_task_check".into(),
1085                message: "missing required parameter: task_id".into(),
1086            })?;
1087        let authorization = arguments["authorization"].as_str();
1088
1089        let base = validated_url.trim_end_matches('/');
1090        let task_url = format!("{base}/tasks/{task_id}");
1091
1092        let client = A2aCallTool::build_client().map_err(|e| Error::Tool {
1093            name: "a2a_task_check".into(),
1094            call_id: None,
1095            message: format!("failed to build HTTP client: {e}"),
1096        })?;
1097
1098        let mut req = client.get(&task_url);
1099        if let Some(auth) = authorization {
1100            req = req.header(AUTHORIZATION, auth);
1101        }
1102
1103        let resp = req.send().await.map_err(|e| Error::Tool {
1104            name: "a2a_task_check".into(),
1105            call_id: None,
1106            message: format!("network error fetching {task_url}: {e}"),
1107        })?;
1108
1109        let status = resp.status();
1110        // With `Policy::none()` a 3xx is a normal response, not an error —
1111        // reject it explicitly. Following `Location` would bypass the SSRF URL
1112        // guard (the redirect target is not validated).
1113        if status.is_redirection() {
1114            return Err(redirect_error("a2a_task_check", &resp, status));
1115        }
1116        if !status.is_success() {
1117            let body = resp.text().await.unwrap_or_default();
1118            return Ok(format!("ERROR: HTTP {status} from {task_url}: {body}"));
1119        }
1120
1121        let body_text = resp.text().await.map_err(|e| Error::Tool {
1122            name: "a2a_task_check".into(),
1123            call_id: None,
1124            message: format!("failed to read response body: {e}"),
1125        })?;
1126
1127        let task: Task = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
1128            name: "a2a_task_check".into(),
1129            call_id: None,
1130            message: format!("invalid task JSON: {e}\nbody: {body_text}"),
1131        })?;
1132
1133        let state_str = task.status.state.as_str();
1134        let artifact_text = artifacts_to_text(&task.artifacts);
1135
1136        if task.status.state.is_terminal() {
1137            if artifact_text.is_empty() {
1138                Ok(format!("STATE: {state_str}\n(no artifact text)"))
1139            } else {
1140                Ok(format!("STATE: {state_str}\n{artifact_text}"))
1141            }
1142        } else {
1143            Ok(format!(
1144                "STATE: {state_str}\n(task not yet complete; check again later)"
1145            ))
1146        }
1147    }
1148}
1149
1150// ---------------------------------------------------------------------------
1151// Tests
1152// ---------------------------------------------------------------------------
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use std::io::{Read, Write};
1158    use std::net::TcpListener;
1159    use std::sync::atomic::{AtomicUsize, Ordering};
1160    use std::sync::{Arc, Mutex};
1161
1162    /// `a2a_call` must run in deferred (async) mode — pin `is_deferred` so a
1163    /// mutant flipping it to false (making a2a_call synchronous) is caught.
1164    /// (2026-08-03: cargo-mutants reported this mutant MISSED — the async-mode
1165    /// behaviour was unpinned. Goal 378 follow-up.)
1166    #[test]
1167    fn a2a_call_tool_is_deferred() {
1168        assert!(A2aCallTool::new().is_deferred());
1169    }
1170
1171    /// `a2a_card` is also deferred (network tool) — same pin as above.
1172    #[test]
1173    fn a2a_card_tool_is_deferred() {
1174        assert!(A2aCardTool::new().is_deferred());
1175    }
1176
1177    /// `a2a_task_check` is also deferred (network tool) — same pin as above.
1178    #[test]
1179    fn a2a_task_check_tool_is_deferred() {
1180        assert!(A2aTaskCheckTool::new().is_deferred());
1181    }
1182
1183    /// Spawn a one-shot mock HTTP server on an ephemeral port.
1184    /// `response_body` is the raw HTTP response (including headers) the server sends.
1185    fn spawn_mock_server(response_body: &'static str) -> std::net::SocketAddr {
1186        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1187        let addr = listener.local_addr().unwrap();
1188        std::thread::spawn(move || {
1189            if let Ok((mut stream, _)) = listener.accept() {
1190                let mut buf = [0u8; 8192];
1191                let _ = stream.read(&mut buf);
1192                let _ = stream.write_all(response_body.as_bytes());
1193                let _ = stream.flush();
1194            }
1195        });
1196        addr
1197    }
1198
1199    /// Spawn a mock server that returns different responses for each request.
1200    /// Requests are served in order; extras after the list result in 500.
1201    fn spawn_sequential_mock_server(responses: Vec<&'static str>) -> std::net::SocketAddr {
1202        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1203        let addr = listener.local_addr().unwrap();
1204        let responses = Arc::new(Mutex::new(responses.into_iter()));
1205        std::thread::spawn(move || {
1206            for _ in 0..100 {
1207                if let Ok((mut stream, _)) = listener.accept() {
1208                    let mut buf = [0u8; 8192];
1209                    let _ = stream.read(&mut buf);
1210                    let response = responses
1211                        .lock()
1212                        .unwrap()
1213                        .next()
1214                        .unwrap_or("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 5\r\nConnection: close\r\n\r\nerror");
1215                    let _ = stream.write_all(response.as_bytes());
1216                    let _ = stream.flush();
1217                }
1218            }
1219        });
1220        // Give the server a moment to start
1221        std::thread::sleep(std::time::Duration::from_millis(20));
1222        addr
1223    }
1224
1225    fn make_json_response(body: &str) -> String {
1226        format!(
1227            "HTTP/1.1 200 OK\r\nContent-Type: application/a2a+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1228            body.len(),
1229            body
1230        )
1231    }
1232
1233    // ── Unit tests for data model helpers ─────────────────────────────────
1234
1235    #[test]
1236    fn parts_to_text_concatenates_text_parts() {
1237        let parts = vec![
1238            Part {
1239                text: Some("Hello".into()),
1240            },
1241            Part { text: None },
1242            Part {
1243                text: Some(" World".into()),
1244            },
1245        ];
1246        assert_eq!(parts_to_text(&parts), "Hello\n World");
1247    }
1248
1249    #[test]
1250    fn task_state_is_terminal() {
1251        assert!(TaskState::Completed.is_terminal());
1252        assert!(TaskState::Failed.is_terminal());
1253        assert!(TaskState::Canceled.is_terminal());
1254        assert!(TaskState::Rejected.is_terminal());
1255        assert!(!TaskState::Submitted.is_terminal());
1256        assert!(!TaskState::Working.is_terminal());
1257    }
1258
1259    #[test]
1260    fn missing_prompt_returns_bad_tool_args_error() {
1261        let rt = tokio::runtime::Runtime::new().unwrap();
1262        // `default()` == `new()`: SSRF guard active. The url is public, so the
1263        // failure is the missing prompt (args validation), not the URL guard.
1264        let tool = A2aCallTool::default();
1265        let err = rt
1266            .block_on(tool.execute(json!({"url": "https://example.com"})))
1267            .unwrap_err();
1268        assert!(matches!(err, Error::BadToolArgs { .. }));
1269    }
1270
1271    #[test]
1272    fn missing_url_returns_bad_tool_args_error() {
1273        let rt = tokio::runtime::Runtime::new().unwrap();
1274        // `default()` == `new()`: SSRF guard active. No url arg at all — fails
1275        // args validation before the guard even runs.
1276        let tool = A2aCallTool::default();
1277        let err = rt
1278            .block_on(tool.execute(json!({"prompt": "hi"})))
1279            .unwrap_err();
1280        assert!(matches!(err, Error::BadToolArgs { .. }));
1281    }
1282
1283    #[tokio::test]
1284    async fn immediate_message_response_returns_text() {
1285        let body = r#"{"message":{"parts":[{"text":"The weather is sunny."}]}}"#;
1286        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1287        let addr = spawn_mock_server(raw_resp);
1288        tokio::time::sleep(Duration::from_millis(30)).await;
1289
1290        let tool = A2aCallTool::new_unchecked();
1291        let result = tool
1292            .execute(json!({
1293                "url": format!("http://{addr}"),
1294                "prompt": "What is the weather?"
1295            }))
1296            .await
1297            .unwrap();
1298
1299        assert_eq!(result, "The weather is sunny.");
1300    }
1301
1302    #[tokio::test]
1303    async fn completed_task_response_returns_artifact_text() {
1304        let body = r#"{"task":{"id":"t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Task done!"}]}]}}"#;
1305        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1306        let addr = spawn_mock_server(raw_resp);
1307        tokio::time::sleep(Duration::from_millis(30)).await;
1308
1309        let tool = A2aCallTool::new_unchecked();
1310        let result = tool
1311            .execute(json!({
1312                "url": format!("http://{addr}"),
1313                "prompt": "Do the task"
1314            }))
1315            .await
1316            .unwrap();
1317
1318        assert_eq!(result, "Task done!");
1319    }
1320
1321    #[tokio::test]
1322    async fn working_task_polled_to_completion() {
1323        // First response: WORKING task
1324        let working_resp = make_json_response(
1325            r#"{"task":{"id":"t2","status":{"state":"TASK_STATE_WORKING"},"artifacts":[]}}"#,
1326        );
1327        // Second response (GET /tasks/t2): COMPLETED task
1328        let completed_resp = make_json_response(
1329            r#"{"id":"t2","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Polling done"}]}]}"#,
1330        );
1331        let working_resp: &'static str = Box::leak(working_resp.into_boxed_str());
1332        let completed_resp: &'static str = Box::leak(completed_resp.into_boxed_str());
1333
1334        let addr = spawn_sequential_mock_server(vec![working_resp, completed_resp]);
1335
1336        let tool = A2aCallTool::new_unchecked();
1337        let result = tool
1338            .execute(json!({
1339                "url": format!("http://{addr}"),
1340                "prompt": "Do the task",
1341                "timeout_secs": 30
1342            }))
1343            .await
1344            .unwrap();
1345
1346        assert_eq!(result, "Polling done");
1347    }
1348
1349    #[tokio::test]
1350    async fn failed_task_returns_error_string() {
1351        let body = r#"{"task":{"id":"t3","status":{"state":"TASK_STATE_FAILED"},"artifacts":[]}}"#;
1352        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1353        let addr = spawn_mock_server(raw_resp);
1354        tokio::time::sleep(Duration::from_millis(30)).await;
1355
1356        let tool = A2aCallTool::new_unchecked();
1357        let result = tool
1358            .execute(json!({
1359                "url": format!("http://{addr}"),
1360                "prompt": "Do the task"
1361            }))
1362            .await
1363            .unwrap();
1364
1365        assert!(
1366            result.starts_with("ERROR: task t3 ended with state TASK_STATE_FAILED"),
1367            "got: {result}"
1368        );
1369    }
1370
1371    #[tokio::test]
1372    async fn http_error_returns_error_string() {
1373        let raw_resp = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndown";
1374        let addr = spawn_mock_server(raw_resp);
1375        tokio::time::sleep(Duration::from_millis(30)).await;
1376
1377        let tool = A2aCallTool::new_unchecked();
1378        let result = tool
1379            .execute(json!({
1380                "url": format!("http://{addr}"),
1381                "prompt": "ping"
1382            }))
1383            .await
1384            .unwrap();
1385
1386        assert!(result.starts_with("ERROR: HTTP"), "got: {result}");
1387    }
1388
1389    // ── A2aCardTool tests ─────────────────────────────────────────────────
1390
1391    #[test]
1392    fn agent_card_summary_full() {
1393        let card = AgentCard {
1394            name: "Weather Bot".into(),
1395            description: "Provides forecasts.".into(),
1396            capabilities: AgentCapabilities {
1397                streaming: true,
1398                push_notifications: false,
1399            },
1400            skills: vec![AgentSkill {
1401                id: "s1".into(),
1402                name: "get_weather".into(),
1403                description: "Get current weather.".into(),
1404            }],
1405            authentication: Some(AgentAuthentication {
1406                schemes: vec!["Bearer".into()],
1407            }),
1408        };
1409        let summary = card.summary();
1410        assert!(summary.contains("Agent: Weather Bot"), "{summary}");
1411        assert!(summary.contains("Streaming: supported"), "{summary}");
1412        assert!(
1413            summary.contains("Description: Provides forecasts."),
1414            "{summary}"
1415        );
1416        assert!(
1417            summary.contains("Push notifications: not supported"),
1418            "{summary}"
1419        );
1420        assert!(summary.contains("Auth: Bearer"), "{summary}");
1421        assert!(summary.contains("get_weather"), "{summary}");
1422    }
1423
1424    #[test]
1425    fn agent_card_summary_empty_name_uses_unnamed() {
1426        let card = AgentCard {
1427            name: String::new(),
1428            description: String::new(),
1429            capabilities: AgentCapabilities::default(),
1430            skills: vec![],
1431            authentication: None,
1432        };
1433        let summary = card.summary();
1434        assert!(summary.contains("Agent: <unnamed>"), "{summary}");
1435        assert!(summary.contains("Auth: none"), "{summary}");
1436    }
1437
1438    #[tokio::test]
1439    async fn a2a_card_parses_valid_agent_card() {
1440        let body = r#"{
1441            "name": "Echo Agent",
1442            "description": "Echoes your message.",
1443            "capabilities": {"streaming": false, "pushNotifications": false},
1444            "skills": [{"id": "echo", "name": "echo", "description": "Echo text."}]
1445        }"#;
1446        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1447        let addr = spawn_mock_server(raw_resp);
1448        tokio::time::sleep(Duration::from_millis(30)).await;
1449
1450        let tool = A2aCardTool::new_unchecked();
1451        let result = tool
1452            .execute(json!({"url": format!("http://{addr}")}))
1453            .await
1454            .unwrap();
1455
1456        assert!(result.contains("Echo Agent"), "{result}");
1457        assert!(result.contains("Streaming: not supported"), "{result}");
1458        assert!(result.contains("echo"), "{result}");
1459    }
1460
1461    #[tokio::test]
1462    async fn a2a_card_returns_error_on_http_404() {
1463        let raw_resp =
1464            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found";
1465        let addr = spawn_mock_server(raw_resp);
1466        tokio::time::sleep(Duration::from_millis(30)).await;
1467
1468        let tool = A2aCardTool::new_unchecked();
1469        let result = tool
1470            .execute(json!({"url": format!("http://{addr}")}))
1471            .await
1472            .unwrap();
1473
1474        assert!(result.starts_with("ERROR: HTTP 404"), "{result}");
1475    }
1476
1477    // ── SSE streaming tests ────────────────────────────────────────────────
1478
1479    #[test]
1480    fn parse_sse_event_extracts_artifact_text() {
1481        let event = r#"data: {"type":"TaskArtifactUpdateEvent","task":{"id":"t1","status":{"state":"TASK_STATE_WORKING"},"artifacts":[{"parts":[{"text":"Hello world"}],"index":0}]}}"#;
1482        let (text, state) = parse_sse_event(event);
1483        assert_eq!(text.as_deref(), Some("Hello world"));
1484        assert!(state.is_none() || matches!(state, Some(TaskState::Working)));
1485    }
1486
1487    #[test]
1488    fn parse_sse_event_extracts_terminal_state() {
1489        let event = r#"data: {"type":"TaskStatusUpdateEvent","task":{"id":"t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[]}}"#;
1490        let (text, state) = parse_sse_event(event);
1491        assert!(text.is_none() || text.as_deref() == Some(""));
1492        assert!(matches!(state, Some(TaskState::Completed)));
1493    }
1494
1495    #[test]
1496    fn parse_sse_event_ignores_unknown_data() {
1497        let event = "data: {\"not_a2a\": true}";
1498        let (text, state) = parse_sse_event(event);
1499        assert!(text.is_none());
1500        assert!(state.is_none());
1501    }
1502
1503    #[tokio::test]
1504    async fn streaming_mode_accumulates_artifact_text() {
1505        // Build a minimal SSE response with two artifact events and a COMPLETED status.
1506        let body_parts = [
1507            "data: {\"type\":\"TaskStatusUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[]}}\n\n",
1508            "data: {\"type\":\"TaskArtifactUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[{\"parts\":[{\"text\":\"Hello \"}],\"index\":0}]}}\n\n",
1509            "data: {\"type\":\"TaskArtifactUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_WORKING\"},\"artifacts\":[{\"parts\":[{\"text\":\"world!\"}],\"index\":0}]}}\n\n",
1510            "data: {\"type\":\"TaskStatusUpdateEvent\",\"task\":{\"id\":\"s1\",\"status\":{\"state\":\"TASK_STATE_COMPLETED\"},\"artifacts\":[]}}\n\n",
1511        ].concat();
1512        let sse_body = body_parts.as_str();
1513        let raw_resp = format!(
1514            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1515            sse_body.len(),
1516            sse_body
1517        );
1518        let raw_resp: &'static str = Box::leak(raw_resp.into_boxed_str());
1519        let addr = spawn_mock_server(raw_resp);
1520        tokio::time::sleep(Duration::from_millis(30)).await;
1521
1522        let tool = A2aCallTool::new_unchecked();
1523        let result = tool
1524            .execute(json!({
1525                "url": format!("http://{addr}"),
1526                "prompt": "say hello",
1527                "streaming": true,
1528                "timeout_secs": 10
1529            }))
1530            .await
1531            .unwrap();
1532
1533        assert_eq!(result, "Hello world!", "got: {result}");
1534    }
1535
1536    // ── Goal 179: async_mode + a2a_task_check tests ───────────────────────
1537
1538    #[tokio::test]
1539    async fn async_mode_returns_task_id_immediately() {
1540        let body = r#"{"task":{"id":"async-t1","status":{"state":"TASK_STATE_SUBMITTED"},"artifacts":[]}}"#;
1541        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1542        let addr = spawn_mock_server(raw_resp);
1543        tokio::time::sleep(Duration::from_millis(30)).await;
1544
1545        let tool = A2aCallTool::new_unchecked();
1546        let result = tool
1547            .execute(json!({
1548                "url": format!("http://{addr}"),
1549                "prompt": "do something slow",
1550                "async_mode": true
1551            }))
1552            .await
1553            .unwrap();
1554
1555        assert!(result.contains("TASK_ID: async-t1"), "got: {result}");
1556        assert!(result.contains("TASK_STATE_SUBMITTED"), "got: {result}");
1557        assert!(result.contains("run_background"), "got: {result}");
1558        assert!(result.contains("curl"), "got: {result}");
1559    }
1560
1561    #[tokio::test]
1562    async fn a2a_task_check_completed_task() {
1563        let body = r#"{"id":"check-t1","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"Done!"}]}]}"#;
1564        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1565        let addr = spawn_mock_server(raw_resp);
1566        tokio::time::sleep(Duration::from_millis(30)).await;
1567
1568        let tool = A2aTaskCheckTool::new_unchecked();
1569        let result = tool
1570            .execute(json!({
1571                "url": format!("http://{addr}"),
1572                "task_id": "check-t1"
1573            }))
1574            .await
1575            .unwrap();
1576
1577        assert!(result.contains("TASK_STATE_COMPLETED"), "got: {result}");
1578        assert!(result.contains("Done!"), "got: {result}");
1579    }
1580
1581    #[tokio::test]
1582    async fn a2a_task_check_working_task_hints_to_retry() {
1583        let body = r#"{"id":"check-t2","status":{"state":"TASK_STATE_WORKING"},"artifacts":[]}"#;
1584        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1585        let addr = spawn_mock_server(raw_resp);
1586        tokio::time::sleep(Duration::from_millis(30)).await;
1587
1588        let tool = A2aTaskCheckTool::new_unchecked();
1589        let result = tool
1590            .execute(json!({
1591                "url": format!("http://{addr}"),
1592                "task_id": "check-t2"
1593            }))
1594            .await
1595            .unwrap();
1596
1597        assert!(result.contains("TASK_STATE_WORKING"), "got: {result}");
1598        assert!(result.contains("check again later"), "got: {result}");
1599    }
1600
1601    #[tokio::test]
1602    async fn a2a_task_check_http_error_returns_error_string() {
1603        let raw_resp =
1604            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found";
1605        let addr = spawn_mock_server(raw_resp);
1606        tokio::time::sleep(Duration::from_millis(30)).await;
1607
1608        let tool = A2aTaskCheckTool::new_unchecked();
1609        let result = tool
1610            .execute(json!({
1611                "url": format!("http://{addr}"),
1612                "task_id": "missing-task"
1613            }))
1614            .await
1615            .unwrap();
1616
1617        assert!(result.starts_with("ERROR: HTTP 404"), "got: {result}");
1618    }
1619
1620    // ── Goal 371: SSRF guard on a2a tools ─────────────────────────────────
1621
1622    /// SSRF targets must be rejected at args-validation time (BadToolArgs),
1623    /// BEFORE any socket opens. Uses `new()` so the guard is active.
1624    #[tokio::test]
1625    async fn a2a_call_rejects_ssrf_targets() {
1626        let tool = A2aCallTool::new();
1627        for bad_url in [
1628            "http://127.0.0.1",
1629            "http://169.254.169.254",
1630            "http://10.0.0.1",
1631            "http://[::1]",
1632            "http://localhost",
1633        ] {
1634            let args = json!({"url": bad_url, "prompt": "hi"});
1635            let res = tool.execute(args).await;
1636            assert!(res.is_err(), "{bad_url} should be rejected");
1637            match res.unwrap_err() {
1638                Error::BadToolArgs { .. } => {}
1639                other => panic!("expected BadToolArgs for {bad_url}, got {other:?}"),
1640            }
1641        }
1642    }
1643
1644    /// `a2a_card` GETs `{url}/.well-known/agent.json` — same attacker host.
1645    #[tokio::test]
1646    async fn a2a_card_rejects_ssrf_targets() {
1647        let tool = A2aCardTool::new();
1648        for bad_url in [
1649            "http://127.0.0.1",
1650            "http://169.254.169.254",
1651            "http://10.0.0.1",
1652            "http://[::1]",
1653            "http://localhost",
1654        ] {
1655            let args = json!({"url": bad_url});
1656            let res = tool.execute(args).await;
1657            assert!(res.is_err(), "{bad_url} should be rejected");
1658            match res.unwrap_err() {
1659                Error::BadToolArgs { .. } => {}
1660                other => panic!("expected BadToolArgs for {bad_url}, got {other:?}"),
1661            }
1662        }
1663    }
1664
1665    /// `a2a_task_check` polls `{url}/tasks/{id}` — same attacker host.
1666    #[tokio::test]
1667    async fn a2a_task_check_rejects_ssrf_targets() {
1668        let tool = A2aTaskCheckTool::new();
1669        for bad_url in [
1670            "http://127.0.0.1",
1671            "http://169.254.169.254",
1672            "http://10.0.0.1",
1673            "http://[::1]",
1674            "http://localhost",
1675        ] {
1676            let args = json!({"url": bad_url, "task_id": "t1"});
1677            let res = tool.execute(args).await;
1678            assert!(res.is_err(), "{bad_url} should be rejected");
1679            match res.unwrap_err() {
1680                Error::BadToolArgs { .. } => {}
1681                other => panic!("expected BadToolArgs for {bad_url}, got {other:?}"),
1682            }
1683        }
1684    }
1685
1686    // ── Goal 378: redirects disabled + async task.id sanitization ─────────
1687
1688    /// A 302 (any 3xx) must be surfaced as an explicit redirect error and the
1689    /// `Location` target must never be fetched — following it would bypass the
1690    /// SSRF URL guard (same fix as web_fetch.rs, Goal 372).
1691    #[tokio::test]
1692    async fn redirect_response_is_rejected_without_following() {
1693        // Target server: if the client followed the Location header, this
1694        // accept fires and the hit counter increments.
1695        let hits = Arc::new(AtomicUsize::new(0));
1696        let hits_target = hits.clone();
1697        let target_listener = TcpListener::bind("127.0.0.1:0").unwrap();
1698        let target_addr = target_listener.local_addr().unwrap();
1699        std::thread::spawn(move || {
1700            if let Ok((mut stream, _)) = target_listener.accept() {
1701                hits_target.fetch_add(1, Ordering::SeqCst);
1702                let _ = stream.write_all(
1703                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK",
1704                );
1705                let _ = stream.flush();
1706            }
1707        });
1708
1709        // Redirecting server: answers the initial POST with a 302 to target.
1710        let body = format!(
1711            "HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/tasks/evil\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1712        );
1713        let raw_resp: &'static str = Box::leak(body.into_boxed_str());
1714        let addr = spawn_mock_server(raw_resp);
1715        tokio::time::sleep(Duration::from_millis(30)).await;
1716
1717        let tool = A2aCallTool::new_unchecked();
1718        let result = tool
1719            .execute(json!({
1720                "url": format!("http://{addr}"),
1721                "prompt": "hello"
1722            }))
1723            .await;
1724
1725        // The 3xx must be surfaced explicitly, mentioning status + Location.
1726        match result {
1727            Err(Error::Tool { message, .. }) => {
1728                assert!(message.contains("redirect"), "got: {message}");
1729                assert!(message.contains("302"), "got: {message}");
1730                assert!(message.contains(&target_addr.to_string()), "got: {message}");
1731            }
1732            other => panic!("expected redirect Tool error, got: {other:?}"),
1733        }
1734
1735        // …and the Location target must never have been fetched.
1736        std::thread::sleep(Duration::from_millis(200));
1737        assert_eq!(
1738            hits.load(Ordering::SeqCst),
1739            0,
1740            "redirect Location target was fetched — SSRF bypass"
1741        );
1742    }
1743
1744    /// A server-controlled `task.id` containing shell metacharacters must
1745    /// never be embedded into the async-mode poll script; the call falls back
1746    /// to synchronous polling with a warning instead.
1747    #[tokio::test]
1748    async fn async_mode_unsafe_task_id_falls_back_to_sync() {
1749        let submitted_resp = make_json_response(
1750            r#"{"task":{"id":"\"; rm -rf /; echo \"","status":{"state":"TASK_STATE_SUBMITTED"},"artifacts":[]}}"#,
1751        );
1752        let completed_resp = make_json_response(
1753            r#"{"id":"x","status":{"state":"TASK_STATE_COMPLETED"},"artifacts":[{"parts":[{"text":"sync result"}]}]}}"#,
1754        );
1755        let submitted_resp: &'static str = Box::leak(submitted_resp.into_boxed_str());
1756        let completed_resp: &'static str = Box::leak(completed_resp.into_boxed_str());
1757        let addr = spawn_sequential_mock_server(vec![submitted_resp, completed_resp]);
1758
1759        let tool = A2aCallTool::new_unchecked();
1760        let result = tool
1761            .execute(json!({
1762                "url": format!("http://{addr}"),
1763                "prompt": "do it",
1764                "async_mode": true,
1765                "timeout_secs": 10
1766            }))
1767            .await;
1768
1769        match result {
1770            Ok(out) => {
1771                assert!(out.contains("WARNING"), "got: {out}");
1772                assert!(out.contains("unsafe task.id"), "got: {out}");
1773                // No background poll script may be built for an unsafe id.
1774                assert!(!out.contains("curl"), "got: {out}");
1775            }
1776            // reqwest may refuse to even build the poll URL for an id with
1777            // quotes — that is also an acceptable "no injection" outcome.
1778            Err(Error::Tool { message, .. }) => {
1779                assert!(!message.contains("curl"), "got: {message}");
1780            }
1781            Err(other) => panic!("unexpected error: {other:?}"),
1782        }
1783    }
1784
1785    /// A well-formed task id (`t-123_abc.xyz`) still produces the async poll
1786    /// script exactly as before.
1787    #[tokio::test]
1788    async fn async_mode_safe_task_id_produces_poll_script() {
1789        let body = r#"{"task":{"id":"t-123_abc.xyz","status":{"state":"TASK_STATE_SUBMITTED"},"artifacts":[]}}"#;
1790        let raw_resp = Box::leak(make_json_response(body).into_boxed_str());
1791        let addr = spawn_mock_server(raw_resp);
1792        tokio::time::sleep(Duration::from_millis(30)).await;
1793
1794        let tool = A2aCallTool::new_unchecked();
1795        let result = tool
1796            .execute(json!({
1797                "url": format!("http://{addr}"),
1798                "prompt": "do something slow",
1799                "async_mode": true
1800            }))
1801            .await
1802            .unwrap();
1803
1804        assert!(result.contains("TASK_ID: t-123_abc.xyz"), "got: {result}");
1805        assert!(result.contains("curl"), "got: {result}");
1806        assert!(result.contains("tasks/t-123_abc.xyz"), "got: {result}");
1807    }
1808}