Fix prompt fence stripping policy

- Restrict code fence stripping to fences at start-of-line (prevents inline "~~~markdown" from being treated as a fence and merging lines) - Only strip fences for full JSON templates in Agent.read_prompt() and files.parse_file() - Preserve fenced examples in markdown/tool prompts while keeping json-only prompts parseable - Add regression tests for fence stripping and prompt fence policy

Rafael Uzarowski committed Dec 28, 2025 at 22:33 UTC cc7af97ed35a2884fafffc5c647351683d4b20a3
4 files changed +85 -12
agent.py
+5 -2
@@ -564,7 +564,7 @@ class Agent:
564 self.handle_critical_exception(e)
565
566 error_message = errors.format_error(e)
567 -
567 +
568 self.context.log.log(
569 type="warning", content="Critical error occurred, retrying..."
570 )
@@ -628,7 +628,10 @@ class Agent:
628 def read_prompt(self, file: str, **kwargs) -> str:
629 dirs = subagents.get_paths(self, "prompts")
630 prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
631 - prompt = files.remove_code_fences(prompt)
631 + # Only strip code fences when the *entire* prompt is a JSON template (e.g. fw.initial_message.md),
632 + # so embedded fenced examples in markdown prompts remain intact.
633 + if files.is_full_json_template(prompt):
634 + prompt = files.remove_code_fences(prompt)
635 return prompt
636
637 def get_data(self, field: str):
python/helpers/files.py
+11 -10
@@ -96,7 +96,10 @@ def parse_file(
96 content = f.read()
97
98 is_json = is_full_json_template(content)
99 - content = remove_code_fences(content)
99 + # Only strip code fences for full JSON templates - embedded fenced blocks in markdown prompts
100 + # should remain intact, otherwise examples/instructions lose structure.
101 + if is_json:
102 + content = remove_code_fences(content)
103 variables = load_plugin_variables(absolute_path, _directories, **kwargs) or {} # type: ignore
104 variables.update(kwargs)
105 if is_json:
@@ -333,17 +336,16 @@ def get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str = "*"):
336
337
338 def remove_code_fences(text):
336 - # Pattern to match code fences with optional language specifier
337 - pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"
339 + # Strip fenced blocks (``` / ~~~) only when the fence marker is at the start of a line.
340 + #
341 + # This prevents accidental stripping when the fence marker appears inline, e.g.
342 + # "... do not wrap ~~~markdown" (which should remain literal text).
343 + pattern = r"(?ms)^[ \t]*(```|~~~)[^\n]*\n(.*?)(?:^[ \t]*\1[ \t]*$)"
344
339 - # Function to replace the code fences
345 def replacer(match):
341 - return match.group(3) # Return the code without fences
342 -
343 - # Use re.DOTALL to make '.' match newlines
344 - result = re.sub(pattern, replacer, text, flags=re.DOTALL)
346 + return match.group(2) # Return the code without fences
347
346 - return result
348 + return re.sub(pattern, replacer, text)
349
350
351 def is_full_json_template(text):
@@ -597,4 +599,3 @@ def list_files_in_dir_recursively(relative_path: str) -> list[str]:
599 rel_path = os.path.relpath(file_path, abs_path)
600 result.append(rel_path)
601 return result
600 -
\ No newline at end of file
tests/test_prompt_fence_policy.py new
+40
@@ -0,0 +1,40 @@
1 +import sys
2 +import json
3 +from pathlib import Path
4 +from types import SimpleNamespace
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +from agent import Agent
11 +
12 +
13 +class _DummyContext:
14 + def get_data(self, key: str, recursive: bool = True):
15 + return None
16 +
17 +
18 +def _dummy_agent(profile: str = "agent0"):
19 + # Agent.read_prompt only needs config.profile and context.get_data() to resolve prompt paths.
20 + return SimpleNamespace(config=SimpleNamespace(profile=profile), context=_DummyContext())
21 +
22 +
23 +def test_agent_read_prompt_preserves_embedded_fenced_examples_in_markdown_prompts():
24 + dummy = _dummy_agent()
25 + text = Agent.read_prompt(dummy, "agent.system.tool.response.md")
26 +
27 + assert "usage:" in text
28 + assert "~~~json" in text
29 + assert "~~~" in text
30 +
31 +
32 +def test_agent_read_prompt_strips_full_json_template_fences_for_json_consumers():
33 + dummy = _dummy_agent()
34 + text = Agent.read_prompt(dummy, "fw.initial_message.md")
35 +
36 + assert "```" not in text
37 + assert "~~~" not in text
38 +
39 + parsed = json.loads(text)
40 + assert parsed.get("tool_name") == "response"
tests/test_remove_code_fences.py new
+29
@@ -0,0 +1,29 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +if str(PROJECT_ROOT) not in sys.path:
6 + sys.path.insert(0, str(PROJECT_ROOT))
7 +
8 +from python.helpers.files import remove_code_fences
9 +
10 +
11 +def test_remove_code_fences_does_not_strip_inline_tildes_or_join_lines():
12 + src = (
13 + "full message is automatically markdown do not wrap ~~~markdown\n"
14 + "use emojis as icons improve readability\n"
15 + "usage:\n"
16 + "~~~json\n"
17 + "{\n"
18 + ' \"a\": 1\n'
19 + "}\n"
20 + "~~~\n"
21 + )
22 +
23 + out = remove_code_fences(src)
24 +
25 + assert "wrap ~~~markdown\nuse emojis" in out
26 + assert "wrap ~~~markdownuse emojis" not in out
27 + assert "~~~json" not in out
28 + assert "\n~~~\n" not in out
29 + assert '"a": 1' in out