Repository navigation
a2a-rust v0.13.0
Breaking Changes
In the 0.x series a minor release may break and a patch may not
(STABILITY.md §2), and each item below is a minor-only change by that list.
The four crates bump in lockstep.
bindings/a2a-protocol-slimrpc moves with them, to 0.5.0 on the 0.13
pins: SlimRpcServer::builder takes Arc<RequestHandler>, so
a2a-protocol-server and a2a-protocol-types are public dependencies and
its requirement on them is a tight 0.13. That bump is in this release rather
than after it because the binding depends on the workspace by path as well
as by version, so ^0.12 stops resolving the moment the crates read 0.13.0.
Publishing it remains the separate step RELEASING.md describes — and the
one that has never been run.
-
RequestContextis#[non_exhaustive]and carries a newcall_context
field. Measured withcargo semver-checks check-release -p a2a-protocol-server --baseline-version 0.12.1at the time that bullet was
written: 196 checks, 195 pass, and one fails —
struct_marked_non_exhaustiveonRequestContext. That "exactly one" was
never right even then: the third bullet below renames a public field on
PurgeReport, which is public, unconditionally reachable and not
#[non_exhaustive], sostruct_pub_field_missingmust fire too. Treat the
figure as the record of one run, not as evidence that the list is complete —
the list is complete because each entry below was derived from the diff. The
added field is not a second finding, because once a struct is
#[non_exhaustive]an added field is no longer separately observable.What breaks: constructing a
RequestContextwith a struct literal from
outsidea2a-protocol-server. Nothing in this repository did —
git ls-files '*.rs' | xargs grep 'RequestContext {'finds only the
definition and function signatures, and all 19 construction sites already
useRequestContext::new.book/src/deployment/testing.mddid teach a
literal, in arust,ignorefence that nothing compiled; it now usesnew.Migration: replace
RequestContext { message, task_id, context_id, .. }
withRequestContext::new(message, task_id, context_id)followed by
.with_stored_task(..)/.with_metadata(..)for anything else you set.The attribute is deliberate rather than incidental. This type grows, and
every previous growth would have been a break for a literal nobody writes;
#[non_exhaustive]is what makes this the last time. -
EventQueueReader::readnow yields aStreamEvent, not a
StreamResponse. The event travels the queue with the position it holds
in the task's log:StreamEvent { seq: Option<u64>, event: StreamResponse }.
Reattached::Channeland the persistence channel carry the same type.What breaks: any code that matched on the result of
read()directly. The
migration is.eventfor a value, or.map(|e| e.event)for theResult:// before while let Some(Ok(StreamResponse::StatusUpdate(u))) = reader.read().await { .. } // after while let Some(Ok(ev)) = reader.read().await { if let StreamResponse::StatusUpdate(u) = ev.event { .. } }
Why it is a type change and not an accessor: the position has to be the
same number the store wrote, and the only way to guarantee that is to
assign it once and carry it. See theAddedentry below. -
PurgeReport::journal_orphans_deletedis noworphan_rows_deleted.
The sweep reclaims two side tables now, not one — the artifact journal and
the event log — so the old name described half of what the number counts.
Renamed rather than kept and widened: a field whose name names one of its
two sources is read as the count for that source.Migration: rename the field at the read site. Its meaning is unchanged for
anyone who had only the journal, and it is still normally zero (a non-zero
count means rows outlived their task, which happens on a pool without
foreign_keys=ON). -
build()refuses an agent card it would have to edit. The builder
advertises the extensions this server can honour by appending to
capabilities.extensions, andcanonicalize_cardstrips only
signatures— so those entries are inside the bytes a signature covers. A
card signed before they existed was served canonicalizing to bytes nobody
signed, and every client that verified it failed, silently, because nothing
on the server side reads the signature again. Measured on the default
configuration: the operator's card canonicalized to 360 bytes, the card
actually served to 767.book/src/deployment/security.mdtells operators to sign the card and hand
it toRequestHandlerBuilder, so the instruction and the code together
produced the failure.What breaks:
build()now returnsInvalidParamsfor a card that carries a
signature and does not already declare every extension this server
advertises. A card that declares them is served byte-identical to what was
signed; an unsigned card, or one with an empty signature list, is
advertised on as before.Migration: declare the extensions on the card before signing it —
IDEMPOTENCY_EXTENSION_URIwhen the configured store reports
supports_idempotency(), andFAILURE_EXTENSION_URIalways — then sign the
card that results. The security page shows the order in a block that
compiles. -
IdempotencyClaimandKeyErrorare#[non_exhaustive]. Both were
exhaustive public enums that callers mustmatch. Every out-of-tree
TaskStorematches on the first, and the outcomes it can report are not a
closed set — an expiry, or an "in flight, wait" that would let a second
caller be told to retry instead of handed aTaskNotFound, are both
plausible additions.STABILITY.md§4 promises new variants will not break
external implementations, and without the attribute they would have. Their
siblings added in this same release —RecordedEvent,StreamEvent,
Outcome,CheckResult,TraceContextError— were all marked; these two
were missed.What breaks: a
matchon either from outside its crate needs a wildcard
arm.IdempotencyClaimis also re-exported fromstorenow, where its
siblingRecordedEventalready sat — it is the return type of a public
TaskStoremethod and was reachable only at
store::task_store::IdempotencyClaim. -
FailureClass::ALLis&'static [FailureClass], not[Self; 5]. The
enum is#[non_exhaustive]so that adding a variant is not breaking, and
then exported a constant whose type encoded the variant count — so the
sixth variant would have changedALL's type and broken every caller that
bound it, which is the exact break the attribute three lines above it
promises not to inflict.Migration:
for c in FailureClass::ALLbecomesfor &c in FailureClass::ALL;.into_iter()becomes.iter().copied(). -
A keyed
message/sendis retried only against a peer known to honour the
key.RetryTransporttreated any valid key inMessage.metadataas
making the send retryable, on the stated grounds that a server without the
extension "refuses a keyed send outright". That is true of this SDK's
server and of nothing else: the key rides inMessage.metadata, which A2A
defines as free-form, and the extension is deliberately not part of A2A
v1.0 — a conformant Python, Java, Go or JavaScript server ignores it and
runs the send. Retrying there starts a second task, which is the exact
double executionis_idempotent_methodexists to prevent. There is no
handshake that would fix it: A2A'sA2A-Extensionsheader is the server
reporting what it activated, not a demand a server must reject.What breaks: a client built for a bare endpoint no longer retries a keyed
send after an ambiguous failure.ClientBuilder::from_cardreads the
advertisement off the agent card and enables it automatically;
ClientBuilder::with_peer_honouring_idempotency(true)asserts it for a peer
you know implements the extension. -
message.idis validated at ingress. It is now checked for being
non-empty after trimming, and bounded in length. It was checked nowhere,
while being stored verbatim inidempotency_keys.message_id, keying history
de-duplication, and interpolated into the error a caller sees when an
idempotency key is held by a different message. A spec-invalid empty id was
accepted, stored, and echoed in task history, where an empty id collides
with every other empty id.The length bound is not
max_id_length, and the difference matters.
That field is documented as covering task and context ids, which are
commonly short and often chosen by the deployment; a message id is minted by
the client and is conventionally a UUID — 36 characters, which is what
Message::id's rustdoc tells a caller to send. Holding both to one number
meant a deployment tighteningmax_id_lengthbelow 36, a reasonable thing
to do for the two ids it was documented to cover, refused every message
from a conformant client.HandlerLimits::effective_max_message_id_length
ismax_id_lengthfloored atMIN_MESSAGE_ID_LENGTH(36), so the bound
still tightens with the field but never past what the protocol requires.What breaks: a send whose
messageIdis empty, whitespace-only, or longer
thaneffective_max_message_id_length()is rejected withInvalidParams
instead of accepted.
Added
-
SSE frames carry an
id:, andLast-Event-IDresumes from it. This is
what the event log was for. A reconnecting client sends back the position of
the last frame it saw and receives exactly what it missed, after theTask
snapshot and before the live stream.The measured problem: in
mcp-bridge's demo the sample agent emits three
progress steps 120 ms apart and the caller sees one, because a snapshot
is a fold and a poller only ever observes the latest one. Three events
arriving as one is not a rendering detail — it is the intermediate states
being unobservable.The position is assigned once, in
InMemoryQueueWriter::write, and
carried on both channels. That is the whole design. The writer is the
single fan-out point — it feeds the persistence channel that writes the log
and the broadcast channel that feeds SSE — so theid:a subscriber reads
and theseqthe store holds are the same value by construction. The
alternative, counting frames at the SSE layer, agrees with the log right up
until the first lagged consumer, snapshot frame, or failed append; and a
resumption offset that is off by one drops an event with nothing to
indicate it.tests/event_log_tests/resumption.rsasserts the two
sequences are equal, end to end throughbuild_sse_response.Frames the server synthesized carry no
id:— theSubscribeToTask
snapshot, and the terminal frame the reattach hook rebuilds from stored
state. Neither is in the log, so neither can be replayed from, and giving
either one a position would hand the client an offset that loses an event.A continuation resumes the numbering. The queue is per-turn but the log
is per-task, so the writer is seeded fromlast_event_seqbefore the
executor starts. Without it a second turn would restart at 1 and, because
appends are idempotent by position, have every one of its events silently
swallowed as a replay.The offset is client-supplied, so it is bounded and parsed. Replay is
capped by the newHandlerLimits::subscribe_replay_limit(default 1,000),
and truncation is not loss: each replayed frame carries its ownid:, so a
client continues from the last one it got. ALast-Event-IDthat is not a
position is ignored rather than rejected — a client may echo one back from
an unrelated stream, and failing the reconnect over it would turn a
harmless mistake into a dropped connection. A store that keeps no log is
the same: the header is ignored and the client gets the snapshot, which is
the pre-resumption behaviour.CorsConfig's defaultAccess-Control-Allow-Headersgainslast-event-id
for the same reasona2a-versionanda2a-extensionsare already there: a
cross-origin client that cannot send the header cannot resume.WebSocket, gRPC and SLIM streams are unchanged. Resumption is the SSE
binding'sid:/Last-Event-IDpair; inventing a spelling for the others
would be a protocol extension this server made up. Note that
bindings/a2a-protocol-slimrpcis outside the root workspace and takes
RequestHandleras a public dependency, socargo check --workspacedoes
not compile it — itsevent_streamadapter needed the same one-line change
and only the binding's own gates caught it. -
An append-only event log, so what the agent emitted is recorded and not
just what it folded into.TaskStoregainssupports_event_log,
append_event,last_event_seqandread_events, all defaulted.A task's state is a fold: a stored snapshot folded together with deltas.
Issue #130 happened because that fold was wrong in a way nothing could
observe — artifacts from one task appeared on another, and the only record
was the folded result, which is to say the bug itself. There was nothing to
check it against. This adds the thing there was nothing to check against.The snapshot stays and stays authoritative for reads; the log is a
parallel, ordered record of the events themselves. That makes a wrong
snapshot detectable, gives a reconnecting subscriber the events it missed
rather than a fold it cannot interpret, and is the substrate anything like
a signed execution receipt would need.seqis a position, not a counter. The same(task_id, seq)written
twice leaves one event, so a retried or overlapping append is safe without
a read first — the propertysqlite_store::journalalready relies on, for
the same reason. That makeslast_event_seqload-bearing rather than
convenient: a task parked atinput-requiredand then continued gets a
second processor, and aseqrestarting at 1 would collide with
positions the first one wrote, so the continuation's events would be
silently dropped. Both processors seed from the store instead.Both fold paths record, which is not one site but two. A blocking send
folds insync_collector; a streaming one folds in the background
processor. A log covering only streaming sends would be a history whose
completeness depended on which method the caller happened to use. Each
records before folding, so what the agent emitted is durable before the
state derived from it is.A failed append is logged and counted under the new
event_append
persistence-error label, never fatal. The log is a record of the run, not a
precondition for it — and a gap is visible, because the sequence skips a
position.All four methods default to refusing rather than succeeding. A custom store
that does not implement them reports no log, which is inconvenient;
defaultingappend_eventtoOk(())would advertise a log that silently
lost every event, which is worse.supports_event_log()is what a caller
checks rather than discovering it by getting nothing back. -
The event log is backed by every store this crate ships, not only the
in-memory one:SqliteTaskStore,PostgresTaskStore,
TenantAwareSqliteTaskStore,TenantAwarePostgresTaskStoreand
TenantAwareInMemoryTaskStoreall reportsupports_event_log() == true.Two tables,
task_eventsandtenant_task_events, created both by the
migration runners (SQLitemigration 7,PostgreSQLmigration 5) and by
each store'sfrom_poolDDL. Both paths, because a store built by one of
them and not the other would still answersupports_event_log() == true—
the flag is a property of the type, not of the schema — so the failure
would be an empty history rather than an error at startup. That is the
mistake the artifact journal shipped with, and there is now a test for each
path on each backend.The tenant tables are keyed
(tenant_id, task_id, seq). Task ids are
caller-supplied, so two tenants may legitimately use the same one; an
unscoped log would hand one tenant's resuming subscriber the other's
messages, which is a cross-tenant read of message content rather than a
missed deduplication. Unliketenant_idempotency_keysthese tables do
carryON DELETE CASCADE: a key that outlives its task is the safe
direction, an event that outlives its task is not.Deletion is explicit as well as cascaded, and the
SQLiteretention sweep
reclaims orphans by anti-join, becauseON DELETE CASCADEonly fires with
foreign_keys=ON— which this crate's own pool sets and a pool handed to
from_poolmay not. -
A conformance harness for
AgentExecutor, behind theconformance
feature. The TCK grades servers. Nothing graded the thing an adopter
actually writes, and the paths they get wrong are the awkward ones —
cancellation arriving mid-work, a parked task reported as an error, an
event emitted after a terminal status. Those are exactly the cases people
skip when writing tests by hand.conformance::check(Arc::new(MyExecutor)).await.assert_pass()drives the
executor against a real event queue — no server, no ports, no model — and
grades thirteen protocol invariants that hold for any agent whatever it
does: it ends in a terminal or interrupted state, its transitions are legal,
nothing follows a terminal status, artifacts carry ids, parking is not
reported as an error, it returns within the harness's time limit, it
observes a cancellation token set before it starts and one that arrives
during its first write, it does not report cancellation as a failure,
cancelleaves subscribers a terminal state, and it does not panic — on
the normal run, the cancelled run, or the mid-run cancellation. It says
nothing about whether the agent is any good at its job.Three rules keep the score honest, and each is the repository's own. A
check that did not apply is not graded rather than counted as a pass — a
report that grades nothing fails rather than reporting full marks, which is
the failuretck/'s README records having shipped once. Every verdict
carries its reason, so a failure says what was observed and why it matters,
not just which check. And the executor runs insidetokio::spawn, so a
panicking executor is a graded failure rather than a panic that takes the
adopter's whole test run down with it.Its own tests are deliberately broken executors, each breaking one invariant
(or, where an executor genuinely breaks two, naming both), asserting the
harness names those and no others. A conformance
harness whose failures are untested is a gate that cannot fail, which is
the thing this repository checks for everywhere else — and the discipline
paid immediately: the first draft of one fixture broke two invariants
rather than one, and the harness was right where the test was wrong.No new dependencies. Documented on
book/src/deployment/testing.mdwith a
compiled example. -
A failed task says why, as a class a caller can
matchon. Shipped as
the declared extensionhttps://a2a-rust.com/extensions/failure/v1, so
servers stay conformant and the TCK is unaffected.A failed task was
TaskState::Failedplus prose written by whoever wrote
the executor, which left a caller matching English for decisions that are
genuinely different: never retry and fix the request; retry with backoff;
stop and escalate to a person; retry only with more budget. An orchestrator
without those either retries what can never succeed or abandons what would
have worked on the second attempt.a2a_protocol_types::failureholdsFailureClass—InvalidRequest,
Transient,PolicyRefusal,BudgetExhausted,Internal— with
is_retryable()andneeds_human(), plusset_class/class_of.
Task::failure_class()reads it off the status message. The prose stays
exactly where it was, for the human reading the incident.Most classification is automatic and its limit is stated rather than
papered over. When an executor returnsErr, the server maps the error
code — which can only ever produceInvalidRequestorInternal, because
noErrorCodecarries the meaning "transient" or "refused on policy".
GuessingTransientthere would tell callers to retry things that can
never succeed. An executor deadline isBudgetExhausted, since a deadline
is a bound that was hit rather than an agent that broke. The two classes no
error code can express need the agent itself, which is what
EventEmitter::fail(class, reason)is for.BudgetExhaustedis deliberately notis_retryable(): the identical
request hits the identical bound. It is retryable with a larger budget,
which is a different request. Asserted in its own test so the distinction
cannot be quietly relaxed into "retry anything that is not the caller's
fault".An unrecognised class from a newer peer reads as
Internalrather than as
an error. A peer that classifies more finely must not have its failures
become unreadable, and "something went wrong at the agent" is true of every
class a future version could add.Advertised unconditionally on the card, unlike idempotency, whose
advertisement is gated on the store: every server classifies, because the
classification happens in the server's own failure path. Neverrequired,
and an operator's own declaration is left alone.New book page,
book/src/client/failure-classes.md, with both examples
compiled and run as doctests. -
W3C trace context crosses an A2A hop. The defining property of A2A is
that work crosses process and organisational boundaries, and until now
nothing survived the crossing: each agent mints its own task id, so even
correlating by hand failed at the first hop. One identifier now does —
traceparent, which every other ecosystem already speaks.a2a_protocol_types::trace_contextis the wire format:TraceContext
with strict W3C parsing, the
traceparent/tracestateheader names, andchild/child_bytesfor
deriving the next hop. It mints nothing — this crate depends onserdeand
serde_jsonand has no random source — so a caller supplies the identifier
bytes, the same call theArtifact::newid already makes.The server parses an inbound
traceparent, advances the span, and exposes
it asRequestContext::trace_context(). The client gains
TracePropagationInterceptor, which writes the ambientCurrentTraceonto
every outbound request on the canonical A2A transports — JSON-RPC, REST and
gRPC. Thewebsockettransport is the exception, and cannot be
otherwise: it carries headers only on the HTTP upgrade, and a
traceparentfixed at connect time would report every request on that
connection as the same span, which W3C §3.4 forbids. A dropped trace header
is reported once per connection atwarn; a dropped credential header is
still reported on every call, because that one can be fixed by reconnecting.
CurrentTrace::scopesets it and
CurrentTrace::start_root()begins a chain. The indirection through a
task-local is deliberate: a client is built once and reused for many
delegated calls, so a trace fixed at construction would label all of them
with the first request's.Three decisions worth stating, because each could reasonably have gone the
other way. The server propagates and never invents —Nonefrom
trace_context()is evidence that the caller was not tracing, not a gap in
the plumbing, and a chain that wants a trace starts one at its first hop.
A malformedtraceparentis dropped, not repaired — uppercase hex, an
all-zero id, a bad width: attaching work to a guessed-at trace is a wrong
answer where a missing trace is only an absent one. An explicit header on
the request wins over the ambient scope, because silently replacing it
would move the callee into a different trace than its caller asked for.This is propagation, not tracing. Nothing records a span, measures a
duration or exports anything, and theotelfeature is still metrics-only.
What it buys is that whatever does record spans can stitch the hops
together — across languages, sincetraceparentis a wire format and the
interoperability kit already runs Python, JavaScript, Go and Java agents.Proven over a real socket rather than asserted:
tests/trace_propagation_e2e.rs
drives two chained agents and checks that the trace id holds across both
hops while each hop advances the span — the two ways a propagator can be
wrong while looking right. -
An executor can see who called it.
RequestContextgains
call_context, and with it five accessors:caller_identity(),
tenant(),http_header(name),activated_extensions()and
request_id().The handler already built a
CallContextcarrying all of this and handed
it to the interceptor chain — then dropped it.build_request_contexttook
a message, two ids and a metadata blob, so the seam was severed by
construction. The consequence was not cosmetic: an executor could not
enforce "only this tenant may invoke this skill", which rules out a large
class of real deployments, and the only channel left for anything
caller-specific wasMessage.metadata— which the caller writes, so it
is not a fact about the caller at all.Every accessor returns
Nonerather than a default when nobody said.
An executor that refuses anonymous work needsNoneto mean "nobody
established an identity", never a value that reads like an answer.
call_contextisNonewhen an executor is driven directly, as a unit
test or a conformance harness does, rather than being given a synthetic
context claiming a call that never happened.CallContextalso gainstenant()andwith_tenant()— private field,
so additive — populated from the tenant scope the handler has already
entered. -
Constructors for
Message,TaskandMessageSendParams, and a
# Constructionpointer on the threeagent_cardstructs. Measured on
this tree, the workspace holds 105Messagestruct literals, 75
MessageSendParamsliterals and 82AgentCardliterals.Messagehad two
inherent methods and no constructor;TaskandMessageSendParamshad no
implblock at all, so sending one line of text cost a ten-lineMessage
literal — five of its eight fields written asNone— inside a four-line
MessageSendParamsliteral, and reading the answer back out cost a
hand-writtenartifacts → first → textwalk that six files under
examples/had each written separately.Message::new,user,agent,user_text,agent_text, andwith_*for
all five optional fields.MessageSendParams::newpluswith_tenant,
with_configuration,with_metadata.Task::textandTask::texts.
Nothing is breaking: every field stays public and every literal still
compiles.The id stays a parameter rather than something the constructor invents.
a2a-protocol-typesdepends onserdeandserde_jsonand nothing else,
so it has no random source, and a clock-derived id is not unique under
concurrency — the same reason
Artifact::newalready takes one.Task::textskips artifacts that carry no text rather than stopping at the
first, so it answers where the hand-writtenartifacts.first()chain
returnsNone. That divergence is deliberate, documented on the method, and
asserted in its test; it is the ruleMessage::textalready applies across
parts.The
# Constructionsections exist because discoverability, not absence,
was theAgentCardproblem: the twelvewith_*builders have shipped since
0.10 inagent_card/builders.rs, one file over from the field definitions,
and three examples written in a single session on 2026-09-19 each reached
for a literal instead — 38, 43 and 45 lines, verified by counting the three
functions. -
Client-supplied idempotency keys on
message/send, as a declared
extension. A send whose connection drops after the request bytes are on the
wire is ambiguous: the task may exist or it may not. The client rightly
refuses to retry it —message/sendcreates server-side state — which left a
caller with no recovery but to list a context's tasks and pattern-match
message content to guess whether the send landed. With a key the server
deduplicates on, a retry returns the task the first attempt created, in
whatever state it reached, and the agent runs exactly once.Not part of A2A v1.0, and shipped as
https://a2a-rust.com/extensions/idempotency/v1so servers stay conformant
and the TCK is unaffected.Message.extensionsis a list of extension URIs
and cannot carry a value, so the key travels inMessage.metadataunder
a2a-rust.com/idempotency-keywhileextensionsdeclares the URI.
a2a_protocol_types::idempotencyholds the constants,set_key,key_of
andvalidate_key.Keys are scoped to the tenant, not the context: a retry of a send whose
context_idthe server assigned would otherwise mint a fresh context and
miss the deduplication, which is the case the key exists for. They must be
unguessable — within a tenant the key is the handle to the task — so
MIN_KEY_LENrejects the short mistakes and the character set is restricted
to ASCII alphanumerics and-,_,.,:, because a key reaches store
keys and log lines.Three outcomes, and the third is the one worth stating. A free key is
claimed; the same message replays to the first task without executing
anything; a different message on the same key is refused with
InvalidParams. A genuine retry resends the identical message, so a
differing message id means the key was reused across two distinct sends, and
returning the first task there would answer a message that was never sent —
a silent wrong answer, which is the failure a caller can least detect.The claim sits under the per-context lock, after everything that can reject
the request on its own terms and before the send's first side effect:
claiming later would let two racing duplicates each lease an event queue,
claiming earlier would burn a key on a request that was never going to run.
Every failure path after it releases the key, the discipline the queue lease
and cancellation token already follow — a key held by a send that never
created a task would make the caller's own retry replay to a task that never
existed. A streaming replay attaches to the original task's live queue as
SubscribeToTaskdoes, rather than returning a snapshot and closing, which
would read as a task that had finished emitting. -
Agent cards advertise idempotency support, derived from the store. The
builder adds the extension exactly whenTaskStore::supports_idempotency()
istrue, so the card and the behaviour cannot drift: swapping in a store
without the index removes the advertisement in the same change. An operator's
own declaration is left untouched,requiredflag included; the generated
entry is neverrequired, since a client that sends no key must not be
locked out. A server whose store cannot honour a key refuses a keyed send
rather than running it undeduplicated, so the failure mode is a loud refusal
and never silent at-least-once delivery. -
Every bundled store honours keys: in-memory,
SQLite,PostgreSQLand
the tenant-aware variant of each. The SQL stores claim inside one
transaction —INSERT ... ON CONFLICT DO NOTHINGpicks the winner and the
loser reads the holder underSQLite's write lock or Postgres's
FOR UPDATE— because a claim that neither inserted nor found a holder must
never pass for a successful one. The tenant-aware SQL stores key the table
(tenant_id, key)to matchtenant_tasks: without the tenant in that key
one tenant's key would collide with another's and the second tenant's send
would replay to the first tenant's task. No table carries a foreign key to
the tasks table, deliberately — a cascade would free the key when a retention
sweep removed its task, letting that send run a second time.TaskStoregainssupports_idempotency,claim_idempotency_keyand
release_idempotency_key. All three have defaults, so existing custom stores
keep compiling; the defaults report no support rather than a successful
claim, so forgetting to implement them cannot produce a send that quietly
runs twice. -
A keyed send is retried on the ambiguous failure it was built for
(client).SendMessageandSendStreamingMessageremain non-idempotent by
method, but a send carrying a valid key now joins the idempotent set for that
call, so a dropped connection is retried instead of surfaced. The safety
argument needs no capability negotiation: either the key is honoured and the
retry is deduplicated, or the server refuses the keyed send outright rather
than running it undeduplicated. The key is validated rather than merely
found, so the predicate answers "this send will be deduplicated" and not
"something is present under that name". -
Book: "Idempotent Sends" (
book/src/client/idempotency.md), covering how
to attach a key, how to choose one, what the three outcomes mean, how to tell
whether a server honours keys, and the two obligations on a custom store —
an atomic claim, and never deleting a key with its task. Every Rust block on
the page is compiled and run as a doctest. -
Tool calling, in
examples/rig-agent. Nothing in this repository showed
it:grep -ril 'tool_call\|ToolCall\|tool_choice\|function_call'over
examples/,crates/andbook/src/returned zero files, and the rig
example's own comment said "no tools, no history". An A2A SDK whose examples
never call a tool cannot show an adopter how to build an agent that does
anything, which is the gap this closes.It is deliberately not in the crates. A2A has no tool concept — the
protocol carries messages, tasks and artifacts between agents and says
nothing about what happens inside one — so tool calling belongs between an
executor and its model, andexamples/rig-agent/src/tools.rsimports no
a2a-protocol-*type at all. Many model turns, one A2A task. Putting a tool
abstraction ina2a-protocol-serverwould make it a framework and put it on
a model-provider's release treadmill; hosting one costs neither.src/agent.rswrites the loop out rather than delegating torig-agent's
runner, because the loop is the part an adopter has to get right. Three
things it gets right, each a bug if the shape is copied without them: the
catalogue goes on every request, since a provider holds no state between
turns; a tool error is returned to the model as that call's result rather
than failing the task, so an unknown service is recoverable; and the loop is
bounded (MAX_TURNS, 6), because unbounded it holds the A2A task open until
the server's hour-long executor timeout.src/tools.rsdeclares two tools
over a fixed inventory — one with no arguments, one with a required string —
which covers both JSON Schema shapes a provider must encode and, because a
model must discover the inventory before querying it, produces a two-round
loop. One round is the case that still works when the loop is written wrong.When tools ran, the task carries a second
tool-traceartifact naming each
call and its result. Eleven new tests drive the loop against a scripted
model that records what it was sent, covering the tool-error and turn-limit
branches a live provider reaches only by chance. -
examples/mcp-agent— tools over MCP, agents over A2A. The A2A project's
own guidance is that the two protocols compose: "A2A handles inter-agent
collaboration and MCP handles tool integration." Nothing showed what that
looks like, so this does. The agent spawns an MCP server as a child process,
asks it what it offers withtools/list, builds its catalogue and its
agent-card skills from the answer, and calls it withtools/call. A2A
never carries a tool call; MCP never carries a task; they meet in one
process and nowhere else.It is
rig-agentwith exactly one thing changed, which is the point. Diff
the twosrc/agent.rsfiles: the loop is the same — same three rules, same
bound — and only the tool source moves. Nothing in the crate knows what the
tools are, soMCP_SERVER_BINrepoints it at any other MCP stdio server, in
any language, with no code change.The distinction the bridge exists to keep is between a tool that ran and
refused and a session that is gone. The first is information for the
model — it can read the reason and try something else — so it returns as a
tool result and the A2A task stays alive. The second cannot be re-prompted
away, and answering regardless would hand the caller a guess dressed as a
researched answer, so the task fails. The model-unreachable fallback the
sibling examples use is deliberately not extended to a dead MCP server, and
an agent that cannot reach its tools at startup refuses to start rather than
serve a card advertising them.Built on
rmcp3.4, the official Rust MCP SDK, for both halves: the client
and the bundledmcp-tool-serverbinary. Fourteen tests, and the split
matters — ten drive the bridge over an in-processtokio::io::duplexpipe,
which is real MCP framing and reaches the branches (a refusal, a session
dying mid-call, a model that never stops calling); four spawn the shipped
binary the way a real MCP client does, which is what proves its schemas
reach the wire from theJsonSchemaderive and that itsmainwrites
nothing but MCP to stdout. A strayprintln!there desynchronizes every
frame, and no in-process test would notice.Verified end to end against llama.cpp
b23701fserving Qwen3-1.7B: both
demo questions answered over A2A with tools fetched over MCP, including the
recovery path — the server refused an unknown service, the refusal crossed
back as a tool result, the model read the hint the server wrote and called
the tool it named. Worth noting from that run: the first question took two
MCP round trips where the same question againstrig-agent's in-process
tools took one. The tool count is the model's choice, not a property of the
code, which is why the bound is not optional. -
examples/mcp-bridge— the reverse direction: an A2A agent, callable as
an MCP tool.mcp-agentserves A2A agents; this serves everyone else. An
MCP client needs no A2A library, no A2A concept and no new code to call a
remote agent — it needs a command in its server list. The bridge discovers
the agent card at startup, publishes one MCP tool per advertised skill with
the agent's own descriptions, and forwards each call as an A2A task.More of A2A survives the crossing than expected, and that is the finding
worth recording.Completedis a tool result andFailed/Rejected/
CanceledcarryisError, which is unsurprising. What is new is that
MCP's2026-07-28specification added long-running tasks (SEP-2663), so an
A2A task now maps onto an MCP task rather than a blocked request: the
caller pollstasks/get, the agent's own status messages cross to it, and
tasks/cancelreaches the agent asCancelTask, cooperative on both
sides. Before that extension this bridge could not have been built without
lying about one side or the other.Three asymmetries are stated where they happen rather than papered over.
A2A skills carry no argument schema — A2A's calling convention is a
Message— so every published tool takes one string and the skill's
description is what steers a caller's model; inventing a schema per skill
would publish a contract the agent never agreed to. A2A has no skill
selector, so the chosen tool travels as message metadata the agent may
route on or ignore. Andinput-requiredis reported rather than bridged:
MCP has a counterpart, but wiring it needs the bridge to hold an A2A task
id across MCP calls, and a half-built version would strand tasks with no
way to answer them, so a paused task returns an error saying what the agent
is waiting for — a caller can tell "waiting" from "broken".It refuses to start when the agent is unreachable, when the card advertises
no skills, and when two skill ids collide as MCP tool names (naming both).
An MCP server that came up with an empty or wrong tool list is
indistinguishable, to its caller, from an agent with nothing to offer.Fifteen tests. The mapping is pure, so its awkward cases are asserted on
values; the bridge needs a real agent, so those tests start one on
localhost and drive the bridge with a real MCP client over an in-process
pipe.bridge-democovers the child-process spawn by standing up a sample
agent, spawning the bridge against it and acting as an MCP client — no
model, no network, deterministic.Its transcript is in the README, and it earns its place: the sample agent
echoes back the skill hint, proving the metadata arrived; the agent's own
progress message crosses two protocols intotasks/get; and it arrives
coarsened — three agent steps 120 ms apart, of which the caller sees one,
because the bridge polls at 250 ms. The cost of polling rather than
streaming, visible in the output rather than discovered later. -
The OTLP export sequence is documented.
book/src/deployment/observability.md
covered metrics conceptually and the instrument catalogue, and said nothing
about how to make bytes leave the process. It now carries the call order as
a compiled example, and the four things that bite:init_otlp_pipelinemust be called from inside a Tokio runtime — tonic
spawns onto the ambient runtime while building the channel — and since the
release profile setspanic = "abort", getting that wrong aborts the
process rather than returning an error. The transport is gRPC only, so
OTEL_EXPORTER_OTLP_PROTOCOLhas no effect and an endpoint pointed at a
collector's:4318produces silence.OTEL_SERVICE_NAMEis overridden
by theservice_nameargument:Resource::builder()installs
EnvResourceDetector, which reads it, and.with_attributesthen
overwrites what it found — the opposite of what the OTel
environment-variable specification prescribes. And the install is
last-write-wins, process-wide.A table now says which
OTEL_*variables reach the exporter (endpoint,
headers, timeout) and which do not (protocol, service name).book-tests
gains theotelfeature so the sequence compiles as a doctest rather than
being anignored block, which is the same reasonpostgreswas added for
the horizontal-scaling page.The catalogue section gains the Prometheus name for every instrument,
measured by rendering the catalogue throughopentelemetry-prometheus
0.32.0 rather than derived from the translation rules:a2a.server.requests
arrives asa2a_server_requests_total,a2a.server.latencyas
a2a_server_latency_seconds, and gauges keep their names unsuffixed. The
dotted names are correct — OTel names metrics
http.server.request.duration— and nothing in the book had previously
said what they translate to, which is the likeliest reason mapping them
took guesswork.Two deviations are now stated there rather than changed, because the page
calls these names a contract and changing a published contract needs its
own release and upgrade note: the units are not UCUM (requestshould be
{request}), which the same measurement shows costs nothing observable —
UCUM and non-UCUM units render byte-identical exposition through this
exporter — anda2a.server.latencyshould conventionally be
a2a.server.request.duration, which is visible, since no OTel dashboard
template will matcha2a_server_latency_seconds.
Changed
-
Seven example files and five book pages now use the new constructors.
Net 113 lines removed fromexamples/and 108 frombook/:rig-agent's 43-line
AgentCardliteral,mcp-bridge's 45-line one andmcp-agent's 38-line
one become chained builders;hello-agent's 16-line send becomes
MessageSendParams::new(Message::user("test-msg", parts))and its
five-line artifact walk becomestask.text(). The examples are what an
adopter copies, so leaving them on the literal would have shipped the
ergonomics and taught around them.Three cards stopped setting
AgentCard.url. That field is
#[serde(skip_serializing)]— a served card never carries it, so no client
could ever read what those agents were setting — and its own doc says to
publish an address throughsupported_interfaces, which all three already
did.rig-agent's test asserted on it; it now asserts on
supported_interfaces[0].url, which is the address a client actually sees.examples/mcp-agentandexamples/mcp-bridgeeach carried auuid_like()
helper — a nanosecond timestamp, not unique under concurrency and
sequential enough to guess — written to avoid auuiddependency the
workspace already carries and seven other examples already use. Both now
takeuuidand callUuid::new_v4().One book snippet did not compile.
book/src/deployment/testing.mdset a
context_idfield onMessageSendParams, which has no such field — the
snippet was inside arust,ignorefence, so nothing ever built it. It is
now three lines that would. -
examples/rig-agentdefaults toqwen3:1.7b, notqwen3.5:0.8b.
Measured 2026-09-19 against llama.cppb23701fwith--jinjaand the
example's own catalogue: Qwen3.5-0.8B-Q4_0 answers in prose, and forced with
tool_choice: "required"still emits no tool call, running to
finish_reason: "length"after 7,400+ tokens. Qwen3-1.7B-Q4_K_M returns
finish_reason: "tool_calls"on the first request. The sibling LLM examples
call no tools, so their 0.8B default stays right for them; the README
records both results so the next person does not rediscover this.The end-to-end run is in
examples/rig-agent/README.mdas two verbatim
transcripts. The first is kept with the model's arithmetic error intact —
it reports an uptime the tool never returned, and the trace line beneath it
shows the real figure. That is the argument for the trace artifact stated as
evidence rather than as a claim: grounded and verifiable are different
properties, and only the second is one the protocol can carry.
Fixed
-
The idempotency extension's card description carried 30 literal
spaces. Someone reflowed a multi-line string literal without stripping
the indentation, so the description a client reads off the card said "a
retried … send returns". Noticed while adding the
failure extension beside it. -
Three documents said the SDK had no
traceparent, and now say what it
does have.otel/pipeline.rs's "no part of this workspace reads or
writes W3Ctraceparent",book/src/deployment/observability.md's "There
are no traces", and thedocs/rust-sdk-assessment.mdcomparison row were
all accurate on 2026-09-19 and were made false by the change above. Each
now separates the two claims that were being run together: no spans are
exported, which is still true, and trace context is not carried, which is
no longer. The assessment row has now been corrected twice in opposite
directions, which is recorded there rather than quietly overwritten. -
The spawned executor ran under the empty tenant.
spawn_executorused
a baretokio::spawn, andTenantContextis atokio::task_local, which
a spawned task does not inherit. So every store call an executor made ran
withTenantContext::current() == ""— against a tenant-aware store, that
partitions the executor's writes away from the request that caused them.
The handler's own doc comment recorded the mechanism and the measurement
("The same probe's executor saw\"\"") without the spawn being fixed.Two other spawns in the same crate already did the right thing — the
background event processor and the sync collector both capture the tenant
and re-enterTenantContext::scope. This one now does too.The regression test is
the_spawned_executor_runs_inside_the_tenant_scope
intests/request_context_tests.rs, and it was checked against the defect
rather than merely written: with the scope removed it fails naming the
empty tenant, with it in place it passes. -
A test's panic hook was silencing every other test in its binary. Three
tests — two ina2a-protocol-server'sagent_card/hot_reload.rs, one in
a2a-protocol-client'sauth.rs— wrapped an expected panic in
take_hook()/set_hook(Box::new(|_| {}))/set_hook(hook).set_hook
is process-global and libtest runs a binary's tests as parallel threads in
one process, so each of those windows silenced every thread for its
duration: a genuinely failing test that raced one was listed under
failures:with no---- stdout ----section and no message, and the build
went red with its reason absent from the log, non-deterministically.It had already cost something measurable. Three gates in
scripts/prove_gates_fail.sh—
cargo test -p a2a-protocol-server --features {sqlite,postgres,auth-jwt}—
reportedINCONCLUSIVErather thanPROVEN, because the injected defect
failed correctly and its panic message never reached the output the prover
greps.The fix is a deletion at all three sites.
catch_unwindreturns the payload
each test asserts on, and libtest already captures panic output per test and
discards it when the test passes, so the swap was buying a suppression it
already had. Measured on the pattern in isolation: an unrelated failing test
in the same binary lost its marker in 3 of 3 parallel runs with the swap and
kept it in 3 of 3 without, and the expected panic's own text appeared in 0 of
3 runs either way. -
examples/rig-agent's executor was undocumented. Its doc comment had
run together with the one below it, so both attached toSLOW_PREFIXand
RigAgentExecutorcarried none. -
Three documents claimed OpenTelemetry trace export that does not
exist. Theotelfeature is metrics-only:opentelemetry_sdkis
compiled withfeatures = ["metrics", …],opentelemetry-otlpwith
["grpc-tonic", "metrics"], there is noTracerProvider, and
grep -rni 'traceparent\|tracestate' crates/matched nothing before this
change — it now matches exactly the one doc comment added here that says so.
Corrected in
book/src/deployment/troubleshooting.md("Metrics / traces over
OTLP"),docs/rust-sdk-assessment.md(✅ otel feature: **traces** + metrics, in a row comparing this project againsta2a-rs— an overclaim
in the worst possible place, now◑ metrics onlywith a §10 recording the
change), andbook/src/deployment/observability.md, which told readers
that identifiers on spans let one incident "be followed across the
delegation chain when agents call agents". It cannot: separate processes
produce separate span trees, and each agent mints its own task id, so the
identifier changes at every hop. -
init_otlp_pipeline_with_endpoint's rustdoc said
OTEL_EXPORTER_OTLP_PROTOCOLstill applies. It cannot —
MetricExporter::builder().with_tonic()is hard-coded and the
HTTP/protobuf exporter is not compiled in. A reader who set
http/protobufand pointed the endpoint at a collector's:4318would
get gRPC spoken at an HTTP port, silence, and a doc line saying that
should have worked.
Internal
-
scripts/check_panic_hooks.shforbids process-global panic hooks. The
regression test for the defect above, and for its class: a grep gate is the
only mechanical check available, because "some other test lost its message"
is not observable from inside the test that lost it. Registered inci.yml's
Format job, paired with an injection inscripts/prove_gates_fail.sh, and
listed in the gate-reachability input table. Proven both ways before
shipping — exit 0 on the fixed tree, exit 1 naming all six lines on the tree
as it stood, andprove_gates_fail.sh --only check_panic_hooksreporting
PROVEN with the tree clean afterwards. The harness counts 66 gates now.The three gates the hook had made INCONCLUSIVE —
cargo test -p a2a-protocol-server --features {sqlite,postgres,auth-jwt}—
were re-run afterwards and all three report PROVEN, "gate exited 101 citing
the injected defect". With a local PostgreSQL installed, three gates that
had been PRE-BROKEN for want of one prove as well, so the selection is
9 proven, 0 unproven. -
check_doc_versions.pygates dependency snippets in prose against the
current release line.a2a-protocol-sdk = "0.7"means^0.7, which resolves
to nothing in the 0.12 line, so a reader copying it got an SDK without any
fix released since — including the #130 artifact-duplication fix. Measured at
the 0.12.1 tag, 28 snippets across 14 files named 0.7, 0.8 or 0.11 against
exactly one that was current.release.ymlcould not catch it: it verifies
the four crate manifests against the tag, and prose is not a manifest.
Historical snippets are allowlisted with a reason each, and an entry matching
no snippet fails the gate, so the allowlist cannot rot either. -
tck.ymlgates the three example agents' TCK grades. Those figures were
dated manual measurements printed beside "no CI job gates it", and one had
already drifted to "20/20" before a person reading the page caught it. The
blocker was recorded as a CI-cost decision; it was not one. The TCK grades
protocol conformance, which never reaches the executor's brain, and all three
agents are written to serve without a model — measured 2026-09-17 with no
OPENAI_API_KEY, noOPENAI_BASE_URLand nothing on any model port, all
three score 21/21 graded, 0 failed, 1 N/A, the same figure the pages claimed.
The newtck-example-agentsmatrix costs a build per agent and no secret.
Eight documentation sites now name the job instead of disclaiming one.