Skip to content

Injected synthetic tool-call messages break the DIAL custom_content.state contract, causing hard 500s on gemini-3.5-flash-lite #584

Description

@alex-chernyshov

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:

  1. whatever state a deployment returns on an assistant message is stored with that message and sent back unmodified on the next step;
  2. state is opaque — a client must not interpret, split, merge or relocate it;
  3. 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).

  1. 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.
  2. Send any message.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions