1use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
89use crate::ai::types::{
90 ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
91 StreamDelta, StreamEvent,
92};
93use crate::config::ai::{A2aAgentCard, A2aConfig, AiProviderKind};
94use crate::config::Gateway;
95use serde_json::{json, Map, Value};
96use sha2::{Digest, Sha256};
97use std::collections::BTreeMap;
98use std::sync::atomic::{AtomicU64, Ordering};
99
100#[derive(Debug, Clone, PartialEq)]
107pub enum A2AError {
108 IllegalTransition {
111 from: TaskLifecycle,
112 to: TaskLifecycle,
113 },
114 ParseError(String),
116 NetworkNotWired(String),
120}
121
122impl std::fmt::Display for A2AError {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 A2AError::IllegalTransition { from, to } => {
126 write!(
127 f,
128 "a2a illegal task transition: {} -> {}",
129 from.as_str(),
130 to.as_str()
131 )
132 }
133 A2AError::ParseError(m) => write!(f, "a2a parse error: {m}"),
134 A2AError::NetworkNotWired(m) => write!(f, "a2a network not wired: {m}"),
135 }
136 }
137}
138
139impl std::error::Error for A2AError {}
140
141#[derive(Debug, Clone, PartialEq)]
145pub struct A2AStub {
146 pub transition: String,
148 pub reason: String,
150}
151
152impl A2AStub {
153 pub fn new(transition: impl Into<String>) -> Self {
156 A2AStub {
157 transition: transition.into(),
158 reason: "a2a network call is not wired (DW-114): the adapter does \
159 not own an HTTP client; the transport is the agent's \
160 upstream"
161 .to_string(),
162 }
163 }
164}
165
166impl std::fmt::Display for A2AStub {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 write!(f, "a2a stub ({}): {}", self.transition, self.reason)
169 }
170}
171
172impl std::error::Error for A2AStub {}
173
174impl A2AStub {
175 fn into_parse_error(self, detail: impl Into<String>) -> A2AStub {
180 A2AStub {
181 transition: self.transition,
182 reason: format!("{}: {}", self.reason, detail.into()),
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum TaskLifecycle {
193 Submitted,
195 Working,
197 Completed,
200 Failed,
202 Canceled,
204}
205
206impl TaskLifecycle {
207 pub fn as_str(&self) -> &'static str {
209 match self {
210 TaskLifecycle::Submitted => "submitted",
211 TaskLifecycle::Working => "working",
212 TaskLifecycle::Completed => "completed",
213 TaskLifecycle::Failed => "failed",
214 TaskLifecycle::Canceled => "canceled",
215 }
216 }
217
218 pub fn parse_state(s: &str) -> Option<Self> {
222 match s {
223 "submitted" => Some(TaskLifecycle::Submitted),
224 "working" => Some(TaskLifecycle::Working),
225 "completed" => Some(TaskLifecycle::Completed),
226 "failed" => Some(TaskLifecycle::Failed),
227 "canceled" => Some(TaskLifecycle::Canceled),
228 _ => None,
229 }
230 }
231
232 pub fn is_terminal(&self) -> bool {
234 matches!(
235 self,
236 TaskLifecycle::Completed | TaskLifecycle::Failed | TaskLifecycle::Canceled
237 )
238 }
239
240 pub fn can_transition_to(&self, target: TaskLifecycle) -> bool {
243 matches!(
244 (self, target),
245 (TaskLifecycle::Submitted, TaskLifecycle::Working)
246 | (TaskLifecycle::Submitted, TaskLifecycle::Failed)
247 | (TaskLifecycle::Submitted, TaskLifecycle::Canceled)
248 | (TaskLifecycle::Working, TaskLifecycle::Completed)
249 | (TaskLifecycle::Working, TaskLifecycle::Failed)
250 | (TaskLifecycle::Working, TaskLifecycle::Canceled)
251 )
252 }
253}
254
255#[derive(Debug, Clone)]
266pub struct TaskStateMachine {
267 state: TaskLifecycle,
268}
269
270impl TaskStateMachine {
271 pub fn new() -> Self {
273 TaskStateMachine {
274 state: TaskLifecycle::Submitted,
275 }
276 }
277
278 pub fn from_state(state: TaskLifecycle) -> Self {
281 TaskStateMachine { state }
282 }
283
284 pub fn state(&self) -> TaskLifecycle {
286 self.state
287 }
288
289 pub fn transition_to(&mut self, target: TaskLifecycle) -> Result<TaskLifecycle, A2AError> {
293 if self.state.can_transition_to(target) {
294 self.state = target;
295 Ok(self.state)
296 } else {
297 Err(A2AError::IllegalTransition {
298 from: self.state,
299 to: target,
300 })
301 }
302 }
303
304 pub fn start_work(&mut self) -> Result<TaskLifecycle, A2AError> {
308 self.transition_to(TaskLifecycle::Working)
309 }
310
311 pub fn complete(&mut self) -> Result<TaskLifecycle, A2AError> {
315 self.transition_to(TaskLifecycle::Completed)
316 }
317
318 pub fn fail(&mut self) -> Result<TaskLifecycle, A2AError> {
321 self.transition_to(TaskLifecycle::Failed)
322 }
323
324 pub fn cancel(&mut self) -> Result<TaskLifecycle, A2AError> {
327 self.transition_to(TaskLifecycle::Canceled)
328 }
329
330 pub fn is_terminal(&self) -> bool {
333 self.state.is_terminal()
334 }
335}
336
337impl Default for TaskStateMachine {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343#[derive(Debug, Clone, PartialEq)]
348pub struct AgentCard {
349 pub name: String,
351 pub description: Option<String>,
353 pub url: String,
355 pub version: Option<String>,
357 pub capabilities: Value,
360 pub authentication: Value,
365}
366
367pub struct AgentCardParser;
374
375impl AgentCardParser {
376 pub fn parse_inline(value: &Value) -> Result<AgentCard, A2AStub> {
380 let obj = value.as_object().ok_or_else(|| {
381 A2AStub::new("agent_card_parse").into_parse_error("agent card is not a JSON object")
382 })?;
383 Self::parse_object(obj)
384 }
385
386 pub fn parse_path(path: &str) -> Result<AgentCard, A2AStub> {
390 let bytes = std::fs::read(path).map_err(|e| {
391 A2AStub::new("agent_card_parse")
392 .into_parse_error(format!("could not read agent card file '{path}': {e}"))
393 })?;
394 let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
395 A2AStub::new("agent_card_parse")
396 .into_parse_error(format!("agent card file '{path}' is not valid JSON: {e}"))
397 })?;
398 Self::parse_inline(&value)
399 }
400
401 pub fn parse_source(card: Option<&A2aAgentCard>) -> Result<AgentCard, A2AStub> {
405 let Some(card) = card else {
406 return Err(A2AStub::new("agent_card_parse")
407 .into_parse_error("agent has no card (set card.inline or card.path)"));
408 };
409 if let Some(inline) = &card.inline {
410 return Self::parse_inline(inline);
411 }
412 if let Some(path) = &card.path {
413 return Self::parse_path(path);
414 }
415 Err(A2AStub::new("agent_card_parse")
416 .into_parse_error("agent card has neither inline nor path set"))
417 }
418
419 fn parse_object(obj: &Map<String, Value>) -> Result<AgentCard, A2AStub> {
420 let name = obj
421 .get("name")
422 .and_then(Value::as_str)
423 .ok_or_else(|| {
424 A2AStub::new("agent_card_parse")
425 .into_parse_error("agent card is missing the required 'name' field")
426 })?
427 .to_string();
428 let url = obj
429 .get("url")
430 .and_then(Value::as_str)
431 .ok_or_else(|| {
432 A2AStub::new("agent_card_parse")
433 .into_parse_error("agent card is missing the required 'url' field")
434 })?
435 .to_string();
436 let description = obj
437 .get("description")
438 .and_then(Value::as_str)
439 .map(String::from);
440 let version = obj.get("version").and_then(Value::as_str).map(String::from);
441 let capabilities = obj.get("capabilities").cloned().unwrap_or(Value::Null);
442 let authentication = obj.get("authentication").cloned().unwrap_or(Value::Null);
443 Ok(AgentCard {
444 name,
445 description,
446 url,
447 version,
448 capabilities,
449 authentication,
450 })
451 }
452}
453
454pub struct A2AAdapter;
466
467impl ProviderAdapter for A2AAdapter {
468 fn kind(&self) -> AiProviderKind {
469 AiProviderKind::A2a
470 }
471
472 fn build_request(
473 &self,
474 req: &ChatRequest,
475 _provider_model: &str,
476 ) -> Result<ProviderRequest, AiError> {
477 let mut parts: Vec<Value> = Vec::new();
483 for m in &req.messages {
484 if matches!(m.role, ChatRole::System) {
485 let text = m.text_content();
487 if !text.is_empty() {
488 parts.push(json!({"kind": "text", "text": text}));
489 }
490 }
491 }
492 let last_user = req
494 .messages
495 .iter()
496 .rev()
497 .find(|m| matches!(m.role, ChatRole::User));
498 let user_parts = if let Some(m) = last_user {
499 canonical_to_a2a_parts(m)
500 } else {
501 vec![json!({"kind": "text", "text": ""})]
504 };
505 parts.extend(user_parts);
506
507 let now_ms = std::time::SystemTime::now()
509 .duration_since(std::time::UNIX_EPOCH)
510 .map(|d| d.as_millis())
511 .unwrap_or(0);
512 let message_id = format!("msg-{now_ms:x}");
513 let rpc_id = format!("rpc-{now_ms:x}");
514
515 let mut message = Map::new();
516 message.insert("messageId".into(), json!(message_id));
517 message.insert("role".into(), json!("user"));
518 message.insert("parts".into(), Value::Array(parts));
519
520 let mut params = Map::new();
521 params.insert("message".into(), Value::Object(message));
522
523 let method = if req.stream {
524 "message/stream"
525 } else {
526 "message/send"
527 };
528
529 let mut body = Map::new();
530 body.insert("jsonrpc".into(), json!("2.0"));
531 body.insert("id".into(), json!(rpc_id));
532 body.insert("method".into(), json!(method));
533 body.insert("params".into(), Value::Object(params));
534
535 Ok(ProviderRequest {
536 method: http::Method::POST,
537 path: "/".to_string(),
542 headers: vec![],
543 body: Value::Object(body),
544 })
545 }
546
547 fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
548 let obj = body
549 .as_object()
550 .ok_or_else(|| AiError::Translation("a2a response is not a JSON object".to_string()))?;
551 let result = obj.get("result").unwrap_or(body);
553 let result_obj = result
554 .as_object()
555 .ok_or_else(|| AiError::Translation("a2a result is not a JSON object".to_string()))?;
556
557 let is_message = result_obj.contains_key("parts")
561 || result_obj
562 .get("kind")
563 .and_then(Value::as_str)
564 .is_some_and(|k| k == "message");
565 let is_task = result_obj.contains_key("status")
566 || result_obj
567 .get("kind")
568 .and_then(Value::as_str)
569 .is_some_and(|k| k == "task");
570
571 let id = obj.get("id").and_then(Value::as_str).map(String::from);
572
573 if is_message {
574 let content = parse_a2a_message(result)?;
575 return Ok(ChatResponse {
576 id,
577 model: None,
578 choices: vec![Choice {
579 index: 0,
580 message: content,
581 finish_reason: FinishReason::Stop,
582 }],
583 usage: None,
584 });
585 }
586
587 if is_task {
588 let state = result_obj
589 .get("status")
590 .and_then(|s| s.get("state"))
591 .and_then(Value::as_str)
592 .unwrap_or("unknown");
593 let finish_reason = parse_finish_reason(state);
594 let content = extract_task_text(result_obj);
596 return Ok(ChatResponse {
597 id,
598 model: None,
599 choices: vec![Choice {
600 index: 0,
601 message: content,
602 finish_reason,
603 }],
604 usage: None,
605 });
606 }
607
608 if let Some(msg) = result_obj.get("message") {
611 if let Ok(content) = parse_a2a_message(msg) {
612 return Ok(ChatResponse {
613 id,
614 model: None,
615 choices: vec![Choice {
616 index: 0,
617 message: content,
618 finish_reason: FinishReason::Stop,
619 }],
620 usage: None,
621 });
622 }
623 }
624
625 if let Ok(content) = parse_a2a_message(result) {
629 if !content.content.is_empty() {
630 return Ok(ChatResponse {
631 id,
632 model: None,
633 choices: vec![Choice {
634 index: 0,
635 message: content,
636 finish_reason: FinishReason::Stop,
637 }],
638 usage: None,
639 });
640 }
641 }
642
643 Err(AiError::Translation(
644 "a2a result is neither a Message nor a Task".to_string(),
645 ))
646 }
647
648 fn parse_error(&self, body: &Value) -> ProviderErrorBody {
649 match body.get("error") {
652 Some(Value::Object(e)) => ProviderErrorBody {
653 message: e
654 .get("message")
655 .and_then(Value::as_str)
656 .unwrap_or("a2a agent returned an error")
657 .to_string(),
658 error_type: e.get("data").and_then(Value::as_str).map(String::from),
659 code: e.get("code").map(|c| match c {
660 Value::String(s) => s.clone(),
661 other => other.to_string(),
662 }),
663 },
664 Some(Value::String(s)) => ProviderErrorBody {
665 message: s.clone(),
666 error_type: None,
667 code: None,
668 },
669 _ => ProviderErrorBody {
670 message: "a2a agent returned an error".to_string(),
671 error_type: None,
672 code: None,
673 },
674 }
675 }
676
677 fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
678 let obj = data.as_object().ok_or_else(|| {
685 AiError::Translation("a2a stream event is not a JSON object".to_string())
686 })?;
687 let result = obj.get("result").unwrap_or(data);
688 let result_obj = result.as_object().ok_or_else(|| {
689 AiError::Translation("a2a stream result is not a JSON object".to_string())
690 })?;
691
692 let mut out = Vec::new();
693
694 if result_obj.contains_key("parts") {
696 if let Ok(msg) = parse_a2a_message(result) {
697 let text = msg.text_content();
698 if !text.is_empty() {
699 out.push(StreamEvent::Delta(StreamDelta {
700 index: 0,
701 role: Some(ChatRole::Assistant),
702 content: Some(text),
703 tool_calls: Vec::new(),
704 finish_reason: None,
705 }));
706 }
707 }
708 }
709
710 if let Some(status) = result_obj.get("status").and_then(Value::as_object) {
712 let state = status.get("state").and_then(Value::as_str);
713 if let Some(msg_val) = status.get("message") {
715 if let Ok(msg) = parse_a2a_message(msg_val) {
716 let text = msg.text_content();
717 if !text.is_empty() {
718 out.push(StreamEvent::Delta(StreamDelta {
719 index: 0,
720 role: Some(ChatRole::Assistant),
721 content: Some(text),
722 tool_calls: Vec::new(),
723 finish_reason: None,
724 }));
725 }
726 }
727 }
728 if let Some(s) = state {
730 let fr = parse_finish_reason(s);
731 if !matches!(fr, FinishReason::Other(_)) || is_terminal_state(s) {
732 out.push(StreamEvent::Delta(StreamDelta {
733 index: 0,
734 role: None,
735 content: None,
736 tool_calls: Vec::new(),
737 finish_reason: Some(fr),
738 }));
739 }
740 }
741 }
742
743 if let Some(artifact) = result_obj.get("artifact").and_then(Value::as_object) {
745 if let Some(parts) = artifact.get("parts").and_then(Value::as_array) {
746 let text = extract_text_from_parts(parts);
747 if !text.is_empty() {
748 out.push(StreamEvent::Delta(StreamDelta {
749 index: 0,
750 role: Some(ChatRole::Assistant),
751 content: Some(text),
752 tool_calls: Vec::new(),
753 finish_reason: None,
754 }));
755 }
756 }
757 }
758
759 Ok(out)
760 }
761}
762
763fn is_terminal_state(state: &str) -> bool {
765 matches!(state, "completed" | "failed" | "canceled" | "rejected")
766}
767
768fn extract_task_text(task_obj: &Map<String, Value>) -> ChatMessage {
771 if let Some(msg) = task_obj.get("status").and_then(|s| s.get("message")) {
773 if let Ok(content) = parse_a2a_message(msg) {
774 return content;
775 }
776 }
777 if let Some(artifacts) = task_obj.get("artifacts").and_then(Value::as_array) {
779 let mut all_text = String::new();
780 for artifact in artifacts {
781 if let Some(parts) = artifact.get("parts").and_then(Value::as_array) {
782 all_text.push_str(&extract_text_from_parts(parts));
783 }
784 }
785 if !all_text.is_empty() {
786 return ChatMessage::text(ChatRole::Assistant, all_text);
787 }
788 }
789 ChatMessage::text(ChatRole::Assistant, "")
791}
792
793fn extract_text_from_parts(parts: &[Value]) -> String {
795 let mut text = String::new();
796 for p in parts {
797 match p.get("kind").and_then(Value::as_str) {
798 Some("text") => {
799 if let Some(t) = p.get("text").and_then(Value::as_str) {
800 text.push_str(t);
801 }
802 }
803 Some("data") => {
804 if let Some(data) = p.get("data") {
806 text.push_str(&data.to_string());
807 }
808 }
809 _ => {}
810 }
811 }
812 text
813}
814
815fn canonical_to_a2a_parts(m: &ChatMessage) -> Vec<Value> {
817 m.content
818 .iter()
819 .map(|p| match p {
820 ContentPart::Text { text } => json!({"kind": "text", "text": text}),
821 ContentPart::Image {
822 url,
823 media_type,
824 data_b64,
825 } => {
826 if let Some(b64) = data_b64 {
829 json!({
830 "kind": "file",
831 "file": {
832 "name": "image",
833 "mimeType": media_type.as_deref().unwrap_or("image/png"),
834 "bytes": b64,
835 }
836 })
837 } else {
838 json!({
839 "kind": "file",
840 "file": {
841 "name": "image",
842 "mimeType": media_type.as_deref().unwrap_or("image/png"),
843 "uri": url.clone().unwrap_or_default(),
844 }
845 })
846 }
847 }
848 })
849 .collect()
850}
851
852fn parse_finish_reason(s: &str) -> FinishReason {
854 match s {
855 "completed" => FinishReason::Stop,
856 "failed" => FinishReason::Other("failed".to_string()),
857 "canceled" => FinishReason::Other("canceled".to_string()),
858 "rejected" => FinishReason::Other("rejected".to_string()),
859 other => FinishReason::Other(other.to_string()),
860 }
861}
862
863fn parse_a2a_message(v: &Value) -> Result<ChatMessage, AiError> {
869 let obj = v
870 .as_object()
871 .ok_or_else(|| AiError::Translation("a2a message is not an object".to_string()))?;
872 let role = match obj.get("role").and_then(Value::as_str) {
873 Some("system") => ChatRole::System,
874 Some("user") => ChatRole::User,
875 Some("assistant") => ChatRole::Assistant,
876 Some("tool") => ChatRole::Tool,
877 _ => ChatRole::Assistant,
878 };
879 let mut content = Vec::new();
880
881 if let Some(parts) = obj.get("parts").and_then(Value::as_array) {
883 for p in parts {
884 match p.get("kind").and_then(Value::as_str) {
885 Some("text") => content.push(ContentPart::Text {
886 text: p
887 .get("text")
888 .and_then(Value::as_str)
889 .unwrap_or_default()
890 .to_string(),
891 }),
892 Some("file") => {
893 if let Some(file) = p.get("file").and_then(Value::as_object) {
894 if let Some(b64) = file.get("bytes").and_then(Value::as_str) {
896 content.push(ContentPart::Image {
897 url: None,
898 media_type: file
899 .get("mimeType")
900 .and_then(Value::as_str)
901 .map(String::from),
902 data_b64: Some(b64.to_string()),
903 });
904 } else if let Some(uri) = file.get("uri").and_then(Value::as_str) {
905 content.push(ContentPart::Image {
906 url: Some(uri.to_string()),
907 media_type: file
908 .get("mimeType")
909 .and_then(Value::as_str)
910 .map(String::from),
911 data_b64: None,
912 });
913 }
914 }
915 }
916 Some("data") => {
917 if let Some(data) = p.get("data") {
919 content.push(ContentPart::Text {
920 text: data.to_string(),
921 });
922 }
923 }
924 _ => {}
925 }
926 }
927 }
928
929 if content.is_empty() {
931 match obj.get("content") {
932 Some(Value::String(s)) => content.push(ContentPart::Text { text: s.clone() }),
933 Some(Value::Array(parts)) => {
934 for p in parts {
935 match p.get("type").and_then(Value::as_str) {
936 Some("text") => content.push(ContentPart::Text {
937 text: p
938 .get("text")
939 .and_then(Value::as_str)
940 .unwrap_or_default()
941 .to_string(),
942 }),
943 Some("image") => content.push(ContentPart::Image {
944 url: p.get("image_url").and_then(Value::as_str).map(String::from),
945 media_type: None,
946 data_b64: None,
947 }),
948 _ => {}
949 }
950 }
951 }
952 _ => {}
953 }
954 }
955
956 Ok(ChatMessage {
957 role,
958 content,
959 name: obj.get("name").and_then(Value::as_str).map(String::from),
960 tool_calls: Vec::new(),
961 tool_call_id: None,
962 })
963}
964
965#[allow(dead_code)]
971const DEFAULT_TTL_SECS: u64 = 3600;
972
973#[allow(dead_code)]
977const DEFAULT_MAX_CONCURRENT: usize = 1000;
978
979static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
982
983fn generate_session_id(agent: &str) -> String {
988 let n = std::time::SystemTime::now()
989 .duration_since(std::time::UNIX_EPOCH)
990 .map(|d| d.as_nanos())
991 .unwrap_or(0);
992 let c = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
993 let mut hasher = Sha256::new();
994 hasher.update(n.to_le_bytes());
995 hasher.update(c.to_le_bytes());
996 hasher.update(agent.as_bytes());
997 let hash = hasher.finalize();
998 let hex: String = hash.iter().take(16).map(|b| format!("{b:02x}")).collect();
999 format!("a2a-{hex}")
1000}
1001
1002#[derive(Debug, Clone)]
1007pub struct A2ASession {
1008 pub id: String,
1010 pub agent: String,
1012 pub ttl_secs: u64,
1014 pub max_concurrent: usize,
1016 pub task: TaskStateMachine,
1018}
1019
1020impl A2ASession {
1021 pub fn new(agent: &str, ttl_secs: u64, max_concurrent: usize) -> Self {
1025 A2ASession {
1026 id: generate_session_id(agent),
1027 agent: agent.to_string(),
1028 ttl_secs,
1029 max_concurrent,
1030 task: TaskStateMachine::new(),
1031 }
1032 }
1033
1034 pub fn id(&self) -> &str {
1036 &self.id
1037 }
1038
1039 pub fn task_state(&self) -> TaskLifecycle {
1041 self.task.state()
1042 }
1043
1044 pub fn start_work(&mut self) -> Result<TaskLifecycle, A2AError> {
1047 self.task.start_work()
1048 }
1049
1050 pub fn complete(&mut self) -> Result<TaskLifecycle, A2AError> {
1052 self.task.complete()
1053 }
1054
1055 pub fn fail(&mut self) -> Result<TaskLifecycle, A2AError> {
1057 self.task.fail()
1058 }
1059
1060 pub fn cancel(&mut self) -> Result<TaskLifecycle, A2AError> {
1062 self.task.cancel()
1063 }
1064}
1065
1066#[derive(Debug, Clone)]
1072pub struct CompiledA2aAgent {
1073 pub name: String,
1075 pub url: String,
1077 pub card: Option<AgentCard>,
1081 pub upstream: String,
1083 pub sessions_ttl_secs: u64,
1085 pub sessions_max_concurrent: usize,
1087}
1088
1089#[derive(Debug, Clone)]
1094pub struct CompiledA2a {
1095 pub agents: BTreeMap<String, CompiledA2aAgent>,
1097 pub sessions_ttl_secs: u64,
1099 pub sessions_max_concurrent: usize,
1101}
1102
1103impl CompiledA2a {
1104 pub fn compile(config: Option<&A2aConfig>) -> Option<Self> {
1112 {
1115 let cfg = config?;
1116 if !cfg.enabled {
1117 return None;
1118 }
1119 let sessions = cfg.sessions.as_ref();
1120 let sessions_ttl_secs = sessions
1121 .and_then(|s| s.ttl_secs)
1122 .unwrap_or(DEFAULT_TTL_SECS);
1123 let sessions_max_concurrent = sessions
1124 .and_then(|s| s.max_concurrent)
1125 .unwrap_or(DEFAULT_MAX_CONCURRENT);
1126 let mut agents = BTreeMap::new();
1127 for a in &cfg.agents {
1128 let card = match AgentCardParser::parse_source(a.card.as_ref()) {
1129 Ok(c) => Some(c),
1130 Err(e) => {
1131 tracing::warn!(
1132 code = "a2a_agent_card_parse_failed",
1133 agent = %a.name,
1134 "a2a agent card could not be parsed; agent compiles \
1135 without a card: {e}"
1136 );
1137 None
1138 }
1139 };
1140 agents.insert(
1141 a.name.clone(),
1142 CompiledA2aAgent {
1143 name: a.name.clone(),
1144 url: a.url.clone(),
1145 card,
1146 upstream: a.upstream.clone(),
1147 sessions_ttl_secs,
1148 sessions_max_concurrent,
1149 },
1150 );
1151 }
1152 Some(CompiledA2a {
1153 agents,
1154 sessions_ttl_secs,
1155 sessions_max_concurrent,
1156 })
1157 }
1158 }
1159
1160 pub fn agent(&self, name: &str) -> Option<&CompiledA2aAgent> {
1162 self.agents.get(name)
1163 }
1164
1165 pub fn agent_names(&self) -> impl Iterator<Item = &str> {
1167 self.agents.keys().map(|s| s.as_str())
1168 }
1169}
1170
1171pub fn handle_a2a_request(
1186 _agent: &CompiledA2aAgent,
1187 _req: &ChatRequest,
1188) -> Result<ChatResponse, A2AStub> {
1189 Err(A2AStub::new("handle_a2a_request"))
1190}
1191
1192pub fn compile_a2a(gateway: &Gateway) -> Option<CompiledA2a> {
1196 let ai = gateway.ai.as_ref()?;
1197 CompiledA2a::compile(ai.a2a.as_ref())
1198}
1199
1200pub fn a2a_providers(gateway: &Gateway) -> Vec<(String, String)> {
1207 let Some(compiled) = compile_a2a(gateway) else {
1208 return Vec::new();
1209 };
1210 compiled
1211 .agents
1212 .values()
1213 .map(|a| (a.name.clone(), a.upstream.clone()))
1214 .collect()
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220
1221 #[test]
1228 fn task_lifecycle_wire_names_round_trip() {
1229 for s in &[
1230 TaskLifecycle::Submitted,
1231 TaskLifecycle::Working,
1232 TaskLifecycle::Completed,
1233 TaskLifecycle::Failed,
1234 TaskLifecycle::Canceled,
1235 ] {
1236 assert_eq!(TaskLifecycle::parse_state(s.as_str()), Some(*s));
1237 }
1238 assert_eq!(TaskLifecycle::parse_state("unknown"), None);
1239 }
1240
1241 #[test]
1242 fn task_lifecycle_is_terminal() {
1243 assert!(!TaskLifecycle::Submitted.is_terminal());
1244 assert!(!TaskLifecycle::Working.is_terminal());
1245 assert!(TaskLifecycle::Completed.is_terminal());
1246 assert!(TaskLifecycle::Failed.is_terminal());
1247 assert!(TaskLifecycle::Canceled.is_terminal());
1248 }
1249
1250 #[test]
1251 fn task_lifecycle_can_transition_to_legal() {
1252 assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Working));
1253 assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Failed));
1254 assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Canceled));
1255 assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Completed));
1256 assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Failed));
1257 assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Canceled));
1258 }
1259
1260 #[test]
1261 fn task_lifecycle_cannot_transition_to_illegal() {
1262 for terminal in [
1264 TaskLifecycle::Completed,
1265 TaskLifecycle::Failed,
1266 TaskLifecycle::Canceled,
1267 ] {
1268 for target in [
1269 TaskLifecycle::Submitted,
1270 TaskLifecycle::Working,
1271 TaskLifecycle::Completed,
1272 TaskLifecycle::Failed,
1273 TaskLifecycle::Canceled,
1274 ] {
1275 assert!(
1276 !terminal.can_transition_to(target),
1277 "terminal {:?} should not transition to {:?}",
1278 terminal,
1279 target
1280 );
1281 }
1282 }
1283 assert!(!TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Completed));
1285 assert!(!TaskLifecycle::Working.can_transition_to(TaskLifecycle::Submitted));
1287 }
1288
1289 #[test]
1290 fn state_machine_happy_path_submitted_to_working_to_completed() {
1291 let mut sm = TaskStateMachine::new();
1292 assert_eq!(sm.state(), TaskLifecycle::Submitted);
1293 assert!(!sm.is_terminal());
1294
1295 let s = sm.start_work().expect("submitted -> working");
1296 assert_eq!(s, TaskLifecycle::Working);
1297 assert_eq!(sm.state(), TaskLifecycle::Working);
1298
1299 let s = sm.complete().expect("working -> completed");
1300 assert_eq!(s, TaskLifecycle::Completed);
1301 assert_eq!(sm.state(), TaskLifecycle::Completed);
1302 assert!(sm.is_terminal());
1303 }
1304
1305 #[test]
1306 fn state_machine_fail_from_submitted() {
1307 let mut sm = TaskStateMachine::new();
1308 let s = sm.fail().expect("submitted -> failed");
1309 assert_eq!(s, TaskLifecycle::Failed);
1310 assert!(sm.is_terminal());
1311 }
1312
1313 #[test]
1314 fn state_machine_fail_from_working() {
1315 let mut sm = TaskStateMachine::new();
1316 sm.start_work().expect("submitted -> working");
1317 let s = sm.fail().expect("working -> failed");
1318 assert_eq!(s, TaskLifecycle::Failed);
1319 assert!(sm.is_terminal());
1320 }
1321
1322 #[test]
1323 fn state_machine_cancel_from_submitted() {
1324 let mut sm = TaskStateMachine::new();
1325 let s = sm.cancel().expect("submitted -> canceled");
1326 assert_eq!(s, TaskLifecycle::Canceled);
1327 assert!(sm.is_terminal());
1328 }
1329
1330 #[test]
1331 fn state_machine_cancel_from_working() {
1332 let mut sm = TaskStateMachine::new();
1333 sm.start_work().expect("submitted -> working");
1334 let s = sm.cancel().expect("working -> canceled");
1335 assert_eq!(s, TaskLifecycle::Canceled);
1336 assert!(sm.is_terminal());
1337 }
1338
1339 #[test]
1340 fn state_machine_rejects_completed_to_working() {
1341 let mut sm = TaskStateMachine::new();
1342 sm.start_work().expect("submitted -> working");
1343 sm.complete().expect("working -> completed");
1344 let err = sm.start_work().expect_err("completed is terminal");
1345 assert!(matches!(
1346 err,
1347 A2AError::IllegalTransition {
1348 from: TaskLifecycle::Completed,
1349 to: TaskLifecycle::Working,
1350 }
1351 ));
1352 }
1353
1354 #[test]
1355 fn state_machine_rejects_failed_to_completed() {
1356 let mut sm = TaskStateMachine::new();
1357 sm.fail().expect("submitted -> failed");
1358 let err = sm.complete().expect_err("failed is terminal");
1359 assert!(matches!(
1360 err,
1361 A2AError::IllegalTransition {
1362 from: TaskLifecycle::Failed,
1363 to: TaskLifecycle::Completed,
1364 }
1365 ));
1366 }
1367
1368 #[test]
1369 fn state_machine_rejects_canceled_to_working() {
1370 let mut sm = TaskStateMachine::new();
1371 sm.cancel().expect("submitted -> canceled");
1372 let err = sm.start_work().expect_err("canceled is terminal");
1373 assert!(matches!(
1374 err,
1375 A2AError::IllegalTransition {
1376 from: TaskLifecycle::Canceled,
1377 to: TaskLifecycle::Working,
1378 }
1379 ));
1380 }
1381
1382 #[test]
1383 fn state_machine_rejects_submitted_to_completed() {
1384 let mut sm = TaskStateMachine::new();
1385 let err = sm.complete().expect_err("must go through working");
1386 assert!(matches!(
1387 err,
1388 A2AError::IllegalTransition {
1389 from: TaskLifecycle::Submitted,
1390 to: TaskLifecycle::Completed,
1391 }
1392 ));
1393 }
1394
1395 #[test]
1396 fn state_machine_rejects_working_to_submitted() {
1397 let mut sm = TaskStateMachine::new();
1398 sm.start_work().expect("submitted -> working");
1399 let err = sm
1400 .transition_to(TaskLifecycle::Submitted)
1401 .expect_err("no going back");
1402 assert!(matches!(
1403 err,
1404 A2AError::IllegalTransition {
1405 from: TaskLifecycle::Working,
1406 to: TaskLifecycle::Submitted,
1407 }
1408 ));
1409 }
1410
1411 #[test]
1412 fn state_machine_from_state() {
1413 let sm = TaskStateMachine::from_state(TaskLifecycle::Working);
1414 assert_eq!(sm.state(), TaskLifecycle::Working);
1415 }
1416
1417 #[test]
1418 fn session_task_lifecycle() {
1419 let mut s = A2ASession::new("my-agent", 3600, 1000);
1420 assert!(s.id().starts_with("a2a-"));
1421 assert_eq!(s.task_state(), TaskLifecycle::Submitted);
1422
1423 s.start_work().expect("submitted -> working");
1424 assert_eq!(s.task_state(), TaskLifecycle::Working);
1425
1426 s.complete().expect("working -> completed");
1427 assert_eq!(s.task_state(), TaskLifecycle::Completed);
1428 }
1429
1430 #[test]
1431 fn session_cancel_from_submitted() {
1432 let mut s = A2ASession::new("my-agent", 3600, 1000);
1433 s.cancel().expect("submitted -> canceled");
1434 assert_eq!(s.task_state(), TaskLifecycle::Canceled);
1435 }
1436
1437 #[test]
1438 fn session_fail_from_working() {
1439 let mut s = A2ASession::new("my-agent", 3600, 1000);
1440 s.start_work().expect("submitted -> working");
1441 s.fail().expect("working -> failed");
1442 assert_eq!(s.task_state(), TaskLifecycle::Failed);
1443 }
1444
1445 #[test]
1446 fn session_rejects_illegal_transition() {
1447 let mut s = A2ASession::new("my-agent", 3600, 1000);
1448 s.complete().expect_err("submitted -> completed is illegal");
1449 assert_eq!(s.task_state(), TaskLifecycle::Submitted);
1450 }
1451
1452 #[test]
1453 fn handle_a2a_request_is_stubbed() {
1454 let agent = CompiledA2aAgent {
1455 name: "a".to_string(),
1456 url: "https://agent.example.com".to_string(),
1457 card: None,
1458 upstream: "u".to_string(),
1459 sessions_ttl_secs: 3600,
1460 sessions_max_concurrent: 1000,
1461 };
1462 let req = ChatRequest {
1463 model: "m".to_string(),
1464 messages: vec![ChatMessage::text(ChatRole::User, "hi")],
1465 tools: Vec::new(),
1466 tool_choice: None,
1467 temperature: None,
1468 top_p: None,
1469 max_tokens: None,
1470 stop: None,
1471 stream: false,
1472 stream_options_include_usage: false,
1473 response_format: None,
1474 prompt: None,
1475 prompt_variables: Default::default(),
1476 other: BTreeMap::new(),
1477 };
1478 let err = handle_a2a_request(&agent, &req).unwrap_err();
1479 assert_eq!(err.transition, "handle_a2a_request");
1480 }
1481}