1use anyhow::{bail, ensure, Context, Result};
4use serde_json::{json, Map, Value};
5
6pub const PROTOCOL_VERSION: &str = "1.0";
7pub const VERSION_HEADER: &str = "A2A-Version";
8pub const CARD_PATH: &str = "/.well-known/agent-card.json";
9pub const MAX_BODY_BYTES: usize = 1024 * 1024;
10pub const MAX_PARTS: usize = 64;
11pub const MAX_TEXT_BYTES: usize = 256 * 1024;
12
13pub const TASK_NOT_FOUND: i64 = -32001;
14pub const TASK_NOT_CANCELABLE: i64 = -32002;
15pub const PUSH_NOT_SUPPORTED: i64 = -32003;
16pub const UNSUPPORTED_OPERATION: i64 = -32004;
17pub const CONTENT_TYPE_NOT_SUPPORTED: i64 = -32005;
18pub const INVALID_AGENT_RESPONSE: i64 = -32006;
19pub const EXTENDED_CARD_NOT_CONFIGURED: i64 = -32007;
20pub const VERSION_NOT_SUPPORTED: i64 = -32009;
21
22pub const STATES: &[(&str, &str)] = &[
23 ("submitted", "TASK_STATE_SUBMITTED"),
24 ("working", "TASK_STATE_WORKING"),
25 ("completed", "TASK_STATE_COMPLETED"),
26 ("failed", "TASK_STATE_FAILED"),
27 ("canceled", "TASK_STATE_CANCELED"),
28 ("input_required", "TASK_STATE_INPUT_REQUIRED"),
29 ("rejected", "TASK_STATE_REJECTED"),
30 ("auth_required", "TASK_STATE_AUTH_REQUIRED"),
31];
32
33pub fn state_wire(short: &str) -> Result<&'static str> {
34 STATES.iter().find(|(s, _)| *s == short).map(|(_, w)| *w).with_context(|| format!("unknown task state '{short}'; use one of submitted, working, completed, failed, canceled, input_required, rejected, auth_required"))
35}
36pub fn state_short(wire: &str) -> Option<&'static str> {
37 STATES.iter().find(|(_, w)| *w == wire).map(|(s, _)| *s)
38}
39pub fn terminal(short: &str) -> bool {
40 matches!(short, "completed" | "failed" | "canceled" | "rejected")
41}
42
43pub fn budget_ok(v: &Value) -> bool {
44 crate::utils::json_budget::within_budget(v, MAX_BODY_BYTES, 50_000, 32)
45}
46
47pub fn new_id() -> String {
48 uuid::Uuid::new_v4().to_string()
49}
50
51pub fn version_ok(header: Option<&str>) -> bool {
53 header.is_some_and(|v| {
54 let mut parts = v.trim().split('.');
55 parts.next() == Some("1")
56 && parts.next() == Some("0")
57 && parts.all(|p| p.bytes().all(|b| b.is_ascii_digit()))
58 })
59}
60
61pub fn rpc_result(id: &Value, result: Value) -> Value {
62 json!({"jsonrpc": "2.0", "id": id, "result": result})
63}
64pub fn rpc_error(id: &Value, code: i64, message: &str) -> Value {
65 json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
66}
67
68pub fn sse(value: &Value) -> String {
70 format!("data: {}\n\n", value)
71}
72
73fn parts_view(parts: &[Value]) -> Result<(String, Vec<Value>)> {
75 ensure!(
76 !parts.is_empty() && parts.len() <= MAX_PARTS,
77 "a message carries 1..=64 parts"
78 );
79 let mut text = String::new();
80 let mut view = Vec::new();
81 for p in parts {
82 let o = p.as_object().context("each part must be an object")?;
83 if let Some(t) = o.get("text").and_then(Value::as_str) {
84 ensure!(t.len() <= MAX_TEXT_BYTES, "text part exceeds 256 KiB");
85 if !text.is_empty() {
86 text.push('\n');
87 }
88 text.push_str(t);
89 view.push(json!({"kind": "text", "text": t}));
90 } else if let Some(d) = o.get("data") {
91 view.push(json!({"kind": "data", "data": d}));
92 } else if let Some(u) = o.get("url").and_then(Value::as_str) {
93 view.push(json!({"kind": "file", "url": u, "filename": o.get("filename"), "media_type": o.get("mediaType")}));
94 } else if let Some(r) = o.get("raw").and_then(Value::as_str) {
95 view.push(json!({"kind": "file", "inline_bytes": r.len() / 4 * 3, "filename": o.get("filename"), "media_type": o.get("mediaType")}));
97 } else {
98 bail!("a part needs text, data, url or raw");
99 }
100 }
101 ensure!(text.len() <= MAX_TEXT_BYTES, "message text exceeds 256 KiB");
102 Ok((text, view))
103}
104
105pub struct Incoming {
107 pub message_id: String,
108 pub context_id: Option<String>,
109 pub task_id: Option<String>,
110 pub text: String,
111 pub parts: Vec<Value>,
112}
113
114pub fn incoming_message(params: &Value) -> Result<Incoming> {
115 let m = params
116 .get("message")
117 .and_then(Value::as_object)
118 .context("params.message is required")?;
119 let message_id = m
120 .get("messageId")
121 .and_then(Value::as_str)
122 .filter(|s| !s.is_empty() && s.len() <= 256)
123 .context("message.messageId is required")?;
124 ensure!(
125 m.get("role").and_then(Value::as_str) == Some("ROLE_USER"),
126 "message.role must be ROLE_USER"
127 );
128 let parts = m
129 .get("parts")
130 .and_then(Value::as_array)
131 .context("message.parts is required")?;
132 let (text, view) = parts_view(parts)?;
133 let id = |k: &str| -> Result<Option<String>> {
134 match m.get(k) {
135 None | Some(Value::Null) => Ok(None),
136 Some(Value::String(s)) if !s.is_empty() && s.len() <= 256 => Ok(Some(s.clone())),
137 _ => bail!("message.{k} must be a non-empty string"),
138 }
139 };
140 Ok(Incoming {
141 message_id: message_id.into(),
142 context_id: id("contextId")?,
143 task_id: id("taskId")?,
144 text,
145 parts: view,
146 })
147}
148
149pub fn parts_from(text: Option<&str>, parts: Option<&Value>) -> Result<Vec<Value>> {
151 let mut out = Vec::new();
152 if let Some(t) = text {
153 ensure!(t.len() <= MAX_TEXT_BYTES, "text exceeds 256 KiB");
154 out.push(json!({"text": t}));
155 }
156 if let Some(list) = parts.filter(|p| !p.is_null()) {
157 for p in list.as_array().context("parts must be an array")? {
158 let o = p.as_object().context("each part must be an object")?;
159 let part = if let Some(t) = o.get("text").and_then(Value::as_str) {
160 json!({"text": t})
161 } else if let Some(d) = o.get("data") {
162 json!({"data": d})
163 } else if let Some(u) = o.get("url").and_then(Value::as_str) {
164 ensure!(
165 u.starts_with("http://") || u.starts_with("https://"),
166 "file parts reference an http(s) url"
167 );
168 let mut f = json!({"url": u});
169 if let Some(n) = o.get("filename").and_then(Value::as_str) {
170 f["filename"] = json!(n);
171 }
172 if let Some(m) = o.get("media_type").and_then(Value::as_str) {
173 f["mediaType"] = json!(m);
174 }
175 f
176 } else {
177 bail!("a part needs text, data or url");
178 };
179 out.push(part);
180 }
181 }
182 ensure!(
183 !out.is_empty() && out.len() <= MAX_PARTS,
184 "an answer carries 1..=64 parts"
185 );
186 Ok(out)
187}
188
189pub fn agent_message(
190 text: Option<&str>,
191 parts: Option<&Value>,
192 context_id: &str,
193 task_id: Option<&str>,
194) -> Result<Value> {
195 let mut m = json!({"messageId": new_id(), "contextId": context_id, "role": "ROLE_AGENT", "parts": parts_from(text, parts)?});
196 if let Some(t) = task_id {
197 m["taskId"] = json!(t);
198 }
199 Ok(m)
200}
201
202pub fn timestamp() -> String {
203 let now = crate::utils::clock::SystemTime::now()
204 .duration_since(crate::utils::clock::UNIX_EPOCH)
205 .unwrap_or_default();
206 let secs = now.as_secs();
207 let days = (secs / 86_400) as i64;
208 let rem = secs % 86_400;
209 let z = days + 719_468;
210 let era = z.div_euclid(146_097);
211 let doe = z - era * 146_097;
212 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
213 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
214 let mp = (5 * doy + 2) / 153;
215 let d = doy - (153 * mp + 2) / 5 + 1;
216 let m = if mp < 10 { mp + 3 } else { mp - 9 };
217 let y = yoe + era * 400 + i64::from(m <= 2);
218 format!(
219 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
220 rem / 3600,
221 rem % 3600 / 60,
222 rem % 60
223 )
224}
225
226pub fn task(spec: &Value, default_id: &str, context_id: &str) -> Result<Value> {
228 let o = spec.as_object().context("task must be an object")?;
229 let id = o.get("id").and_then(Value::as_str).unwrap_or(default_id);
230 ensure!(
231 !id.is_empty() && id.len() <= 256,
232 "task id must be 1..=256 characters"
233 );
234 let state = state_wire(
235 o.get("state")
236 .and_then(Value::as_str)
237 .context("task.state is required")?,
238 )?;
239 let mut status = json!({"state": state, "timestamp": timestamp()});
240 if let Some(t) = o.get("text").and_then(Value::as_str) {
241 status["message"] = agent_message(Some(t), None, context_id, Some(id))?;
242 }
243 let mut t = json!({"id": id, "contextId": o.get("context_id").and_then(Value::as_str).unwrap_or(context_id), "status": status});
244 if let Some(list) = o.get("artifacts").filter(|a| !a.is_null()) {
245 let mut arts = Vec::new();
246 for a in list.as_array().context("artifacts must be an array")? {
247 ensure!(arts.len() < MAX_PARTS, "too many artifacts");
248 let mut art = json!({"artifactId": a.get("id").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(new_id), "parts": parts_from(a.get("text").and_then(Value::as_str), a.get("parts"))?});
249 if let Some(n) = a.get("name").and_then(Value::as_str) {
250 art["name"] = json!(n);
251 }
252 arts.push(art);
253 }
254 t["artifacts"] = Value::Array(arts);
255 }
256 ensure!(budget_ok(&t), "task exceeds the A2A bounds");
257 Ok(t)
258}
259
260pub fn stream_events(id: &Value, task: &Value) -> Vec<Value> {
262 let task_id = task["id"].clone();
263 let context_id = task["contextId"].clone();
264 let mut out = vec![rpc_result(
265 id,
266 json!({"task": {"id": task_id, "contextId": context_id, "status": {"state": "TASK_STATE_SUBMITTED"}}}),
267 )];
268 let final_state = task["status"]["state"]
269 .as_str()
270 .unwrap_or("TASK_STATE_COMPLETED");
271 if final_state != "TASK_STATE_SUBMITTED" {
272 out.push(rpc_result(id, json!({"statusUpdate": {"taskId": task_id, "contextId": context_id, "status": {"state": "TASK_STATE_WORKING", "timestamp": timestamp()}}})));
273 }
274 if let Some(arts) = task["artifacts"].as_array() {
275 for a in arts {
276 out.push(rpc_result(id, json!({"artifactUpdate": {"taskId": task_id, "contextId": context_id, "artifact": a, "lastChunk": true}})));
277 }
278 }
279 if !matches!(final_state, "TASK_STATE_SUBMITTED" | "TASK_STATE_WORKING") {
280 out.push(rpc_result(id, json!({"statusUpdate": {"taskId": task_id, "contextId": context_id, "status": task["status"]}})));
281 }
282 out
283}
284
285pub fn card(
287 name: &str,
288 description: &str,
289 version: &str,
290 url: &str,
291 streaming: bool,
292 skills: &[Value],
293) -> Value {
294 json!({
295 "name": name,
296 "description": description,
297 "version": version,
298 "supportedInterfaces": [{"url": url, "protocolBinding": "JSONRPC", "protocolVersion": PROTOCOL_VERSION}],
299 "capabilities": {"streaming": streaming, "pushNotifications": false},
300 "defaultInputModes": ["text/plain", "application/json"],
301 "defaultOutputModes": ["text/plain", "application/json"],
302 "skills": skills,
303 })
304}
305
306pub fn card_rpc_url(card: &Value) -> Result<String> {
308 let interfaces = card
309 .get("supportedInterfaces")
310 .and_then(Value::as_array)
311 .context("agent card lacks supportedInterfaces")?;
312 let iface = interfaces
313 .iter()
314 .find(|i| {
315 i["protocolBinding"] == "JSONRPC"
316 && i["protocolVersion"]
317 .as_str()
318 .is_some_and(|v| version_ok(Some(v)))
319 })
320 .context("agent card offers no JSON-RPC interface for protocol 1.0")?;
321 let url = iface["url"].as_str().context("interface url missing")?;
322 ensure!(
323 url.starts_with("http://") || url.starts_with("https://"),
324 "interface url must be http(s)"
325 );
326 Ok(url.to_owned())
327}
328
329pub fn check_result(method: &str, result: &Value) -> Result<()> {
331 ensure!(budget_ok(result), "result exceeds the A2A bounds");
332 let o: &Map<String, Value> = result.as_object().context("result must be an object")?;
333 match method {
334 "SendMessage" | "SendStreamingMessage" => ensure!(
335 ["task", "message", "statusUpdate", "artifactUpdate"]
336 .iter()
337 .any(|k| o.contains_key(*k)),
338 "a send result carries task, message, statusUpdate or artifactUpdate"
339 ),
340 "GetTask" | "CancelTask" => ensure!(
341 o.get("id").is_some_and(Value::is_string)
342 && o.get("status").is_some_and(Value::is_object),
343 "a task result needs id and status"
344 ),
345 "ListTasks" => ensure!(
346 o.get("tasks").is_none_or(Value::is_array),
347 "tasks must be an array"
348 ),
349 _ => {}
350 }
351 Ok(())
352}