QuickApps version
latest (development, verified against 17f555f)
Why this matters
On gemini-3.5-flash-lite a broken custom_content.state chain is not graceful degradation. The model starts emitting invalid function calls and the user gets a hard HTTP 500 mid-conversation:
The function call generated by the model is invalid
It is not transient — it survives the retries the model deployment performs internally, and the user's turn is lost.
It is also model-specific. Same agent loop, same 6 tools, same harness, state withheld from the assistant turns, three orchestrator deployments interleaved in one session:
| orchestrator deployment |
runs ending in the 500 |
gemini-3.5-flash-lite |
17/21 |
gemini-3.5-flash |
0/6 |
gemini-3-flash-preview |
0/6 |
So a state defect that is invisible on gemini-3.5-flash breaks conversations outright on gemini-3.5-flash-lite. Any QuickApp whose orchestrator is a flash-lite tier model is exposed, and the exposure grows with the number of tool iterations.
The contract
custom_content.state is the DIAL mechanism by which a deployment carries opaque per-message context across conversation steps. The contract for a DIAL client is:
- whatever
state a deployment returns on an assistant message is stored with that message and sent back unmodified on the next step;
state is opaque — a client must not interpret, split, merge or relocate it;
- correspondingly, a client must not present the deployment with assistant messages it never produced, because such a message has no
state and is indistinguishable from one whose state the client dropped.
QuickApps is a DIAL client with respect to its orchestrator deployment. It honours (1) and (2) — that plumbing is correct, see "What was ruled out" below. It breaks (3).
For Gemini 3 models the state carries the model's reasoning continuity across a tool-calling turn (Google calls it a thought signature). An assistant message carrying a function call the model never made, with no state attached, tells the model it previously took an action it has no record of taking. The model's behaviour from there is undefined — and on flash-lite the outcome is the 500 above.
What steps will reproduce the bug?
Orchestrator model: gemini-3.5-flash-lite (any Gemini 3 deployment reproduces the contract break; flash-lite is where it hurts).
- Configure a QuickApp with any feature that injects a synthetic tool-call pair — a skill, the timestamp tooling, attachment notifications, the
lazy_on_demand attachment strategy, or an eager MCP resource.
- Send any message.
- Inspect the messages QuickApps sends to the orchestrator deployment: they contain assistant messages with
tool_calls and no custom_content.state.
Four sites build such a message:
Note the tool half of each pair does get state (_enrich_state); only the assistant half is built bare.
What is the expected behavior?
QuickApps presents the orchestrator deployment with a conversation in which every assistant message is one the deployment actually produced, carrying the state the deployment returned with it. Whatever mechanism injects instructions, timestamps, file listings or MCP resources should not require fabricating assistant turns with tool calls.
What do you see instead?
Assistant messages with tool_calls and no state reach the deployment. The VertexAI adapter reports it, once per such message:
Cannot find thought_signature for a function call block. Defaulting to a fake thought_signature.
The adapter does what it can to keep the request working, but it cannot reconstruct context the client never sent.
There is a second, operational cost. Because QuickApps emits these messages by design on every request that uses a skill, a timestamp or an attachment, that warning is constant — so when a conversation genuinely loses state, the signal is already saturated. Operators cannot tell a benign injected message from every assistant message arriving unsigned. This directly cost triage time on a production incident.
Measured impact of the injected pairs specifically
To be precise about severity — the injected pairs alone did not reproduce the 500 in my harness:
| history contains |
runs ending in the 500 |
| no synthetic pairs (control) |
0/24 |
| 1, 3 and 8 unsigned synthetic pairs |
0/12 |
assistant turns replayed without their state |
17/21 |
Read together: the injected pairs are the same contract break as the last row, dialled down — a handful of state-less assistant messages rather than all of them — and at 1–8 pairs the model absorbed it. That is a margin, not a guarantee. It is the same violation, on a model tier where the violation's failure mode is a user-visible 500 rather than a quality dip, and the pair count grows with attachments and skills.
Additional information
What was ruled out while investigating
The custom_content.state round-trip for the model's own assistant messages is correct — verified end to end by code audit, no state is lost or altered:
- the
{orchestrator_state: …} wrap at orchestrator.py:199
_promote_orchestrator_state_to_top_level at core/agent/_chat_completion_config_builder.py:177
tool_execution_history persistence (_build_tool_execution_history) and restoration (_messages_setup.extract_tool_calls)
merge_state in the stream accumulator
- the scrub-extra-fields, attachment-filter and timestamp pre-invocation transformers
content=stream_result.content or " " at orchestrator.py:214 was also tested as a candidate — it alters an assistant message the model did produce, but it yielded 0/9 failures and is not implicated.
Suggested handling
The fix should restore invariant (3): the deployment only ever sees assistant messages it authored. Options are yours to weigh — carrying the injected material as user/tool content rather than as a fabricated assistant tool call, or marking these messages so they are never replayed as model turns.
Independently of the chosen fix, the goal worth keeping in sight is that a missing-state warning in a deployment log should mean something is genuinely wrong, rather than being produced on every request by design — so the next flash-lite incident can be triaged from logs instead of by bisection.
Reproduction harness
A notebook driving these arms directly against the model deployment (control / state dropped / altered content / unsigned synthetic pairs, plus the three-model comparison) is available on request.
QuickApps version
latest (
development, verified against17f555f)Why this matters
On
gemini-3.5-flash-litea brokencustom_content.statechain is not graceful degradation. The model starts emitting invalid function calls and the user gets a hard HTTP 500 mid-conversation:It is not transient — it survives the retries the model deployment performs internally, and the user's turn is lost.
It is also model-specific. Same agent loop, same 6 tools, same harness,
statewithheld from the assistant turns, three orchestrator deployments interleaved in one session:gemini-3.5-flash-litegemini-3.5-flashgemini-3-flash-previewSo a
statedefect that is invisible ongemini-3.5-flashbreaks conversations outright ongemini-3.5-flash-lite. Any QuickApp whose orchestrator is a flash-lite tier model is exposed, and the exposure grows with the number of tool iterations.The contract
custom_content.stateis the DIAL mechanism by which a deployment carries opaque per-message context across conversation steps. The contract for a DIAL client is:statea deployment returns on an assistant message is stored with that message and sent back unmodified on the next step;stateis opaque — a client must not interpret, split, merge or relocate it;stateand is indistinguishable from one whosestatethe client dropped.QuickApps is a DIAL client with respect to its orchestrator deployment. It honours (1) and (2) — that plumbing is correct, see "What was ruled out" below. It breaks (3).
For Gemini 3 models the
statecarries the model's reasoning continuity across a tool-calling turn (Google calls it a thought signature). An assistant message carrying a function call the model never made, with nostateattached, tells the model it previously took an action it has no record of taking. The model's behaviour from there is undefined — and on flash-lite the outcome is the 500 above.What steps will reproduce the bug?
Orchestrator model:
gemini-3.5-flash-lite(any Gemini 3 deployment reproduces the contract break; flash-lite is where it hurts).lazy_on_demandattachment strategy, or an eager MCP resource.tool_callsand nocustom_content.state.Four sites build such a message:
common/synthetic_injection/synthetic_tool_call_injector.py:204_build_pair— base class for the timestamp injector, the attachment-notification injector and the skills file-transfer instruction injectororchestrator_attachment_strategies/lazy_on_demand/_attachment_get_content_injector.py:120_build_pair— one pair per attachment, so the count scales with the conversation's filesmcp_tooling/_mcp_eager_resource_transformer.py:21_build_synthetic_paircore/application/_messages_setup.py_extract_legacy_format— breaks the contract the other way: it deep-copies one assistant message'scustom_contentonto several fabricated per-call assistant messages, so one message'sstateis replayed against messages it does not belong toNote the tool half of each pair does get state (
_enrich_state); only the assistant half is built bare.What is the expected behavior?
QuickApps presents the orchestrator deployment with a conversation in which every assistant message is one the deployment actually produced, carrying the
statethe deployment returned with it. Whatever mechanism injects instructions, timestamps, file listings or MCP resources should not require fabricating assistant turns with tool calls.What do you see instead?
Assistant messages with
tool_callsand nostatereach the deployment. The VertexAI adapter reports it, once per such message:The adapter does what it can to keep the request working, but it cannot reconstruct context the client never sent.
There is a second, operational cost. Because QuickApps emits these messages by design on every request that uses a skill, a timestamp or an attachment, that warning is constant — so when a conversation genuinely loses
state, the signal is already saturated. Operators cannot tell a benign injected message from every assistant message arriving unsigned. This directly cost triage time on a production incident.Measured impact of the injected pairs specifically
To be precise about severity — the injected pairs alone did not reproduce the 500 in my harness:
stateRead together: the injected pairs are the same contract break as the last row, dialled down — a handful of
state-less assistant messages rather than all of them — and at 1–8 pairs the model absorbed it. That is a margin, not a guarantee. It is the same violation, on a model tier where the violation's failure mode is a user-visible 500 rather than a quality dip, and the pair count grows with attachments and skills.Additional information
What was ruled out while investigating
The
custom_content.stateround-trip for the model's own assistant messages is correct — verified end to end by code audit, no state is lost or altered:{orchestrator_state: …}wrap atorchestrator.py:199_promote_orchestrator_state_to_top_levelatcore/agent/_chat_completion_config_builder.py:177tool_execution_historypersistence (_build_tool_execution_history) and restoration (_messages_setup.extract_tool_calls)merge_statein the stream accumulatorcontent=stream_result.content or " "atorchestrator.py:214was also tested as a candidate — it alters an assistant message the model did produce, but it yielded 0/9 failures and is not implicated.Suggested handling
The fix should restore invariant (3): the deployment only ever sees assistant messages it authored. Options are yours to weigh — carrying the injected material as user/tool content rather than as a fabricated assistant tool call, or marking these messages so they are never replayed as model turns.
Independently of the chosen fix, the goal worth keeping in sight is that a missing-
statewarning in a deployment log should mean something is genuinely wrong, rather than being produced on every request by design — so the next flash-lite incident can be triaged from logs instead of by bisection.Reproduction harness
A notebook driving these arms directly against the model deployment (control /
statedropped / altered content / unsigned synthetic pairs, plus the three-model comparison) is available on request.