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