In Google ADK, how would you model a workflow that validates input, performs independent lookups in parallel, combines the results, and retries a bounded enrichment loop? How should state and failures move between those steps?
Use a SequentialAgent to enforce validation, fan out independent work through a ParallelAgent, join it with a combiner, and finish with a LoopAgent whose maxiterations bounds enrichment attempts. Pass results through explicit session-state keys, use unique keys for parallel branches, and represent expected failures as structured data while letting unrecoverable errors stop the run.
How to think about it
Use a SequentialAgent for the top-level order: validate, fan out to a ParallelAgent, combine the branch results, then run a LoopAgent with a small max_iterations value for enrichment. Pass data between those agents through explicit ADK state keys, give every parallel branch its own output key, and turn expected failures into structured results instead of letting them masquerade as missing data.
Why this is the right ADK model
The interviewer is probing whether you can express control flow as a graph rather than hoping one large LLM prompt will behave like a workflow engine.
A SequentialAgent runs its child agents in order. That gives you a hard gate: invalid input should never reach a billing lookup. A ParallelAgent starts independent children together and waits for all of them before the next sequential child runs. A LoopAgent repeats its children until one signals termination or its iteration limit is reached.
The useful mental model is a railway junction:
- One train arrives with a request.
- The validator either rejects it or puts a normalized request on the track.
- Three lookup trains leave at once.
- The join waits for all three and builds one candidate.
- A small enrichment loop makes at most three attempts to improve that candidate.
ADK state is the track between stations. An agent can write a result with output_key, and later agents can read that value from session state. An instruction can reference state with a placeholder such as {validated_request}. For deterministic state changes, a custom BaseAgent or a tool using ToolContext can write keys directly.
The important distinction is between control state and business data. customer_lookup is business data. enrichment_ready and enrichment_attempt are control state. Keep both explicit. Otherwise, six weeks later, someone will be debugging a loop that stopped because a model happened to write the word “done”.
A concrete workflow
Suppose the request is:
customer_id = C-1042
country = US
The validator checks the identifier format, confirms that the country is supported, and normalizes the request. It writes a structured value such as:
{
"status": "valid",
"customer_id": "C-1042",
"country": "US"
}
Now three services can run independently:
- CRM profile lookup: 120 milliseconds
- Billing status lookup: 80 milliseconds
- Entitlements lookup: 200 milliseconds
Running them serially costs roughly 400 milliseconds before network overhead. Running them through ParallelAgent makes the critical path roughly the slowest branch, about 200 milliseconds in this example. That is the reason for the parallel node. It is not because “parallel” sounds suitably agentic.
A configuration sketch looks like this:
validate_input = validate_agent # deterministic BaseAgent; writes validated_request
lookups = ParallelAgent(
name="independent_lookups",
sub_agents=[
LlmAgent(
name="crm_lookup",
model="gemini-2.5-flash",
instruction="Look up the CRM profile for {validated_request}. Return only the result.",
output_key="crm_result",
),
LlmAgent(
name="billing_lookup",
model="gemini-2.5-flash",
instruction="Look up billing status for {validated_request}. Return only the result.",
output_key="billing_result",
),
LlmAgent(
name="entitlements_lookup",
model="gemini-2.5-flash",
instruction="Look up entitlements for {validated_request}. Return only the result.",
output_key="entitlements_result",
),
],
)
combine = LlmAgent(
name="combine_results",
model="gemini-2.5-flash",
instruction=(
"Combine {crm_result}, {billing_result}, and {entitlements_result}. "
"Preserve each source status and never invent a missing value."
),
output_key="profile_candidate",
)
enrichment_loop = LoopAgent(
name="bounded_enrichment",
max_iterations=3,
sub_agents=[enrich_once, stop_when_ready],
)
root_agent = SequentialAgent(
name="customer_profile_workflow",
sub_agents=[
validate_input,
lookups,
combine,
enrichment_loop,
],
)
validate_agent, enrich_once, and stop_when_ready represent custom agents or carefully wrapped tools. They are not magic names from ADK.
enrich_once might call an address-verification service. If the service returns pending, it writes the latest result and leaves enrichment_ready as False. If it returns a complete address, it writes True. stop_when_ready reads that flag and emits an event with EventActions(escalate=True) when the result is ready. In a LoopAgent, that escalation is the normal way to stop early. If the service remains pending, max_iterations=3 is the hard ceiling.
The resulting state might look like this:
| Stage | State written | Example failure |
|---|---|---|
| Validation | validated_request, validation_status | invalid_id |
| CRM branch | crm_result | timeout, not_found |
| Billing branch | billing_result | service_unavailable |
| Entitlements branch | entitlements_result | not_found |
| Combiner | profile_candidate | partial_sources |
| Enrichment loop | enrichment_result, enrichment_attempt, enrichment_ready | pending_after_limit |
The combiner is a join, not a second chance for the model to guess. If billing says not_found, the combined record should preserve billing_status: "not_found". It should not silently turn that into billing_status: "clear".
How failures should move
Validation failure is usually an expected business result. Write a reason such as invalid_customer_id, mark the validation status, and stop the sequential workflow using the agent’s escalation mechanism. Do not throw a generic exception for a user who typed C-10O2 with the letter “O” instead of zero. The caller needs a useful response, not a stack trace.
A lookup failure needs a typed contract. Each branch should return something like:
{
"status": "error",
"retryable": true,
"code": "billing_timeout",
"value": null
}
A missing record is different:
{
"status": "not_found",
"retryable": false,
"value": null
}
An expected remote-service failure should be caught at the tool boundary and represented this way. An uncaught exception is not automatically converted into an empty branch result by ParallelAgent; depending on the runner and error path, it can fail the invocation. Do not design the combiner around that assumption.
The combiner can then make an explicit policy decision:
- all sources are usable, so enrichment proceeds;
- one source is unavailable but the result can be degraded;
- a required source failed, so the workflow returns
incomplete; - a source says
not_found, which is a valid answer rather than a retry signal.
The enrichment loop should retry only the enrichment operation. Do not put validation and the three lookups inside the loop. Otherwise, a temporary address-service timeout can charge the customer lookup three times and create three sets of audit records.
The loop also needs idempotency. An idempotent operation can be repeated without creating a second side effect. A read or an upsert keyed by customer_id is usually safer than “create enrichment record” on every iteration. Track enrichment_attempt and use service timeouts. max_iterations=3 bounds ADK control flow; it does not make a non-idempotent external API safe.
The senior-level nuance
A ParallelAgent is appropriate only when the branches are genuinely independent. If the risk lookup needs the billing account number produced by the billing branch, those two branches are not independent. Keep them sequential or create two parallel waves.
There is also a state race. Parallel branches share the workflow’s state context, so two branches must not write the same key. crm_result, billing_result, and entitlements_result are safe. A shared key called lookup_result is a race with a pleasingly predictable disaster: whichever branch writes last wins.
State is excellent for passing bounded workflow data. It is not a replacement for a durable database, a job queue, or an audit log. Keep large documents and secrets out of prompts and state where possible. Store a reference, such as document_uri, and enforce access at the tool boundary.
Finally, model-generated validation and combination are useful when the input is ambiguous or the data is messy. They are not substitutes for hard checks on identifiers, authorization, monetary values, or retry limits. Put those guarantees in Python, tools, schemas, and workflow topology. Let the model handle interpretation where interpretation is actually required.
What they’ll ask next
“What happens if one parallel lookup fails?”
Return a structured branch error with status, code, and retryable. The combiner applies a required-versus-optional policy. Do not silently omit the branch, and do not retry the entire graph by default.
“How does the loop stop before three attempts?”
The loop’s checker reads a state flag such as enrichment_ready and emits an event with EventActions(escalate=True). If the flag never becomes true, max_iterations stops the loop.
“Would you use an LLM for validation?”
Only for interpretation, such as extracting fields from a natural-language request. Format, authorization, ranges, and required fields belong in deterministic code or tools. The LLM can propose a normalized request; code should decide whether it is admissible.
One line to say in the room
“I’d make the topology explicit: SequentialAgent for the gate and join, ParallelAgent for independent reads, and a three-iteration LoopAgent for enrichment, with unique state keys and typed failures so a timeout never looks like a successful empty result.”