Skip to content

feat: add RunConfig.dollarEscapedPlaceholders to leave escaped placeholders in instructions as written - #1569

Merged
copybara-service[bot] merged 1 commit into
google:mainfrom
Laurianti:fix-instruction-dollar-brace-placeholders
Oct 22, 2026
Merged

copybara-service[bot] merged 1 commit into
google:mainfrom
Laurianti:fix-instruction-dollar-brace-placeholders

Conversation

@Laurianti

@Laurianti Laurianti commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

2. Or, if no issue exists, describe the change:

Ports the adk-python templating rule of google/adk-python@0caecd2 (google/adk-python#5706) as an opt-in: adk-python made it the default in 2.10.0 and listed it under behavior changes, while adk-java 1.x keeps today's output unless a run opts in.

Problem:

InstructionUtils.injectSessionState resolves a placeholder right after $ or \ like any other and keeps the character in front, so instructions that show a template syntax cannot reach the model as written:

Template Output
The formatString supports interpolation via ${expression} syntax. IllegalArgumentException: Context variable not found
Workflow syntax: ${{expression}} (expression = foo) Workflow syntax: $foo
Price: ${price} (price = 9.99) Price: $9.99
A ${var?} B (var missing) A $ B
\{user_name} (user_name = Foo) \Foo

Solution:

RunConfig.dollarEscapedPlaceholders(), default false, lets a run opt into the adk-python rule: a placeholder right after $ or \ is left as written, backslash included, so each template above reaches the model unchanged. Other placeholders, including {{key}} and optional ones, resolve as before.

  • RunConfig: the dollarEscapedPlaceholders property, set to false by builder() and copied by builder(RunConfig).
  • InstructionUtils.injectSessionState: selects the adk-python pattern when context.runConfig().dollarEscapedPlaceholders() is true; the current pattern stays the default, also when runConfig() is null. The rule is documented in its Placeholder Syntax section and in the templating paragraph of Instruction.
  • RunConfigCodec.fromJava (tokt): the Kotlin engine cannot honor the flag, so a RunConfig with it set is rejected with an IllegalArgumentException naming dollarEscapedPlaceholders, like the other unsupported settings.

Every templated instruction goes through injectSessionState, so the flag applies to agent instructions, global instructions and GlobalInstructionPlugin. An Instruction.Provider is not templated, so the flag does not affect it.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.

  • All unit tests pass locally.

  • InstructionUtilsTest: the four adk-python cases with the flag on, the escaped case as \{user_name} with state set, plus Price: ${price}, A ${var?} B, \{{user_name}}, and unescaped {key}, {{key}} and {key?} still resolving with the flag on. With the default RunConfig, today's output is pinned: ${expression} throws, ${{expression}} renders $foo, Price: ${price} renders $9.99, A ${var?} B renders A $ B, \{user_name} renders \Foo.

  • InstructionsTest: agent instruction with the flag on and with the default; global and sub-agent instructions with the flag on; a provider instruction left untouched.

  • GlobalInstructionPluginTest: the plugin with the flag on; the existing tests, whose mocked context returns a null runConfig(), keep today's output.

  • RunConfigTest: the default, and the flag kept by builder(RunConfig) and toBuilder().

  • KtRunnerInteropTest: RunConfigCodec.fromJava with the flag on throws an IllegalArgumentException naming dollarEscapedPlaceholders.

Each production change fails at least one of these tests when reverted on its own. mvn -pl core,tokt -am test: core 2477 tests, 0 failures, 0 errors, 28 skipped; tokt 107 tests, 0 failures, 0 errors, 1 skipped.

Manual End-to-End (E2E) Tests:

Covered by the InstructionsTest cases above, which check the system instruction built for the model request.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.

@hemasekhar-p hemasekhar-p self-assigned this Sep 29, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @Laurianti, Thank you for your contribution and for taking the time to submit this pull request. Our team is currently reviewing your changes and we will reach out if we need any further information. Thank you.

@dosadczuk

Copy link
Copy Markdown
Contributor

Thanks for the port and the clear write-up.

We cannot take this as-is on 1.x. adk-java 1.x does not ship behavior changes, and this changes the output of templates that work today:

Template Today With this PR
Price: ${price} (price = 9.99) Price: $9.99 Price: ${price}
A ${var?} B (var missing) A $ B A ${var?} B
\{user_name} (user_name = Foo) \Foo \{user_name}

None of these throw an error, so callers only notice when values stop reaching the model. adk-python made this change in 2.10.0 and listed it under behavior changes. For Java, we want this opt-in on 1.x, default in 2.0.

If you need this change, could you rework the PR to put the new parsing behind a flag? Here is what we need:

  • On LlmAgent, add Builder.preserveEscapedPlaceholders(boolean) defaulting to false, an accessor, and the matching LlmAgentConfig field for YAML agents. The name is open to suggestions.
  • Keep the current pattern as default in InstructionUtils. Add a 3-arg injectSessionState(InvocationContext, String, boolean preserveEscapedPlaceholders). Have the existing 2-arg method use context.agent()'s flag when it's an LlmAgent, leaving everyone else unchanged.
  • In Instructions, use the agent's flag for its own instruction and the root agent's flag for the global instruction.
  • Add a constructor to GlobalInstructionPlugin that accepts the flag, leaving existing constructors unchanged regardless of current agent.
  • Document the rule (a placeholder right after $ or \ is left as written, backslash included) in the injectSessionState Placeholder Syntax section, the Instruction templating paragraph, and the builder method. Callers cannot see the comment on the private field.
  • For tests, run your four cases with the flag on, and pin today's output with the flag off (${expression} throws, ${{expression}} renders $foo, Price: ${price} renders $9.99). Rewrite the escaped test case as \{user_name} with state set, because the current \{expression\} test passes without the fix. Add tests for the global instruction with the root flag, the plugin constructor, the YAML key, and an opted-in agent wrapped in AgentTool.
  • Retitle the PR with feat:.

NOTE: The listed changes might not be the only changes needed to make it work. If you decide to follow-up, make sure the impact of your change is fully covered.

@dosadczuk dosadczuk added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update and removed needs review labels Sep 30, 2026
@dosadczuk
dosadczuk self-requested a review September 30, 2026 14:03
@Laurianti
Laurianti force-pushed the fix-instruction-dollar-brace-placeholders branch from fd023bd to 4cb1a5e Compare September 30, 2026 14:52
@Laurianti Laurianti changed the title fix: do not match dollar-brace or escaped patterns in instructions feat: add an opt-in to leave escaped placeholders in instructions as written Sep 30, 2026
@Laurianti

Laurianti commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the table and the precise list. Reworked in 9928b62, still one commit:

  • LlmAgent: Builder.preserveEscapedPlaceholders(boolean), default false, the preserveEscapedPlaceholders() accessor, and preserve_escaped_placeholders in LlmAgentConfig. I kept the name.
  • InstructionUtils: the current pattern stays the default. The new injectSessionState(InvocationContext, String, boolean preserveEscapedPlaceholders) selects the adk-python one, and the 2-arg method uses the flag of context.agent() when it is an LlmAgent, false otherwise.
  • Instructions: the agent's flag for its own instruction, the root agent's flag for the global instruction.
  • GlobalInstructionPlugin: new GlobalInstructionPlugin(String globalInstruction, String name, boolean preserveEscapedPlaceholders). The existing string constructors pass false, so they ignore the current agent's flag.
  • Docs: the rule is in the Placeholder Syntax section of injectSessionState, the Instruction templating paragraph and the builder method, and also in the GlobalInstructionPlugin class Javadoc.
  • Tests: your four cases with the flag on; today's output pinned with the flag off (${expression} throws, ${{expression}} renders $foo, Price: ${price} renders $9.99, plus A ${var?} B and \{user_name}); the escaped case rewritten as \{user_name} with state set; the global instruction with the root flag, in both directions against the sub-agent's flag; the new and the existing plugin constructors; the YAML key; an opted-in agent wrapped in AgentTool. I also added a run of an opted-in LlmAgent that checks the instruction the model receives, and a check that {key}, {{key}} and {key?} still resolve with the flag on.
  • Title: now feat:.

On impact: InstructionUtils.injectSessionState has no other callers in the repository, including tokt, and an Instruction.Provider is not templated, so the flag does not affect it. Each production change fails at least one test when reverted on its own.

@Laurianti
Laurianti force-pushed the fix-instruction-dollar-brace-placeholders branch from 4cb1a5e to 9928b62 Compare September 30, 2026 15:00
@dosadczuk dosadczuk added ready to pull and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update labels Oct 1, 2026
@Laurianti
Laurianti requested a review from dosadczuk October 12, 2026 18:20

@dosadczuk dosadczuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi! With this change we need to be very careful, because it can negatively affect multiple users of the library.

Could you consider moving the preserveEscapePlaceholders flag into RunConfig? I'd like to see pros and cons of the change, because it seems like current solution has some negative side effects, e.g. root agent can set different flag value than specific agent. Also, let's find better name for the flag.

Let's discuss the solution, then we'll follow up with the changes.

@dosadczuk dosadczuk added needs update waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed ready to pull labels Oct 20, 2026
@Laurianti

Copy link
Copy Markdown
Contributor Author

Thanks, agreed. Here is how moving it to RunConfig compares:

On the agent (now) In RunConfig
Scope Each agent, so a root agent, its sub-agents and the global instruction can disagree The whole run: every agent, the global instruction plugin, and agents called through AgentTool, which follows the caller's RunConfig
API added LlmAgent.Builder, LlmAgentConfig (YAML), a 3-argument injectSessionState, a GlobalInstructionPlugin constructor One RunConfig property, read through context.invocationContext().runConfig()
Set by The agent definition, YAML included The app that runs the agents, not YAML
Per-agent control Yes No

I don't see a case where one agent of a run needs today's substitution and another the adk-python rule, so one value per run seems the better fit. It also matches how RunConfig carries other behavior switches, such as groupFunctionResponsesInHistoryOverride.

For the name, I'd suggest literalEscapedPlaceholders (default false in 1.x, true for the adk-python rule), since it says what reaches the model.

@dosadczuk

Copy link
Copy Markdown
Contributor

Thanks! Let's try RunConfig with dolarEscapedPlaceholders=false to match current behavior.

@Laurianti
Laurianti force-pushed the fix-instruction-dollar-brace-placeholders branch from 9928b62 to 81c5ed7 Compare October 21, 2026 12:29
@Laurianti Laurianti changed the title feat: add an opt-in to leave escaped placeholders in instructions as written feat: add RunConfig.dollarEscapedPlaceholders to leave escaped placeholders in instructions as written Oct 21, 2026
@Laurianti

Copy link
Copy Markdown
Contributor Author

Done: the flag is now RunConfig.dollarEscapedPlaceholders(), default false, so current behavior is unchanged. As in adk-python, it also covers \{key}. The description is updated.

@dosadczuk dosadczuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the update. The new pattern matches adk-python's, and the default output is unchanged. Two things are still missing:
1. tokt drops the flag without an error.
KotlinAdkToJava.asJavaRunner converts the caller's RunConfig with RunConfigCodec.fromJava (tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt). That method rejects each Java setting the Kotlin engine cannot honor, but it does not check the new flag. The Kotlin engine cannot honor the flag, so add it to the unsupported list:

if (config.dollarEscapedPlaceholders()) add("dollarEscapedPlaceholders")

Then add a test that expects an IllegalArgumentException naming dollarEscapedPlaceholders.
2. The Instruction Javadoc no longer describes the rule.
Your previous revision added it to the templating paragraph in core/src/main/java/com/google/adk/agents/Instruction.java; this revision dropped it. Please add it back, pointing at the new flag.

@Laurianti
Laurianti force-pushed the fix-instruction-dollar-brace-placeholders branch from 81c5ed7 to 72b4af8 Compare October 21, 2026 15:01
@Laurianti

Copy link
Copy Markdown
Contributor Author

Thanks, both done in 72b4af8:

  1. RunConfigCodec.fromJava adds dollarEscapedPlaceholders to the unsupported list. runConfigFromJava_dollarEscapedPlaceholders_failsRatherThanDroppingIt expects an IllegalArgumentException naming it.
  2. The templating paragraph of Instruction describes the rule again and links RunConfig#dollarEscapedPlaceholders().

The description is updated.

@dosadczuk dosadczuk added ready to pull and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update labels Oct 21, 2026
@copybara-service
copybara-service Bot merged commit da10073 into google:main Oct 22, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants