A `{variable}` left in an agent's system-prompt comment crashes the stream in silence — Google ADK's template injection trap

verified · provenanceused 0× by assistantsadk

Google ADK (and any agent framework that treats a plain string as a live template) will silently swallow an agent's entire reply the instant an instruction file contains a stray {lowercase_word} that the framework can't resolve from session state — and the same booby trap re-appears, in a different syntactic disguise, in any Python f-string prompt that isn't rigorously brace-escaped.

The signal that tells you this is the bug

The report from the user side is maximally unhelpful: a chat message is sent and nothing comes back — no reply, no streaming tokens, no visible error, no HTTP failure the browser surfaces. It reads exactly like "the AI stopped working" or "the AI went silent," which sends people looking at the wrong layer first (network, auth, the model provider) because there is no client-visible signal pointing at the prompt itself.

The real tell is server-side only, in the ADK service logs, and it's a very specific stack shape:

`` File ".../google/adk/utils/instructions_utils.py", in _replace_match raise KeyError(f'Context variable not found: {var_name}.') KeyError: 'Context variable not found: <var_name>.' ``

<var_name> is always a single lowercase, underscore-joined word — a session id, a date-hash, a cost figure, a UI label, anything that *reads* like an internal variable name even when the author meant it as prose. If you see this exact exception shape, you are not looking at a model failure, a network failure, or a bug in your agent's logic — you are looking at the prompt pre-processor itself refusing to run.

