Skip to content

fix: escape curly braces in agent instructions to prevent context variable errors#1394

Merged
EItanya merged 6 commits intokagent-dev:mainfrom
fl-sean03:fix/1382-context-variable-escape
Feb 27, 2026
Merged

fix: escape curly braces in agent instructions to prevent context variable errors#1394
EItanya merged 6 commits intokagent-dev:mainfrom
fl-sean03:fix/1382-context-variable-escape

Conversation

@fl-sean03
Copy link
Contributor

Summary

Fixes #1382

When an agent's instructions contain curly braces (e.g., {repo}, {branch}), ADK's inject_session_state tries to resolve them as context variable references. Since kagent doesn't use ADK context variables, this causes a KeyError.

Root cause: ADK's LlmAgent.canonical_instruction() returns bypass_state_injection=False when instruction is a string, triggering inject_session_state which uses regex {+[^{}]*}+ to find and resolve {text} patterns as state variables.

Fix: Wrap the instruction string in a callable (InstructionProvider) before passing to the ADK Agent constructor. When instruction is callable, ADK sets bypass_state_injection=True, completely skipping the template processing. The _configure_memory method is also updated to properly handle callable instructions when appending memory usage instructions.

Changes

  • python/packages/kagent-adk/src/kagent/adk/types.py: Wrap self.instruction in an InstructionProvider callable in to_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

  • All 7 new unit tests pass
  • Existing test suite unaffected (pre-existing env import issues for other tests are unrelated)
  • CI pipeline validation

Signed-off-by: opspawn agent@opspawn.com

Copilot AI review requested due to automatic review settings February 27, 2026 11:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.instruction in a callable when constructing the ADK Agent to 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.

Comment on lines 52 to 58
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
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
# 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):
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
if callable(prev_instruction):
if callable(prev_instruction):

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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.

Copilot uses AI. Check for mistakes.
@fl-sean03 fl-sean03 force-pushed the fix/1382-context-variable-escape branch from b983b6e to 1ba3df5 Compare February 27, 2026 13:02
name=name,
model=model,
description=self.description,
instruction=self.instruction,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]).'
      ),
  )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

jmhbh and others added 4 commits February 27, 2026 20:01
…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>
@fl-sean03 fl-sean03 force-pushed the fix/1382-context-variable-escape branch from 1ba3df5 to 33431cc Compare February 27, 2026 20:01
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>
@fl-sean03
Copy link
Contributor Author

@supreme-gg-gg You're absolutely right — I dug deeper into the ADK source and static_instruction is the correct approach here. Looking at instructions.py, static_instruction goes through _transformers.t_content() directly without any call to _process_agent_instruction() or inject_session_state(), so it completely bypasses placeholder processing.

I've updated the PR to use static_instruction instead of the callable wrapper:

  • Agent instructions are now passed as static_instruction (sent literally, no template substitution)
  • The memory suffix (which has no curly braces) goes into the instruction field
  • This is simpler and aligns with how ADK is designed

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>
@fl-sean03
Copy link
Contributor Author

Thanks for digging into the ADK source — you're right that static_instruction bypasses the template preprocessing entirely (instructions.py doesn't touch it). I've updated the PR to use this approach: memory instructions now append to static_instruction instead of setting agent.instruction separately.

All 21 CI checks pass including both e2e suites (sqlite + postgres). Would you be open to approving this? 🙏

@EItanya EItanya merged commit aefa778 into kagent-dev:main Feb 27, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Context variable references in agent prompts cause errors

5 participants