diff --git a/src/agent_tools/filesystem_tools.py b/src/agent_tools/filesystem_tools.py index 59130463d..e0991ca7c 100644 --- a/src/agent_tools/filesystem_tools.py +++ b/src/agent_tools/filesystem_tools.py @@ -25,6 +25,31 @@ _BINARY_ARTIFACT_SUFFIXES = _STRUCTURED_DOCUMENT_SUFFIXES | frozenset({ ".png", ".wav", ".webm", ".webp", ".zip", }) +# Models frequently put source artifacts in a Markdown code fence even when a +# tool schema asks for the raw file body. Persisting that fence makes HTML, +# CSS, JavaScript, and source files invalid. Restrict normalization to +# code-like targets so a user can still write a literal fence to Markdown. +_FENCED_SOURCE_SUFFIXES = frozenset({ + ".css", ".csv", ".html", ".htm", ".js", ".json", ".jsx", ".mjs", + ".py", ".sh", ".sql", ".svg", ".ts", ".tsx", ".xml", ".yaml", ".yml", +}) + + +def _unwrap_fenced_source_body(body: str, path: str) -> str: + """Remove an accidental outer Markdown fence from a source artifact. + + An opening fence is enough to normalize: generation can end during a tool + call while its argument remains otherwise usable, and retaining the fence + corrupts the artifact. This only applies to source-like file extensions. + """ + if os.path.splitext(path)[1].casefold() not in _FENCED_SOURCE_SUFFIXES: + return body + match = re.match(r"^(\s*)```[^\r\n]*\r?\n", body) + if not match: + return body + unwrapped = body[match.end():] + return re.sub(r"\r?\n```\s*$", "", unwrapped) + def _glob_to_regex(pat: str) -> "re.Pattern": """Translate a forward-slash glob (**, *, ?) into a compiled regex. @@ -247,6 +272,7 @@ class WriteFileTool: path = _resolve_tool_path(raw_path) except ValueError as e: return {"error": f"write_file: {e}", "exit_code": 1} + body = _unwrap_fenced_source_body(body, path) # A frequent multimodal artifact failure is writing SVG markup to a # path whose extension promises a raster image. The file exists, so # ordinary artifact checks pass, but image judges cannot decode it. diff --git a/tests/test_filesystem_tool_argument_validation.py b/tests/test_filesystem_tool_argument_validation.py index 2e0ceade0..0958d55a1 100644 --- a/tests/test_filesystem_tool_argument_validation.py +++ b/tests/test_filesystem_tool_argument_validation.py @@ -1,3 +1,5 @@ +import json + import pytest from src.agent_tools.filesystem_tools import EditFileTool, ReadFileTool, WriteFileTool @@ -90,3 +92,34 @@ async def test_write_file_still_rewrites_existing_text_file(tmp_path, monkeypatc assert result["exit_code"] == 0 assert target.read_text(encoding="utf-8") == "new" + + +@pytest.mark.asyncio +async def test_write_file_unwraps_markdown_fence_for_html_artifact(tmp_path, monkeypatch): + import src.tool_execution as tool_execution + + target = tmp_path / "output.html" + monkeypatch.setattr(tool_execution, "_resolve_tool_path", lambda _path: str(target)) + + result = await WriteFileTool().execute( + '{"path":"output.html","content":"```html\\n