Skip to content

a2a-rust v0.13.0

Choose a tag to compare

@github-actions github-actions released this 20 Sep 19:44
· 1061 commits to main since this release
391f0df

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.

  • RequestContext is #[non_exhaustive] and carries a new call_context
    field.
    Measured with cargo semver-checks check-release -p a2a-protocol-server --baseline-version 0.12.1 at the time that bullet was
    written
    : 196 checks, 195 pass, and one fails —
    struct_marked_non_exhaustive on RequestContext. 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], so struct_pub_field_missing must 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 RequestContext with a struct literal from
    outside a2a-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
    use RequestContext::new. book/src/deployment/testing.md did teach a
    literal, in a rust,ignore fence that nothing compiled; it now uses new.

    Migration: replace RequestContext { message, task_id, context_id, .. }
    with RequestContext::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::read now yields a StreamEvent, 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::Channel and the persistence channel carry the same type.

    What breaks: any code that matched on the result of read() directly. The
    migration is .event for a value, or .map(|e| e.event) for the Result:

    // 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 the Added entry below.

  • PurgeReport::journal_orphans_deleted is now orphan_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, and canonicalize_card strips 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.md tells operators to sign the card and hand
    it to RequestHandlerBuilder, so the instruction and the code together
    produced the failure.

    What breaks: build() now returns InvalidParams for 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_URI when the configured store reports
    supports_idempotency(), and FAILURE_EXTENSION_URI always — then sign the
    card that results. The security page shows the order in a block that
    compiles.

  • IdempotencyClaim and KeyError are #[non_exhaustive]. Both were
    exhaustive public enums that callers must match. Every out-of-tree
    TaskStore matches 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 a TaskNotFound, 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 match on either from outside its crate needs a wildcard
    arm. IdempotencyClaim is also re-exported from store now, where its
    sibling RecordedEvent already sat — it is the return type of a public
    TaskStore method and was reachable only at
    store::task_store::IdempotencyClaim.

  • FailureClass::ALL is &'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 changed ALL'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::ALL becomes for &c in FailureClass::ALL; .into_iter() becomes .iter().copied().

  • A keyed message/send is retried only against a peer known to honour the
    key.
    RetryTransport treated any valid key in Message.metadata as
    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 in Message.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 execution is_idempotent_method exists to prevent. There is no
    handshake that would fix it: A2A's A2A-Extensions header 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_card reads 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.id is 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 in idempotency_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 tightening max_id_length below 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
    is max_id_length floored at MIN_MESSAGE_ID_LENGTH (36), so the bound
    still tightens with the field but never past what the protocol requires.

    What breaks: a send whose messageId is empty, whitespace-only, or longer
    than effective_max_message_id_length() is rejected with InvalidParams
    instead of accepted.

