Context
PR #436 extracted shared placeholder extraction helpers to the Template module. However, there are still two identical expand_template/2 private functions that were not consolidated:
lib/ptc_runner/sub_agent.ex:439-454
lib/ptc_runner/sub_agent/loop.ex:409-424
Both are textually identical and perform the same "lenient" expansion (returning {{key}} unchanged on missing keys).
Problem
Code duplication between SubAgent.expand_template/2 and Loop.expand_template/2. Both functions:
- Use the same regex:
~r/\{\{\s*(\w+)\s*\}\}/
- Have identical logic for atom/string key fallback
- Return
{{key}} unchanged when a key is missing
The existing Template.expand/2 returns {:error, {:missing_keys, [...]}} on missing keys (strict behavior), while these private functions need lenient behavior.
Solution
Add an on_missing option to Template.expand/2:
@spec expand(String.t(), map(), keyword()) :: {:ok, String.t()} | {:error, {:missing_keys, [String.t()]}}
# Default: strict - error on missing (current behavior)
Template.expand("{{name}}", %{})
# => {:error, {:missing_keys, ["name"]}}
# Opt-in: lenient - keep placeholders unchanged
Template.expand("{{name}}", %{}, on_missing: :keep)
# => {:ok, "{{name}}"}
Then replace the duplicate private functions with:
defp expand_template(prompt, context) do
{:ok, result} = Template.expand(prompt, context, on_missing: :keep)
result
end
Implementation Steps
- Add
on_missing option to Template.expand/2 (default :error for backward compatibility)
- Add tests for the new
:keep behavior
- Replace
expand_template/2 in SubAgent (line 439) with call to Template.expand/3
- Replace
expand_template/2 in Loop (line 409) with call to Template.expand/3
- Verify all existing tests pass
Acceptance Criteria
Files to Modify
lib/ptc_runner/sub_agent/template.ex - Add on_missing option
lib/ptc_runner/sub_agent.ex - Replace private function
lib/ptc_runner/sub_agent/loop.ex - Replace private function
test/ptc_runner/sub_agent/template_test.exs - Add tests for :keep behavior
Notes
- The existing
Prompt.expand_mission/2 already demonstrates the pattern for handling Template.expand/2 with lenient fallback - this consolidation will make that pattern reusable
- The private implementations don't support nested paths (
{{user.name}}), but this is acceptable since those callers don't currently use nested paths
Context
PR #436 extracted shared placeholder extraction helpers to the
Templatemodule. However, there are still two identicalexpand_template/2private functions that were not consolidated:lib/ptc_runner/sub_agent.ex:439-454lib/ptc_runner/sub_agent/loop.ex:409-424Both are textually identical and perform the same "lenient" expansion (returning
{{key}}unchanged on missing keys).Problem
Code duplication between
SubAgent.expand_template/2andLoop.expand_template/2. Both functions:~r/\{\{\s*(\w+)\s*\}\}/{{key}}unchanged when a key is missingThe existing
Template.expand/2returns{:error, {:missing_keys, [...]}}on missing keys (strict behavior), while these private functions need lenient behavior.Solution
Add an
on_missingoption toTemplate.expand/2:Then replace the duplicate private functions with:
Implementation Steps
on_missingoption toTemplate.expand/2(default:errorfor backward compatibility):keepbehaviorexpand_template/2inSubAgent(line 439) with call toTemplate.expand/3expand_template/2inLoop(line 409) with call toTemplate.expand/3Acceptance Criteria
Template.expand/2continues to error on missing keys (backward compatible)Template.expand("{{x}}", %{}, on_missing: :keep)returns{:ok, "{{x}}"}expand_template/2functions remain in SubAgent or Loopprompt_test.exs:57-65continues to pass (handles missing variables gracefully)Files to Modify
lib/ptc_runner/sub_agent/template.ex- Addon_missingoptionlib/ptc_runner/sub_agent.ex- Replace private functionlib/ptc_runner/sub_agent/loop.ex- Replace private functiontest/ptc_runner/sub_agent/template_test.exs- Add tests for:keepbehaviorNotes
Prompt.expand_mission/2already demonstrates the pattern for handlingTemplate.expand/2with lenient fallback - this consolidation will make that pattern reusable{{user.name}}), but this is acceptable since those callers don't currently use nested paths