1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Part {
33 #[serde(default)]
35 pub text: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Artifact {
41 #[serde(default)]
42 pub parts: Vec<Part>,
43}
44
45#[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 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct MessageResponse {
103 #[serde(default)]
104 pub parts: Vec<Part>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(untagged)]
110pub enum SendMessageResponse {
111 HasTask { task: Task },
113 HasMessage { message: MessageResponse },
115}
116
117fn 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
138pub struct A2aCallTool {
144 #[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 #[cfg(test)]
164 pub(crate) fn new_unchecked() -> Self {
165 Self {
166 allow_private_urls: true,
167 }
168 }
169
170 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 .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 #[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 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 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 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 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 if use_async {
367 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 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 poll_task_to_completion(&client, base, task, authorization, timeout_secs).await
415 }
416 }
417 }
418}
419
420fn 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
446fn 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
460async 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
542fn 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 let mut text: Option<String> = None;
566 let mut state: Option<TaskState> = None;
567
568 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 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
606async 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 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 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 break;
689 };
690
691 if let Ok(text_chunk) = std::str::from_utf8(&bytes) {
692 buffer.push_str(text_chunk);
693 }
694
695 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
744pub struct AgentCapabilities {
745 #[serde(default)]
747 pub streaming: bool,
748 #[serde(rename = "pushNotifications", default)]
750 pub push_notifications: bool,
751}
752
753#[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#[derive(Debug, Clone, Serialize, Deserialize)]
765pub struct AgentAuthentication {
766 pub schemes: Vec<String>,
767}
768
769#[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 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
832pub struct A2aCardTool {
839 #[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 #[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 #[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 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
978pub struct A2aTaskCheckTool {
988 #[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 #[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 #[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 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#[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 #[test]
1167 fn a2a_call_tool_is_deferred() {
1168 assert!(A2aCallTool::new().is_deferred());
1169 }
1170
1171 #[test]
1173 fn a2a_card_tool_is_deferred() {
1174 assert!(A2aCardTool::new().is_deferred());
1175 }
1176
1177 #[test]
1179 fn a2a_task_check_tool_is_deferred() {
1180 assert!(A2aTaskCheckTool::new().is_deferred());
1181 }
1182
1183 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 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 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 #[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 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 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 let working_resp = make_json_response(
1325 r#"{"task":{"id":"t2","status":{"state":"TASK_STATE_WORKING"},"artifacts":[]}}"#,
1326 );
1327 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[tokio::test]
1692 async fn redirect_response_is_rejected_without_following() {
1693 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 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 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 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 #[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 assert!(!out.contains("curl"), "got: {out}");
1775 }
1776 Err(Error::Tool { message, .. }) => {
1779 assert!(!message.contains("curl"), "got: {message}");
1780 }
1781 Err(other) => panic!("unexpected error: {other:?}"),
1782 }
1783 }
1784
1785 #[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}