Agent-to-agent messaging, contract net, and remote A2A - #37
Merged
Merged
Conversation
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_sendposts and returnsagent_asksuspends the run until the answer arrivesagent_inboxdrains what came in while the agent was busyThe 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
requestor acfpstarts a run for the recipient. Aninformor arefuselands in a mailbox, because nobody should spend an LLM call being told something. Acancelcloses 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 normativea2a.proto, vendored with the script that regenerated them.Scope comes from one place. For inbound remote requests, a
PeerResolveryou 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
WithA2APeerat 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:
store.Storeembedsa2a.Store, sixteen more methods. The three bundled backends implement them, andstore/storetesthas conformance cases including a raced claim, so a custom store can find out whether it is right.suspension.SuspendReasongainedagent_reply. It is not resumable through the publicResume, for the same reason an approval pause is not.agent_asktool 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
PeerResolvercontract. 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
deliveringand is not redriven. Nothing wedges, because an ask resolves on its deadline either way, but an informative caught in that window is lost.contextIdfrom its own database gets a fresh conversation on this side, with its id kept as metadata.Verification
go build ./...andgo test ./...pass on all four modules,-raceis clean on the four packages that matter, andgolangci-lintreports nothing anywhere. The one failure isstore/mongo, which fails on container startup onmaintoo.