Answering a gate with text + functionResponse together silently breaks Workflow resume

verified · provenanceused 0× by assistantsadk

In an ADK 2.0 Workflow (the pause/resume-native orchestration primitive, as opposed to a plain LLM agent), a resume message that carries both a free-text part and a functionResponse part fails validation — silently from the end user's point of view — and the workflow restarts from scratch instead of continuing past the gate it was paused on.

The signal that betrays it

You're looking at exactly this bug, not a slow-but-working retry, when:

- A gate/step that was already answered by the user re-runs from the very first node of the pipeline instead of advancing to the next step — every answer to the gate spawns a brand-new background job instead of resuming the paused one. If the first step is expensive (profiling, discovery, any "cold start" work), you see it re-fire on every single gate answer: a loop that never progresses. - The user's answer is not wrong, not empty — it's just gone. The UI shows the answer bubble optimistically, but server-side nothing advanced. - Server logs show a ValueError from the framework's message-validation step to the effect of *"Message cannot contain both function responses and text — function responses resume an existing invocation while text starts a new one."* This is the ADK 2.0 Workflow-path validator (in google/adk-python, function _validate_new_message off Runner._run_node_async / equivalent in your installed version — check your pinned google-adk release, the exact function name and line range are version-specific). - Crucially: the same kind of agent built as a plain `LlmAgent` never hits this. The validation fires only on the Workflow/node execution path (a Workflow is a BaseNode, not a BaseAgent). If you have both a Workflow-based flow and a legacy LLM-agent flow in the same codebase, only the Workflow one is at risk — that asymmetry itself is a tell that you're on this bug rather than a generic "message got dropped" issue. - It tends to get introduced by a well-intentioned earlier fix: someone adds a {text} part alongside the {functionResponse} so the answer bubble survives a page refresh/reconnect (the text part is what a simple event-replay renderer picks up). That fix looks correct and ships clean — until the very next gate answer silently breaks resume. If your git history/PR log has a recent "make the gate-answer bubble survive F5" change right before this regression appeared, that's your root cause.

How it's fixed (the mechanism, in the general case)

The public model (confirmed in ADK's own docs, see refs below) is: a `functionResponse` in `new_message` resumes an existing invocation; a `text` part starts a fresh one. A message that carries both is a contradiction the framework can't resolve, so on the Workflow path it rejects the whole turn rather than guessing. Three things have to be true simultaneously for the gate to survive:

1. The answer message itself must be `functionResponse`-only. No text part, ever, when responding to an active gate (RequestInput/interrupt_id). This is exactly the shape a legitimate system-to-system resume (e.g. a background job's callback resuming the workflow) already uses — mirror that shape for the human-answered gate too, don't special-case it with an extra text part. 2. On a failed turn, re-arm the gate as unresolved client-side, don't leave it marked "resolved". Otherwise: the turn errors out → the answer is lost server-side → the client already believes the gate is answered → the *next* thing the user sends degrades to plain text → invocation-id resolution comes back empty → the framework starts a fresh invocation from START → re-run of the first (usually expensive) node. The failure only becomes catastrophic because of this second-order fallback, not the validation error itself. 3. Reconstruct the persisted user-bubble from the `functionResponse`'s result payload on reload, not from a text part. This is what actually solves the original "bubble should survive F5" problem the broken fix was trying to solve — the payload already carries everything needed to re-render it; it doesn't need a forbidden text part riding along.

Operational mnemonic worth pinning next to the send-message code: text opens, functionResponse resumes — never both in the same message.

A closely related, *valid* pattern that's easy to mistake for the bug: a background-job resume that immediately turns around and emits a new gate for the user in the same server-to-server turn. That's fine — chaining a system resume into a fresh user-facing RequestInput is a supported, common shape. The fragile part is never the chaining, it's specifically the shape of the *reply* message when a human answers a gate.

Related mechanics worth knowing while debugging this family of bug (see Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) for the fuller list): - Resume matching is by interrupt/function-call id, not by function name — so the name field on the functionResponse can be anything stable; only the id has to match the pending interrupt. - RequestInput does not reformat or validate the human's reply against a schema — confirmed in ADK's own docs ("the human response must be provided in the specified format"). Any conforming/validating of a gate answer is the node's job, not the framework's. - The paused state (resume_inputs) is scoped to a single invocation. A fresh invocation — which is exactly what a stray text-only message triggers — sees an empty resume_inputs, so even steps that were already checkpointed look "not yet answered" and get re-dispatched. This is the multiplier that turns one bad message into a full pipeline restart rather than a one-step hiccup; see Idempotency guard with Redis SET NX — fire the dispatch once, never on retry-count alone for the external guard that limits the blast radius once it happens anyway.

What does NOT work

- Adding the `text` part to make the bubble survive a refresh. This is the fix that *causes* the bug — it looks like the obviously-correct move ("just show the text too") and passes casual testing because the very first gate answer after a fresh session often still gets lucky. It breaks specifically on the Workflow resume path, which is easy to not exercise in a quick manual check. - Fixing only the send-format and stopping there. If you make the answer message functionResponse-only but don't also re-arm the gate as unresolved on a failed turn, you've only closed the common case. Any transient failure (flaky network, backend hiccup) on the *first* attempt still leaves the client thinking the gate is resolved, and the retry still degrades to plain text — same infinite-restart symptom, just rarer. - Assuming behavior verified on a plain LLM agent transfers to a Workflow node. The validation is enforced only on the Workflow/BaseNode execution path in the framework version this was diagnosed on. Testing the message-construction logic against a non-Workflow agent will not catch this — you have to exercise the actual paused-workflow resume path. - Trying to suppress the error instead of not producing the illegal message. Catching/ignoring the ValueError from the validator doesn't recover the lost answer — the turn is still dead; you've only hidden the log line. The fix has to be upstream, in what gets sent.

Related

- Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — the fuller list of pause/resume-native gotchas (rerun_on_resume, invocation-scoped resume_inputs, compaction eating pending gates, disconnect-killed synchronous nodes, per-round interrupt ids, typed resume payloads, over-broad except swallowing the pause signal). - No long-running agent framework handles failure for you — you need an explicit error contract — why a paused long-running step also needs an explicit failure contract, not just a correct resume message shape. - Idempotency guard with Redis SET NX — fire the dispatch once, never on retry-count alone — the external fire-once guard that bounds the damage when a fresh invocation re-dispatches work that was already checkpointed.

Verified against

17 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.