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