Skip to content

Agent-to-agent messaging, contract net, and remote A2A - #37

Merged
juicycleff merged 50 commits into
mainfrom
feat/a2a-messaging
Sep 3, 2026
Merged

juicycleff merged 50 commits into
mainfrom
feat/a2a-messaging

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Agents can talk to each other now. Directly, by name, inside one engine or across the network to agents that were never built with cortex.

This is three specs and their plans, all in docs/superpowers/. Read those if you want the reasoning; this is the short version.

What you get

Messaging. An agent addresses a peer by name and either carries on or waits for the answer. Three tools, and they only exist if you ask for them with engine.WithA2A:

  • agent_send posts and returns
  • agent_ask suspends the run until the answer arrives
  • agent_inbox drains what came in while the agent was busy

The wait is a row in your database rather than a goroutine, so it survives a restart. An agent can wait minutes on a peer that is itself waiting on a third agent, the process can die in the middle, and the next one picks it all up.

FIPA-ACL, all 22 performatives. Cortex routes on the speech act. A request or a cfp starts a run for the recipient. An inform or a refuse lands in a mailbox, because nobody should spend an LLM call being told something. A cancel closes the conversation and un-pauses everyone waiting on it.

Contract net. Ask several agents at once and your run waits for the whole field, then resumes with every proposal and every refusal together. You pick, you award. Cortex does not choose the contractor and will not: that is a judgement, and an agent is what makes it.

Remote agents. A new module, a2aremote, serves your agents over A2A 1.0.0 and lets them call agents at other companies. All three protocol bindings, both directions, with streaming. gRPC lives in its own module so a host serving JSON-RPC does not inherit grpc-go.

Things worth knowing before you read the diff

The protocol moved and I checked rather than assumed. A2A is at 1.0.0 under the Linux Foundation. Method names are PascalCase, and agent cards live at /.well-known/agent-card.json. A server built from the older spec is invisible to every current client. The gRPC types are generated from the normative a2a.proto, vendored with the script that regenerated them.

Scope comes from one place. For inbound remote requests, a PeerResolver you implement returns the scope, and nothing in a message body or a header can influence it. Every sender a peer claims is namespaced under the node the resolver assigned, so a peer cannot present as one of your agents. Cortex ships the seam and no authentication of its own, because you already have an identity system and a weaker second one inside cortex would be a liability.

Outbound trust is configuration, not data. Peers come from WithA2APeer at construction. An agent's own output cannot introduce a host to call, so a prompt-injected agent can at worst misuse a peer you already trusted.

Sqlite needs a busy timeout now. The dispatcher writes on its own goroutines while your runs write on theirs, and sqlite refuses a concurrent writer rather than waiting unless told to. Open with cortex.db?_pragma=busy_timeout(5000). Postgres needs nothing.

Breaking changes

Three, all in the changelog with the reasoning:

  1. store.Store embeds a2a.Store, sixteen more methods. The three bundled backends implement them, and store/storetest has conformance cases including a raced claim, so a custom store can find out whether it is right.
  2. suspension.SuspendReason gained agent_reply. It is not resumable through the public Resume, for the same reason an approval pause is not.
  3. The agent_ask tool result is a list now. It was one reply; a call for proposals is several. Shipping two shapes, one per recipient count, would have cost every prompt forever.

What the tests found

The loopback test, two engines over a real HTTP server, caught four things no unit test could see. The worst: the delivery path never consulted the transport seam, so a message addressed to somebody else's system would have been answered by whichever local agent shared the name. The others were an unparsed agent@node, a delivery stranded for thirty seconds by a lost claim race, and card serving with no scope to read its agents under.

Where I would look hardest

The PeerResolver contract. Everything about inbound security rests on it, and it is the seam a host implements, so a weak implementation defeats the tests around it.

Known gaps, all documented

  • Mongo is compile-verified only. Its conformance cases exist and will run wherever containers start; they would not start here.
  • A delivery claimed by a process that then dies stays marked delivering and is not redriven. Nothing wedges, because an ask resolves on its deadline either way, but an informative caught in that window is lost.
  • Conversations are not stitched across engines. A peer quoting a contextId from its own database gets a fresh conversation on this side, with its id kept as metadata.
  • Push notifications, card signatures and the extended agent card are declared unsupported in the card rather than silently missing.

Verification

go build ./... and go test ./... pass on all four modules, -race is clean on the four packages that matter, and golangci-lint reports nothing anywhere. The one failure is store/mongo, which fails on container startup on main too.

a2a needs the identical seam orchestration already had, so the interface
moves up and orchestration aliases it. Aliases keep every existing caller
and the engine adapter compiling untouched.
…nd asks

The claim is the interesting part. ClaimPendingAsk and ClaimDelivery are
conditional updates, so a late reply, a deadline sweep and a cancel can
all reach for one row and exactly one of them changes it. Conformance
races both against a real database, which is the only place that
guarantee can actually be tested.

Rescope now skips scoped tables with no id column. Pending asks are keyed
by their reply-with token, and a table born with scope columns has no
legacy rows to backfill.
…the composite

Postgres mirrors the sqlite implementation with jsonb columns and the
same conditional-update claim. Mongo claims with FindOneAndUpdate, whose
match and write are one operation, and keys pending asks by their
reply-with token so the ledger's one-row-per-token guarantee comes from
the primary key itself.

