“Let’s split it into agents” has become the AI equivalent of “let’s make it a microservice.”
Sometimes the boundary is useful. Sometimes it only creates more state, more coordination, and a harder failure to explain.
The most dangerous assumption is that separate agents should run in parallel. Parallelism is safe only when the branches are genuinely independent. If one branch changes the world while another is evaluating it, both agents can make locally reasonable decisions that are unsafe together.
Google ADK 2.0 makes workflow topology explicit through graph-based Workflow objects. That is valuable because sequences, branches, and joins become part of the program instead of an agreement hidden in a supervisor prompt.
Series note: This is Part 5 of Reliable Google AI Agents in TypeScript. The examples were checked against
@google/adk2.0.0 in September 2026.
Start with the dependency, not the agent count
Imagine a system preparing a hotel recommendation.
It needs live inventory, company travel policy, and a final recommendation. Inventory lookup and policy evaluation can run concurrently because both observe the same request and neither changes shared state. The final decision must wait for both.
Now consider a different pair of operations:
- one agent changes the reservation;
- another calculates an upgrade using the current reservation.
Those branches are not independent. Running them concurrently can make the upgrade decision depend on state that no longer exists.
Before drawing a parallel branch, ask:
- Do both operations only read the same starting state?
- Can either operation change data the other consumes?
- Can either produce an irreversible side effect?
- Is there a deterministic way to combine their results?
- What happens when one succeeds and the other times out?
If those answers are unclear, parallel is an optimization you have not earned yet.
Encode safe parallelism as fan-out and join
ADK’s TypeScript Workflow graph can express two independent branches and a join barrier directly:
import { JoinNode, node, NodeContext, Workflow } from "@google/adk";
type Request = {
city: string;
maxNightlyPriceUsd: number;
};
const fetchInventory = node(
async (_ctx: NodeContext, request: Request) =>
searchHotels(request.city, request.maxNightlyPriceUsd),
{ name: "fetch_inventory" },
);
const evaluatePolicy = node(
async (_ctx: NodeContext, request: Request) =>
checkTravelPolicy(request),
{ name: "evaluate_policy" },
);
const evidenceReady = new JoinNode({ name: "evidence_ready" });
const chooseRecommendation = node(
(_ctx: NodeContext, results: Record<string, unknown>) => {
if (!("fetch_inventory" in results) || !("evaluate_policy" in results)) {
throw new Error("Recommendation requires both evidence branches");
}
return selectCompliantHotel(
results["fetch_inventory"],
results["evaluate_policy"],
);
},
{ name: "choose_recommendation" },
);
export const rootAgent = new Workflow({
name: "hotel_recommendation_workflow",
edges: [
["START", fetchInventory, evidenceReady],
["START", evaluatePolicy, evidenceReady],
[evidenceReady, chooseRecommendation],
],
});
The topology is the contract:
START ─┬─> fetch_inventory ──┐
└─> evaluate_policy ──┴─> evidence_ready ─> choose_recommendation
JoinNode waits for every predecessor and passes the next node a record keyed by predecessor name. Each predecessor must produce output. Validate that record at the join instead of allowing a missing branch to surface as an unrelated failure several nodes later.
Use sequences when state must move in order
Some work is naturally sequential:
normalize request
↓
search inventory
↓
request approval
↓
create booking
↓
send confirmation
The matching ADK graph is deliberately boring:
export const bookingWorkflow = new Workflow({
name: "booking_workflow",
edges: [[
"START",
normalizeRequest,
searchInventory,
requestApproval,
createBooking,
sendConfirmation,
]],
});
That sequence is safer than asking a supervisor model to remember the required order on every run. The model can still make bounded decisions inside individual nodes; the workflow owns the invariant.
This is a recurring production pattern:
Use probabilistic reasoning inside deterministic control flow.
Parallel branches should produce evidence, not mutations
Shared mutable state is where fan-out becomes dangerous.
If two branches write selected_hotel, the last writer wins. If one updates a booking while another reads it, behavior depends on timing. If both send a notification, the user receives duplicate side effects.
A safer ownership rule is:
| Stage | Responsibility |
|---|---|
| Parallel branches | Gather and normalize evidence |
| Join | Verify all required evidence arrived |
| Decision node | Select one outcome |
| Mutation node | Own the state transition or side effect |
When distributed mutation is unavoidable, use an idempotency key, resource-level concurrency control, and a durable result record. Do not rely on a model to notice that another branch is already acting.
Failure semantics belong in the graph
Parallelism introduces failure combinations that a happy-path diagram hides:
- inventory succeeds while policy times out;
- both branches succeed but one returns stale evidence;
- a retry produces the same side effect twice;
- the join receives structurally valid but semantically incompatible results.
Each branch needs a timeout and retry budget appropriate to its operation. The join needs a rule for partial failure: fail closed, use an explicitly degraded mode, or ask for human review. “Continue with whatever arrived” should be a named policy—not an accident.
A2A is a network boundary, not a style choice
ADK can expose and consume remote agents through the Agent2Agent protocol. That is useful when a capability belongs to another team, runtime, deployment, or trust domain.
But a remote agent is not a helper function. It introduces:
- network latency and partial failure;
- authentication and authorization;
- an independently versioned contract;
- deployment and ownership boundaries;
- a larger attack surface.
Keep tightly coupled work in one local workflow unless there is a real service boundary. Use A2A when that boundary already exists for organizational or platform reasons, not because a diagram looks more “agentic.”
Test the shape of the run
Output-only testing misses topology regressions.
A stable execution contract for the recommendation workflow might require:
-
fetch_inventoryandevaluate_policyboth complete; -
choose_recommendationoccurs afterevidence_ready; - no reservation mutation occurs in a parallel evidence branch;
- at most one booking side effect occurs;
- booking is forbidden before approval.
The evidence can be rendered as an execution tree:
hotel_recommendation_workflow
├─ fetch_inventory
├─ evaluate_policy
├─ evidence_ready
└─ choose_recommendation
A local evidence tool such as AgentInspect can check required, forbidden, and ordered operations after those ADK events have been mapped into its run format. That wording is deliberate: AgentInspect does not currently advertise a first-class ADK adapter, so the integration boundary should remain explicit until one ships.
The goal is not to make the model deterministic. It is to make the workflow contract deterministic.
The topology is part of correctness
Multi-agent architecture is not automatically better than one well-designed agent. Parallel execution is not automatically faster once retries, joins, and coordination are included. Remote delegation is not automatically modular once network contracts are involved.
Use parallel branches for independent evidence gathering. Use sequences for dependent work. Give one node ownership of each mutation. Use remote agents only at genuine service boundaries. Record the execution shape so the team can see what actually happened.
The production measure is not how many agents participated.
It is whether the system reached the right outcome without conflicting actions, hidden races, or an execution path nobody can explain.
References
- ADK graph-based workflows
- ADK graph routes, fan-out, and join
- ADK for TypeScript
- ADK guidance on local agents and A2A
Earlier in the series: Gemini Function Calling Is Not an Agent Runtime · Testing Google ADK TypeScript Agents Without Chasing Sentences · From Local Traces to Production Observability for Google AI Agents
Top comments (8)
The guard at
choose_recommendationchecks the one property the join already gives you. IfJoinNodewaits for every predecessor and each predecessor must produce output, then"fetch_inventory" in resultsis already true by the time that node runs, and it stays true when the branch returned nothing usable, becauseinreports the key and not the value. I ran it on a record with both keys set toundefinedand the guard still passes.The failures you list two sections later are the ones that survive it, stale evidence and structurally valid but semantically incompatible results. Those are value properties, so a join guard that earns its place has to assert on freshness and on the shape of each branch's payload rather than on membership. Otherwise the case you opened with, a missing branch surfacing as an unrelated failure several nodes later, is still what happens, with
undefineddoing the travelling.@vinhnguyenthanhdn, you’re right—the membership check is redundant under
JoinNode’s contract and acceptsundefined, so it does not enforce the value-level invariant the surrounding text claims. The guard should parse both branch outputs into typed result schemas and validate freshness and compatibility beforeselectCompliantHotel; an empty or invalid payload should become a named join failure, not travel downstream. I’ll tighten the example accordingly. Would you keep freshness inside each branch result schema, or enforce it at the join where both timestamps can be compared?Both, but they are not the same check. The branch is the only place that knows when the underlying read happened, so the timestamp has to be produced there; the join is the only place that knows the moment of use, so the predicate belongs there. I put a TTL inside the branch to see how far that alone gets, and it fails on the fast branch:
That is node v25.9.0, two branches behind
Promise.all, straggler at 900ms. The branch-level verdict is computed against a clock reading that is stale by exactly the straggler's latency, and that term is not visible from inside the branch. The skew line is the other half of your question: two results can each sit inside their own bound and still describe the world 881ms apart, which is the propertyselectCompliantHotelactually depends on.One boundary on that, since it is easy to lose: it only holds if the branch stamps when the source was read rather than when it finished returning. Stamp it at return and the gap is back, now hidden inside the field that was supposed to close it.
@vinhnguyenthanhdn, that experiment makes the distinction concrete. The branch needs to emit an
observedAttimestamp from the actual source read; the join then needs to evaluate both age-at-use and cross-source skew. A branch-local TTL cannot see the straggler delay, and a return-time stamp would hide it. For this workflow I’d reject the join when either the maximum evidence age or the mutual skew exceeds its contract, and record both values in the trace so a replay explains the decision. I’ll revise the example around that two-part invariant—thank you for testing the exact failure mode.Good post. I wanna discuss further about collaboration. How about you?
@kevinpruett023_kevinpruet, happy to discuss it. The collaboration boundary I’m most interested in here is how separate agents exchange typed evidence without sharing mutable state or hiding partial failure behind a supervisor. If you have a concrete workflow or failure case, share the dependency graph and we can reason about which edges should be sequential, parallel, or remote.
Hi, thanks for the thoughtful response. This is exactly the type of problem space I’m interested in.
One workflow I’ve been exploring is an enterprise knowledge automation system where multiple specialized agents collaborate without sharing mutable state.
A simplified dependency graph looks like this:
User Request
|
v
Planner Agent
|
+---------+
| |
v v
Retrieval Agent Data Validation Agent
| |
+---+---+
|
v
Reasoning Agent
|
v
Response/Evaluation Agent
My preference is to treat each agent as a deterministic boundary with typed inputs/outputs rather than allowing agents to directly mutate shared context.
For example:
RetrievalResult {
documents: List[Document]
confidence_score: float
source_metadata: Metadata
}
ValidationResult {
passed: bool
issues: List[ValidationError]
}
The supervisor should not hide failures. Instead, each agent should return explicit evidence, status, and uncertainty so downstream agents can make informed decisions.
For execution strategy:
Parallel: independent evidence gathering, retrieval, classification, enrichment
Sequential: planning → execution → evaluation steps where each stage depends on previous evidence
Remote: expensive workloads such as model inference, document processing, or specialized agents running independently
The failure case I’m most interested in is when an agent produces a plausible but incomplete result. A supervisor that only checks "success/failure" can incorrectly propagate bad evidence. I think the better approach is evidence-aware orchestration: confidence scores, provenance tracking, validation agents, and replayable execution traces.
I’d be interested in your thoughts on whether you prefer:
contract-first agent communication (schemas/events as the primary boundary), or
capability-based agents where the coordinator negotiates available actions dynamically.
I think the interesting challenge is finding the balance between flexibility and reliability.
I look forward to hearing from you.
Best
@kevinpruett023_kevinpruet, I’d make contract-first communication the default and treat capability discovery as a constrained input to planning. A planner can choose among implementations that advertise a versioned capability, but once the plan is accepted, every edge should carry a schema with provenance, freshness, coverage, and an explicit complete/incomplete/degraded status. I would not gate on
confidence_scorealone: a plausible answer can still be missing required evidence, so the evaluator should compare expected evidence IDs and validation results against what actually arrived. One topology detail matters here too: if the validation agent checks retrieved documents, it belongs after retrieval; it is safe to run in parallel only when it validates the request or sources independently. That preserves flexibility at discovery time without making the run contract dynamic.