agentplane/api/a2a.rs
1//! Serving A2A: this plane, as an agent other agents can call.
2//!
3//! The client side ([`crate::peers::a2a`]) lets this plane *call* peers. This is
4//! the other half — a peer calling us — and it is a different problem, because
5//! everything arriving here came from somebody else.
6//!
7//! # Why this is not a route on the operator API
8//!
9//! Every route on [`crate::api::Api`] authenticates and then authorizes. An
10//! Agent Card is public by design: it is what a caller reads *before* it has
11//! credentials, and a card behind authentication cannot be discovered. Bolting
12//! an unauthenticated path onto a surface whose invariant is "every route
13//! authenticates" would delete that invariant for the one route nobody would
14//! think to check.
15//!
16//! So this is its own router with its own rule: **the card is public, every
17//! method call is authenticated and authorized**.
18//!
19//! # What arrives here is untrusted
20//!
21//! A message from a peer is data written by a party this plane does not control,
22//! so it is admitted as `Tainted` with the sending
23//! peer's identity as its provenance source — never as trusted input. A skill
24//! that wants to act on it has to say so at a gate, and a protected sink field
25//! can name the one counterparty it will accept an amount from.
26//!
27//! This is the same reason the operator API takes an event's `source` from the
28//! authenticated caller rather than the body: a party describing itself is not
29//! evidence about itself.
30//!
31//! # The capability is named, never inferred
32//!
33//! A2A messages do not carry a "call this skill" field — the protocol assumes an
34//! agent works out what is being asked. This plane will not: choosing which
35//! capability to run on the strength of an untrusted message is a dispatch
36//! decision made by inference, and the thing doing the inferring would be a
37//! model reading attacker-controlled text.
38//!
39//! The skill is taken from `message.metadata.skill`, matched against the card's
40//! advertised skill ids. When the agent advertises exactly one there is nothing
41//! to infer and it is used; when it advertises several and none was named, the
42//! call is refused rather than guessed.
43//!
44//! # Optional capabilities say what is wired
45//!
46//! Streaming and non-terminal task subscription are durable journal views.
47//! Push is advertised only after `with_push` supplies durable registration
48//! storage and a retrying transport worker; without that wiring every push
49//! method uses `PushNotificationNotSupportedError` and the card advertises
50//! false.
51//!
52//! # A task belongs to the peer that admitted it
53//!
54//! Every read, write, stream, push configuration and `contextId` join is scoped
55//! to the admitting peer; another peer's task answers `TASK_NOT_FOUND`, never a
56//! refusal that confirms it exists.
57
58use std::sync::Arc;
59
60use axum::extract::State;
61use axum::http::{HeaderMap, StatusCode};
62use axum::response::{IntoResponse, Response};
63use axum::routing::{get, post};
64use axum::{Json, Router};
65use serde::{Deserialize, Serialize};
66use serde_json::{Value, json};
67
68use crate::core::{PolicyDecision, PolicyRequest, RunId, Seq, SourceId, Tainted};
69use crate::journal::RecordKind;
70use crate::manifest::Manifest;
71use crate::peers::{AgentCard, ExtendedAgentCard, WELL_KNOWN_PATH};
72use crate::push::PushNamespace;
73use crate::runtime::Runtime;
74
75use super::{Authenticator, Caller};
76
77/// JSON-RPC method names, exactly as A2A 1.0 spells them.
78///
79/// 1.0 renamed these: `message/send` was the 0.3 spelling, and a server
80/// answering the old names would silently accept clients that have lost half the
81/// protocol. Constants rather than inline literals so the dispatch table and the
82/// refusal table cannot disagree.
83pub mod method {
84 pub const SEND_MESSAGE: &str = "SendMessage";
85 pub const GET_TASK: &str = "GetTask";
86 pub const CANCEL_TASK: &str = "CancelTask";
87 pub const GET_EXTENDED_CARD: &str = "GetExtendedAgentCard";
88
89 /// Optional operations implemented by this server.
90 pub const SEND_STREAMING: &str = "SendStreamingMessage";
91 pub const SUBSCRIBE: &str = "SubscribeToTask";
92 pub const LIST_TASKS: &str = "ListTasks";
93 pub const CREATE_PUSH: &str = "CreateTaskPushNotificationConfig";
94 pub const GET_PUSH: &str = "GetTaskPushNotificationConfig";
95 pub const LIST_PUSH: &str = "ListTaskPushNotificationConfigs";
96 pub const DELETE_PUSH: &str = "DeleteTaskPushNotificationConfig";
97}
98
99/// A2A-specific JSON-RPC error codes, from the spec's mapping table.
100pub mod code {
101 pub const PARSE_ERROR: i32 = -32700;
102 pub const INVALID_REQUEST: i32 = -32600;
103 pub const METHOD_NOT_FOUND: i32 = -32601;
104 pub const INVALID_PARAMS: i32 = -32602;
105 pub const INTERNAL_ERROR: i32 = -32603;
106
107 pub const TASK_NOT_FOUND: i32 = -32001;
108 pub const TASK_NOT_CANCELABLE: i32 = -32002;
109 pub const PUSH_NOT_SUPPORTED: i32 = -32003;
110 pub const UNSUPPORTED_OPERATION: i32 = -32004;
111 pub const CONTENT_TYPE_NOT_SUPPORTED: i32 = -32005;
112 pub const EXTENDED_CARD_NOT_CONFIGURED: i32 = -32007;
113 pub const VERSION_NOT_SUPPORTED: i32 = -32009;
114
115 /// Admission back-pressure: a quota ceiling refused the request before any
116 /// work was admitted.
117 ///
118 /// Not in A2A 1.0's error table — the spec defines only permanent
119 /// missing-capability codes and assigns back-pressure no code at all; its
120 /// own guidance stops at "return appropriate error responses when rate
121 /// limits are exceeded". Reaching into the table anyway would be worse
122 /// than inventing a code: `-32004 UnsupportedOperationError` for a full
123 /// quota teaches a compliant caller that the *operation does not exist
124 /// here* — the correct response to which is to abandon, never to retry —
125 /// when the truthful answer is "not right now". So the code is drawn from
126 /// JSON-RPC's implementation-defined server-error range.
127 ///
128 /// The **number is not the identity**. A2A 1.0 reserves `-32001..-32099`
129 /// for its own errors, the same band JSON-RPC gives implementations, so a
130 /// future spec revision could assign this numeral a meaning of its own.
131 /// What makes the refusal identifiable is the `google.rpc.ErrorInfo`
132 /// beside it — [`ERROR_DOMAIN`](crate::peers::ERROR_DOMAIN) +
133 /// [`QUOTA_EXHAUSTED_REASON`](crate::peers::QUOTA_EXHAUSTED_REASON), a
134 /// domain this project controls — and that pair is what this crate's own
135 /// client keys its refuse-and-come-back classification on. A bare
136 /// `-32029` from a foreign server proves nothing and is classified as an
137 /// unknown fault.
138 ///
139 /// What this code does NOT carry is the quota arithmetic: declines on this
140 /// surface are deliberately uniform, and counters or limits in the message
141 /// would hand an unauthenticated prober the tenant's ceilings. The message
142 /// beside this code is a fixed sentence with no numbers in it.
143 pub const QUOTA_EXHAUSTED: i32 = -32029;
144
145 /// This agent is halted by its operator.
146 ///
147 /// Its own code, beside [`QUOTA_EXHAUSTED`] and
148 /// under the same identification rule — the `(domain, reason)` pair, never
149 /// the numeral — because the two ask opposite things of a caller. A ceiling
150 /// says *come back*; a halt says *somebody is dealing with an incident*,
151 /// and a peer told to back off and retry keeps knocking on the one door
152 /// that means stop. The message is fixed and carries none of the
153 /// operator's reason: the counterparty gets the outcome, not the plane's
154 /// internals.
155 pub const HALTED: i32 = -32030;
156
157 /// This instance is shutting down and did not admit the request.
158 ///
159 /// The third admission refusal, under the same identification rule — the
160 /// `(domain, reason)` pair, never the numeral. It is its own code because
161 /// it is the only one of the three a caller clears by *moving*: a ceiling
162 /// and a halt are facts about the agent, and this is a fact about one
163 /// process serving it. A peer told to back off waits out a window it never
164 /// needed; a peer told to abandon gives up on an agent that is fine.
165 pub const DRAINING: i32 = -32031;
166}
167
168/// Actions this surface asks the policy engine about.
169pub mod action {
170 pub const MESSAGE_SEND: &str = "a2a:message.send";
171 pub const TASK_READ: &str = "a2a:task.read";
172 pub const TASK_CONTINUE: &str = "a2a:task.continue";
173 pub const TASK_CANCEL: &str = "a2a:task.cancel";
174 pub const CARD_EXTENDED: &str = "a2a:card.extended";
175 /// Registering, reading or removing a webhook for a task.
176 pub const TASK_PUSH: &str = "a2a:task.push";
177 /// Supplying an event of a kind, asked on the kind: a continuation is
178 /// delivered as the event its task awaits, so a peer may continue a task
179 /// only with input a rule lets it supply.
180 pub const EVENT_DELIVER: &str = "a2a:event.deliver";
181
182 /// Every action this surface can ask about, so a deployment can enumerate
183 /// what it must write rules for.
184 pub const ALL: &[&str] = &[
185 MESSAGE_SEND,
186 TASK_READ,
187 TASK_CONTINUE,
188 TASK_CANCEL,
189 CARD_EXTENDED,
190 TASK_PUSH,
191 EVENT_DELIVER,
192 ];
193}
194
195/// The policy context every A2A action is asked under: roles, the peer's own
196/// name, and tenant — no chain, no label, no depth. A task action adds the
197/// task's `owner` ([`A2aServer::context`]).
198fn peer_context(caller: &Caller) -> Value {
199 json!({
200 "roles": caller.roles,
201 "peer": caller.actor,
202 "tenant": caller.tenant.as_str(),
203 })
204}
205
206/// Whether `engine` can evaluate every request this surface puts to it.
207///
208/// Each [`action::ALL`] verb is probed in exactly the shapes it is asked in:
209/// with the task's `owner` where it acts on a task, and without it where it
210/// does not — sending, the extended card, the `tasks` listing, and an inline
211/// push registration for a task that does not exist yet. Probing a task action
212/// without its owner would refuse a rule set that reads `context.owner`, which
213/// every such request carries; probing the listing with one would miss a rule
214/// that breaks on the request that carries none. [`A2aServer::hosting`]
215/// refuses a runtime whose engine reports anything: a rule this surface
216/// cannot evaluate declines every peer, and the decline says nothing to the
217/// peer about why.
218#[must_use]
219pub fn policy_problems(engine: &dyn crate::core::PolicyEngine) -> Vec<String> {
220 let caller = Caller::new("preflight", vec!["preflight".to_owned()]);
221 let bare = peer_context(&caller);
222 let owned = A2aServer::context(&caller, Some(&caller.actor));
223 let shapes: &[(&str, &Value)] = &[
224 (action::MESSAGE_SEND, &bare),
225 (action::TASK_READ, &owned),
226 (action::TASK_READ, &bare),
227 (action::TASK_CONTINUE, &owned),
228 (action::TASK_CANCEL, &owned),
229 (action::CARD_EXTENDED, &bare),
230 (action::TASK_PUSH, &owned),
231 (action::TASK_PUSH, &bare),
232 (action::EVENT_DELIVER, &owned),
233 ];
234 let requests: Vec<PolicyRequest<'_>> = shapes
235 .iter()
236 .map(|(action, context)| PolicyRequest {
237 principal: &caller.actor,
238 principal_kind: crate::core::PrincipalKind::Subject,
239 action,
240 resource: "preflight.resource",
241 context,
242 })
243 .collect();
244 engine.preflight(&requests)
245}
246
247/// The `A2A-Version` service parameter.
248const VERSION_HEADER: &str = "a2a-version";
249
250/// The one sentence a full quota answers with, beside
251/// [`code::QUOTA_EXHAUSTED`].
252///
253/// A single constant rather than `why.to_string()` at each site, because the
254/// quota error's own rendering names the counter and its ceiling — numbers an
255/// external caller has no business learning from a decline. Deliberately
256/// digit-free; the test on this path asserts exactly that.
257const QUOTA_EXHAUSTED_MESSAGE: &str =
258 "this agent cannot take the request on right now; retry later";
259
260/// The one sentence a halt answers with, beside [`code::HALTED`]. No reason,
261/// no scope: an incident's description is for the operator's own worklist.
262const HALTED_MESSAGE: &str =
263 "this agent is halted by its operator; do not retry until it is lifted";
264
265/// The one sentence a draining instance answers with, beside [`code::DRAINING`].
266///
267/// No hostname, no instance id and no grace period: which process is going away
268/// is this deployment's topology, and a caller's correct behaviour does not
269/// depend on knowing it.
270const DRAINING_MESSAGE: &str =
271 "this instance is shutting down and did not take the request on; retry";
272
273/// Which of the two admission refusals a quota error is, on the wire.
274///
275/// One function for the three admission sites, because a halt that reached one
276/// of them as a ceiling would be the sibling-divergence shape: a peer told
277/// *retry* by `message/send` and *stop* by `message/stream`.
278fn quota_refusal(e: &crate::quota::QuotaError) -> RpcError {
279 match e {
280 crate::quota::QuotaError::Halted { .. } => RpcError::new(code::HALTED, HALTED_MESSAGE),
281 _ => RpcError::new(code::QUOTA_EXHAUSTED, QUOTA_EXHAUSTED_MESSAGE),
282 }
283}
284
285/// A message its agent cannot read a data subject from, on the wire: invalid
286/// params at every admission site. The error names the binding and why it
287/// did not resolve, never a subject.
288fn subject_unbound(e: &crate::core::RuntimeError) -> RpcError {
289 RpcError::new(code::INVALID_PARAMS, e.to_string())
290}
291
292/// A task's lifecycle state, as A2A names them.
293///
294/// `ProtoJSON` spelling — `TASK_STATE_WORKING`, not `working`. A client matching
295/// on the enum gets nothing from a friendlier spelling.
296///
297/// # Why this is the whole vocabulary, not the part this plane produces
298///
299/// It is **both** an output and an input: `ListTasks` takes a `status` filter,
300/// so a caller may name any state A2A defines and must get a valid, empty
301/// answer rather than `INVALID_PARAMS`. A subset would turn a legitimate query
302/// into a protocol error.
303///
304/// Two of these this plane never *produces*: `TASK_STATE_UNSPECIFIED`, the
305/// protocol's zero value, and `TASK_STATE_SUBMITTED`, which means accepted but
306/// not yet started — there is no such moment here, because a run starts inside
307/// the call that admits it.
308///
309/// `TASK_STATE_AUTH_REQUIRED` is produced for a run whose authority was
310/// withdrawn under it: interrupted, not terminal, so lifting the withdrawal
311/// continues the same task.
312///
313/// `TASK_STATE_REJECTED` **is** produced, and not as a refusal: a sweep, a
314/// break-glass crossing, a lifted control and an observed session take it,
315/// because none is a task any peer submitted. A caller polling one of those is
316/// asking about this plane's record of itself, and gets the same answer as for
317/// a run id that does not exist at all.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319pub enum TaskState {
320 #[serde(rename = "TASK_STATE_UNSPECIFIED")]
321 Unspecified,
322 #[serde(rename = "TASK_STATE_SUBMITTED")]
323 Submitted,
324 #[serde(rename = "TASK_STATE_WORKING")]
325 Working,
326 #[serde(rename = "TASK_STATE_COMPLETED")]
327 Completed,
328 #[serde(rename = "TASK_STATE_FAILED")]
329 Failed,
330 #[serde(rename = "TASK_STATE_CANCELED")]
331 Canceled,
332 #[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
333 InputRequired,
334 #[serde(rename = "TASK_STATE_REJECTED")]
335 Rejected,
336 #[serde(rename = "TASK_STATE_AUTH_REQUIRED")]
337 AuthRequired,
338}
339
340/// The A2A state a live run's status surfaces as.
341///
342/// These are worth stating because the obvious mapping is wrong.
343///
344/// A **suspended** run is `INPUT_REQUIRED`, not `WORKING`: it is stopped and
345/// will not move until something external happens, and a caller polling a
346/// `WORKING` task waits forever for a task that is not running.
347///
348/// A **quarantined** run is `FAILED`, not `REJECTED`. `REJECTED` means the agent
349/// declined the work; quarantine means it accepted the work, started, and can no
350/// longer be trusted to describe what it did. Reporting that as a refusal tells
351/// the caller nothing happened, when something did.
352///
353/// A **withheld** run is `AUTH_REQUIRED`: its authority was withdrawn under
354/// it, its work stands, and it continues once the authorization is restored.
355/// Interrupted rather than terminal, so a stream stays open and a push
356/// registration stays live. Restoring the authorization is the deployment's
357/// act, not the peer's — the status message carries the outcome so nobody
358/// reads it as a request for credentials.
359///
360/// A **failed or exhausted** run is `FAILED` although the runtime can resume
361/// it: A2A has no interrupted state for "stopped until this deployment's
362/// operator acts", so the answer is the run's state at read time. The stream
363/// closes and the push registration is released as for any terminal task; an
364/// operator's resume is a fresh act, and `GetTask` then reports it working.
365///
366/// This and [`sealed_state`] are the **same question asked on two paths**: this
367/// one answers the caller who receives the immediate `SendMessage` response, and
368/// `sealed_state` answers everyone who reads the task back — `GetTask`,
369/// `SubscribeToTask`, and every streamed status update. They must agree, and
370/// `a_live_status_and_its_sealed_outcome_agree` holds them to it over every
371/// variant.
372///
373/// **This is the exhaustive one, and that is the point.** The two were unified
374/// once by making this function delegate to `sealed_state`, which reads as the
375/// tidier direction and is the wrong one: `sealed_state` matches *strings*
376/// behind a `_ => Failed`, so delegating to it deleted the only compile-time
377/// check on the mapping while leaving a comment claiming the compiler still
378/// enforced it. Adding a `RunStatus` variant for something that is **not** a
379/// failure — an authorization wait, a rejection — would then have compiled
380/// cleanly and reported `Failed` on both paths. That is not agreement; it is
381/// the same wrong answer twice, which is strictly harder to notice than two
382/// different ones.
383///
384/// So the enum match is the definition and the string match is checked against
385/// it. A new variant fails to compile *here*, which is where the decision
386/// belongs.
387fn state_of(status: &crate::runtime::RunStatus) -> TaskState {
388 use crate::runtime::RunStatus;
389 match status {
390 RunStatus::Succeeded => TaskState::Completed,
391 RunStatus::Suspended(_) => TaskState::InputRequired,
392 RunStatus::Cancelled { .. } => TaskState::Canceled,
393 RunStatus::Failed(_)
394 | RunStatus::Exhausted(_)
395 | RunStatus::Quarantined(_)
396 // Not `Canceled`: A2A's cancellation means a client asked and the work
397 // stopped, which a peer reads as "nothing of mine is standing". An
398 // abandoned run is the opposite claim — something may well be standing
399 // and nobody could establish what — so it takes the state a peer
400 // investigates rather than the one they dismiss.
401 | RunStatus::Abandoned { .. }
402 | RunStatus::Replanning(_) => TaskState::Failed,
403 RunStatus::Withheld { .. } => TaskState::AuthRequired,
404 // Neither is a task, and `REJECTED` is the state that says so: no peer
405 // submitted a sweep or a break-glass crossing, and a run id that
406 // resolves to one is a caller asking about this plane's record of
407 // itself. `FAILED` would claim the agent tried and could not; `CANCELED`
408 // would claim somebody's work was stopped. Both invent a task where
409 // there was none, and a peer reads either as being about *their*
410 // request. Nothing narrows further here on purpose — which of this
411 // plane's internal runs exists is not a peer's question, so the answer
412 // is the same one for a run that does not exist at all.
413 // An observed session joins them for the same reason and one more: it
414 // is not even this plane's work. No peer submitted it, nothing here
415 // executed it, and the only honest thing to tell a caller asking about
416 // somebody else's agent is the answer they get for a run that is none
417 // of their business.
418 RunStatus::Swept
419 | RunStatus::BrokeGlass { .. }
420 | RunStatus::HaltLifted { .. }
421 | RunStatus::HoldReleased { .. }
422 | RunStatus::Observed => TaskState::Rejected,
423 }
424}
425
426/// A2A's `TaskStatus`.
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428pub struct TaskStatus {
429 pub state: TaskState,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 pub message: Option<A2aMessage>,
432 #[serde(skip_serializing_if = "Option::is_none")]
433 pub timestamp: Option<String>,
434}
435
436/// A2A's `Task` — what this plane calls a run.
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct A2aTask {
440 pub id: String,
441 /// The case, when the run belongs to one.
442 ///
443 /// A2A's `contextId` is "the thing this conversation is part of", which is
444 /// exactly a case: several runs, one matter, shared state. Mapping it to
445 /// anything else would give a caller a correlation handle that does not
446 /// correlate.
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub context_id: Option<String>,
449 pub status: TaskStatus,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub artifacts: Option<Vec<A2aArtifact>>,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub history: Option<Vec<A2aMessage>>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub metadata: Option<Value>,
456}
457
458/// One output produced by an A2A task.
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct A2aArtifact {
462 pub artifact_id: String,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub name: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub description: Option<String>,
467 pub parts: Vec<Part>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub metadata: Option<Value>,
470 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 pub extensions: Vec<String>,
472}
473
474/// One piece of a message.
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct Part {
478 #[serde(skip_serializing_if = "Option::is_none")]
479 pub text: Option<String>,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub data: Option<Value>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub raw: Option<String>,
484 #[serde(skip_serializing_if = "Option::is_none")]
485 pub url: Option<String>,
486 #[serde(skip_serializing_if = "Option::is_none")]
487 pub filename: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
489 pub media_type: Option<String>,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 pub metadata: Option<Value>,
492}
493
494impl Part {
495 /// A text part.
496 #[must_use]
497 pub fn text(text: impl Into<String>) -> Self {
498 Self {
499 text: Some(text.into()),
500 data: None,
501 raw: None,
502 url: None,
503 filename: None,
504 media_type: Some("text/plain".to_owned()),
505 metadata: None,
506 }
507 }
508
509 /// A structured-data part.
510 #[must_use]
511 pub fn data(data: Value) -> Self {
512 Self {
513 text: None,
514 data: Some(data),
515 raw: None,
516 url: None,
517 filename: None,
518 media_type: Some("application/json".to_owned()),
519 metadata: None,
520 }
521 }
522
523 /// A file part carrying its bytes inline, base64-encoded per `ProtoJSON`.
524 #[must_use]
525 pub fn file_raw(
526 raw_base64: impl Into<String>,
527 media_type: impl Into<String>,
528 filename: impl Into<String>,
529 ) -> Self {
530 Self {
531 text: None,
532 data: None,
533 raw: Some(raw_base64.into()),
534 url: None,
535 filename: Some(filename.into()),
536 media_type: Some(media_type.into()),
537 metadata: None,
538 }
539 }
540
541 /// A file part referring to bytes by URL.
542 #[must_use]
543 pub fn file_url(
544 url: impl Into<String>,
545 media_type: impl Into<String>,
546 filename: impl Into<String>,
547 ) -> Self {
548 Self {
549 text: None,
550 data: None,
551 raw: None,
552 url: Some(url.into()),
553 filename: Some(filename.into()),
554 media_type: Some(media_type.into()),
555 metadata: None,
556 }
557 }
558}
559
560/// How a skill shapes its A2A answer, when the default projection is not it.
561///
562/// The default stands for most skills: a string output becomes a text part and
563/// anything else a data part, in one artifact. What that projection cannot say
564/// is *file content* — inline bytes or a URL with a filename — or that the
565/// answer is a quick, stateless `Message` rather than a task's artifact. Both
566/// are ordinary A2A response shapes a peer may expect.
567///
568/// A skill opts in by returning this value (via [`A2aReply::into_value`]) as
569/// its outcome. The runtime journals it as it journals any output — the shape
570/// is a *projection instruction* read at the protocol boundary, not a second
571/// channel around the journal.
572///
573/// ```no_run
574/// # use agentplane::api::a2a::{A2aReply, Part};
575/// # use agentplane::core::{Outcome, Tainted};
576/// // A task whose artifact is a file reference:
577/// let reply = A2aReply::artifact(vec![Part::file_url(
578/// "https://example.com/report.pdf",
579/// "application/pdf",
580/// "report.pdf",
581/// )]);
582/// # let _ = Outcome::done(Tainted::trusted(reply.into_value()));
583/// ```
584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
585pub struct A2aReply {
586 #[serde(skip_serializing_if = "Option::is_none")]
587 message: Option<Vec<Part>>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 artifacts: Option<Vec<Vec<Part>>>,
590}
591
592/// The output key an [`A2aReply`] travels under.
593///
594/// A `$`-prefixed marker, like `$a2a_message` on the inbound side: ordinary
595/// skill output is domain data this runtime never interprets, so the one shape
596/// it *does* interpret must be unmistakably deliberate rather than a field
597/// name a domain object happens to share.
598const REPLY_KEY: &str = "$a2a_reply";
599
600impl A2aReply {
601 /// Answer the blocking send with a direct `Message` instead of a task.
602 ///
603 /// Outside a blocking send — a spawned task, a stream, a later `GetTask` —
604 /// there is no message to deliver, and the parts become the task's
605 /// artifact instead: the content survives, only the envelope differs.
606 #[must_use]
607 pub const fn message(parts: Vec<Part>) -> Self {
608 Self {
609 message: Some(parts),
610 artifacts: None,
611 }
612 }
613
614 /// Answer with one artifact holding these parts.
615 #[must_use]
616 pub fn artifact(parts: Vec<Part>) -> Self {
617 Self {
618 message: None,
619 artifacts: Some(vec![parts]),
620 }
621 }
622
623 /// Answer with several artifacts.
624 #[must_use]
625 pub const fn artifacts(artifacts: Vec<Vec<Part>>) -> Self {
626 Self {
627 message: None,
628 artifacts: Some(artifacts),
629 }
630 }
631
632 /// The outcome value a skill returns.
633 #[must_use]
634 pub fn into_value(self) -> Value {
635 json!({ REPLY_KEY: self })
636 }
637
638 /// Read a reply back out of a run's output, **if the run may declare one**.
639 ///
640 /// A projection instruction is authority: it decides whether the answer is
641 /// a task artifact or a direct `Message`, and what parts it carries —
642 /// including a file part naming a URL. Skill output routinely *contains*
643 /// untrusted data: a summariser quotes its input, a declarative agent's
644 /// answer is a model's words, and an echoing skill returns a peer's own
645 /// bytes. So the marker is honoured only from a **trusted** output.
646 ///
647 /// Otherwise a peer could put the marker in its message, have an ordinary
648 /// echoing skill return it, and choose the envelope its own reply arrived
649 /// in — a file URL of the attacker's naming, presented as the agent's
650 /// answer. Model output is a proposal, never authority; so is a peer's
651 /// message.
652 ///
653 /// An untrusted answer still reaches the caller — as the artifact content
654 /// it is, rather than as an instruction about the envelope.
655 pub(super) fn of_output(output: &crate::core::Tainted<Value>) -> Option<Self> {
656 if output.label().is_untrusted() {
657 return None;
658 }
659 serde_json::from_value(output.peek().get(REPLY_KEY)?.clone()).ok()
660 }
661
662 /// The message parts, when this reply is a direct `Message`.
663 pub(super) fn message_parts(&self) -> Option<Vec<Part>> {
664 self.message.clone()
665 }
666
667 /// Every part, for contexts that can only carry artifacts.
668 pub(super) fn artifact_parts(&self) -> Vec<Vec<Part>> {
669 match (&self.artifacts, &self.message) {
670 (Some(artifacts), _) => artifacts.clone(),
671 (None, Some(message)) => vec![message.clone()],
672 (None, None) => Vec::new(),
673 }
674 }
675}
676
677/// A2A's `Message`.
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "camelCase")]
680pub struct A2aMessage {
681 pub message_id: String,
682 pub role: String,
683 pub parts: Vec<Part>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub context_id: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub task_id: Option<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub metadata: Option<Value>,
690 #[serde(default, skip_serializing_if = "Vec::is_empty")]
691 pub extensions: Vec<String>,
692 #[serde(default, skip_serializing_if = "Vec::is_empty")]
693 pub reference_task_ids: Vec<String>,
694}
695
696impl A2aMessage {
697 fn validate_parts(&self, caller: &Caller) -> Result<(), RpcError> {
698 if self.role != "ROLE_USER" {
699 return Err(RpcError::new(
700 code::INVALID_PARAMS,
701 "an inbound Message role must be ROLE_USER",
702 ));
703 }
704 // The id is half of the admission key — an empty one would fold every
705 // caller's unnamed messages into a single "retry" of the first.
706 if self.message_id.is_empty() {
707 return Err(RpcError::new(
708 code::INVALID_PARAMS,
709 "messageId must be non-empty: it is what lets a retry of this \
710 message be recognised as the same message",
711 ));
712 }
713 // The other half of the same key, bounded by the runtime's admission-key
714 // ceiling. The key also carries the caller's name, so the bound is on
715 // the key this caller's message makes, not on the id alone — and it is
716 // the caller's message that is refused, with a code that says so,
717 // rather than a fault the caller would retry unchanged.
718 let key = crate::core::origin_key(&admission_source(&caller.actor), &self.message_id);
719 if key.len() > crate::runtime::MAX_ADMISSION_KEY_BYTES {
720 return Err(RpcError::new(
721 code::INVALID_PARAMS,
722 format!(
723 "messageId is too long: with the caller's identity it makes an \
724 admission key over {} bytes",
725 crate::runtime::MAX_ADMISSION_KEY_BYTES
726 ),
727 ));
728 }
729 if self.parts.is_empty() {
730 return Err(RpcError::new(
731 code::CONTENT_TYPE_NOT_SUPPORTED,
732 "the message has no parts this agent can read; it accepts text and data parts",
733 ));
734 }
735 for (index, part) in self.parts.iter().enumerate() {
736 // Only the file modalities are refused. A `mediaType` on a text or
737 // data part is a *label* the spec allows on every part type — a
738 // peer sending `text/markdown` text is conformant, and the card's
739 // input modes are a tailoring hint, not a per-part contract the
740 // server may bounce messages on.
741 if part.raw.is_some() || part.url.is_some() {
742 return Err(RpcError::new(
743 code::CONTENT_TYPE_NOT_SUPPORTED,
744 format!(
745 "message.parts[{index}] is file content; this agent card advertises only text/plain and application/json"
746 ),
747 ));
748 }
749 let variants = usize::from(part.text.is_some()) + usize::from(part.data.is_some());
750 if variants != 1 {
751 return Err(RpcError::new(
752 code::INVALID_PARAMS,
753 format!("message.parts[{index}] must contain exactly one of text or data"),
754 ));
755 }
756 }
757 Ok(())
758 }
759
760 /// What the runtime receives as input.
761 ///
762 /// A stable shape, always the same three keys, rather than a clever unwrapping
763 /// that hands a skill a bare string sometimes and an object other times. A
764 /// skill parsing its own input should not have to branch on how many parts
765 /// the caller happened to send. `$a2a_message` preserves the exact protocol
766 /// object for task-history reconstruction; `text` and `data` remain the
767 /// ergonomic projections skills normally consume.
768 fn to_input(&self) -> Value {
769 let text: Vec<&str> = self
770 .parts
771 .iter()
772 .filter_map(|p| p.text.as_deref())
773 .collect();
774 let data: Vec<Value> = self.parts.iter().filter_map(|p| p.data.clone()).collect();
775 json!({
776 "text": text.join("\n"),
777 "data": data,
778 "$a2a_message": self,
779 })
780 }
781
782 /// The skill this message asks for, if it named one.
783 fn requested_skill(&self) -> Option<&str> {
784 self.metadata.as_ref()?.get("skill")?.as_str()
785 }
786}
787
788/// A JSON-RPC 2.0 request.
789#[derive(Debug, Clone, Deserialize)]
790struct RpcRequest {
791 #[serde(default)]
792 jsonrpc: String,
793 #[serde(default)]
794 id: Value,
795 method: String,
796 #[serde(default)]
797 params: Value,
798}
799
800/// The parts of `SendMessageConfiguration` this server acts on.
801#[derive(Debug, Clone, Default, Deserialize)]
802#[serde(rename_all = "camelCase")]
803struct SendConfiguration {
804 /// Return as soon as the task exists, rather than when it finishes.
805 ///
806 /// Blocking is the spec's default and the default here: unset means wait.
807 #[serde(default)]
808 return_immediately: bool,
809 #[serde(default)]
810 accepted_output_modes: Vec<String>,
811 #[serde(default)]
812 history_length: Option<usize>,
813 #[serde(default)]
814 task_push_notification_config: Option<PushRequest>,
815}
816
817impl SendConfiguration {
818 fn validate(&self) -> Result<(), RpcError> {
819 if !self.accepted_output_modes.is_empty()
820 && !self
821 .accepted_output_modes
822 .iter()
823 .any(|mode| matches!(mode.as_str(), "text/plain" | "application/json"))
824 {
825 return Err(RpcError::new(
826 code::CONTENT_TYPE_NOT_SUPPORTED,
827 "acceptedOutputModes contains no mode this agent can produce",
828 ));
829 }
830 Ok(())
831 }
832}
833
834#[derive(Debug, Clone, Deserialize)]
835#[serde(rename_all = "camelCase")]
836struct PushAuthenticationRequest {
837 scheme: String,
838 #[serde(default)]
839 credentials: Option<String>,
840}
841
842#[derive(Debug, Clone, Deserialize)]
843#[serde(rename_all = "camelCase")]
844struct PushRequest {
845 #[serde(default)]
846 id: Option<String>,
847 #[serde(default)]
848 task_id: Option<String>,
849 url: String,
850 #[serde(default)]
851 token: Option<String>,
852 #[serde(default)]
853 authentication: Option<PushAuthenticationRequest>,
854}
855
856/// The longest push configuration id a caller may choose, in bytes.
857const MAX_PUSH_ID_LEN: usize = 128;
858
859/// The most push configurations one task holds.
860///
861/// Every one is a delivery per record, so without a ceiling a peer multiplies
862/// the plane's outbound traffic and store rows by however many it registers.
863/// Checked before the write rather than inside it, so two registrations racing
864/// for the last slot can both land; the bound is on what a peer can build up,
865/// not an exact count.
866const MAX_PUSH_CONFIGS_PER_TASK: usize = 10;
867
868impl PushRequest {
869 /// Refuse an id in the namespace an operator destination owns, or one
870 /// longer than [`MAX_PUSH_ID_LEN`].
871 ///
872 /// A caller and the deployment share one push store, and the two are told
873 /// apart by an id prefix. A caller allowed to write into that namespace could
874 /// point one of the deployment's own destinations at an address it chose —
875 /// and operator destinations are deliberately exempt from the host
876 /// allowlist, HTTPS and the public-address check, because there is supposed
877 /// to be no caller involved. This is the check that keeps that supposition
878 /// true.
879 fn validate(&self) -> Result<(), RpcError> {
880 if self
881 .id
882 .as_deref()
883 .is_some_and(|id| id.len() > MAX_PUSH_ID_LEN)
884 {
885 return Err(RpcError::new(
886 code::INVALID_PARAMS,
887 format!("a pushNotificationConfig id is at most {MAX_PUSH_ID_LEN} bytes"),
888 ));
889 }
890 if self.id.as_deref().is_some_and(crate::push::is_operator_id) {
891 return Err(RpcError::new(
892 code::INVALID_PARAMS,
893 format!(
894 "a pushNotificationConfig id may not begin with '{}': that namespace \
895 belongs to destinations this deployment configured for itself",
896 crate::push::OPERATOR_PREFIX
897 ),
898 ));
899 }
900 Ok(())
901 }
902
903 fn config(&self, task: RunId) -> crate::push::PushConfig {
904 crate::push::PushConfig {
905 id: self.id.clone().unwrap_or_else(|| format!("push-{task}")),
906 task,
907 url: self.url.clone(),
908 token: self.token.clone().map(crate::core::Secret::new),
909 authentication: self.authentication.as_ref().map(|authentication| {
910 crate::push::PushAuthentication {
911 scheme: authentication.scheme.clone(),
912 credentials: crate::core::Secret::new(
913 authentication.credentials.clone().unwrap_or_default(),
914 ),
915 }
916 }),
917 }
918 }
919}
920
921/// What every method's params may carry.
922///
923/// One struct for every method, which is the same shape the binary's arguments
924/// had before they moved onto per-verb structs: a field belonging to one method
925/// was **silently accepted** by another and did nothing. On the wire that reads
926/// worse than at a command line, because the caller is a stranger who cannot
927/// see the source. `ListTasks` was the case that mattered — a request naming
928/// `contxtId`, or the `context_id` the protocol's own conformance kit sends,
929/// parsed cleanly, dropped the filter, and answered with **every** task the
930/// caller may see, shaped exactly like the scoped list that was asked for.
931///
932/// Two mechanisms close it, and they are different questions.
933/// [`deny_unknown_fields`] refuses a name this surface does not know at all
934/// (`contxtId`, `task_id`), which the A2A specification licenses
935/// outright: A2A §5.5 says JSON field names **MUST** be camelCase, so `context_id` is not an
936/// alternative spelling but a violation. [`FIELDS_BY_METHOD`] refuses a name
937/// this surface knows and *this method* does not (`pageSize` on `CancelTask`).
938///
939/// Neither subsumes the other.
940///
941/// [`deny_unknown_fields`]: https://serde.rs/container-attrs.html
942#[derive(Debug, Clone, Default, Deserialize)]
943#[serde(deny_unknown_fields)]
944struct CommonParams {
945 /// A2A's opaque routing identifier.
946 #[serde(default)]
947 tenant: Option<String>,
948 #[serde(default)]
949 message: Option<A2aMessage>,
950 #[serde(default)]
951 id: Option<String>,
952 #[serde(default)]
953 configuration: Option<SendConfiguration>,
954 /// `SendMessageRequest.metadata`, accepted and not interpreted.
955 ///
956 /// Modelled rather than ignored because unknown fields are refused, and
957 /// the two are different answers: the specification defines this field, so
958 /// a conforming client may send it and must not meet `-32602`. It is
959 /// deliberately not read — opaque caller data has no governed meaning here,
960 /// and inventing one would be a control nothing enforces. What the runtime
961 /// does record about an inbound message is its authenticated sender, which
962 /// is provenance rather than a field the sender chose.
963 ///
964 /// `dead_code` is allowed here for the one case where it is the point: the
965 /// field exists so that deserialization *accepts* the name, and reading it
966 /// is what would be wrong.
967 #[serde(default)]
968 #[allow(dead_code)]
969 metadata: Option<Value>,
970 #[serde(default, rename = "taskId")]
971 push_task: Option<String>,
972 #[serde(default)]
973 url: Option<String>,
974 #[serde(default)]
975 token: Option<String>,
976 #[serde(default)]
977 authentication: Option<PushAuthenticationRequest>,
978 #[serde(default, rename = "contextId")]
979 context_id: Option<String>,
980 #[serde(default)]
981 status: Option<TaskState>,
982 #[serde(default, rename = "pageSize")]
983 page_size: Option<usize>,
984 #[serde(default, rename = "pageToken")]
985 page_token: Option<String>,
986 #[serde(default, rename = "historyLength")]
987 history_length: Option<usize>,
988 #[serde(default, rename = "statusTimestampAfter")]
989 status_timestamp_after: Option<String>,
990 #[serde(default, rename = "includeArtifacts")]
991 include_artifacts: bool,
992}
993
994/// A JSON-RPC error, carrying the HTTP status it should be served with.
995#[derive(Debug, Clone)]
996pub struct RpcError {
997 code: i32,
998 message: String,
999 /// The id of the request being answered — `null` when it could not be read.
1000 id: Value,
1001 /// Served as HTTP 401 with a bearer challenge rather than 200.
1002 unauthenticated: bool,
1003}
1004
1005impl RpcError {
1006 fn new(code: i32, message: impl Into<String>) -> Self {
1007 Self {
1008 code,
1009 message: message.into(),
1010 id: Value::Null,
1011 unauthenticated: false,
1012 }
1013 }
1014
1015 /// A caller whose credential was refused: HTTP 401 with
1016 /// `WWW-Authenticate`, as A2A requires, so the status says *authenticate*
1017 /// rather than the body saying *malformed*.
1018 fn unauthenticated(message: impl Into<String>) -> Self {
1019 Self {
1020 unauthenticated: true,
1021 ..Self::new(code::INVALID_REQUEST, message)
1022 }
1023 }
1024
1025 fn with_id(mut self, id: Value) -> Self {
1026 self.id = id;
1027 self
1028 }
1029
1030 /// The machine-readable reason A2A 1.0's error-handling rules require
1031 /// beside an A2A-specific code, as a `google.rpc.ErrorInfo` in
1032 /// `error.data` — `(domain, reason)`.
1033 ///
1034 /// Derived from the code rather than declared per call site, because the
1035 /// two are one fact: the spec's table maps each code to exactly one reason
1036 /// token, and a site that could set them independently is a site that can
1037 /// disagree with itself. Standard JSON-RPC codes carry no reason — the
1038 /// spec assigns them none — so they return `None` and the error omits
1039 /// `data` rather than inventing a token.
1040 ///
1041 /// The spec's codes carry the spec's domain. [`code::QUOTA_EXHAUSTED`]
1042 /// carries this plane's own — [`ERROR_DOMAIN`](crate::peers::ERROR_DOMAIN)
1043 /// — and that domain is load-bearing rather than decorative: the numeric
1044 /// code sits in space A2A 1.0 reserves for its own future errors, so the
1045 /// code alone is not proof of what it means. The `(domain, reason)` pair
1046 /// is, and it is what this crate's client keys its back-off
1047 /// classification on.
1048 ///
1049 /// This adds no information a prober does not already have: the reason is
1050 /// a restatement of the code in the same response. The uniform-refusal
1051 /// rule governs what a *model* is told; this is the protocol channel to an
1052 /// authenticated peer.
1053 const fn reason(&self) -> Option<(&'static str, &'static str)> {
1054 const A2A: &str = "a2a-protocol.org";
1055 const SELF_DOMAIN: &str = crate::peers::ERROR_DOMAIN;
1056 const QUOTA_EXHAUSTED_REASON: &str = crate::peers::QUOTA_EXHAUSTED_REASON;
1057 const HALTED_REASON: &str = crate::peers::HALTED_REASON;
1058 const DRAINING_REASON: &str = crate::peers::DRAINING_REASON;
1059 match self.code {
1060 code::TASK_NOT_FOUND => Some((A2A, "TASK_NOT_FOUND")),
1061 code::TASK_NOT_CANCELABLE => Some((A2A, "TASK_NOT_CANCELABLE")),
1062 code::PUSH_NOT_SUPPORTED => Some((A2A, "PUSH_NOTIFICATION_NOT_SUPPORTED")),
1063 code::UNSUPPORTED_OPERATION => Some((A2A, "UNSUPPORTED_OPERATION")),
1064 code::CONTENT_TYPE_NOT_SUPPORTED => Some((A2A, "CONTENT_TYPE_NOT_SUPPORTED")),
1065 code::EXTENDED_CARD_NOT_CONFIGURED => Some((A2A, "EXTENDED_AGENT_CARD_NOT_CONFIGURED")),
1066 code::VERSION_NOT_SUPPORTED => Some((A2A, "VERSION_NOT_SUPPORTED")),
1067 code::QUOTA_EXHAUSTED => Some((SELF_DOMAIN, QUOTA_EXHAUSTED_REASON)),
1068 code::HALTED => Some((SELF_DOMAIN, HALTED_REASON)),
1069 code::DRAINING => Some((SELF_DOMAIN, DRAINING_REASON)),
1070 _ => None,
1071 }
1072 }
1073
1074 /// The JSON-RPC error member, with the required `ErrorInfo` when one
1075 /// applies.
1076 fn body(&self) -> Value {
1077 match self.reason() {
1078 Some((domain, reason)) => json!({
1079 "code": self.code,
1080 "message": self.message,
1081 "data": [{
1082 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1083 "domain": domain,
1084 "reason": reason,
1085 }],
1086 }),
1087 None => json!({ "code": self.code, "message": self.message }),
1088 }
1089 }
1090}
1091
1092impl IntoResponse for RpcError {
1093 fn into_response(self) -> Response {
1094 // HTTP 200 with a JSON-RPC error body, except for a refused credential.
1095 // JSON-RPC carries its own error channel, and a transport-level status
1096 // for an application-level refusal is how a client ends up retrying a
1097 // permanent decline: an A2A client reads `error.code`, and many treat
1098 // a 5xx as retryable without ever parsing the body. Authentication is
1099 // the transport's question, and A2A answers it with 401.
1100 let body = Json(json!({
1101 "jsonrpc": "2.0",
1102 "id": self.id,
1103 "error": self.body(),
1104 }));
1105 if self.unauthenticated {
1106 return (
1107 StatusCode::UNAUTHORIZED,
1108 [(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
1109 body,
1110 )
1111 .into_response();
1112 }
1113 (StatusCode::OK, body).into_response()
1114 }
1115}
1116
1117/// This plane, served as an A2A agent.
1118#[derive(Clone)]
1119pub struct A2aServer {
1120 runtime: Arc<Runtime>,
1121 auth: Arc<dyn Authenticator>,
1122 policy: Arc<dyn crate::core::PolicyEngine>,
1123 card: AgentCard,
1124 extended: ExtendedAgentCard,
1125 /// Each agent's own card, by agent name.
1126 per_agent: std::collections::BTreeMap<String, crate::peers::AgentCard>,
1127 /// The card's advertised skill ids — what a caller may ask for.
1128 skills: Vec<String>,
1129 push: Option<PushRuntime>,
1130 /// How many of the caller's own tasks one `ListTasks` may examine.
1131 ///
1132 /// See [`Self::filter_scan_budget`] for why this is a ceiling and not a
1133 /// page size.
1134 filter_scan_budget: usize,
1135 /// How many strict replays one `ListTasks` with `includeArtifacts` may
1136 /// perform. See [`Self::artifact_replay_budget`].
1137 artifact_replay_budget: usize,
1138 /// Artifact projections of **sealed** runs, so a poll loop does not buy a
1139 /// strict replay per read. See [`ArtifactCache`].
1140 artifact_cache: Arc<std::sync::Mutex<ArtifactCache>>,
1141 /// Open streams, per caller. See [`Self::streams_per_caller`].
1142 streams: StreamSlots,
1143}
1144
1145/// Default for [`A2aServer::streams_per_caller`].
1146const STREAMS_PER_CALLER: usize = 8;
1147
1148/// How many streams each caller holds open on this server.
1149#[derive(Debug, Clone)]
1150struct StreamSlots {
1151 limit: usize,
1152 open: Arc<std::sync::Mutex<std::collections::HashMap<String, usize>>>,
1153}
1154
1155impl StreamSlots {
1156 fn new(limit: usize) -> Self {
1157 Self {
1158 limit,
1159 open: Arc::default(),
1160 }
1161 }
1162
1163 /// One more stream for `actor`, or `None` at its ceiling.
1164 fn claim(&self, actor: &str) -> Option<StreamSlot> {
1165 let mut open = crate::core::poison::recover(&self.open);
1166 let held = open.entry(actor.to_owned()).or_default();
1167 if *held >= self.limit {
1168 return None;
1169 }
1170 *held += 1;
1171 Some(StreamSlot {
1172 open: Arc::clone(&self.open),
1173 actor: actor.to_owned(),
1174 })
1175 }
1176}
1177
1178/// One open stream, given back when the stream is dropped — whether it ended
1179/// or its client went away.
1180#[derive(Debug)]
1181pub(super) struct StreamSlot {
1182 open: Arc<std::sync::Mutex<std::collections::HashMap<String, usize>>>,
1183 actor: String,
1184}
1185
1186impl Drop for StreamSlot {
1187 fn drop(&mut self) {
1188 let mut open = crate::core::poison::recover(&self.open);
1189 if let Some(held) = open.get_mut(&self.actor) {
1190 *held = held.saturating_sub(1);
1191 if *held == 0 {
1192 open.remove(&self.actor);
1193 }
1194 }
1195 }
1196}
1197
1198/// A bounded in-process cache of sealed runs' artifact projections.
1199///
1200/// A completed task's artifacts exist only as a projection of its journal, so
1201/// every read recovers them by **strict replay** — the most expensive read
1202/// this surface performs. The cache is sound because it holds only sealed
1203/// runs: a seal is the last record a run ever gets, so the projection is a
1204/// pure function of an immutable history and can never go stale. Bounded and
1205/// evicting oldest-first, because an unbounded cache is a leak with a
1206/// performance story; insertion order is enough where every entry is equally
1207/// immutable.
1208#[derive(Debug, Default)]
1209pub(super) struct ArtifactCache {
1210 map: std::collections::HashMap<RunId, Option<Vec<A2aArtifact>>>,
1211 order: std::collections::VecDeque<RunId>,
1212}
1213
1214/// How many sealed runs' artifact projections stay cached per server.
1215const ARTIFACT_CACHE_CAPACITY: usize = 256;
1216
1217/// What one bounded artifact read produced.
1218enum ArtifactRead {
1219 /// The projection — from the cache or from one counted replay.
1220 Artifacts(Option<Vec<A2aArtifact>>),
1221 /// The request's replay budget is spent; the caller must say so.
1222 OverBudget,
1223}
1224
1225impl ArtifactCache {
1226 /// A hit, already wrapped as the read it answers.
1227 fn get(&self, run: RunId) -> Option<ArtifactRead> {
1228 self.map
1229 .get(&run)
1230 .map(|artifacts| ArtifactRead::Artifacts(artifacts.clone()))
1231 }
1232
1233 fn insert(&mut self, run: RunId, artifacts: Option<Vec<A2aArtifact>>) {
1234 if self.map.insert(run, artifacts).is_none() {
1235 self.order.push_back(run);
1236 }
1237 while self.map.len() > ARTIFACT_CACHE_CAPACITY {
1238 let Some(evicted) = self.order.pop_front() else {
1239 break;
1240 };
1241 self.map.remove(&evicted);
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone)]
1247struct PushRuntime {
1248 store: Arc<dyn crate::push::PushStore>,
1249 transport: Arc<dyn crate::push::PushTransport>,
1250}
1251
1252/// Durable A2A webhook delivery, driven by an operator scheduler.
1253///
1254/// A name, not a type of its own: [`A2aServer::push_worker`] hands back the
1255/// [`DeliveryWorker`](crate::push::DeliveryWorker) itself, bound to the A2A
1256/// projection. The struct this alias replaced was a pure delegation shell — its
1257/// own docs said so — that re-stated the loop's retry ceiling and its default
1258/// in a second place, which is two copies of one contract with nothing holding
1259/// them together. The cursor discipline lives in `push` because it has nothing
1260/// to do with A2A; what is A2A about this worker is only the projection it was
1261/// constructed with.
1262pub type A2aPushWorker = crate::push::DeliveryWorker;
1263
1264/// Outcome of one bounded push sweep.
1265///
1266/// Re-exported from [`crate::push`], which owns the delivery loop.
1267pub use crate::push::PushSweepReport;
1268
1269/// `StreamResponse` payloads, for **caller-registered** webhooks only.
1270///
1271/// It claims exactly the registrations an operator destination does not — see
1272/// [`crate::push::Outbox`] on why the two share one store and must not serve
1273/// each other's rows.
1274#[derive(Clone)]
1275struct A2aProjection {
1276 runtime: Arc<Runtime>,
1277}
1278
1279impl std::fmt::Debug for A2aProjection {
1280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1281 f.debug_struct("A2aProjection").finish_non_exhaustive()
1282 }
1283}
1284
1285#[async_trait::async_trait]
1286impl crate::push::Projection for A2aProjection {
1287 async fn messages(
1288 &self,
1289 record: &crate::journal::Record,
1290 ) -> Result<Vec<crate::push::PushMessage>, crate::core::StoreError> {
1291 let case = record.body.case.map(|case| case.to_string());
1292 let payloads =
1293 super::a2a_stream::payloads_for_record(&self.runtime, record, case.as_deref())
1294 .await
1295 // A projection failure is this plane's own bug and always transient
1296 // to the worker; the shape it travels in is the store's error type
1297 // because that is what the seam speaks.
1298 .map_err(|error| crate::core::StoreError::Backend(error.to_string()))?;
1299 // One record can produce a status event and an artifact event, so the
1300 // record's sequence alone is not an identity. The id a receiver
1301 // deduplicates on is the task, the sequence and the position within
1302 // the record — stable across every retry of the same message, distinct
1303 // between the two.
1304 Ok(payloads
1305 .into_iter()
1306 .enumerate()
1307 .map(|(index, payload)| {
1308 crate::push::PushMessage::json(
1309 format!("{}/{}/{index}", record.body.run, record.body.seq),
1310 payload,
1311 )
1312 // A2A's media type, so a receiver routes push and streaming
1313 // through the same parser.
1314 .typed("application/a2a+json")
1315 })
1316 .collect())
1317 }
1318
1319 fn terminal(&self, record: &crate::journal::Record) -> bool {
1320 ends_task(record.kind())
1321 }
1322
1323 fn namespace(&self) -> PushNamespace {
1324 PushNamespace::Caller
1325 }
1326}
1327
1328/// Whether a record ends the task a peer is watching.
1329///
1330/// A conclusion in a terminal A2A state, and nothing else: a withheld run has
1331/// concluded in the journal and is interrupted on this wire, so releasing its
1332/// push registration there would leave the peer to learn how it ends from
1333/// nobody.
1334fn ends_task(kind: &RecordKind) -> bool {
1335 matches!(kind, RecordKind::RunConcluded { outcome, .. } if outcome_ends_task(outcome))
1336}
1337
1338fn outcome_ends_task(outcome: &str) -> bool {
1339 super::a2a_stream::closes(sealed_state(outcome))
1340}
1341
1342impl std::fmt::Debug for A2aServer {
1343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1344 f.debug_struct("A2aServer")
1345 .field("agent", &self.card.name)
1346 .field("skills", &self.skills)
1347 .finish_non_exhaustive()
1348 }
1349}
1350
1351/// Why a server could not be built.
1352#[derive(Debug, thiserror::Error)]
1353pub enum ServerSetupError {
1354 #[error(
1355 "the runtime has no policy engine, so every A2A method would be \
1356 unauthorized. A surface reachable by other agents cannot be the one \
1357 place that skips the gate"
1358 )]
1359 NoPolicy,
1360 /// The policy set cannot evaluate a request this surface asks. A2A asks
1361 /// under `{roles, peer, tenant}` alone, so an unscoped rule reading
1362 /// `context.delegation_depth` or `context.label` unguarded declines every
1363 /// peer.
1364 #[error(
1365 "the policy set cannot evaluate the A2A surface's requests: {problems} — \
1366 they carry only `roles`, `peer` and `tenant`; scope the rule to the \
1367 actions it is about, or guard the read with `context has …`"
1368 )]
1369 PolicyUnevaluable { problems: String },
1370 #[error(
1371 "the runtime has no case layer, so this server cannot mint the \
1372 contextId A2A 1.0 requires on every task — a generated contextId \
1373 must be continuable, and continuation here is a case. Build the \
1374 runtime with `.cases(store)`"
1375 )]
1376 NoCases,
1377 #[error("the agent card could not be derived: {0}")]
1378 Card(#[from] crate::manifest::ManifestError),
1379 #[error("push changes the signed Agent Card; configure it before calling signing_cards_with")]
1380 CardAlreadySigned,
1381 #[error(
1382 "an A2A server serves at least one agent, and no manifest was given — the well-known card path must answer with a card describing something"
1383 )]
1384 NoAgents,
1385 #[error(
1386 "agents '{first}' and '{second}' both advertise skill '{skill}', so a request naming it would be a routing decision the caller did not make. A2A dispatch is named, never inferred: give the skill distinct capability names, or serve the two agents from separate planes"
1387 )]
1388 AmbiguousSkill {
1389 skill: String,
1390 first: String,
1391 second: String,
1392 },
1393}
1394
1395impl A2aServer {
1396 /// Serve `manifest`'s agent at `url`.
1397 ///
1398 /// `url` is where callers reach this plane, and it goes on the card — the
1399 /// same deployment-wiring split as everywhere else: an agent's declaration
1400 /// must not change when its address does.
1401 ///
1402 /// # Errors
1403 ///
1404 /// [`ServerSetupError::NoPolicy`] when the runtime has no policy engine, and
1405 /// [`ServerSetupError::Card`] when the manifest's digest cannot be computed.
1406 pub fn new(
1407 runtime: Arc<Runtime>,
1408 auth: Arc<dyn Authenticator>,
1409 security: &crate::peers::CardSecurity,
1410 manifest: &Manifest,
1411 url: impl Into<String>,
1412 ) -> Result<Self, ServerSetupError> {
1413 Self::hosting(runtime, auth, security, &[manifest], url)
1414 }
1415
1416 /// Serve several declared agents from one plane.
1417 ///
1418 /// A2A's well-known card path is singular per host, so the alternative to
1419 /// this is a server per agent. The first manifest is the one the **well-known card
1420 /// describes** — a room's orchestrator, in the shape the CLI already uses —
1421 /// and every agent additionally gets its own full card at
1422 /// [`agent_card_path`](crate::peers::agent_card_path), listed in the
1423 /// [`EXT_AGENT_DIRECTORY`](crate::peers::EXT_AGENT_DIRECTORY) extension so
1424 /// a caller can find them.
1425 ///
1426 /// Skill dispatch spans every agent, because they are all on the runtime
1427 /// already — what was missing was only discovery. Two agents advertising
1428 /// one skill id is **refused**: dispatch names a skill, and a name that
1429 /// resolves to two agents is a routing decision the caller did not make.
1430 ///
1431 /// # Errors
1432 ///
1433 /// [`ServerSetupError::NoPolicy`], [`ServerSetupError::NoCases`],
1434 /// [`ServerSetupError::NoAgents`] for an empty slice, or
1435 /// [`ServerSetupError::AmbiguousSkill`].
1436 pub fn hosting(
1437 runtime: Arc<Runtime>,
1438 auth: Arc<dyn Authenticator>,
1439 security: &crate::peers::CardSecurity,
1440 manifests: &[&Manifest],
1441 url: impl Into<String>,
1442 ) -> Result<Self, ServerSetupError> {
1443 let policy = runtime.policy().ok_or(ServerSetupError::NoPolicy)?.clone();
1444 let mut problems = policy_problems(policy.as_ref());
1445 // A peer with no chain of its own acts under none on this plane, so
1446 // the runtime's requests for it take a shape the build did not probe.
1447 problems.extend(runtime.served_policy_problems());
1448 if !problems.is_empty() {
1449 return Err(ServerSetupError::PolicyUnevaluable {
1450 problems: problems.join("; "),
1451 });
1452 }
1453 if runtime.cases().is_none() {
1454 return Err(ServerSetupError::NoCases);
1455 }
1456 let [primary, rest @ ..] = manifests else {
1457 return Err(ServerSetupError::NoAgents);
1458 };
1459 let url = url.into();
1460
1461 // Every agent's own card, derived exactly as a lone agent's would be —
1462 // same digest, same skills, same ceilings. A card that differed because
1463 // its agent shared a plane would make the plane part of the identity a
1464 // consumer pins, which the room work already refused for manifests.
1465 let mut directory = Vec::new();
1466 let mut per_agent = std::collections::BTreeMap::new();
1467 let mut owner_of_skill: std::collections::BTreeMap<String, String> =
1468 std::collections::BTreeMap::new();
1469 for m in manifests {
1470 let name = m.metadata.name.clone();
1471 let mut agent_card = AgentCard::derive(m, url.clone())?;
1472 security.apply(&mut agent_card);
1473 for skill in &agent_card.skills {
1474 if let Some(other) = owner_of_skill.insert(skill.id.clone(), name.clone())
1475 && other != name
1476 {
1477 return Err(ServerSetupError::AmbiguousSkill {
1478 skill: skill.id.clone(),
1479 first: other,
1480 second: name,
1481 });
1482 }
1483 }
1484 directory.push(serde_json::json!({
1485 "name": name,
1486 "version": m.metadata.version,
1487 "cardPath": crate::peers::agent_card_path(&name),
1488 "manifestDigest": m.digest()?.to_hex(),
1489 "skills": agent_card.skills.iter().map(|s| s.id.clone()).collect::<Vec<_>>(),
1490 }));
1491 per_agent.insert(name, agent_card);
1492 }
1493
1494 let mut card = AgentCard::derive(primary, url.clone())?;
1495 let mut extended = ExtendedAgentCard::derive(primary, url)?;
1496 security.apply(&mut card);
1497 security.apply(&mut extended.public);
1498
1499 // Only when there is more than one, so a single-agent plane's card is
1500 // byte-for-byte what it was: an extension nobody needs is a claim a
1501 // verifier has to understand for nothing.
1502 if !rest.is_empty() {
1503 let ext = crate::peers::AgentExtension {
1504 uri: crate::peers::EXT_AGENT_DIRECTORY.to_owned(),
1505 description: Some(
1506 "Every agent this plane serves, and where each agent's own card is.".to_owned(),
1507 ),
1508 required: false,
1509 params: Some(serde_json::json!({ "agents": directory })),
1510 };
1511 card.capabilities.extensions.push(ext.clone());
1512 extended.public.capabilities.extensions.push(ext);
1513 }
1514
1515 // The union, because every agent is already on the runtime and a skill
1516 // that dispatches but is not accepted here would be a refusal the plane
1517 // could have answered.
1518 let skills = owner_of_skill.keys().cloned().collect();
1519 let tenant = runtime.tenant().to_string();
1520 let mut server = Self {
1521 runtime,
1522 auth,
1523 policy,
1524 card,
1525 extended,
1526 per_agent,
1527 skills,
1528 push: None,
1529 filter_scan_budget: FILTER_SCAN_BUDGET,
1530 artifact_replay_budget: ARTIFACT_REPLAY_BUDGET,
1531 artifact_cache: Arc::new(std::sync::Mutex::new(ArtifactCache::default())),
1532 streams: StreamSlots::new(STREAMS_PER_CALLER),
1533 };
1534 // A card names the tenant only when there is one to route on. A2A's
1535 // rule is that a client echoes this value back in every request, so
1536 // advertising `default` would make every caller send a routing
1537 // identifier that routes nowhere.
1538 if tenant != crate::core::TenantId::DEFAULT {
1539 for card in server.published_cards() {
1540 for iface in &mut card.supported_interfaces {
1541 iface.tenant = Some(tenant.clone());
1542 }
1543 }
1544 }
1545 Ok(server)
1546 }
1547
1548 /// Every card this server publishes: the well-known card, the extended
1549 /// card's public half, and each agent's own.
1550 ///
1551 /// The deployment facts — tenant, push capability, signature — go on all of
1552 /// them through this one iterator. An agent's card is fetched and pinned
1553 /// exactly as the well-known one is, so one that lacked the tenant would
1554 /// route a client nowhere, one that lacked the push flag would hide a
1555 /// capability the server honours, and one left unsigned would be the copy
1556 /// an attacker substitutes.
1557 fn published_cards(&mut self) -> impl Iterator<Item = &mut AgentCard> {
1558 [&mut self.card, &mut self.extended.public]
1559 .into_iter()
1560 .chain(self.per_agent.values_mut())
1561 }
1562
1563 /// Bound how many streams one caller may hold open at once.
1564 ///
1565 /// A `SendStreamingMessage` or `SubscribeToTask` is a connection held for
1566 /// as long as the run lives and a journal read per poll interval — so an
1567 /// unbounded count is a cost any authenticated peer chooses for this
1568 /// plane. Past the ceiling a new stream is refused as back-pressure
1569 /// ([`code::QUOTA_EXHAUSTED`]), before anything is admitted; one ending,
1570 /// or its client going away, frees the slot.
1571 #[must_use]
1572 pub fn streams_per_caller(mut self, limit: usize) -> Self {
1573 self.streams = StreamSlots::new(limit.max(1));
1574 self
1575 }
1576
1577 /// Bound what one `ListTasks` may cost.
1578 ///
1579 /// The spec's `totalSize` is the exact total, and a `status` or `contextId`
1580 /// filter is decided only by reading each candidate's journal — so the
1581 /// cost of a listing is one the *caller* chooses.
1582 ///
1583 /// The budget counts the caller's own tasks. The index a listing reads is
1584 /// narrowed to them, so another caller's volume — or the embedder's — does
1585 /// not refuse this one's listing.
1586 ///
1587 /// Over budget, the request is refused with the narrowing lever named —
1588 /// `statusTimestampAfter` is answered from the index, so tightening it
1589 /// shrinks the candidate set without reading anything. A refusal is honest
1590 /// where a truncated total would be a lie shaped like an answer: the spec
1591 /// requires the exact count, and a bound that quietly stopped counting
1592 /// would report a smaller tenant, not a bounded scan.
1593 #[must_use]
1594 pub fn filter_scan_budget(mut self, budget: usize) -> Self {
1595 self.filter_scan_budget = budget.max(1);
1596 self
1597 }
1598
1599 /// Bound what one `ListTasks` with `includeArtifacts` may cost.
1600 ///
1601 /// A completed task's artifacts are recovered by strict replay, and a page
1602 /// holds up to a hundred tasks — so without a ceiling, one authenticated
1603 /// request buys a hundred full replays, per request, forever. Replays
1604 /// served from the sealed-run cache cost nothing and do not count; past
1605 /// the budget a task's artifacts are **omitted and marked** (see
1606 /// [`ARTIFACTS_OMITTED_KEY`]) rather than the request refused, because
1607 /// unlike `totalSize` an artifact list is per-task enrichment: omitting
1608 /// one is honest as long as it does not look complete, and `GetTask` on
1609 /// the marked id recovers exactly that task's artifacts.
1610 #[must_use]
1611 pub fn artifact_replay_budget(mut self, budget: usize) -> Self {
1612 self.artifact_replay_budget = budget.max(1);
1613 self
1614 }
1615
1616 /// The single-task read, where one replay is the request's stated cost.
1617 ///
1618 /// A separate entry point rather than `artifacts_bounded(.., None)`,
1619 /// because the caller that passes no budget cannot be handed
1620 /// [`ArtifactRead::OverBudget`] — and saying that with `unreachable!` in a
1621 /// request handler makes a caller's argument the only thing between a
1622 /// JSON-RPC method and a panic. Here the type says it instead.
1623 async fn artifacts_unmetered(
1624 &self,
1625 run: RunId,
1626 state: TaskState,
1627 ) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
1628 match self.artifacts_bounded(run, state, None).await? {
1629 ArtifactRead::Artifacts(artifacts) => Ok(artifacts),
1630 // Unreachable by construction — no budget, nothing to exceed — and
1631 // answered rather than asserted: an empty artifact list is what a
1632 // task with none looks like, which is the honest reading of "this
1633 // read produced none".
1634 ArtifactRead::OverBudget => Ok(None),
1635 }
1636 }
1637
1638 /// A task's artifacts, replaying at most once per sealed run.
1639 ///
1640 /// Consults [`ArtifactCache`] first and fills it for completed runs; the
1641 /// uncached fall-through is [`task_artifacts`]. `budget` is decremented on
1642 /// every actual replay; `None` means unmetered, which reaches here only
1643 /// through [`artifacts_unmetered`](Self::artifacts_unmetered).
1644 ///
1645 /// A budget hit comes back as [`ArtifactRead::OverBudget`], never as an
1646 /// absent list, so the caller must decide how to mark it — silence here
1647 /// would be a bounded result shaped exactly like a complete one.
1648 async fn artifacts_bounded(
1649 &self,
1650 run: RunId,
1651 state: TaskState,
1652 budget: Option<&mut usize>,
1653 ) -> Result<ArtifactRead, crate::core::RuntimeError> {
1654 if state != TaskState::Completed {
1655 return Ok(ArtifactRead::Artifacts(None));
1656 }
1657 if let Some(cached) = crate::core::poison::recover(&self.artifact_cache).get(run) {
1658 return Ok(cached);
1659 }
1660 if let Some(budget) = &budget
1661 && **budget == 0
1662 {
1663 return Ok(ArtifactRead::OverBudget);
1664 }
1665 let artifacts = task_artifacts(&self.runtime, run, state).await?;
1666 if let Some(budget) = budget {
1667 *budget -= 1;
1668 }
1669 crate::core::poison::recover(&self.artifact_cache).insert(run, artifacts.clone());
1670 Ok(ArtifactRead::Artifacts(artifacts))
1671 }
1672
1673 /// Publish a **signed** card.
1674 ///
1675 /// The card is served unauthenticated from a host a caller may not control.
1676 /// TLS says the bytes came from that host; it says nothing about whether the
1677 /// host is the party whose capabilities the card describes. A signature says
1678 /// that, and it keeps saying it after the card has been copied into a
1679 /// registry, a cache, or somebody's repository.
1680 ///
1681 /// Signed here rather than at derivation because the signature covers the
1682 /// **published** card, interface URL and tenant included — those are
1683 /// deployment facts, and a signature taken before they were set would cover
1684 /// a document nobody serves.
1685 ///
1686 /// # Errors
1687 ///
1688 /// If the card cannot be canonicalized.
1689 pub fn signing_cards_with(
1690 mut self,
1691 signer: &dyn crate::peers::CardSigner,
1692 ) -> Result<Self, crate::peers::CardSignatureError> {
1693 for card in self.published_cards() {
1694 card.sign(signer)?;
1695 }
1696 Ok(self)
1697 }
1698
1699 /// Enable A2A push configuration and expose a durable delivery worker.
1700 ///
1701 /// The worker uses the task journal itself as its outbox. A registration's
1702 /// cursor advances only after a receiver returns 2xx, so a crash between
1703 /// POST and acknowledgement persistence repeats an event and never loses
1704 /// one. Call [`A2aServer::push_worker`] before consuming the server into its
1705 /// router and schedule
1706 /// [`DeliveryWorker::run_once`](crate::push::DeliveryWorker::run_once) from
1707 /// every instance.
1708 pub fn with_push(
1709 mut self,
1710 store: Arc<dyn crate::push::PushStore>,
1711 transport: Arc<dyn crate::push::PushTransport>,
1712 ) -> Result<Self, ServerSetupError> {
1713 if self
1714 .published_cards()
1715 .any(|card| !card.signatures.is_empty())
1716 {
1717 return Err(ServerSetupError::CardAlreadySigned);
1718 }
1719 for card in self.published_cards() {
1720 card.capabilities.push_notifications = true;
1721 }
1722 self.push = Some(PushRuntime { store, transport });
1723 Ok(self)
1724 }
1725
1726 /// A cloneable worker handle, when push is configured.
1727 ///
1728 /// The worker is [`crate::push::DeliveryWorker`] itself, bound to the A2A
1729 /// projection — rows belonging to an operator
1730 /// [`Outbox`](crate::push::Outbox) are left alone, because the projection
1731 /// declares the caller namespace and the store's due query filters on it.
1732 /// Retry ceiling, backoff and abandonment reporting are the loop's own;
1733 /// see its docs rather than a restatement here.
1734 #[must_use]
1735 pub fn push_worker(&self) -> Option<A2aPushWorker> {
1736 self.push.clone().map(|push| {
1737 crate::push::DeliveryWorker::new(
1738 Arc::clone(self.runtime.journal()),
1739 push.store,
1740 push.transport,
1741 Arc::new(A2aProjection {
1742 runtime: Arc::clone(&self.runtime),
1743 }),
1744 )
1745 })
1746 }
1747
1748 /// The router.
1749 ///
1750 /// The card path is unauthenticated by design and everything else is not.
1751 ///
1752 /// The RPC endpoint answers with and without a trailing slash, and that is
1753 /// an interoperability fact rather than a courtesy: mainstream HTTP clients
1754 /// that take a base URL — httpx among them, which is what the official A2A
1755 /// conformance kit and the reference Python SDK are built on — resolve a
1756 /// request for `"/"` against the card's interface URL per RFC 3986, so the
1757 /// wire carries `POST {interface}/`. A router that 404s the slash form
1758 /// passes every test written here and fails the first real peer. Exactness
1759 /// elsewhere (method names, version headers) guards protocol *semantics*;
1760 /// a trailing slash is a client-side join artifact with none.
1761 pub fn router(self) -> Router {
1762 Router::new()
1763 .route(WELL_KNOWN_PATH, get(agent_card))
1764 // Unauthenticated like the well-known card, and for the same
1765 // reason: a card a caller must already be trusted to read is a card
1766 // that cannot be discovered.
1767 .route("/agents/{agent}/agent-card.json", get(one_agent_card))
1768 .route("/a2a", post(rpc))
1769 .route("/a2a/", post(rpc))
1770 .with_state(self)
1771 }
1772
1773 /// Find the task, and refuse it unless this caller admitted it — then
1774 /// authorize, with the owner in the rule's context.
1775 ///
1776 /// A task id is a bearer of nothing. Policy decides what a caller may do
1777 /// with *its* tasks; whose task an id names is decided here, before policy
1778 /// is asked, and a task another peer admitted — or one nobody admitted over
1779 /// this surface — answers exactly as a task that does not exist. Anything
1780 /// else tells a peer holding a guessed or leaked id that it is real.
1781 ///
1782 /// Hands back the task's journal, read once, because every caller of this
1783 /// was about to read it anyway.
1784 async fn gate_task(
1785 &self,
1786 caller: &Caller,
1787 action: &str,
1788 params: &CommonParams,
1789 ) -> Result<(RunId, Vec<crate::journal::Record>), RpcError> {
1790 let run = task_id(params)?;
1791 let records = self.authorized_task(caller, action, run).await?;
1792 Ok((run, records))
1793 }
1794
1795 /// [`gate_task`](Self::gate_task) for a task id already parsed.
1796 async fn authorized_task(
1797 &self,
1798 caller: &Caller,
1799 action: &str,
1800 run: RunId,
1801 ) -> Result<Vec<crate::journal::Record>, RpcError> {
1802 let records = self
1803 .runtime
1804 .journal()
1805 .read(run, 1)
1806 .await
1807 .map_err(|e| internal("reading a task's journal", &e))?;
1808 let owner = task_owner(&records);
1809 if owner != Some(caller.actor.as_str()) {
1810 return Err(task_not_found(run));
1811 }
1812 self.authorize(caller, action, &run.to_string(), owner)?;
1813 Ok(records)
1814 }
1815
1816 /// Who is calling, from the credential — and only if it is this tenant.
1817 async fn authenticate(&self, headers: &HeaderMap) -> Result<Caller, RpcError> {
1818 let caller = self
1819 .auth
1820 .authenticate(headers)
1821 .await
1822 .map_err(|e| RpcError::unauthenticated(e.to_string()))?;
1823 // The caller's tenant is the second half of the check `check_tenant`
1824 // starts. That one compares what the *request* asked for against what
1825 // the card advertises; this compares it against what the *credential*
1826 // says. A peer that authenticates into one tenant must not be served
1827 // from another's runs by naming it in a field, and a peer holding a
1828 // valid credential for a different tenant is exactly who would try.
1829 // Refused as a credential, in the same sentence as any other this
1830 // endpoint does not accept: naming the tenant would tell a prober the
1831 // token is valid somewhere else.
1832 if caller.tenant != *self.runtime.tenant() {
1833 return Err(RpcError::unauthenticated(
1834 crate::api::AuthError::Rejected.to_string(),
1835 ));
1836 }
1837 Ok(caller)
1838 }
1839
1840 /// What a rule on this surface can key on.
1841 ///
1842 /// `owner` is present on a task action: the peer that admitted the task,
1843 /// which by the time a rule is asked is always the caller — so a rule set
1844 /// can say "a peer reads its own tasks" in its own words rather than trust
1845 /// this surface to have said it.
1846 fn context(caller: &Caller, owner: Option<&str>) -> Value {
1847 let mut context = peer_context(caller);
1848 if let Some(owner) = owner {
1849 context["owner"] = json!(owner);
1850 }
1851 context
1852 }
1853
1854 /// The policy's answer for this caller, action and resource — the one
1855 /// request both [`authorize`](Self::authorize) and
1856 /// [`permits`](Self::permits) ask.
1857 fn decision(
1858 &self,
1859 caller: &Caller,
1860 action: &str,
1861 resource: &str,
1862 owner: Option<&str>,
1863 ) -> PolicyDecision {
1864 let context = Self::context(caller, owner);
1865 self.policy.authorize(&PolicyRequest {
1866 principal: &caller.actor,
1867 principal_kind: crate::core::PrincipalKind::Subject,
1868 action,
1869 resource,
1870 context: &context,
1871 })
1872 }
1873
1874 fn authorize(
1875 &self,
1876 caller: &Caller,
1877 action: &str,
1878 resource: &str,
1879 owner: Option<&str>,
1880 ) -> Result<(), RpcError> {
1881 match self.decision(caller, action, resource, owner) {
1882 PolicyDecision::Permit => Ok(()),
1883 PolicyDecision::Deny { reason } => {
1884 // The determining policy and its reason stay operator-side. A
1885 // Cedar denial names the action, the resource, and the policy
1886 // ids that fired; returned to an external caller that is a
1887 // probe-able map of the authorization vocabulary. A decline
1888 // carries no reason on the wire — the runtime's own denial
1889 // already names the action and resource the gate keyed on. The
1890 // caller learns only that it was declined; the reason reaches
1891 // whoever runs the plane.
1892 tracing::warn!(
1893 target: "agentplane::a2a",
1894 action,
1895 resource,
1896 reason,
1897 "A2A request denied at admission"
1898 );
1899 Err(RpcError::new(
1900 code::INVALID_REQUEST,
1901 "this request was not permitted",
1902 ))
1903 }
1904 // The rules could not be evaluated. The caller is declined
1905 // exactly as for a denial — telling a peer that the plane's
1906 // policy set is broken is a fact about our operations, and a
1907 // probe would learn which shapes break it — but the operator's
1908 // side says defect rather than refusal, because the fix is the
1909 // policy set and nobody should be reading rules looking for the
1910 // one that fired.
1911 PolicyDecision::Malformed { reason } => {
1912 tracing::error!(
1913 target: "agentplane::a2a",
1914 action,
1915 resource,
1916 policy_error = true,
1917 reason,
1918 "A2A request refused because the policy set could not be evaluated"
1919 );
1920 Err(RpcError::new(
1921 code::INVALID_REQUEST,
1922 "this request was not permitted",
1923 ))
1924 }
1925 }
1926 }
1927
1928 fn permits(&self, caller: &Caller, action: &str, resource: &str, owner: Option<&str>) -> bool {
1929 matches!(
1930 self.decision(caller, action, resource, owner),
1931 PolicyDecision::Permit
1932 )
1933 }
1934
1935 /// Refuse a version this server does not speak.
1936 ///
1937 /// An **absent** header is a refusal, not a default. The spec says an empty
1938 /// value means 0.3, so a missing header is a 0.3 client — and answering it
1939 /// with 1.0 semantics is how a caller silently loses half the protocol.
1940 /// Matching is on `Major.Minor`, as the spec requires.
1941 fn check_version(headers: &HeaderMap) -> Result<(), RpcError> {
1942 let claimed = headers
1943 .get(VERSION_HEADER)
1944 .and_then(|v| v.to_str().ok())
1945 .unwrap_or("");
1946 let claimed_version = crate::peers::protocol_major_minor(claimed);
1947 if claimed_version == crate::peers::protocol_major_minor(crate::peers::PROTOCOL_VERSION)
1948 && claimed_version.is_some()
1949 {
1950 return Ok(());
1951 }
1952 let seen = if claimed.is_empty() {
1953 "0.3 (no A2A-Version header, which the spec reads as 0.3)".to_owned()
1954 } else {
1955 claimed.to_owned()
1956 };
1957 Err(RpcError::new(
1958 code::VERSION_NOT_SUPPORTED,
1959 format!(
1960 "this agent speaks A2A {}, and the request asked for {seen}",
1961 crate::peers::PROTOCOL_VERSION
1962 ),
1963 ))
1964 }
1965
1966 /// Refuse a request routed to a different tenant.
1967 ///
1968 /// A2A's rule is that the client echoes the `tenant` from the interface it
1969 /// selected on the card, omitting it when the card omits it. So a value that
1970 /// does not match ours is a request meant for somebody else — plausibly
1971 /// another plane behind the same address — and answering it would serve one
1972 /// tenant's caller from another tenant's runs.
1973 fn check_tenant(&self, params: &CommonParams) -> Result<(), RpcError> {
1974 let ours = self.runtime.tenant().as_str();
1975 let advertised = if ours == crate::core::TenantId::DEFAULT {
1976 ""
1977 } else {
1978 ours
1979 };
1980 let sent = params.tenant.as_deref().unwrap_or("");
1981 if sent == advertised {
1982 return Ok(());
1983 }
1984 Err(RpcError::new(
1985 code::INVALID_PARAMS,
1986 format!(
1987 "this endpoint serves the tenant advertised on its card, and \
1988 the request named '{sent}'. A2A clients echo the `tenant` from \
1989 the interface they selected; a different value is a request \
1990 for a different agent"
1991 ),
1992 ))
1993 }
1994}
1995
1996/// The public Agent Card.
1997///
1998/// Unauthenticated on purpose — see the module docs. It is derived from the
1999/// manifest, so it cannot describe a capability the plane would refuse to
2000/// dispatch, which is what makes publishing it safe.
2001async fn agent_card(State(server): State<A2aServer>) -> Json<AgentCard> {
2002 Json(server.card)
2003}
2004
2005/// One agent's own card.
2006///
2007/// A 404 for an unknown name, deliberately naming nothing: the directory
2008/// extension on the well-known card is how a caller learns which names exist,
2009/// and answering an unknown one with the list would make this path a way to
2010/// enumerate a plane without reading the card that governs it.
2011async fn one_agent_card(
2012 State(server): State<A2aServer>,
2013 axum::extract::Path(agent): axum::extract::Path<String>,
2014) -> Response {
2015 server.per_agent.get(&agent).map_or_else(
2016 || (StatusCode::NOT_FOUND, "no such agent on this plane").into_response(),
2017 |card| Json(card.clone()).into_response(),
2018 )
2019}
2020
2021async fn rpc(
2022 State(server): State<A2aServer>,
2023 headers: HeaderMap,
2024 body: Result<Json<RpcRequest>, axum::extract::rejection::JsonRejection>,
2025) -> Response {
2026 let req = match body {
2027 Ok(Json(req)) => req,
2028 // Each refusal is its own spec row, and they must not share a code: a
2029 // body that is not `application/json` is `ContentTypeNotSupported`
2030 // (-32005) in A2A's own mapping table; a body that is not JSON at all
2031 // is JSON-RPC's ParseError; valid JSON that is not a request object —
2032 // a batch array, a missing `method` — is InvalidRequest. Collapsing
2033 // them tells a caller with a wrong header or a wrong shape that its
2034 // serializer is broken. A body over the size limit is not a JSON-RPC
2035 // question and keeps its HTTP 413.
2036 Err(rejection) => {
2037 use axum::extract::rejection::JsonRejection;
2038 let error = match &rejection {
2039 JsonRejection::MissingJsonContentType(_) => RpcError::new(
2040 code::CONTENT_TYPE_NOT_SUPPORTED,
2041 "the request body must be application/json",
2042 ),
2043 JsonRejection::JsonDataError(_) => RpcError::new(
2044 code::INVALID_REQUEST,
2045 "the request body is JSON but not a JSON-RPC request object",
2046 ),
2047 JsonRejection::BytesRejection(_) => return rejection.into_response(),
2048 _ => RpcError::new(code::PARSE_ERROR, "the request body is not valid JSON"),
2049 };
2050 return error.into_response();
2051 }
2052 };
2053 // JSON-RPC's id is a string, a number or null. Echoing an object or an
2054 // array back would hand the caller an id its own client cannot match.
2055 if !matches!(req.id, Value::Null | Value::String(_) | Value::Number(_)) {
2056 return RpcError::new(
2057 code::INVALID_REQUEST,
2058 "`id` must be a string, a number or null",
2059 )
2060 .into_response();
2061 }
2062 let id = req.id.clone();
2063
2064 if req.jsonrpc != "2.0" {
2065 return RpcError::new(
2066 code::INVALID_REQUEST,
2067 format!("`jsonrpc` must be \"2.0\", not {:?}", req.jsonrpc),
2068 )
2069 .with_id(id)
2070 .into_response();
2071 }
2072 if let Err(e) = A2aServer::check_version(&headers) {
2073 return e.with_id(id).into_response();
2074 }
2075 // Before anything about the request is read. Parameters, the tenant field
2076 // and the method name all answer differently depending on what was sent,
2077 // and an unauthenticated caller is owed the challenge and nothing it could
2078 // map the surface with.
2079 let caller = match server.authenticate(&headers).await {
2080 Ok(caller) => caller,
2081 Err(e) => return e.with_id(id).into_response(),
2082 };
2083
2084 // Streaming methods are dispatched first because they answer with a
2085 // different *kind* of response: an SSE body, not a JSON-RPC envelope. Folding
2086 // them into `dispatch` would mean a function whose return type is "a value
2087 // or an entire HTTP response", which is how one of the two paths quietly
2088 // stops setting its content type.
2089 if matches!(
2090 req.method.as_str(),
2091 method::SEND_STREAMING | method::SUBSCRIBE
2092 ) {
2093 return match stream_method(server, caller, req).await {
2094 Ok(sse) => sse.into_response(),
2095 Err(e) => e.with_id(id).into_response(),
2096 };
2097 }
2098
2099 // **Boxed, because this future is the whole runtime.** `dispatch` fans out
2100 // to every A2A method and one of them admits and executes a run, so the
2101 // state machine inlined here carries the executor's own locals — sixteen
2102 // kilobytes of them. Held on the stack it is sixteen kilobytes *per
2103 // concurrent request*, paid by every caller including the ones asking for a
2104 // task's status. One heap allocation per request buys that back, and a
2105 // request that reaches this line is already doing far more work than an
2106 // allocation.
2107 match Box::pin(dispatch(&server, &caller, &req)).await {
2108 Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
2109 Err(e) => e.with_id(id).into_response(),
2110 }
2111}
2112
2113/// `SendStreamingMessage` and `SubscribeToTask`.
2114///
2115/// Both are the same thing once the run exists: a view of the journal from a
2116/// point onward. The only difference is whether this call is what created it.
2117#[allow(clippy::too_many_lines)]
2118async fn stream_method(
2119 server: A2aServer,
2120 caller: Caller,
2121 req: RpcRequest,
2122) -> Result<
2123 axum::response::sse::Sse<
2124 impl futures_util::stream::Stream<
2125 Item = Result<axum::response::sse::Event, std::convert::Infallible>,
2126 >,
2127 >,
2128 RpcError,
2129> {
2130 let params = parse_params(&req.method, &req.params)?;
2131 server.check_tenant(¶ms)?;
2132 if req.method == method::SEND_STREAMING
2133 && let Some(configuration) = ¶ms.configuration
2134 {
2135 configuration.validate()?;
2136 }
2137
2138 // A slot before anything else is done for this caller: a stream is a
2139 // connection held open and a journal polled for as long as the run lives,
2140 // so how many one peer may hold is a bound this surface states. Taken
2141 // before admission, so a caller at its ceiling starts no work it then
2142 // cannot watch; released when the stream ends or the client goes away.
2143 let slot = server
2144 .streams
2145 .claim(&caller.actor)
2146 .ok_or_else(|| RpcError::new(code::QUOTA_EXHAUSTED, QUOTA_EXHAUSTED_MESSAGE))?;
2147
2148 let (run, records) = if req.method == method::SUBSCRIBE {
2149 let id = task_id(¶ms)?;
2150 let records = server
2151 .authorized_task(&caller, action::TASK_READ, id)
2152 .await?;
2153 (id, records)
2154 } else {
2155 let Some(message) = params.message.clone() else {
2156 return Err(RpcError::new(
2157 code::INVALID_PARAMS,
2158 "`message` is required by SendStreamingMessage",
2159 ));
2160 };
2161 message.validate_parts(&caller)?;
2162 refuse_push_on_continuation(&message, params.configuration.as_ref())?;
2163 let run = if message.task_id.is_some() {
2164 continue_task(&server, &caller, &message).await?
2165 } else {
2166 let skill = resolve_skill(&server, &message)?;
2167 server.authorize(&caller, action::MESSAGE_SEND, &skill, None)?;
2168 check_context(&server, &message, &caller).await?;
2169 if let Some(push) = params
2170 .configuration
2171 .as_ref()
2172 .and_then(|configuration| configuration.task_push_notification_config.as_ref())
2173 {
2174 validate_inline_push(&server, &caller, &skill, push)?;
2175 }
2176 // Admitted before the stream opens. A stream that begins and *then*
2177 // reports a refusal has already told the client the work started —
2178 // and an SSE body cannot carry a JSON-RPC error the client is looking
2179 // for at that point.
2180 let source = super::peer_source(&caller.actor);
2181 let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
2182 let terms = run_terms(&message, &caller)?;
2183 match spawn_a2a(&server, &skill, input, terms).await {
2184 Ok(run) => {
2185 if let Some(push) = params.configuration.as_ref().and_then(|configuration| {
2186 configuration.task_push_notification_config.as_ref()
2187 }) {
2188 register_push(&server, push, run, 1).await?;
2189 }
2190 run
2191 }
2192 Err(
2193 crate::core::RuntimeError::PolicyDenied(_)
2194 | crate::core::RuntimeError::Delegation(_),
2195 ) => {
2196 return Err(RpcError::new(
2197 code::UNSUPPORTED_OPERATION,
2198 "this agent declined the request",
2199 ));
2200 }
2201 Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
2202 return Err(quota_refusal(&e));
2203 }
2204 Err(crate::core::RuntimeError::Draining) => {
2205 return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
2206 }
2207 Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => {
2208 return Err(subject_unbound(&e));
2209 }
2210 Err(e) => return Err(internal("admitting a streamed message", &e)),
2211 }
2212 };
2213 let records = server
2214 .runtime
2215 .journal()
2216 .read(run, 1)
2217 .await
2218 .map_err(|e| internal("reading a task's journal", &e))?;
2219 (run, records)
2220 };
2221
2222 // Decided from the records before anything is replayed: a subscription
2223 // to a finished task is refused, and refusing it after a strict replay to
2224 // build artifacts nobody will receive is a cost the caller chose for us.
2225 let Some((state, detail)) = state_from_history(&records) else {
2226 return Err(task_not_found(run));
2227 };
2228 // `closes` and not a second `matches!` with the same four states: this
2229 // decides whether a subscription is *refused*, and `closes` decides whether
2230 // a stream *ends*. Two spellings of one rule disagree the day somebody adds
2231 // a terminal state to one of them, and the result is either a subscription
2232 // accepted that shuts immediately or one refused that would have streamed.
2233 if req.method == method::SUBSCRIBE && super::a2a_stream::closes(state) {
2234 return Err(RpcError::new(
2235 code::UNSUPPORTED_OPERATION,
2236 "SubscribeToTask requires a non-terminal task",
2237 ));
2238 }
2239 let case = records
2240 .iter()
2241 .find_map(|r| r.body.case.map(|c| c.to_string()));
2242 let from = records.last().map_or(1, |last| last.body.seq + 1);
2243 let mut task = task_of(run, state, &detail, case.clone());
2244 task.artifacts = server
2245 .artifacts_unmetered(run, state)
2246 .await
2247 .map_err(|e| internal("projecting a task's artifacts", &e))?;
2248
2249 Ok(super::a2a_stream::tail(
2250 Arc::clone(&server.runtime),
2251 Arc::clone(&server.artifact_cache),
2252 slot,
2253 run,
2254 case,
2255 req.id,
2256 task,
2257 from,
2258 ))
2259}
2260
2261async fn dispatch(
2262 server: &A2aServer,
2263 caller: &Caller,
2264 req: &RpcRequest,
2265) -> Result<Value, RpcError> {
2266 let params = parse_params(&req.method, &req.params)?;
2267 server.check_tenant(¶ms)?;
2268
2269 match req.method.as_str() {
2270 // Boxed: a send admits and may run a whole run inline, and that future
2271 // is larger than the others this match holds.
2272 method::SEND_MESSAGE => Box::pin(send_message(server, caller, params)).await,
2273 method::GET_TASK => get_task(server, caller, params).await,
2274 method::CANCEL_TASK => cancel_task(server, caller, params).await,
2275 method::GET_EXTENDED_CARD => get_extended_card(server, caller),
2276
2277 // Streaming is handled before dispatch; reaching here means the router
2278 // changed and this arm did not.
2279 method::SEND_STREAMING | method::SUBSCRIBE => Err(RpcError::new(
2280 code::INTERNAL_ERROR,
2281 "a streaming method reached the non-streaming dispatcher",
2282 )),
2283 method::LIST_TASKS => list_tasks(server, caller, ¶ms).await,
2284 method::CREATE_PUSH => push_create(server, caller, ¶ms).await,
2285 method::GET_PUSH => push_get(server, caller, ¶ms).await,
2286 method::LIST_PUSH => push_list(server, caller, ¶ms).await,
2287 method::DELETE_PUSH => push_delete(server, caller, ¶ms).await,
2288 other => Err(RpcError::new(
2289 code::METHOD_NOT_FOUND,
2290 format!("no such A2A method: {other}"),
2291 )),
2292 }
2293}
2294
2295/// Which parameter names each method actually reads.
2296///
2297/// `tenant` is on every row rather than special-cased: A2A's routing identifier
2298/// is orthogonal to the method, and leaving it out of one row would refuse a
2299/// correctly-routed request for that method alone — the kind of hole a table
2300/// exists to make visible.
2301const FIELDS_BY_METHOD: &[(&str, &[&str])] = &[
2302 (
2303 method::SEND_MESSAGE,
2304 &["tenant", "message", "configuration", "metadata"],
2305 ),
2306 (
2307 method::SEND_STREAMING,
2308 &["tenant", "message", "configuration", "metadata"],
2309 ),
2310 (method::GET_TASK, &["tenant", "id", "historyLength"]),
2311 (method::CANCEL_TASK, &["tenant", "id"]),
2312 (method::SUBSCRIBE, &["tenant", "id"]),
2313 (method::GET_EXTENDED_CARD, &["tenant"]),
2314 (
2315 method::LIST_TASKS,
2316 &[
2317 "tenant",
2318 "contextId",
2319 "status",
2320 "pageSize",
2321 "pageToken",
2322 "historyLength",
2323 "statusTimestampAfter",
2324 "includeArtifacts",
2325 ],
2326 ),
2327 (
2328 method::CREATE_PUSH,
2329 &["tenant", "taskId", "id", "url", "token", "authentication"],
2330 ),
2331 (method::GET_PUSH, &["tenant", "taskId", "id"]),
2332 (
2333 method::LIST_PUSH,
2334 &["tenant", "taskId", "pageSize", "pageToken"],
2335 ),
2336 (method::DELETE_PUSH, &["tenant", "taskId", "id"]),
2337];
2338
2339fn parse_params(method: &str, value: &Value) -> Result<CommonParams, RpcError> {
2340 if value.is_null() {
2341 return Ok(CommonParams::default());
2342 }
2343 let Some(object) = value.as_object() else {
2344 return Err(RpcError::new(
2345 code::INVALID_PARAMS,
2346 "A2A method parameters must be a JSON object",
2347 ));
2348 };
2349
2350 // Before deserializing, because the union struct would accept the field and
2351 // the method would then ignore it. An unknown method falls through to the
2352 // dispatcher's own `METHOD_NOT_FOUND`, which is a better answer than a
2353 // parameter complaint about a method that does not exist.
2354 if let Some((_, allowed)) = FIELDS_BY_METHOD.iter().find(|(m, _)| *m == method)
2355 && let Some(stray) = object.keys().find(|k| !allowed.contains(&k.as_str()))
2356 {
2357 return Err(RpcError::new(
2358 code::INVALID_PARAMS,
2359 format!(
2360 "'{stray}' is not a parameter of {method}; it takes {}",
2361 allowed.join(", ")
2362 ),
2363 ));
2364 }
2365
2366 serde_json::from_value(value.clone()).map_err(|error| {
2367 RpcError::new(
2368 code::INVALID_PARAMS,
2369 format!("request parameters do not match the A2A method schema: {error}"),
2370 )
2371 })
2372}
2373
2374/// Answer a message aimed at a task that has already sealed.
2375///
2376/// Decided from the task's own journal: the event store's subscription may
2377/// already be retired, and routing a retransmit through `deliver_to` would
2378/// answer a legitimate retry with a terminal-state error. The journal records
2379/// every delivered continuation message verbatim, so the retry of the message
2380/// that completed the task is recognisable there — and a *new* message aimed
2381/// at a finished task stays refused.
2382fn continue_sealed_task(
2383 run: RunId,
2384 records: &[crate::journal::Record],
2385 message: &A2aMessage,
2386) -> Result<RunId, RpcError> {
2387 let delivered_before = records.iter().any(|record| {
2388 matches!(
2389 record.kind(),
2390 RecordKind::EffectDone { output, .. }
2391 if output
2392 .get("$a2a_message")
2393 .and_then(|m| m.get("messageId"))
2394 .and_then(Value::as_str)
2395 == Some(message.message_id.as_str())
2396 )
2397 });
2398 if delivered_before {
2399 return Ok(run);
2400 }
2401 Err(RpcError::new(
2402 code::UNSUPPORTED_OPERATION,
2403 "this task has completed and is not waiting for input",
2404 ))
2405}
2406
2407/// Refuse a push configuration on a message that continues a task.
2408///
2409/// A continuation answers with the task it continued, and registering a
2410/// webhook on that task is `CreateTaskPushNotificationConfig`'s job, under its
2411/// own gate. Accepting the field here and doing nothing with it would tell the
2412/// caller a webhook was registered that never will be.
2413fn refuse_push_on_continuation(
2414 message: &A2aMessage,
2415 configuration: Option<&SendConfiguration>,
2416) -> Result<(), RpcError> {
2417 if message.task_id.is_some()
2418 && configuration.is_some_and(|c| c.task_push_notification_config.is_some())
2419 {
2420 return Err(RpcError::new(
2421 code::INVALID_PARAMS,
2422 "configuration.taskPushNotificationConfig is not accepted on a message that \
2423 continues a task; register it with CreateTaskPushNotificationConfig",
2424 ));
2425 }
2426 Ok(())
2427}
2428
2429/// Continue one interrupted A2A task with a client message.
2430///
2431/// The run remains append-only: the message becomes the output of the exact
2432/// `event.await` effect on which the task stopped, then ordinary resume replay
2433/// carries execution forward. `EventStore::deliver_to` is task-addressed and
2434/// atomic, so another run sharing the same business correlation key cannot
2435/// consume this message.
2436async fn continue_task(
2437 server: &A2aServer,
2438 caller: &Caller,
2439 message: &A2aMessage,
2440) -> Result<RunId, RpcError> {
2441 let raw = message
2442 .task_id
2443 .as_deref()
2444 .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
2445 let run = RunId::parse(raw)
2446 .map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
2447 let records = server
2448 .authorized_task(caller, action::TASK_CONTINUE, run)
2449 .await?;
2450 let Some(last) = records.last() else {
2451 return Err(task_not_found(run));
2452 };
2453 if matches!(
2454 last.kind(),
2455 RecordKind::RunSuspended {
2456 reason: crate::core::SuspendReason::AwaitingTime { .. }
2457 }
2458 ) {
2459 return Err(RpcError::new(
2460 code::UNSUPPORTED_OPERATION,
2461 "this task is sleeping until a timer fires and cannot accept input",
2462 ));
2463 }
2464 if !matches!(
2465 last.kind(),
2466 RecordKind::RunSuspended { .. } | RecordKind::RunConcluded { .. }
2467 ) {
2468 return Err(RpcError::new(
2469 code::UNSUPPORTED_OPERATION,
2470 "this task is not waiting for input",
2471 ));
2472 }
2473 if let RecordKind::RunConcluded { outcome, .. } = last.kind() {
2474 if sealed_state(outcome) == TaskState::AuthRequired {
2475 return Err(RpcError::new(
2476 code::UNSUPPORTED_OPERATION,
2477 "this task is paused because the authority it acts under was withdrawn; \
2478 it continues when this deployment restores it, not on a message",
2479 ));
2480 }
2481 return continue_sealed_task(run, &records, message);
2482 }
2483 // Search history rather than only the last record so a transport retry of
2484 // the message that completed a task can still be recognized as the same
2485 // `(source, messageId)` and return the current task instead of a spurious
2486 // terminal-task error. A different message id finds no live subscription
2487 // and remains refused.
2488 let (kind, correlation) = records
2489 .iter()
2490 .rev()
2491 .find_map(|record| match record.kind() {
2492 RecordKind::RunSuspended {
2493 reason:
2494 crate::core::SuspendReason::AwaitingEvent {
2495 kind, correlation, ..
2496 },
2497 } => Some((kind.clone(), correlation.clone())),
2498 _ => None,
2499 })
2500 .ok_or_else(|| {
2501 RpcError::new(
2502 code::UNSUPPORTED_OPERATION,
2503 "this task has no input wait to continue",
2504 )
2505 })?;
2506 let context = records
2507 .iter()
2508 .find_map(|record| record.body.case.map(|case| case.to_string()));
2509 if let Some(sent) = message.context_id.as_deref()
2510 && context.as_deref() != Some(sent)
2511 {
2512 return Err(RpcError::new(
2513 code::INVALID_PARAMS,
2514 "message.contextId does not match the referenced task",
2515 ));
2516 }
2517 // The task gate says this peer may continue its own task; this one says it
2518 // may supply the kind the task awaits.
2519 server.authorize(caller, action::EVENT_DELIVER, &kind, Some(&caller.actor))?;
2520
2521 let event = crate::core::InboundEvent {
2522 // `peer:{actor}`, the one spelling a counterparty's provenance has on
2523 // every transport — see [`super::peer_source`]. This source becomes the
2524 // delivered value's provenance, so a transport-qualified variant here
2525 // would give one counterparty two names.
2526 source: super::peer_source(&caller.actor),
2527 id: message.message_id.clone(),
2528 kind,
2529 correlation,
2530 payload: message.to_input(),
2531 // A counterparty's message, so nobody on this plane minted it. The
2532 // sender is provenance, not an operator, and the one thing this field
2533 // must not become is somewhere a peer can claim to be a person here.
2534 by: None,
2535 };
2536 match server.runtime.deliver_to(run, &event).await {
2537 // `Buffered` is a success too: when the owner's lease is stalled the
2538 // input is held durably and the sweep resumes the run — an internal
2539 // error here would tell the caller a continuation failed that
2540 // actually succeeded-pending.
2541 Ok(
2542 crate::core::Delivery::Resumed { .. }
2543 | crate::core::Delivery::Duplicate
2544 | crate::core::Delivery::Buffered,
2545 ) => Ok(run),
2546 Err(crate::core::RuntimeError::PlanContract(_)) => Err(RpcError::new(
2547 code::UNSUPPORTED_OPERATION,
2548 "this task is no longer waiting for input",
2549 )),
2550 // A run waiting on a human task waits for the worklist's answer, and a
2551 // message addressed to it by task id is not one.
2552 Err(crate::core::RuntimeError::ReservedEventKind { .. }) => Err(RpcError::new(
2553 code::UNSUPPORTED_OPERATION,
2554 "this task is waiting for a decision on this plane's worklist, not for input",
2555 )),
2556 Err(error) => Err(internal("delivering a continuation", &error)),
2557 }
2558}
2559
2560#[allow(clippy::too_many_lines)]
2561async fn send_message(
2562 server: &A2aServer,
2563 caller: &Caller,
2564 params: CommonParams,
2565) -> Result<Value, RpcError> {
2566 if let Some(configuration) = ¶ms.configuration {
2567 configuration.validate()?;
2568 }
2569 let inline_push = params
2570 .configuration
2571 .as_ref()
2572 .and_then(|configuration| configuration.task_push_notification_config.clone());
2573 let Some(message) = params.message else {
2574 return Err(RpcError::new(
2575 code::INVALID_PARAMS,
2576 "`message` is required by SendMessage",
2577 ));
2578 };
2579 message.validate_parts(caller)?;
2580 refuse_push_on_continuation(&message, params.configuration.as_ref())?;
2581 if message.task_id.is_some() {
2582 let history_length = params
2583 .configuration
2584 .as_ref()
2585 .and_then(|configuration| configuration.history_length);
2586 let run = continue_task(server, caller, &message).await?;
2587 return get_task(
2588 server,
2589 caller,
2590 CommonParams {
2591 id: Some(run.to_string()),
2592 history_length,
2593 ..CommonParams::default()
2594 },
2595 )
2596 .await
2597 .map(|task| json!({ "task": task }));
2598 }
2599 let skill = resolve_skill(server, &message)?;
2600 server.authorize(caller, action::MESSAGE_SEND, &skill, None)?;
2601 check_context(server, &message, caller).await?;
2602 if let Some(push) = &inline_push {
2603 validate_inline_push(server, caller, &skill, push)?;
2604 }
2605
2606 // Untrusted, and provenanced to the peer that sent it. A protected sink
2607 // field can then name the one counterparty it will take an amount from, and
2608 // a skill that wants to act on this has to pass a gate to do it.
2609 let source = super::peer_source(&caller.actor);
2610 let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
2611 let terms = run_terms(&message, caller)?;
2612
2613 // Non-blocking: the spec requires returning as soon as the task exists,
2614 // with an in-progress state, leaving the caller to poll `GetTask`. Admission
2615 // still happens synchronously, so a refusal is still an immediate answer —
2616 // returning a task id for a run the gate rejected would hand the caller a
2617 // handle to nothing and turn a decline into a task that never appears.
2618 if params
2619 .configuration
2620 .as_ref()
2621 .is_some_and(|c| c.return_immediately)
2622 {
2623 return match spawn_a2a(server, &skill, input, terms).await {
2624 Ok(run) => {
2625 if let Some(push) = &inline_push {
2626 register_push(server, push, run, 1).await?;
2627 }
2628 let case = task_context(server, run).await?;
2629 Ok(json!({
2630 "task": task_of(run, TaskState::Working, "accepted", case)
2631 }))
2632 }
2633 Err(
2634 crate::core::RuntimeError::PolicyDenied(_)
2635 | crate::core::RuntimeError::Delegation(_),
2636 ) => Ok(json!({ "message": declined(&skill) })),
2637 Err(crate::core::RuntimeError::QuotaExceeded(e)) => Err(quota_refusal(&e)),
2638 Err(crate::core::RuntimeError::Draining) => {
2639 Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE))
2640 }
2641 Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => Err(subject_unbound(&e)),
2642 Err(e) => Err(internal("admitting a message", &e)),
2643 };
2644 }
2645
2646 let admission = match run_a2a(server, &skill, input, terms).await {
2647 Ok(admission) => admission,
2648 // A policy denial is the agent *declining*, not the agent breaking.
2649 // Reported as `-32603 Internal error` it reads as "this server is
2650 // faulty, retry later", and the caller retries a decision that will
2651 // never change.
2652 //
2653 // Answered as a `Message` rather than a rejected `Task` because no task
2654 // exists: nothing was admitted, so there is no id to poll and no
2655 // history to fetch. A2A's response is a oneof for exactly this.
2656 Err(
2657 crate::core::RuntimeError::PolicyDenied(_) | crate::core::RuntimeError::Delegation(_),
2658 ) => {
2659 return Ok(json!({ "message": declined(&skill) }));
2660 }
2661 // Back-pressure, not a fault. `-32603` reads as "the far side is
2662 // broken, retry later" and a caller may well retry the same second;
2663 // this says *the agent cannot take this on right now*, which is what a
2664 // ceiling means and what a caller should back off from. The code and
2665 // message are fixed — see `code::QUOTA_EXHAUSTED` for why neither is
2666 // the spec's `-32004` and why the quota arithmetic stays out of it.
2667 Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
2668 return Err(quota_refusal(&e));
2669 }
2670 // Not a fault and not back-pressure from the agent: this process is
2671 // going away and admitted nothing. A caller retrying now reaches a
2672 // different instance and is served.
2673 Err(crate::core::RuntimeError::Draining) => {
2674 return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
2675 }
2676 // The message, not the server: it lacks what the agent's declaration
2677 // reads its data subject from, and the same message is refused again.
2678 Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => {
2679 return Err(subject_unbound(&e));
2680 }
2681 Err(e) => return Err(internal("admitting a message", &e)),
2682 };
2683 let outcome = match admission {
2684 crate::runtime::Admission::Fresh(outcome)
2685 | crate::runtime::Admission::Replayed(outcome) => outcome,
2686 // Another instance is executing this message right now. The honest
2687 // answer to a retry is *accepted, already in progress* — a Working
2688 // task naming the run — never a retry-provoking failure.
2689 crate::runtime::Admission::InFlight(run) => {
2690 let case = task_context(server, run).await?;
2691 return Ok(json!({
2692 "task": task_of(run, TaskState::Working, "accepted", case)
2693 }));
2694 }
2695 };
2696
2697 let case = task_context(server, outcome.run_id).await?;
2698 // A declared `Message` reply answers the blocking send directly — A2A's
2699 // response is a oneof for exactly this, and it is the only path with a
2700 // caller still waiting to hand a message to. Returned before push
2701 // registration, because a response with no task has no task to push about;
2702 // the run and its journal exist either way.
2703 if matches!(outcome.status, crate::runtime::RunStatus::Succeeded)
2704 && let Some(reply) = outcome.output.as_ref().and_then(A2aReply::of_output)
2705 && let Some(parts) = reply.message_parts()
2706 {
2707 return Ok(json!({
2708 "message": A2aMessage {
2709 message_id: format!("reply-{}", outcome.run_id),
2710 role: "ROLE_AGENT".to_owned(),
2711 parts,
2712 context_id: case,
2713 task_id: None,
2714 metadata: None,
2715 extensions: Vec::new(),
2716 reference_task_ids: Vec::new(),
2717 }
2718 }));
2719 }
2720 if let Some(push) = &inline_push {
2721 register_push(server, push, outcome.run_id, 1).await?;
2722 }
2723 let mut task = task_of_outcome(&outcome);
2724 task.context_id = case;
2725 // Unset means the full (capped) history, per the protocol's default —
2726 // only an explicit `0` skips the read.
2727 let history_length = params
2728 .configuration
2729 .as_ref()
2730 .and_then(|configuration| configuration.history_length);
2731 if history_length != Some(0) {
2732 let records = server
2733 .runtime
2734 .journal()
2735 .read(outcome.run_id, 1)
2736 .await
2737 .map_err(|_| {
2738 RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read")
2739 })?;
2740 task.history = task_history(
2741 outcome.run_id,
2742 &records,
2743 history_length,
2744 task.context_id.as_deref(),
2745 );
2746 }
2747 Ok(json!({ "task": task }))
2748}
2749
2750async fn run_a2a(
2751 server: &A2aServer,
2752 skill: &str,
2753 input: Tainted<Value>,
2754 (key, terms): (String, crate::runtime::RunTerms),
2755) -> Result<crate::runtime::Admission, crate::core::RuntimeError> {
2756 server
2757 .runtime
2758 .start(skill, input)
2759 .terms(terms)
2760 .once(key)
2761 .run()
2762 .await
2763}
2764
2765async fn spawn_a2a(
2766 server: &A2aServer,
2767 skill: &str,
2768 input: Tainted<Value>,
2769 (key, terms): (String, crate::runtime::RunTerms),
2770) -> Result<RunId, crate::core::RuntimeError> {
2771 // Producer-scoped and deduplicating, for the reasons `run_terms` states.
2772 // A duplicate answers with the run the key already admitted, which is
2773 // exactly what a retrying caller needs back.
2774 Ok(server
2775 .runtime
2776 .start(skill, input)
2777 .terms(terms)
2778 .once(key)
2779 .spawn()
2780 .await?
2781 .run)
2782}
2783
2784/// The key and terms every A2A admission runs under: keyed by the producer-scoped
2785/// message id, inside the case the caller named or a fresh one, and **acting
2786/// as the caller** — under the chain its credential carried, or under none.
2787///
2788/// The key is also what makes the run this caller's: its source half is the
2789/// authenticated sender, so every later read or write of the task can ask
2790/// whose it is without a second record — see [`task_owner`].
2791///
2792/// The key carries its producer, in
2793/// [`origin_key`](crate::core::origin_key)'s spelling: a bare `messageId` would
2794/// let two counterparties swallow each other's messages as apparent retries —
2795/// or *join* each other's cases, since correlation matches any open case in the
2796/// tenant. And it is an **admission** key, not only a correlation key: this
2797/// crate's own client keeps `messageId` stable across retries precisely so a
2798/// peer can deduplicate, and a server that correlates without deduplicating
2799/// starts a second run inside the right case. A different key space from an
2800/// event's, and the same construction, because the forgery it refuses is the
2801/// same one.
2802///
2803/// The chain is the caller's, never the plane's: a plane that admitted every
2804/// peer's run under its own chain would answer "on whose behalf" with the same
2805/// name for all of them, and act for a caller whose credential permits less. A
2806/// caller that presented no chain acts under **none**: the plane's chain
2807/// bounds what it may be admitted for, and lends it none of its authority —
2808/// the fallback would make the operator's authority ambient.
2809fn run_terms(
2810 message: &A2aMessage,
2811 caller: &Caller,
2812) -> Result<(String, crate::runtime::RunTerms), RpcError> {
2813 let keyed = crate::core::origin_key(&admission_source(&caller.actor), &message.message_id);
2814 // The authenticated peer, never a body field: the four-eyes exclusion
2815 // reads it, and a caller who could name it could name somebody else.
2816 let terms =
2817 crate::runtime::RunTerms::served(caller.acting_as.clone()).admitted_by(&caller.actor);
2818 let terms = match message.context_id.as_deref() {
2819 Some(context) => {
2820 // `check_context` has already refused this, in the same words; the
2821 // parse is repeated because the terms need the typed id.
2822 let case = crate::core::CaseId::parse(context).map_err(|_| {
2823 RpcError::new(code::TASK_NOT_FOUND, format!("no such context: {context}"))
2824 })?;
2825 terms.in_case(case)
2826 }
2827 // Always correlated: `A2aServer::new` refuses a runtime without a case
2828 // layer, so every task gets a real, continuable context — the
2829 // contextId returned is one a client can send back, not a string that
2830 // satisfies a schema and continues nothing.
2831 None => terms.correlated(
2832 "a2a.context",
2833 &[crate::core::CorrelationKey::new(
2834 CONTEXT_KEY_NAMESPACE,
2835 &keyed,
2836 )],
2837 ),
2838 };
2839 Ok((keyed, terms))
2840}
2841
2842/// The `source` half of every A2A admission key: this surface, then the
2843/// authenticated sender.
2844///
2845/// Its own namespace rather than the bare [`peer_source`](super::peer_source)
2846/// an inbound event's key carries, because ownership is read back from it: an
2847/// embedder that keys a run with an event's `dedup_key` — the key this crate
2848/// recommends — must not thereby hand that run to the event's sender as an A2A
2849/// task.
2850fn admission_source(actor: &str) -> String {
2851 format!("{ADMISSION_NAMESPACE}{}", super::peer_source(actor))
2852}
2853
2854const ADMISSION_NAMESPACE: &str = "a2a/";
2855
2856/// The peer that admitted a task over this surface, read from the run's
2857/// admission key. `None` for a run nobody admitted here.
2858fn task_owner(records: &[crate::journal::Record]) -> Option<&str> {
2859 records
2860 .first()?
2861 .admission_source()?
2862 .strip_prefix(ADMISSION_NAMESPACE)?
2863 .strip_prefix("peer:")
2864}
2865
2866/// The one answer for a task this caller may not address, whether it exists
2867/// or not.
2868fn task_not_found(run: RunId) -> RpcError {
2869 RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {run}"))
2870}
2871
2872/// A fault on this plane's side, told to the peer in one fixed sentence —
2873/// see [`withheld_fault`](crate::core::withheld_fault).
2874fn internal(doing: &str, error: &dyn std::fmt::Display) -> RpcError {
2875 RpcError::new(
2876 code::INTERNAL_ERROR,
2877 crate::core::withheld_fault("a2a", doing, error),
2878 )
2879}
2880
2881/// Refuse a `contextId` this caller did not open.
2882///
2883/// A context is a case, and joining one puts this caller's run beside another
2884/// peer's work and its case state in reach. So the case must be one a message
2885/// from this caller opened — its correlation key carries the admitting
2886/// sender — and anything else answers as a context that does not exist.
2887async fn check_context(
2888 server: &A2aServer,
2889 message: &A2aMessage,
2890 caller: &Caller,
2891) -> Result<(), RpcError> {
2892 let Some(context) = message.context_id.as_deref() else {
2893 return Ok(());
2894 };
2895 let not_found = || RpcError::new(code::TASK_NOT_FOUND, format!("no such context: {context}"));
2896 let case = crate::core::CaseId::parse(context).map_err(|_| not_found())?;
2897 let Some(cases) = server.runtime.cases() else {
2898 return Err(not_found());
2899 };
2900 let Some(case) = cases
2901 .case(case)
2902 .await
2903 .map_err(|e| internal("reading a context's case", &e))?
2904 else {
2905 return Err(not_found());
2906 };
2907 let ours = admission_source(&caller.actor);
2908 let opened_by_caller = case.correlation.iter().any(|key| {
2909 key.namespace == CONTEXT_KEY_NAMESPACE
2910 && crate::core::origin_source(&key.value) == Some(ours.as_str())
2911 });
2912 if !opened_by_caller {
2913 return Err(not_found());
2914 }
2915 // Refused here rather than by admission: the runtime's refusal of a closed
2916 // case is a contract error shared with every other misuse, and reading
2917 // this one out of it would answer them all as "no such context".
2918 if case.status.is_closed() {
2919 return Err(RpcError::new(
2920 code::TASK_NOT_FOUND,
2921 format!("context {context} is closed and accepts no new task"),
2922 ));
2923 }
2924 Ok(())
2925}
2926
2927/// The correlation namespace a fresh A2A context is opened under.
2928const CONTEXT_KEY_NAMESPACE: &str = "a2a-message";
2929
2930async fn task_context(server: &A2aServer, run: RunId) -> Result<Option<String>, RpcError> {
2931 server
2932 .runtime
2933 .journal()
2934 .read(run, 1)
2935 .await
2936 .map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read"))
2937 .map(|records| {
2938 records
2939 .iter()
2940 .find_map(|record| record.body.case.map(|case| case.to_string()))
2941 })
2942}
2943
2944pub(super) fn task_of_outcome(outcome: &crate::runtime::RunOutcome) -> A2aTask {
2945 // A declared reply carries its own parts; otherwise the default projection
2946 // stands — a string is a text part, anything else a data part.
2947 let artifacts_parts: Vec<Vec<Part>> =
2948 match outcome.output.as_ref().and_then(A2aReply::of_output) {
2949 Some(reply) => reply.artifact_parts(),
2950 None => vec![vec![match outcome
2951 .output
2952 .as_ref()
2953 .map_or(Value::Null, |o| o.peek().clone())
2954 {
2955 Value::String(text) => Part::text(text),
2956 data => Part::data(data),
2957 }]],
2958 };
2959 A2aTask {
2960 id: outcome.run_id.to_string(),
2961 context_id: None,
2962 status: TaskStatus {
2963 state: state_of(&outcome.status),
2964 message: None,
2965 timestamp: None,
2966 },
2967 artifacts: matches!(outcome.status, crate::runtime::RunStatus::Succeeded).then(|| {
2968 artifacts_parts
2969 .into_iter()
2970 .enumerate()
2971 .map(|(i, parts)| A2aArtifact {
2972 artifact_id: format!("{}-result-{i}", outcome.run_id),
2973 name: None,
2974 description: None,
2975 parts,
2976 metadata: None,
2977 extensions: Vec::new(),
2978 })
2979 .collect()
2980 }),
2981 history: None,
2982 metadata: None,
2983 }
2984}
2985
2986/// Which capability this message asks for.
2987///
2988/// Named or unambiguous — never inferred. See the module docs: picking a
2989/// capability from the content of an untrusted message is a dispatch decision
2990/// made by reading attacker-controlled text.
2991fn resolve_skill(server: &A2aServer, message: &A2aMessage) -> Result<String, RpcError> {
2992 if let Some(asked) = message.requested_skill() {
2993 if server.skills.iter().any(|s| s == asked) {
2994 return Ok(asked.to_owned());
2995 }
2996 return Err(RpcError::new(
2997 code::INVALID_PARAMS,
2998 format!(
2999 "this agent has no skill '{asked}'. Its card advertises: {}",
3000 server.skills.join(", ")
3001 ),
3002 ));
3003 }
3004 match server.skills.as_slice() {
3005 [only] => Ok(only.clone()),
3006 [] => Err(RpcError::new(
3007 code::UNSUPPORTED_OPERATION,
3008 "this agent advertises no skills, so there is nothing to send a \
3009 message to",
3010 )),
3011 many => Err(RpcError::new(
3012 code::INVALID_PARAMS,
3013 format!(
3014 "this agent advertises {} skills, so `message.metadata.skill` \
3015 must name one of: {}. It is not inferred from the message — \
3016 choosing what to run by reading the text would let the sender \
3017 pick the capability",
3018 many.len(),
3019 many.join(", ")
3020 ),
3021 )),
3022 }
3023}
3024
3025async fn get_task(
3026 server: &A2aServer,
3027 caller: &Caller,
3028 params: CommonParams,
3029) -> Result<Value, RpcError> {
3030 let (id, records) = server.gate_task(caller, action::TASK_READ, ¶ms).await?;
3031 load_task(server, id, &records, params.history_length).await
3032}
3033
3034async fn load_task(
3035 server: &A2aServer,
3036 id: RunId,
3037 records: &[crate::journal::Record],
3038 history_length: Option<usize>,
3039) -> Result<Value, RpcError> {
3040 // No records is no such task: a run this plane never admitted and a task id
3041 // a caller invented are the same fact from here.
3042 let (state, detail) = state_from_history(records).ok_or_else(|| task_not_found(id))?;
3043 let case = records
3044 .iter()
3045 .find_map(|r| r.body.case.map(|c| c.to_string()));
3046
3047 let mut task = task_of(id, state, &detail, case.clone());
3048 task.history = task_history(id, records, history_length, case.as_deref());
3049 // Unmetered: one task, one replay, and the sealed-run cache spares the
3050 // poll loop that reads the same finished task every few seconds.
3051 task.artifacts = server
3052 .artifacts_unmetered(id, state)
3053 .await
3054 .map_err(|error| internal("projecting a task's artifacts", &error))?;
3055 serde_json::to_value(task).map_err(|error| internal("encoding a task", &error))
3056}
3057
3058#[derive(Debug, Serialize, Deserialize)]
3059#[serde(rename_all = "camelCase")]
3060struct TaskCursor {
3061 updated_at: u64,
3062 run: String,
3063 context_id: Option<String>,
3064 status: Option<TaskState>,
3065 status_timestamp_after: Option<String>,
3066 /// How many matching tasks the earlier pages delivered.
3067 ///
3068 /// A continuation scans only from the cursor on, so without this its
3069 /// `totalSize` counts what is left rather than the listing's total, and the
3070 /// number shrinks page by page. Carried rather than recounted because a
3071 /// recount re-reads every task before the cursor — the cost the scan budget
3072 /// exists to bound. The token is the caller's, so a value it edits misstates
3073 /// only its own total.
3074 before: u64,
3075}
3076
3077/// How many index rows `list_tasks` pulls per store round trip.
3078///
3079/// Bounded so no path holds the tenant's whole index in memory — which is what
3080/// the unbounded read this replaced did on *every* call, before going on to
3081/// read the complete journal of every run it returned.
3082const TASK_SCAN: usize = 256;
3083
3084/// Default for [`A2aServer::filter_scan_budget`]: the most candidates one
3085/// `ListTasks` may examine before being refused as too broad.
3086///
3087/// The number is a cost ceiling, not a result limit — results are bounded by
3088/// `pageSize` already. At the default, the worst request a peer can make costs
3089/// on the order of a thousand journal reads, once, and is told how to narrow;
3090/// without it the cost was every run the tenant ever wrote, per request,
3091/// forever.
3092const FILTER_SCAN_BUDGET: usize = 1024;
3093
3094/// Default for [`A2aServer::artifact_replay_budget`]: the most strict replays
3095/// one `ListTasks` with `includeArtifacts` may perform.
3096///
3097/// A cost ceiling in the same spirit as [`FILTER_SCAN_BUDGET`], for the other
3098/// caller-priced read: each completed task's artifacts are a full strict
3099/// replay, a page holds up to a hundred tasks, and the sealed-run cache means
3100/// only first sight of a run pays it. Sixteen replays is a bounded worst case
3101/// per request; a page that wanted more marks the remainder omitted, and a
3102/// second request finds the first sixteen cached.
3103const ARTIFACT_REPLAY_BUDGET: usize = 16;
3104
3105/// The task-metadata key marking artifacts withheld by the replay budget.
3106///
3107/// A bounded result must not be shaped like a complete one — a silent
3108/// truncation reads as an answer. A
3109/// task past the budget is returned without artifacts — indistinguishable from
3110/// several honest states — so the omission says its own name, in `Task.metadata`
3111/// because that is the field A2A gives a server for exactly this kind of
3112/// annotation; a new top-level response member would be a schema no client
3113/// expects. `GetTask` on the marked id recovers the artifacts, and warms the
3114/// cache doing it.
3115pub const ARTIFACTS_OMITTED_KEY: &str = "io.agentplane.a2a/artifactsOmitted";
3116
3117#[allow(clippy::too_many_lines)]
3118async fn list_tasks(
3119 server: &A2aServer,
3120 caller: &Caller,
3121 params: &CommonParams,
3122) -> Result<Value, RpcError> {
3123 server.authorize(caller, action::TASK_READ, "tasks", None)?;
3124 let page_size = params.page_size.unwrap_or(50);
3125 if !(1..=100).contains(&page_size) {
3126 return Err(RpcError::new(
3127 code::INVALID_PARAMS,
3128 "pageSize must be between 1 and 100",
3129 ));
3130 }
3131 let after = params
3132 .status_timestamp_after
3133 .as_deref()
3134 .map(|value| {
3135 time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
3136 .map_err(|_| {
3137 RpcError::new(
3138 code::INVALID_PARAMS,
3139 "statusTimestampAfter must be an RFC 3339 timestamp",
3140 )
3141 })
3142 })
3143 .transpose()?;
3144 let cursor = params
3145 .page_token
3146 .as_deref()
3147 .map(decode_task_cursor)
3148 .transpose()?;
3149 if let Some(cursor) = &cursor
3150 && (cursor.context_id != params.context_id
3151 || cursor.status != params.status
3152 || cursor.status_timestamp_after != params.status_timestamp_after)
3153 {
3154 return Err(RpcError::new(
3155 code::INVALID_PARAMS,
3156 "pageToken was issued for different ListTasks filters",
3157 ));
3158 }
3159
3160 // Whether the caller asked anything that can only be answered by reading a
3161 // run's whole journal. `statusTimestampAfter` is not one of those — it
3162 // compares against the activity index's own timestamp.
3163 //
3164 // The candidates are the caller's own runs and nobody else's: the index
3165 // is narrowed to the admission source this surface keys the caller's runs
3166 // with, so another peer's runs and the embedder's cost this listing
3167 // nothing. An unfiltered listing reads the whole journal only of the
3168 // tasks on the page; a content-filtered one reads the whole journal of
3169 // every task it must examine.
3170 let content_filtered = params.context_id.is_some() || params.status.is_some();
3171 let source = admission_source(&caller.actor);
3172
3173 let mut cursor_pos = cursor
3174 .as_ref()
3175 .and_then(|c| RunId::parse(&c.run).ok().map(|run| (c.updated_at, run)));
3176 let mut page: Vec<(RunId, u64, A2aTask)> = Vec::new();
3177 let mut matched: u64 = 0;
3178 let mut has_more = false;
3179 let mut examined: usize = 0;
3180
3181 'scan: loop {
3182 let batch = server
3183 .runtime
3184 .journal()
3185 .recent_runs_from(&source, cursor_pos, TASK_SCAN)
3186 .await
3187 .map_err(|e| internal("reading the task index", &e))?;
3188 if batch.is_empty() {
3189 break;
3190 }
3191 cursor_pos = batch.last().map(|(run, updated)| (*updated, *run));
3192
3193 for (run, updated) in batch {
3194 if after.is_some_and(|cutoff| {
3195 i64::try_from(updated)
3196 .ok()
3197 .and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
3198 .is_none_or(|value| value < cutoff)
3199 }) {
3200 // The index is newest first, so every row after this one is
3201 // older still: the cutoff ends the scan, not just this row.
3202 break 'scan;
3203 }
3204
3205 // Whose task it is was settled by the index: every run in this
3206 // range was admitted under the caller's source.
3207 let owner = Some(caller.actor.as_str());
3208 // The ceiling on what one listing may cost, refused rather than
3209 // truncated — see `A2aServer::filter_scan_budget`.
3210 examined += 1;
3211 if examined > server.filter_scan_budget {
3212 return Err(RpcError::new(
3213 code::INVALID_PARAMS,
3214 format!(
3215 "this listing would require examining more than {} tasks to answer \
3216 exactly — narrow it with statusTimestampAfter and page from there",
3217 server.filter_scan_budget
3218 ),
3219 ));
3220 }
3221 // Load-bearing for more than cost: a run the caller may not read
3222 // must not reach the total either, or `totalSize` discloses the
3223 // existence of tasks it cannot see.
3224 if !server.permits(caller, action::TASK_READ, &run.to_string(), owner) {
3225 continue;
3226 }
3227
3228 let wanted = page.len() < page_size;
3229 if !content_filtered && !wanted {
3230 matched += 1;
3231 has_more = true;
3232 continue;
3233 }
3234 let records = server
3235 .runtime
3236 .journal()
3237 .read(run, 1)
3238 .await
3239 .map_err(|e| internal("reading a task's journal", &e))?;
3240 let task = task_from_records(run, &records, updated, params.history_length);
3241 if params
3242 .context_id
3243 .as_ref()
3244 .is_some_and(|context| task.context_id.as_ref() != Some(context))
3245 || params
3246 .status
3247 .as_ref()
3248 .is_some_and(|status| &task.status.state != status)
3249 {
3250 continue;
3251 }
3252 matched += 1;
3253 if wanted {
3254 page.push((run, updated, task));
3255 } else {
3256 has_more = true;
3257 }
3258 }
3259 }
3260
3261 // Exact, and counted over the same rows the caller was allowed to see. The
3262 // easy mistake is reporting the page's length, which tells every caller the
3263 // total is whatever fits on a screen; the dangerous one is counting the
3264 // store's index directly, which is cheaper and reveals the tasks policy
3265 // just hid. Saturated at the wire type's ceiling: the proto field is an
3266 // `int32`, and a count past it must not overflow a conformant client's
3267 // deserializer.
3268 let before = cursor.as_ref().map_or(0, |cursor| cursor.before);
3269 let total_size = before
3270 .saturating_add(matched)
3271 .min(u64::try_from(i32::MAX).unwrap_or(u64::MAX));
3272
3273 let visible = &page[..];
3274 let next_page_token = if has_more {
3275 let (run, updated, _) = visible.last().expect("a page with more has a last item");
3276 encode_task_cursor(&TaskCursor {
3277 updated_at: *updated,
3278 run: run.to_string(),
3279 context_id: params.context_id.clone(),
3280 status: params.status,
3281 status_timestamp_after: params.status_timestamp_after.clone(),
3282 before: before.saturating_add(u64::try_from(visible.len()).unwrap_or(u64::MAX)),
3283 })?
3284 } else {
3285 String::new()
3286 };
3287 let mut tasks = Vec::with_capacity(visible.len());
3288 // The replay budget for this whole request. Cache hits are free; each
3289 // cache miss is a strict replay and spends one. Past it a task's artifacts
3290 // are omitted **and marked** — see [`ARTIFACTS_OMITTED_KEY`] for why the
3291 // omission must say its own name.
3292 let mut replays_left = server.artifact_replay_budget;
3293 for (run, _, task) in visible {
3294 let mut task = task.clone();
3295 if params.include_artifacts {
3296 match server
3297 .artifacts_bounded(*run, task.status.state, Some(&mut replays_left))
3298 .await
3299 .map_err(|error| internal("projecting a task's artifacts", &error))?
3300 {
3301 ArtifactRead::Artifacts(artifacts) => task.artifacts = artifacts,
3302 ArtifactRead::OverBudget => {
3303 task.metadata = Some(json!({ ARTIFACTS_OMITTED_KEY: true }));
3304 }
3305 }
3306 }
3307 tasks.push(task);
3308 }
3309 Ok(json!({
3310 "tasks": tasks,
3311 "nextPageToken": next_page_token,
3312 "pageSize": page_size,
3313 "totalSize": total_size,
3314 }))
3315}
3316
3317fn encode_task_cursor(cursor: &TaskCursor) -> Result<String, RpcError> {
3318 crate::core::canon::to_bytes(cursor)
3319 .map(crate::core::b64::encode_url)
3320 .map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task cursor could not be encoded"))
3321}
3322
3323fn decode_task_cursor(token: &str) -> Result<TaskCursor, RpcError> {
3324 crate::core::b64::decode_url(token)
3325 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
3326 .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "pageToken is not a valid task cursor"))
3327}
3328
3329fn task_from_records(
3330 run: RunId,
3331 records: &[crate::journal::Record],
3332 updated: u64,
3333 history_length: Option<usize>,
3334) -> A2aTask {
3335 // A run with no records is one whose first append has not landed. It is
3336 // reported as working rather than refused, because this builds a row in a
3337 // listing where a single unreadable run must not fail the page.
3338 let (state, detail) =
3339 state_from_history(records).unwrap_or_else(|| (TaskState::Working, "running".to_owned()));
3340 let case = records
3341 .iter()
3342 .find_map(|record| record.body.case.map(|case| case.to_string()));
3343 let timestamp = i64::try_from(updated)
3344 .ok()
3345 .and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
3346 .and_then(|value| {
3347 value
3348 .format(&time::format_description::well_known::Rfc3339)
3349 .ok()
3350 });
3351 let history = task_history(run, records, history_length, case.as_deref());
3352 let mut task = task_of(run, state, &detail, case);
3353 task.status.timestamp = timestamp;
3354 task.history = history;
3355 task
3356}
3357
3358/// The most messages an unbounded history request returns.
3359///
3360/// The protocol reads an **unset** `historyLength` as "the full history", so
3361/// the bound is the server's, stated here rather than left to whatever the
3362/// journal happens to hold — the same shape as the artifact replay budget.
3363const HISTORY_CAP: usize = 128;
3364
3365fn task_history(
3366 run: RunId,
3367 records: &[crate::journal::Record],
3368 history_length: Option<usize>,
3369 case: Option<&str>,
3370) -> Option<Vec<A2aMessage>> {
3371 // Unset means full (capped); `0` is the explicit request for none —
3372 // the protocol's own default, which a conformant client expecting its
3373 // conversation back depends on.
3374 let limit = history_length.unwrap_or(HISTORY_CAP);
3375 {
3376 if limit == 0 {
3377 return None;
3378 }
3379 let mut history = Vec::new();
3380 for record in records {
3381 let input = match record.kind() {
3382 RecordKind::RunAdmitted { input, .. } => Some(input),
3383 RecordKind::EffectDone { output, .. } if output.get("$a2a_message").is_some() => {
3384 Some(output)
3385 }
3386 _ => None,
3387 };
3388 let Some(input) = input else { continue };
3389 if let Some(message) = input.get("$a2a_message")
3390 && let Ok(mut message) = serde_json::from_value::<A2aMessage>(message.clone())
3391 {
3392 message.task_id = Some(run.to_string());
3393 if message.context_id.is_none() {
3394 message.context_id = case.map(ToOwned::to_owned);
3395 }
3396 history.push(message);
3397 continue;
3398 }
3399 // Non-A2A admission retained for old/directly-created tasks.
3400 if history.is_empty() {
3401 let text = input
3402 .get("text")
3403 .and_then(Value::as_str)
3404 .map(ToOwned::to_owned);
3405 let media_type = if text.is_some() {
3406 "text/plain"
3407 } else {
3408 "application/json"
3409 };
3410 history.push(A2aMessage {
3411 message_id: format!("{run}-input"),
3412 role: "ROLE_USER".to_owned(),
3413 parts: vec![Part {
3414 data: text.is_none().then(|| input.clone()),
3415 text,
3416 raw: None,
3417 url: None,
3418 filename: None,
3419 media_type: Some(media_type.to_owned()),
3420 metadata: None,
3421 }],
3422 context_id: case.map(ToOwned::to_owned),
3423 task_id: Some(run.to_string()),
3424 metadata: None,
3425 extensions: Vec::new(),
3426 reference_task_ids: Vec::new(),
3427 });
3428 }
3429 }
3430 let keep_from = history.len().saturating_sub(limit);
3431 (!history.is_empty()).then(|| history.split_off(keep_from))
3432 }
3433}
3434
3435/// The uncached artifact projection: one strict replay of a completed run.
3436///
3437/// Server request paths go through [`A2aServer::artifacts_bounded`], which
3438/// caches sealed runs and meters `ListTasks`, and a stream through
3439/// [`cached_artifacts`]; this stays the raw read for the push projection.
3440pub(super) async fn task_artifacts(
3441 runtime: &Runtime,
3442 run: RunId,
3443 state: TaskState,
3444) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
3445 if state != TaskState::Completed {
3446 return Ok(None);
3447 }
3448 runtime
3449 .replay(run, crate::runtime::Mode::Strict)
3450 .await
3451 .map(|outcome| task_of_outcome(&outcome).artifacts)
3452}
3453
3454/// A task's artifacts through the sealed-run cache, unmetered.
3455///
3456/// What a stream reads when the run it watches concludes: every subscriber to
3457/// one task would otherwise buy its own strict replay of the same immutable
3458/// history.
3459pub(super) async fn cached_artifacts(
3460 runtime: &Runtime,
3461 cache: &std::sync::Mutex<ArtifactCache>,
3462 run: RunId,
3463 state: TaskState,
3464) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
3465 if state != TaskState::Completed {
3466 return Ok(None);
3467 }
3468 if let Some(ArtifactRead::Artifacts(artifacts)) = crate::core::poison::recover(cache).get(run) {
3469 return Ok(artifacts);
3470 }
3471 let artifacts = task_artifacts(runtime, run, state).await?;
3472 crate::core::poison::recover(cache).insert(run, artifacts.clone());
3473 Ok(artifacts)
3474}
3475
3476/// A sealed run's outcome word, as an A2A state.
3477///
3478/// The outcome is the same string [`RunStatus::as_str`](crate::runtime::RunStatus::as_str)
3479/// produces, which is what the executor seals with — so this is [`state_of`]
3480/// reached through a string, and the two are held to agreement by
3481/// `a_live_status_and_its_sealed_outcome_agree`.
3482///
3483/// The `_` arm is a *wire* decision rather than a modelling shortcut: an A2A
3484/// client must be told some state, and a word this build cannot interpret is not
3485/// something it may describe as completed or waiting. It is deliberately **not**
3486/// how the runtime itself treats an unrecognised outcome — `resume_is_closed`
3487/// quarantines on one, because refusing to guess is available there and is not
3488/// available here. The agreement test is what keeps this arm from quietly
3489/// swallowing a variant somebody added and forgot to map.
3490pub(super) fn sealed_state(outcome: &str) -> TaskState {
3491 match outcome {
3492 "succeeded" => TaskState::Completed,
3493 "cancelled" => TaskState::Canceled,
3494 "suspended" => TaskState::InputRequired,
3495 // `RunStatus::Withheld`'s outcome; `a_live_status_and_its_sealed_outcome_agree`
3496 // pins the spelling against the enum.
3497 "withheld" => TaskState::AuthRequired,
3498 // Not tasks at all: nobody submitted this plane's record of a sweep, of
3499 // an operator crossing a tenant boundary or lifting a control, or of a session it merely
3500 // watched somebody else's agent run. `state_of` decides that and this
3501 // agrees, which is the direction the test beside it enforces.
3502 "swept"
3503 | "broke-glass"
3504 | crate::runtime::HALT_LIFTED_OUTCOME
3505 | crate::runtime::HOLD_RELEASED_OUTCOME
3506 | crate::runtime::OBSERVED_OUTCOME => TaskState::Rejected,
3507 _ => TaskState::Failed,
3508 }
3509}
3510
3511/// The A2A state of a run, read from the last record of its history.
3512///
3513/// Every surface that reports a task's state answers here: `tasks/get`,
3514/// `tasks/list`, and the event stream a client subscribes to. They are three
3515/// views of one fact, and three copies of this match would agree until somebody
3516/// added a record kind or reworded a suspension — after which a client polling
3517/// and the same client streaming would be told different things about the same
3518/// run, which is worse than either answer being wrong.
3519///
3520/// **The last record, not any record.** A run that waited, was resumed and
3521/// carried on has a suspension in its history and is not suspended now.
3522///
3523/// An empty history has no state to report and is not this function's to
3524/// invent: the caller decides whether that is a task that does not exist or one
3525/// whose first record has not landed yet, and those get different answers.
3526pub(super) fn state_from_history(
3527 records: &[crate::journal::Record],
3528) -> Option<(TaskState, String)> {
3529 let last = records.last()?;
3530 Some(match last.kind() {
3531 RecordKind::RunSuspended { reason } => (TaskState::InputRequired, reason.to_string()),
3532 RecordKind::RunConcluded { outcome, .. } => (sealed_state(outcome), outcome.clone()),
3533 _ => (TaskState::Working, "running".to_owned()),
3534 })
3535}
3536
3537async fn cancel_task(
3538 server: &A2aServer,
3539 caller: &Caller,
3540 params: CommonParams,
3541) -> Result<Value, RpcError> {
3542 let (id, records) = server
3543 .gate_task(caller, action::TASK_CANCEL, ¶ms)
3544 .await?;
3545 let Some(last) = records.last() else {
3546 return Err(task_not_found(id));
3547 };
3548 // Which runs a stop can reach is the runtime's decision, not this door's:
3549 // a failed, exhausted or withheld run has concluded and is still
3550 // resumable, and stopping it is how its work gets unwound. A closed run
3551 // (succeeded, cancelled, abandoned) and a quarantined one — whose unwind a
3552 // quarantine forbids — are refused with A2A's code for exactly this.
3553 let fresh = server
3554 .runtime
3555 .request_cancel(
3556 id,
3557 &crate::core::Operator::authenticated(caller.actor.clone())
3558 .map_err(|e| internal("naming the authenticated caller", &e))?,
3559 "cancelled over A2A",
3560 )
3561 .await
3562 .map_err(|e| match e {
3563 crate::core::RuntimeError::AlreadyConcluded { outcome, .. } => RpcError::new(
3564 code::TASK_NOT_CANCELABLE,
3565 format!("this task already finished as '{outcome}'"),
3566 ),
3567 crate::core::RuntimeError::CannotUnwind { .. } => RpcError::new(
3568 code::TASK_NOT_CANCELABLE,
3569 "this task is quarantined; its work cannot be unwound until the \
3570 deployment resolves it",
3571 ),
3572 other => internal("requesting a cancellation", &other),
3573 })?;
3574 // Somebody stopped it already and it has finished stopping: telling this
3575 // caller a cancellation is under way would describe a stop that is over.
3576 // (The runtime answers a closed run `false` rather than an error only when
3577 // the run was cancelled.)
3578 if let RecordKind::RunConcluded { outcome, .. } = last.kind()
3579 && !fresh
3580 && outcome == "cancelled"
3581 {
3582 return Err(RpcError::new(
3583 code::TASK_NOT_CANCELABLE,
3584 format!("this task already finished as '{outcome}'"),
3585 ));
3586 }
3587
3588 // The same resolution every other path uses, from the records already in
3589 // hand: A2A 1.0 puts `contextId` on every task, and the one place that
3590 // hard-coded it absent handed a canceling caller a task it could not
3591 // continue or correlate.
3592 let case = records
3593 .iter()
3594 .find_map(|record| record.body.case.map(|case| case.to_string()));
3595
3596 // Still `WORKING`, deliberately. The request is durable, and the run stops
3597 // at its next step boundary — reporting `CANCELED` here would claim it had
3598 // already stopped and unwound, which is exactly what has not happened yet.
3599 serde_json::to_value(task_of(
3600 id,
3601 TaskState::Working,
3602 "cancellation requested",
3603 case,
3604 ))
3605 .map_err(|error| internal("encoding a task", &error))
3606}
3607
3608fn get_extended_card(server: &A2aServer, caller: &Caller) -> Result<Value, RpcError> {
3609 server.authorize(caller, action::CARD_EXTENDED, &server.card.name, None)?;
3610 serde_json::to_value(&server.extended).map_err(|e| internal("encoding the extended card", &e))
3611}
3612
3613fn task_id(params: &CommonParams) -> Result<RunId, RpcError> {
3614 let Some(raw) = params.id.as_deref() else {
3615 return Err(RpcError::new(code::INVALID_PARAMS, "`id` is required"));
3616 };
3617 RunId::parse(raw).map_err(|_| {
3618 // Not found rather than invalid params: whether a string is a run id
3619 // this plane issued is not something a caller should learn from the
3620 // shape of the refusal.
3621 RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}"))
3622 })
3623}
3624
3625/// The agent declining, with nothing about *why*.
3626///
3627/// The reason is deliberately absent. A denial reason describes the rule that
3628/// fired, and a rule describes the classification it protects — so a caller who
3629/// can send messages and read refusals can map the policy by probing it. The
3630/// operator sees the reason in the journal, where it belongs; the peer sees that
3631/// it was declined, which is all it can act on anyway.
3632fn declined(skill: &str) -> A2aMessage {
3633 A2aMessage {
3634 message_id: format!("declined-{skill}"),
3635 role: "ROLE_AGENT".to_owned(),
3636 parts: vec![Part {
3637 text: Some("this agent declined the request".to_owned()),
3638 data: None,
3639 raw: None,
3640 url: None,
3641 filename: None,
3642 media_type: Some("text/plain".to_owned()),
3643 metadata: None,
3644 }],
3645 context_id: None,
3646 task_id: None,
3647 metadata: None,
3648 extensions: Vec::new(),
3649 reference_task_ids: Vec::new(),
3650 }
3651}
3652
3653pub(super) fn task_of(run: RunId, state: TaskState, detail: &str, case: Option<String>) -> A2aTask {
3654 A2aTask {
3655 id: run.to_string(),
3656 context_id: case,
3657 status: TaskStatus {
3658 state,
3659 message: Some(A2aMessage {
3660 message_id: format!("{run}-status"),
3661 role: "ROLE_AGENT".to_owned(),
3662 parts: vec![Part {
3663 text: Some(detail.to_owned()),
3664 data: None,
3665 raw: None,
3666 url: None,
3667 filename: None,
3668 media_type: Some("text/plain".to_owned()),
3669 metadata: None,
3670 }],
3671 context_id: None,
3672 task_id: Some(run.to_string()),
3673 metadata: None,
3674 extensions: Vec::new(),
3675 reference_task_ids: Vec::new(),
3676 }),
3677 timestamp: None,
3678 },
3679 artifacts: None,
3680 history: None,
3681 metadata: None,
3682 }
3683}
3684
3685fn push_runtime(server: &A2aServer) -> Result<&PushRuntime, RpcError> {
3686 server.push.as_ref().ok_or_else(push_not_supported_error)
3687}
3688
3689fn push_not_supported_error() -> RpcError {
3690 RpcError::new(
3691 code::PUSH_NOT_SUPPORTED,
3692 "this agent does not implement push notifications; its card advertises pushNotifications as false",
3693 )
3694}
3695
3696/// The task a push method addresses, refused unless this caller admitted it.
3697///
3698/// Owned before anything is read or written: a webhook registration is a
3699/// standing instruction to send a task's history somewhere, so one peer
3700/// registering on — or reading, listing, deleting the registrations of —
3701/// another's task would be a read of that task with a delivery address
3702/// attached.
3703async fn push_task<'a>(
3704 server: &'a A2aServer,
3705 caller: &Caller,
3706 raw: Option<&str>,
3707) -> Result<(RunId, &'a PushRuntime), RpcError> {
3708 let push = push_runtime(server)?;
3709 let raw = raw.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
3710 let task = RunId::parse(raw)
3711 .map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
3712 server
3713 .authorized_task(caller, action::TASK_PUSH, task)
3714 .await?;
3715 Ok((task, push))
3716}
3717
3718fn push_request(params: &CommonParams) -> Result<PushRequest, RpcError> {
3719 let url = params
3720 .url
3721 .clone()
3722 .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`url` is required"))?;
3723 Ok(PushRequest {
3724 id: params.id.clone(),
3725 task_id: params.push_task.clone(),
3726 url,
3727 token: params.token.clone(),
3728 authentication: params.authentication.clone(),
3729 })
3730}
3731
3732fn validate_push_request(server: &A2aServer, request: &PushRequest) -> Result<(), RpcError> {
3733 let push = push_runtime(server)?;
3734 request.validate()?;
3735 let config = request.config(RunId::generate());
3736 if let Some(authentication) = &config.authentication {
3737 authentication
3738 .validate()
3739 .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3740 }
3741 push.transport
3742 .validate(&config)
3743 .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))
3744}
3745
3746fn validate_inline_push(
3747 server: &A2aServer,
3748 caller: &Caller,
3749 skill: &str,
3750 request: &PushRequest,
3751) -> Result<(), RpcError> {
3752 if request
3753 .task_id
3754 .as_deref()
3755 .is_some_and(|task| !task.is_empty())
3756 {
3757 return Err(RpcError::new(
3758 code::INVALID_PARAMS,
3759 "taskPushNotificationConfig.taskId must be empty in SendMessage",
3760 ));
3761 }
3762 server.authorize(caller, action::TASK_PUSH, &format!("new:{skill}"), None)?;
3763 validate_push_request(server, request)
3764}
3765
3766async fn register_push(
3767 server: &A2aServer,
3768 request: &PushRequest,
3769 task: RunId,
3770 next_seq: Seq,
3771) -> Result<crate::push::PushConfig, RpcError> {
3772 let push = push_runtime(server)?;
3773 if request
3774 .task_id
3775 .as_deref()
3776 .is_some_and(|configured| !configured.is_empty() && configured != task.to_string())
3777 {
3778 return Err(RpcError::new(
3779 code::INVALID_PARAMS,
3780 "push configuration taskId does not match its task",
3781 ));
3782 }
3783 request.validate()?;
3784 let config = request.config(task);
3785 if let Some(authentication) = &config.authentication {
3786 authentication
3787 .validate()
3788 .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3789 }
3790 push.transport
3791 .validate(&config)
3792 .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3793 // The deployment's own destinations are not the caller's to spend.
3794 let held: Vec<String> = push
3795 .store
3796 .list(task)
3797 .await
3798 .map_err(|error| internal("listing push configurations", &error))?
3799 .into_iter()
3800 .map(|c| c.id)
3801 .filter(|id| !crate::push::is_operator_id(id))
3802 .collect();
3803 if held.len() >= MAX_PUSH_CONFIGS_PER_TASK && !held.contains(&config.id) {
3804 return Err(RpcError::new(
3805 code::INVALID_PARAMS,
3806 format!(
3807 "a task holds at most {MAX_PUSH_CONFIGS_PER_TASK} push configurations; \
3808 delete one first"
3809 ),
3810 ));
3811 }
3812 push.store
3813 .put(&config, next_seq)
3814 .await
3815 .map_err(|error| internal("storing a push configuration", &error))?;
3816 Ok(config)
3817}
3818
3819async fn push_create(
3820 server: &A2aServer,
3821 caller: &Caller,
3822 params: &CommonParams,
3823) -> Result<Value, RpcError> {
3824 let (task, _) = push_task(server, caller, params.push_task.as_deref()).await?;
3825 let request = push_request(params)?;
3826 let head = server
3827 .runtime
3828 .journal()
3829 .head(task)
3830 .await
3831 .map_err(|error| internal("reading a task's head", &error))?;
3832 let tail = server
3833 .runtime
3834 .journal()
3835 .read(task, head.seq)
3836 .await
3837 .map_err(|error| internal("reading a task's journal", &error))?;
3838 let next_seq = if tail
3839 .last()
3840 .is_some_and(|record| matches!(record.kind(), RecordKind::RunConcluded { .. }))
3841 {
3842 head.seq
3843 } else {
3844 head.seq.saturating_add(1)
3845 };
3846 Ok(register_push(server, &request, task, next_seq)
3847 .await?
3848 .redacted())
3849}
3850
3851async fn push_get(
3852 server: &A2aServer,
3853 caller: &Caller,
3854 params: &CommonParams,
3855) -> Result<Value, RpcError> {
3856 let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3857 let (id, reachable) = caller_push_id(params)?;
3858 let missing = || {
3859 RpcError::new(
3860 code::TASK_NOT_FOUND,
3861 format!("no push configuration '{id}' for task {task}"),
3862 )
3863 };
3864 if !reachable {
3865 return Err(missing());
3866 }
3867 push.store
3868 .get(task, id)
3869 .await
3870 .map_err(|error| internal("reading a push configuration", &error))?
3871 .map(|config| config.redacted())
3872 .ok_or_else(missing)
3873}
3874
3875/// The `id` a push method names, and whether it lies in the caller's half of
3876/// the store — the only half a push method may reach.
3877///
3878/// A caller's webhooks and the deployment's own destinations share one
3879/// [`PushStore`](crate::push::PushStore), told apart by
3880/// [`OPERATOR_PREFIX`](crate::push::OPERATOR_PREFIX), and the outbox registers
3881/// an operator destination against every run. The A2A methods answer for the
3882/// caller's namespace and nothing else: an operator id is answered exactly as
3883/// a configuration that does not exist, so a peer can neither read the
3884/// deployment's destination URL nor delete it (and its delivery cursor) from
3885/// under the operator.
3886fn caller_push_id(params: &CommonParams) -> Result<(&str, bool), RpcError> {
3887 let id = params
3888 .id
3889 .as_deref()
3890 .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`id` is required"))?;
3891 Ok((id, PushNamespace::Caller.owns_id(id)))
3892}
3893
3894async fn push_list(
3895 server: &A2aServer,
3896 caller: &Caller,
3897 params: &CommonParams,
3898) -> Result<Value, RpcError> {
3899 let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3900 let configs = push
3901 .store
3902 .list(task)
3903 .await
3904 .map_err(|error| internal("listing push configurations", &error))?;
3905 Ok(json!({
3906 "configs": configs
3907 .iter()
3908 .filter(|config| PushNamespace::Caller.owns_id(&config.id))
3909 .map(crate::push::PushConfig::redacted)
3910 .collect::<Vec<_>>(),
3911 "nextPageToken": "",
3912 }))
3913}
3914
3915async fn push_delete(
3916 server: &A2aServer,
3917 caller: &Caller,
3918 params: &CommonParams,
3919) -> Result<Value, RpcError> {
3920 let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3921 let (id, reachable) = caller_push_id(params)?;
3922 if !reachable {
3923 // The same answer a delete of a missing configuration gets.
3924 return Ok(json!({}));
3925 }
3926 push.store
3927 .delete(task, id)
3928 .await
3929 .map_err(|error| internal("deleting a push configuration", &error))?;
3930 Ok(json!({}))
3931}
3932
3933#[cfg(test)]
3934mod state_agreement_tests {
3935 use super::{TaskState, sealed_state, state_of};
3936
3937 /// The same task must not have two states depending on which path a client took.
3938 ///
3939 /// `state_of` answers the caller holding the immediate `SendMessage`
3940 /// response; `sealed_state` answers `GetTask`, `SubscribeToTask` and every
3941 /// streamed status update. They read the same run.
3942 ///
3943 /// The check runs over the crate's one `RunStatus` list, so adding a variant
3944 /// fails to compile in `state_of` (the match is exhaustive) and, once
3945 /// somebody maps it there, fails *here* until `sealed_state` is taught the
3946 /// same answer. That is the ordering the defect needs: the enum decides, the
3947 /// string agrees. Unifying them the other way — `state_of` delegating to
3948 /// `sealed_state` — makes both paths return the `_ => Failed` fallback for a
3949 /// new variant, with nothing to notice, and that is what this test exists to
3950 /// stop being reintroduced as a tidy-up.
3951 #[test]
3952 fn a_live_status_and_its_sealed_outcome_agree() {
3953 let statuses = crate::runtime::every_status();
3954 assert_eq!(
3955 statuses.len(),
3956 14,
3957 "a RunStatus variant was added or removed — decide which A2A state it \
3958 surfaces as, in `state_of` and in `sealed_state` both"
3959 );
3960 for status in &statuses {
3961 assert_eq!(
3962 state_of(status),
3963 sealed_state(status.as_str()),
3964 "'{}' surfaces as one state live and another once sealed",
3965 status.as_str()
3966 );
3967 }
3968 }
3969
3970 /// And the mapping is not one answer for everything.
3971 ///
3972 /// Without this, `sealed_state` and `state_of` could both be changed to
3973 /// return `Failed` unconditionally and the agreement test above would pass
3974 /// perfectly — every A2A client would see every task as failed.
3975 #[test]
3976 fn the_mapping_distinguishes_more_than_failure() {
3977 let seen: std::collections::BTreeSet<_> = crate::runtime::every_status()
3978 .iter()
3979 .map(|status| format!("{:?}", state_of(status)))
3980 .collect();
3981 assert!(
3982 seen.len() >= 4,
3983 "the run-status mapping collapsed to {seen:?}; a client cannot tell \
3984 completed from cancelled from waiting"
3985 );
3986 assert_eq!(
3987 state_of(&crate::runtime::RunStatus::Succeeded),
3988 TaskState::Completed
3989 );
3990 }
3991
3992 /// A withheld run is interrupted, not finished.
3993 ///
3994 /// Its work stands and it continues once its authority is restored. A
3995 /// terminal answer would close the peer's stream and release its push
3996 /// registration, so the peer would never hear how the task ended.
3997 #[test]
3998 fn a_withheld_task_is_interrupted_not_terminal() {
3999 let withheld = crate::runtime::every_status()
4000 .into_iter()
4001 .find(|status| matches!(status, crate::runtime::RunStatus::Withheld { .. }))
4002 .expect("every_status lists Withheld");
4003 assert_eq!(state_of(&withheld), TaskState::AuthRequired);
4004 assert!(
4005 !super::outcome_ends_task(withheld.as_str()),
4006 "a withheld conclusion released the peer's push registration"
4007 );
4008 assert!(super::outcome_ends_task("succeeded"));
4009 assert!(super::outcome_ends_task("failed"));
4010 }
4011}