Added

  • SSE frames carry an id:, and Last-Event-ID resumes 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 the Task
    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 the id: a subscriber reads
    and the seq the 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.rs asserts the two
    sequences are equal, end to end through build_sse_response.

    Frames the server synthesized carry no id: — the SubscribeToTask
    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 from last_event_seq before 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 new HandlerLimits::subscribe_replay_limit (default 1,000),
    and truncation is not loss: each replayed frame carries its own id:, so a
    client continues from the last one it got. A Last-Event-ID that 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 default Access-Control-Allow-Headers gains last-event-id
    for the same reason a2a-version and a2a-extensions are 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's id:/Last-Event-ID pair; inventing a spelling for the others
    would be a protocol extension this server made up. Note that
    bindings/a2a-protocol-slimrpc is outside the root workspace and takes
    RequestHandler as a public dependency, so cargo check --workspace does
    not compile it — its event_stream adapter 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.
    TaskStore gains supports_event_log,
    append_event, last_event_seq and read_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.

    seq is 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 property sqlite_store::journal already relies on, for
    the same reason. That makes last_event_seq load-bearing rather than
    convenient: a task parked at input-required and then continued gets a
    second processor, and a seq restarting 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 in sync_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;
    defaulting append_event to Ok(()) 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, TenantAwarePostgresTaskStore and
    TenantAwareInMemoryTaskStore all report supports_event_log() == true.

    Two tables, task_events and tenant_task_events, created both by the
    migration runners (SQLite migration 7, PostgreSQL migration 5) and by
    each store's from_pool DDL. Both paths, because a store built by one of
    them and not the other would still answer supports_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. Unlike tenant_idempotency_keys these tables do
    carry ON 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 SQLite retention sweep
    reclaims orphans by anti-join, because ON DELETE CASCADE only fires with
    foreign_keys=ON — which this crate's own pool sets and a pool handed to
    from_pool may not.

  • A conformance harness for AgentExecutor, behind the conformance
    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,
    cancel leaves 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 failure tck/'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 inside tokio::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.md with a
    compiled example.

  • A failed task says why, as a class a caller can match on. Shipped as
    the declared extension https://a2a-rust.com/extensions/failure/v1, so
    servers stay conformant and the TCK is unaffected.

    A failed task was TaskState::Failed plus 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::failure holds FailureClass — InvalidRequest,
    Transient, PolicyRefusal, BudgetExhausted, Internal — with
    is_retryable() and needs_human(), plus set_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 returns Err, the server maps the error
    code — which can only ever produce InvalidRequest or Internal, because
    no ErrorCode carries the meaning "transient" or "refused on policy".
    Guessing Transient there would tell callers to retry things that can
    never succeed. An executor deadline is BudgetExhausted, 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.

    BudgetExhausted is deliberately not is_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 Internal rather 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. Never required,
    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_context is the wire format: TraceContext
    with strict W3C parsing, the
    traceparent/tracestate header names, and child / child_bytes for
    deriving the next hop. It mints nothing — this crate depends on serde and
    serde_json and has no random source — so a caller supplies the identifier
    bytes, the same call the Artifact::new id already makes.

    The server parses an inbound traceparent, advances the span, and exposes
    it as RequestContext::trace_context(). The client gains
    TracePropagationInterceptor, which writes the ambient CurrentTrace onto
    every outbound request on the canonical A2A transports — JSON-RPC, REST and
    gRPC. The websocket transport is the exception, and cannot be
    otherwise:
    it carries headers only on the HTTP upgrade, and a
    traceparent fixed 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 at warn; a dropped credential header is
    still reported on every call, because that one can be fixed by reconnecting.
    CurrentTrace::scope sets 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 — None from
    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 malformed traceparent is 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 the otel feature is still metrics-only.
    What it buys is that whatever does record spans can stitch the hops
    together — across languages, since traceparent is 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. RequestContext gains
    call_context, and with it five accessors: caller_identity(),
    tenant(), http_header(name), activated_extensions() and
    request_id().

    The handler already built a CallContext carrying all of this and handed
    it to the interceptor chain — then dropped it. build_request_context took
    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 was Message.metadata — which the caller writes, so it
    is not a fact about the caller at all.

    Every accessor returns None rather than a default when nobody said.
    An executor that refuses anonymous work needs None to mean "nobody
    established an identity", never a value that reads like an answer.
    call_context is None when 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.

    CallContext also gains tenant() and with_tenant() — private field,
    so additive — populated from the tenant scope the handler has already
    entered.

  • Constructors for Message, Task and MessageSendParams, and a
    # Construction pointer on the three agent_card structs.
    Measured on
    this tree, the workspace holds 105 Message struct literals, 75
    MessageSendParams literals and 82 AgentCard literals. Message had two
    inherent methods and no constructor; Task and MessageSendParams had no
    impl block at all, so sending one line of text cost a ten-line Message
    literal — five of its eight fields written as None — inside a four-line
    MessageSendParams literal, and reading the answer back out cost a
    hand-written artifacts → first → text walk that six files under
    examples/ had each written separately.

    Message::new, user, agent, user_text, agent_text, and with_* for
    all five optional fields. MessageSendParams::new plus with_tenant,
    with_configuration, with_metadata. Task::text and Task::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-types depends on serde and serde_json and nothing else,
    so it has no random source, and a clock-derived id is not unique under
    concurrency — the same reason
    Artifact::new already takes one.

    Task::text skips artifacts that carry no text rather than stopping at the
    first, so it answers where the hand-written artifacts.first() chain
    returns None. That divergence is deliberate, documented on the method, and
    asserted in its test; it is the rule Message::text already applies across
    parts.

    The # Construction sections exist because discoverability, not absence,
    was the AgentCard problem: the twelve with_* builders have shipped since
    0.10 in agent_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/send creates 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/v1 so servers stay conformant
    and the TCK is unaffected. Message.extensions is a list of extension URIs
    and cannot carry a value, so the key travels in Message.metadata under
    a2a-rust.com/idempotency-key while extensions declares the URI.
    a2a_protocol_types::idempotency holds the constants, set_key, key_of
    and validate_key.

    Keys are scoped to the tenant, not the context: a retry of a send whose
    context_id the 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_LEN rejects 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
    SubscribeToTask does, 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 when TaskStore::supports_idempotency()
    is true, 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, required flag included; the generated
    entry is never required, 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, PostgreSQL and
    the tenant-aware variant of each. The SQL stores claim inside one
    transaction — INSERT ... ON CONFLICT DO NOTHING picks the winner and the
    loser reads the holder under SQLite'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 match tenant_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.

    TaskStore gains supports_idempotency, claim_idempotency_key and
    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). SendMessage and SendStreamingMessage remain 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/ and book/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, and examples/rig-agent/src/tools.rs imports no
    a2a-protocol-* type at all. Many model turns, one A2A task. Putting a tool
    abstraction in a2a-protocol-server would make it a framework and put it on
    a model-provider's release treadmill; hosting one costs neither.

    src/agent.rs writes the loop out rather than delegating to rig-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.rs declares 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-trace artifact 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 with tools/list, builds its catalogue and its
    agent-card skills
    from the answer, and calls it with tools/call. A2A
    never carries a tool call; MCP never carries a task; they meet in one
    process and nowhere else.

    It is rig-agent with exactly one thing changed, which is the point. Diff
    the two src/agent.rs files: 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, so MCP_SERVER_BIN repoints 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 rmcp 3.4, the official Rust MCP SDK, for both halves: the client
    and the bundled mcp-tool-server binary. Fourteen tests, and the split
    matters — ten drive the bridge over an in-process tokio::io::duplex pipe,
    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 the JsonSchema derive and that its main writes
    nothing but MCP to stdout. A stray println! there desynchronizes every
    frame, and no in-process test would notice.

    Verified end to end against llama.cpp b23701f serving 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 against rig-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-agent serves 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. Completed is a tool result and Failed/Rejected/
    Canceled carry isError, which is unsurprising. What is new is that
    MCP's 2026-07-28 specification added long-running tasks (SEP-2663), so an
    A2A task now maps onto an MCP task rather than a blocked request: the
    caller polls tasks/get, the agent's own status messages cross to it, and
    tasks/cancel reaches the agent as CancelTask, 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. And input-required is 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-demo covers 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 into tasks/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_pipeline must be called from inside a Tokio runtime — tonic
    spawns onto the ambient runtime while building the channel — and since the
    release profile sets panic = "abort", getting that wrong aborts the
    process rather than returning an error. The transport is gRPC only, so
    OTEL_EXPORTER_OTLP_PROTOCOL has no effect and an endpoint pointed at a
    collector's :4318 produces silence. OTEL_SERVICE_NAME is overridden
    by the service_name argument: Resource::builder() installs
    EnvResourceDetector, which reads it, and .with_attributes then
    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 the otel feature so the sequence compiles as a doctest rather than
    being an ignored block, which is the same reason postgres was added for
    the horizontal-scaling page.

    The catalogue section gains the Prometheus name for every instrument,
    measured by rendering the catalogue through opentelemetry-prometheus
    0.32.0 rather than derived from the translation rules: a2a.server.requests
    arrives as a2a_server_requests_total, a2a.server.latency as
    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 (request should be
    {request}), which the same measurement shows costs nothing observable —
    UCUM and non-UCUM units render byte-identical exposition through this
    exporter — and a2a.server.latency should conventionally be
    a2a.server.request.duration, which is visible, since no OTel dashboard
    template will match a2a_server_latency_seconds.

Changed

  • Seven example files and five book pages now use the new constructors.
    Net 113 lines removed from examples/ and 108 from book/: rig-agent's 43-line
    AgentCard literal, mcp-bridge's 45-line one and mcp-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 becomes task.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 through supported_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-agent and examples/mcp-bridge each carried a uuid_like()
    helper — a nanosecond timestamp, not unique under concurrency and
    sequential enough to guess — written to avoid a uuid dependency the
    workspace already carries and seven other examples already use. Both now
    take uuid and call Uuid::new_v4().

    One book snippet did not compile. book/src/deployment/testing.md set a
    context_id field on MessageSendParams, which has no such field — the
    snippet was inside a rust,ignore fence, so nothing ever built it. It is
    now three lines that would.

  • examples/rig-agent defaults to qwen3:1.7b, not qwen3.5:0.8b.
    Measured 2026-09-19 against llama.cpp b23701f with --jinja and 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.md as 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 W3C traceparent", book/src/deployment/observability.md's "There
    are no traces", and the docs/rust-sdk-assessment.md comparison 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_executor used
    a bare tokio::spawn, and TenantContext is a tokio::task_local, which
    a spawned task does not inherit. So every store call an executor made ran
    with TenantContext::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-enter TenantContext::scope. This one now does too.

    The regression test is the_spawned_executor_runs_inside_the_tenant_scope
    in tests/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 in a2a-protocol-server's agent_card/hot_reload.rs, one in
    a2a-protocol-client's auth.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} —
    reported INCONCLUSIVE rather than PROVEN, 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_unwind returns 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 to SLOW_PREFIX and
    RigAgentExecutor carried none.

  • Three documents claimed OpenTelemetry trace export that does not
    exist.
    The otel feature is metrics-only: opentelemetry_sdk is
    compiled with features = ["metrics", …], opentelemetry-otlp with
    ["grpc-tonic", "metrics"], there is no TracerProvider, 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 against a2a-rs — an overclaim
    in the worst possible place, now ◑ metrics only with a §10 recording the
    change), and book/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_PROTOCOL still applies.
    It cannot —
    MetricExporter::builder().with_tonic() is hard-coded and the
    HTTP/protobuf exporter is not compiled in. A reader who set
    http/protobuf and pointed the endpoint at a collector's :4318 would
    get gRPC spoken at an HTTP port, silence, and a doc line saying that
    should have worked.

Internal

  • scripts/check_panic_hooks.sh forbids 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 in ci.yml's
    Format job, paired with an injection in scripts/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, and prove_gates_fail.sh --only check_panic_hooks reporting
    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.py gates 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.yml could 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.yml gates 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, no OPENAI_BASE_URL and nothing on any model port, all
    three score 21/21 graded, 0 failed, 1 N/A, the same figure the pages claimed.
    The new tck-example-agents matrix costs a build per agent and no secret.
    Eight documentation sites now name the job instead of disclaiming one.