Neither backend was exercised: testcontainers cannot start a database in
the environment this was written in, so both are compile-verified only
and the conformance suite is what will actually prove them.
A run waiting on a peer is not the caller's to answer. The approved bool
becomes a resumeSource, because two authorities fit in a boolean and
three do not: not-approved would have had to mean both an ordinary
caller and the message bus, which are the two that most need telling
apart.
executeBuiltinTool could only report a completed call, which is fine for
knowledge_search and wrong for agent_ask: the ask sends a question and
the step has to suspend around it. The builtin contract grows an outcome
and the loop picks the suspend reason from what pended.

The bus also grew a resolver seam. Routability only ever said a
transport knew the shape of an address, so an ask addressed to a typo
suspended the asking run against a recipient that would never answer.
Now it comes back as an error the model can act on.
Every piece under this test is covered somewhere else. This one is for
the wiring between them, which is what no unit test can see: the planner
asks and stops, the dispatcher runs the worker, and the planner comes
back with the worker's words as its tool result.
Three reads and one write. The write is the interesting one: a message
carrying in_reply_to resumes the run waiting on it, so a person can
answer an agent that asked a question, and a remote peer will terminate
into the same path.
An example under _examples never compiles with the package, so the
wiring in the docs can drift from the wiring the code accepts. These sit
in a test file instead: no Output comment, so they are built and never
run.
Postgres containers do start here, so that backend runs the full
conformance suite including the raced claim. Mongo is the one that
cannot start, and it could not before this branch either.
Checked against the normative proto rather than memory, and the protocol
had moved: A2A is at 1.0.0 under the Linux Foundation, method names are
PascalCase, and the agent card path changed. The useful find is
AgentInterface.tenant, which is the spec's own answer to serving many
agents behind one endpoint, so a cortex agent name maps straight onto it.
The transport seam existed from the first spec and the delivery path
never asked it, so an envelope addressed to another system would have
been answered by whichever local agent happened to share the name.
Delivery now asks whether the receiver is local before it asks what the
performative wants.
The sender is a parameter of EnvelopeParamsFromMessage rather than
anything read out of the message, which is the signature enforcing the
rule that no field a peer controls decides who a message is from.
A task id is the delivery id, not a run id. Nothing has run when
SendMessage returns, so naming a run would mean inventing an id for
something that does not exist; the delivery row is the durable handle
that exists right now, and GetTask follows it to the run once there is
one. DeliveryOutcome and the a2a store grew what that needs.
Credentials go on a wrapping transport rather than per call, because the
card fetch needs them too: a peer that gates its card would otherwise be
undiscoverable, and the failure would read as a missing card rather than
a missing token.
The loopback test earned its keep. It found four things no unit test
could see: the tools never parsed the agent@node form, so a remote
address was looked up as a local agent with an @ in its name; a drain
that lost a claim race stranded the delivery until the next sweep, which
on sqlite is an ordinary collision rather than an exceptional one; card
serving had no scope to read its agents under, because a card is fetched
without credentials; and a peer's contextId names a conversation in the
peer's database, not ours.
Almost nothing is missing. The performatives are carried and routed
already, so the whole protocol is one change: an ask that waits for
several answers rather than exactly one.
Nearly all of the protocol was already here: the four performatives are
carried and routed correctly, and a tender is a conversation like any
other. What was missing was an ask addressed to several agents, so this
is that, plus the vocabulary to name a tender on the wire.

An ask to one agent still resumes on that agent's answer. An ask to
several waits for everyone, or for the deadline, because an initiator
that resumed on the first proposal would be choosing before the rest had
spoken. The count comes from the recipients on the ask's own message and
the answers are the messages replying to it, so a tender keeps no state
of its own.

A refusal now counts as an answer rather than ending the round. In a
tender, an agent that will not bid has told you what you needed to know
about that agent.
The paths are the protocol's own, colon verbs and all, because a client
expects message:send rather than whatever a Go router would prefer. An
error says the same thing twice, as an HTTP status and as the protocol's
numeric code, so a client that reads only one of the two still knows
what happened.
Its own module, because gRPC and protobuf are a large dependency graph
and a host serving JSON-RPC has no business inheriting it. Importing the
module is the opt-in.

The types are generated from the normative a2a.proto, vendored verbatim
with the script that regenerated them, so the wire format is the
specification's rather than an approximation of it. Everything in the
server is translation: not one decision about what an operation means
lives on that side of the boundary, which is why the sender namespacing
and the scope rule hold there without being written down twice.
Task-level streaming, which is what A2A's streaming is: the subscriber
gets the task, then each transition, and the last one carries the output
with final set so a client knows to stop reading. Server-sent events on
the two HTTP bindings, native server streaming on gRPC, and one
implementation behind all three.

Off unless a host asks for it, and the card says so either way. A card
that offered a stream nobody served would be a promise the server could
not keep.
Comment thread store/mongo/a2a.go
juicycleff and others added 5 commits August 26, 2026 22:10
A delivery claimed by a worker that then died stayed marked delivering
forever. Nothing wedged, since an ask resolves on its deadline either
way, but an inform caught in that window was lost.

Reclaiming queues the row rather than delivering it, so a recovered
message takes the ordinary path with the ordinary claim and a worker
that turns out to be alive loses the race instead of duplicating the
work. The TTL is fifteen minutes because a remote delivery legitimately
holds its claim while the peer is polled.
A contextId from a peer names a conversation in the peer's own database,
so an inbound thread used to open a new conversation on this side every
turn. That was worse than untidy. A new conversation is a new hop
budget, so a peer that never reused an id could keep talking past a
ceiling it should have hit.

A conversation now records which remote thread it stands in for, keyed
by node as well as context id: two peers can perfectly well use the same
id, and joining one peer's thread to another's would leak a conversation
across a trust boundary.
… from user-controlled sources'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@juicycleff
juicycleff merged commit 2123cc0 into main Sep 3, 2026
8 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants