fix: escape curly braces in agent instructions to prevent context variable errors#1394
Conversation
There was a problem hiding this comment.
Pull request overview
Disables ADK session-state template injection for kagent agent instructions to prevent {...} braces in prompts from being interpreted as missing context variables (KeyError), and adds unit tests around instruction handling.
Changes:
- Wrap
AgentConfig.instructionin a callable when constructing the ADKAgentto bypass ADK state injection. - Update memory-instruction appending logic to compose instruction callables instead of string-concatenating.
- Add unit tests intended to cover curly-brace and JSON-like instruction content.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| python/packages/kagent-adk/src/kagent/adk/types.py | Wraps instructions in a provider callable and updates memory instruction composition to support callable instructions. |
| python/packages/kagent-adk/tests/unittests/test_instruction_escaping.py | Adds tests for curly-brace-containing instructions and verifies instructions are callable and preserved. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def test_instruction_with_curly_braces_creates_agent(self): | ||
| """Agent with {repo} in instruction should not raise KeyError.""" | ||
| config = _make_agent_config( | ||
| "Clone the repo {repo} and run tests on branch {branch}." | ||
| ) | ||
| agent = config.to_agent("test_agent") | ||
| assert agent is not None |
There was a problem hiding this comment.
These tests don’t currently exercise the ADK code path that was failing (inject_session_state / canonical_instruction template processing). Creating the agent and calling agent.instruction(mock_ctx) will likely pass even with a raw-string instruction, so the suite may not catch regressions. Consider invoking the ADK method that canonicalizes/injects state (or otherwise reproducing the original KeyError) and asserting it returns the unmodified instruction without raising.
| # bypass ADK state injection. Wrap it so the memory suffix is | ||
| # appended at call time without breaking the bypass. | ||
| prev_instruction = agent.instruction | ||
| if callable(prev_instruction): |
There was a problem hiding this comment.
_configure_memory defines a nested function immediately after the if callable(prev_instruction): line. With the repo's Ruff/pycodestyle rules (E306), nested def blocks should be preceded by a blank line; otherwise linting can fail. Add the expected blank line (or refactor to avoid the nested def) before defining _with_memory.
| if callable(prev_instruction): | |
| if callable(prev_instruction): |
|
|
||
| def _make_agent_config(instruction: str): | ||
| """Create a minimal AgentConfig with the given instruction.""" | ||
| # Import types directly (not via kagent.adk) to avoid __init__ side effects |
There was a problem hiding this comment.
The comment says this imports types “directly (not via kagent.adk) to avoid init side effects”, but import kagent.adk.types still executes kagent/adk/__init__.py first (which imports _a2a, etc.). Either adjust the comment to reflect that the test relies on stubbing to make the package importable, or switch to a true direct module load (e.g., via importlib.util.spec_from_file_location) if avoiding __init__ execution is important.
| # Import types directly (not via kagent.adk) to avoid __init__ side effects | |
| # Import types via kagent.adk; __init__ side effects are neutralized by | |
| # the _isolate_types_import fixture stubbing heavy dependencies. |
b983b6e to
1ba3df5
Compare
| name=name, | ||
| model=model, | ||
| description=self.description, | ||
| instruction=self.instruction, |
There was a problem hiding this comment.
Perhaps using this new field in ADK will solve the issue, I believe it would not cause any regression either:
static_instruction: Optional[types.ContentUnion] = Field(
default=None,
description=(
'Optional. LlmAgent.static_instruction. Static content sent literally'
' at position 0 without placeholder processing. When set, changes'
' instruction behavior to go to user content instead of'
' system_instruction. Supports context caching. Accepts'
' types.ContentUnion (str, types.Content, types.Part,'
' PIL.Image.Image, types.File, or list[PartUnion]).'
),
)There was a problem hiding this comment.
@supreme-gg-gg Thanks for the suggestion! I looked into static_instruction — it's an interesting field but it wouldn't fully solve this issue on its own.
The key problem: static_instruction changes where the instruction content goes (from system_instruction to user content), but ADK still runs _process_agent_instruction() on the instruction field, which calls canonical_instruction() → inject_session_state(). Since a raw string instruction returns bypass_state_injection=False, the {curly_brace} KeyError would still occur.
The callable approach directly sets bypass_state_injection=True via ADK's own mechanism — when instruction is callable, canonical_instruction() returns (text, True), completely skipping state injection. This is simpler (one wrapping layer vs two-step setup) and semantically correct for kagent's use case since we don't use ADK context variables at all.
Rebased against main to resolve the merge conflicts. The Copilot review feedback on tests and linting has been addressed in the follow-up commits.
There was a problem hiding this comment.
So I looked deeper and that doesn't seem to be the case, I think static instructions are not preprocessed:
Static instruction content sent literally as system instruction at the beginning....This field is for content that never changes and doesn't contain placeholders. It's sent directly to the model without any processing or variable substitution.
in llm_agent.py
and also in the code in instructions.py. I think this pattern aligns better with how ADK is designed, but yea indeed either works
…iable errors
Wrap agent instructions in a callable (InstructionProvider) before passing
to the ADK Agent constructor. This bypasses ADK's session state injection
which treats curly braces like {repo} as context variable references and
raises KeyError when they aren't found in session state. Since kagent
doesn't use ADK context variables, we return the instruction string as-is.
The _configure_memory method is also updated to handle callable instructions
when appending the memory usage instructions.
Fixes: kagent-dev#1382
Signed-off-by: opspawn <agent@opspawn.com>
Signed-off-by: fl-sean03 <jmhbh@users.noreply.github.com>
Add blank lines around nested _with_memory function definition and join multiline string onto single line per ruff format rules. Signed-off-by: Sean Florez <sean@opspawn.com> Signed-off-by: fl-sean03 <jmhbh@users.noreply.github.com>
- Fix import comment to clarify __init__ side effects are neutralized by fixture stubbing, not avoided - Add TestCanonicalInstructionBypass class that exercises the actual ADK code paths: canonical_instruction() bypass_state_injection flag and inject_session_state() KeyError on unresolved variables Signed-off-by: Sean Florez <sean@opspawn.com> Signed-off-by: fl-sean03 <jmhbh@users.noreply.github.com>
Signed-off-by: fl-sean03 <jmhbh@users.noreply.github.com>
1ba3df5 to
33431cc
Compare
Replace callable instruction wrapper with ADK's built-in static_instruction field, which sends content to the model without placeholder processing. This is the idiomatic ADK approach for instructions that don't use context variables. Signed-off-by: Sean Florez <sean@opspawn.com> Signed-off-by: fl-sean03 <jmhbh@users.noreply.github.com>
|
@supreme-gg-gg You're absolutely right — I dug deeper into the ADK source and I've updated the PR to use
Thanks for pushing back on this — much cleaner solution. |
When memory is enabled, append the memory usage instructions to static_instruction (system prompt) instead of setting the dynamic instruction field. With static_instruction set, ADK sends the dynamic instruction as a separate user message in the conversation, which causes the mock LLM server in e2e tests to fail matching (it matches on the last message, which becomes the memory instruction instead of the expected tool response). This restores the previous behavior where memory instructions were part of the system prompt alongside the main agent instruction. Signed-off-by: fl-sean03 <fl-sean03@users.noreply.github.com>
|
Thanks for digging into the ADK source — you're right that All 21 CI checks pass including both e2e suites (sqlite + postgres). Would you be open to approving this? 🙏 |
Summary
Fixes #1382
When an agent's instructions contain curly braces (e.g.,
{repo},{branch}), ADK'sinject_session_statetries to resolve them as context variable references. Since kagent doesn't use ADK context variables, this causes aKeyError.Root cause: ADK's
LlmAgent.canonical_instruction()returnsbypass_state_injection=Falsewheninstructionis a string, triggeringinject_session_statewhich uses regex{+[^{}]*}+to find and resolve{text}patterns as state variables.Fix: Wrap the instruction string in a callable (
InstructionProvider) before passing to the ADKAgentconstructor. When instruction is callable, ADK setsbypass_state_injection=True, completely skipping the template processing. The_configure_memorymethod is also updated to properly handle callable instructions when appending memory usage instructions.Changes
python/packages/kagent-adk/src/kagent/adk/types.py: Wrapself.instructionin anInstructionProvidercallable into_agent(). Update_configure_memory()to compose callables when appending memory instructions.python/packages/kagent-adk/tests/unittests/test_instruction_escaping.py: 7 unit tests covering curly braces, JSON-like content, nested braces, empty instructions, and verifying the callable bypass mechanism.Test plan
Signed-off-by: opspawn agent@opspawn.com