A second, syntactically different but mechanistically identical signal shows up outside ADK entirely: a NameError raised while *building* an f-string prompt (before it's even sent to any framework), where the undefined name is one letter of what was meant to be a literal set of uppercase abbreviations, e.g. a rule block written as {ALE, TCO, MTTD} inside a Python f"""...""" template. Same root cause (an unescaped {/} block inside a string template), different runtime catching it (the Python interpreter's own comprehension parser instead of ADK's regex).

Root cause

ADK's instructions_utils pre-processes the *entire* instruction string for every agent before each run, scanning for anything matching a curly-brace pattern and attempting to substitute it from ToolContext/session state (this is intentional, documented behavior — {key} is the supported syntax for injecting session.state['key'] into a prompt). The scanner is not scoped to "obvious" template spots: it runs over comments, markdown code fences, and inline documentation strings exactly as it runs over real template slots. If the matched name isn't a key that actually exists in state at that moment, it raises KeyError instead of leaving the text alone, and that exception propagates up through the event generator and kills the response stream before a single token reaches the client — no partial answer, no error surfaced to the user, nothing.

The way this actually gets introduced in practice is almost never "we wrote a template and forgot a variable." It's a developer adding a documentation comment inside the instructions file that describes an internal naming scheme using template-like syntax out of habit — e.g. a code comment meant to read as prose:

``markdown # writes the result under key: some_prefix:{session_id}:{date_hash} ← looks like documentation, is a live template to ADK ``

The author intends {session_id} as "a placeholder you should read as text." ADK intends it as "go fetch state['session_id'] right now." Whichever variable name was chosen for the *documentation example* becomes the crash trigger the moment it isn't also a real state key.

Fix, in order of correctness

1. Never use bare `{word}` for textual placeholders. Reserve {var} exclusively for the cases where you deliberately want ADK's real substitution — and only when you can guarantee that key is populated in state on every run that uses this instruction. For anything meant as documentation, prose, or a naming-scheme example, use an XML-style placeholder instead: <session_id>, <date_hash>. ADK does not treat angle brackets as template syntax, so this notation is inert by construction. 2. Do not rely on doubled braces (`{{ }}`) as an ADK escape. Doubling the braces is not ADK's own escaping syntax at all — the official docs are explicit that a {{ }} you see in an instruction example is *Python's* f-string escaping (it collapses to a single { } before ADK ever scans the string), and recommend a plain single brace {key} when the instruction isn't built as an f-string. Someone carrying over the f-string habit into a plain instruction string gets no such collapsing: the injector's regex still matches the doubled run as-is and can still attempt a substitution, so {{session_id}} can still throw the same KeyError it was meant to avoid. Treat "just double the braces" as a misapplied Python idiom, not a real ADK escape. 3. For instructions that genuinely need literal curly braces (JSON examples, templating syntax shown as an example to the model), pass a function (an InstructionProvider) instead of a plain string for the instruction parameter. A provider receives the context and returns the final string un-scanned — ADK does not run its substitution pass over anything returned this way, so literal braces survive untouched. 4. Make the check fail the deploy, not the conversation. After enough recurrences, a startup-time validator becomes worth the cost: at import time, read every instructions file the service will load, scan each line for {[a-zA-Z_][a-zA-Z0-9_]*}, and compare each match against an explicit whitelist of state keys that are genuinely injected at runtime. Anything not on the whitelist aborts startup with the offending file and line number printed — so the container simply refuses to come up instead of shipping a landmine that only detonates mid-conversation, for one specific user, hours or days later. This is the single highest-leverage fix in the whole pattern: it converts a silent, hard-to-attribute production incident into a loud, file:line-precise failure at build/deploy time, which is exactly where you want to be told about it. 5. Add it to prompt-file code review, explicitly. Any diff that touches an instructions/prompt file gets one extra grep pass — \{[a-z_]+\} — and for every hit, the reviewer asks "does this key exist in state on every run that uses this prompt?" If the honest answer is no or "not sure," it becomes <var>, full stop. 6. For the Python f-string variant specifically: any time a literal set or dict ({A, B, C}, {"a": 1}) needs to appear inside an f-string-based prompt, its braces must be doubled ({{A, B, C}}) — this is standard Python f-string escaping, not an ADK concept, and it is a *separate* mechanism from the ADK case above (Python resolves it at parse time, before ADK ever sees the string). Conflating the two is easy because the visible symptom category is the same ("stray braces broke my prompt"), but the escaping rule and the runtime that enforces it are different in each case, so the fix in one context does not transfer to the other.

What does NOT work

- Manual discipline alone. This exact trap recurred five times in eleven days across otherwise careful refactors, specifically because adding internal documentation comments (references to storage keys, schema notes, example UI strings) is a completely ordinary, low-risk-feeling activity — and habits carried over from other templating conventions (PHP-style, Svelte-style, or plain string-formatting muscle memory) make {word} the default instinct for "a thing that varies," not a red flag. Telling people to "just remember to use <var>" does not survive contact with a deadline; only the automated startup check stopped the recurrence. - Assuming the Python f-string escape carries over. As above: this is a real trap-within-the-trap. A developer who knows {{ }} collapses to a literal brace in a Python f-string can reasonably assume the same trick escapes ADK's own scanner — but the official docs describe that collapsing as Python's behavior, not ADK's, and outside an actual f-string the doubled braces reach ADK's regex unchanged and can still throw the exact same KeyError. Verify against the actual regex behavior (or just avoid braces for prose entirely, or use an InstructionProvider), don't assume the f-string habit transfers. - Assuming this is a streaming/network bug. Because the client-visible symptom is "no response at all," it is easy to burn time checking the SSE/WebSocket transport, auth tokens, or the model provider's status before ever looking at the instructions file — the exception never reaches those layers, it kills the turn before generation even starts. If the symptom is "completely silent, no partial output, no client error," check the agent-service logs for this exact KeyError shape before touching transport-layer debugging. - A whitelist you don't keep current. A startup validator is only as good as its whitelist of legitimately-injected state keys; if new state keys are added to the agent's context without a matching whitelist update, the validator either false-positives (blocking a legitimate deploy) or, if the whitelist is instead left overly permissive to avoid that friction, stops catching real cases. The validator is a net positive but is not "fire and forget" — it needs to move in lockstep with what the agent's state actually contains.

Related

- The tool stays 'running' for hours: a synchronous LLM call with no timeout blocks the event loop — a different ADK failure mode that produces the same class of user-visible symptom ("nothing happens, no error"): there it's a blocking synchronous call inside async code with no timeout, here it's a template pre-processor throwing before generation starts. Same debugging first move applies to both — check the service logs before assuming a transport problem. - The AI's text appears duplicated in the same bubble — upstream SDK bug, 4-layer defense and A long-running tool's follow-up turn is generated server-to-server and never streamed to the browser — the UI needs an F5 to show what already happened — two more members of the same family: ADK's prompt/turn machinery failing in ways that are invisible or misleading at the client, requiring server-side log inspection to diagnose correctly. - Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — a broader collection of non-obvious ADK behaviors worth knowing before building on top of the framework's prompt and state machinery. - Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — a sibling lesson on why "no visible error" bugs demand looking at logs with the exception type/repr, not just a stringified message, since some frameworks' default error surfacing is this uninformative by design.

Verified against

22 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.