Every agent behind one seam: what A2A orchestration actually bought me

A conversational advisor that fans out to four agents per turn, deliberates, and composes a reply. The decision that mattered was not the model or the prompt. It was refusing to let any node import an agent directly. Here are two production bugs, one cost ceiling, and what that seam paid back.

August 25, 2026

Overview

The CanvestAI orchestrator answers a question in stages. It fans out to four discipline agents in parallel: fundamental, technical, astro, and research. It then joins their verdicts and deliberates over them. It ranks ETFs in code, makes a recommendation, challenges that recommendation, and composes a reply. The spine is a LangGraph supervisor, and on paper it is a normal multi-agent chatbot.

The decision that shaped it had nothing to do with model routing. One rule did the work: no graph node may import an agent. Every call goes through `dispatch(name, task)`, a single function on an A2A client. Nodes know agent names. They never see agent modules.

That is a hub topology, and for the MVP it is only half true. The agents run in the same Python process as the graph, because the box has 16 GB of RAM and one server per agent would not fit. The seam is real even though the network is not. Full A2A means the SDK, one server per agent, and agent cards. Getting there is a rewrite of one module. No node changes.

That shortcut is already half-retired. Planetary positions, aspects, and dasha periods used to be computed inside the repo. They now live in a separate service reached over real A2A: a JSON-RPC 2.0 `message/send` carrying a named op and its parameters, pointed at `ASTRO_AGENT_URL`. One module in CanvestAI speaks that protocol. Everything upstream of it changed a single import line. The client functions kept the same names, signatures, and return types as the local functions they replaced. Dates still come back as `datetime.date`, not as ISO strings.

The contracts are deliberately different

The same remote agent is reached two ways, and the difference is the whole point of putting a seam there.

`call(op, **params)` never raises. It returns an unavailable marker. The orchestrator uses this, because a dead astro agent should cost you one lens out of four, not the entire turn.

`require(op, **params)` raises, and names both the URL and how to start the agent. Batch scripts use this, because silently writing a nightly report with no astrology in it is worse than stopping and saying so.

A single "handle the error" convention would have gotten one of those two cases wrong. Which failure is acceptable depends on who is waiting for the answer.

What parallelism broke

Two bugs came out of running the four discipline nodes concurrently, and both are worth writing down because neither is visible in a sequential test.

The first bug: the A2A client is a process-wide singleton, built lazily. The original code published the singleton and then filled its registry. The four discipline nodes run in one superstep on separate threads, and all of them call the accessor. Any thread that arrived after publication but before registration dispatched against a half-built registry. One live turn lost fundamental, technical, and research to `no A2A agent registered` while astro succeeded. The fix is unglamorous: build the client fully, then publish it, under a lock.

The second bug: agent registration imports each agent module. A comment claimed a broken module would simply fail to register. It did not. One bad import raised out of the builder, and the builder runs inside the singleton construction, so a single broken agent took down every agent in the process. Each import is now isolated, and a failure costs only that agent. The graph already knows how to degrade one lens, record why, and compose a reply from the rest.

Both bugs have the same shape. Concurrency turned a latent ordering assumption into a partial outage, and in a fan-out system the partial outage is the dangerous one. The turn still returns an answer, only a quieter and worse-informed one.

Efficiency is a budget

Multi-agent fan-out multiplies the cost of every turn, so the ceiling is enforced instead of hoped for. A LangChain callback meters real token usage across every LLM call in a turn, prices it from a models config, and exposes soft and hard breach checks. One meter runs per graph invocation.

Enforcement is graceful by default. Nodes check the meter before they spawn optional work. On a hard breach, a node degrades to a no-op and records itself in a `degraded` list on the state. The turn still reaches compose, and the reply is assembled from whatever was gathered. Raising a budget error at the user is possible, but it is an opt-in path.

Cost is attributed per agent as well as per turn. Every model the factory builds carries an `agent:<name>` tag, so the meter can say which agent spent the money. That is the number you need before you cut a lens or route it to a cheaper model. The meter also counts cache reads and writes separately from fresh input tokens. Those sit at zero everywhere, which is itself the finding: nothing is being cached yet.

What the seam paid back

  • Agents moved from in-process to a separate service without touching a single graph node. The astro migration changed one import line at each call site.
  • A broken agent degrades one lens with a recorded reason instead of failing the turn. Exactly one place knows how to dispatch and how to fail.
  • Cost is attributable per agent, which makes routing decisions arguable from data rather than intuition.
  • The in-process shortcut stayed honest. It is confined to one module, and the 16 GB constraint that forced it is written down next to it.
  • The failure modes above were fixable in one file each. That is the actual return on refusing to let nodes import agents.

Code and related repositories