From 0a555781435d1555f4c8054b7b19c1699323ba47 Mon Sep 17 00:00:00 2001 From: RerankerGuo <121015044+RerankerGuo@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:35:03 +0800 Subject: [PATCH] fix(frontmatter-read): JSON-encode keys too so every answer entry stays on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multiline YAML key broke the one-key-per-line layout because keys were interpolated raw. Route keys through the same json.dumps renderer as values, update the expected answers to the quoted-key form, correct the renderer docstring and mixed-key test comments (JSON collapses int 1 and string "1" keys alike — the metadata keeps the distinction), and pin the rich-type answer to the quoted "created": "2026-10-12" rendering. --- reme/steps/file_io/frontmatter_read.py | 37 +++++++++++--------------- tests/unit/test_frontmatter_steps.py | 29 ++++++++++---------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 3a091d26..2acdace0 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -2,8 +2,9 @@ Cheap structured read — frontmatter only, no body. Use ``body:read`` for the post-frontmatter content slice, or whole-file ``read`` when -you want both at once. The answer is textual — ``- key: value`` per -line — because MCP callers only ever see ``answer``; the parsed +you want both at once. The answer is textual — one JSON-encoded +``- "key": value`` entry per line, so every key and value stays on a +single line — because MCP callers only ever see ``answer``; the parsed mapping itself is returned in ``metadata["frontmatter"]`` (and ``exists: false`` in metadata when the target doesn't exist). @@ -22,17 +23,19 @@ from ...components import R def _render_value(value) -> str: - """Render one frontmatter value on a single answer line. + """Render one frontmatter key or value on a single answer line. ``frontmatter_read`` is served straight to the LLM, so the answer - has to carry the frontmatter itself. ``json.dumps`` (with - ``default=str`` for YAML-only types such as dates, and - ``ensure_ascii=False`` for non-ASCII) preserves the structural - distinction between a literal ``"\\n"`` two-character sequence and - an actual newline, between the string ``"null"`` and YAML ``null``, - and between mixed mapping key types (an int ``1`` and a string - ``"1"`` round-trip with their JSON-form distinction). The - one-key-per-line layout is enforced upstream by the caller. + has to carry the frontmatter itself, one ``- "key": value`` entry + per line. ``json.dumps`` (with ``default=str`` for YAML-only types + such as dates, and ``ensure_ascii=False`` for non-ASCII) keeps each + entry on one line and preserves the distinction between a literal + ``"\\n"`` two-character sequence and an actual newline, and between + the string ``"null"`` and YAML ``null``. Presentation limitation: + JSON stringifies mapping keys, so an int ``1`` and a string ``"1"`` + both render as ``"1"`` (only the structured metadata keeps them + apart, and re-parsing the fragment keeps the last value). The + fallback covers values JSON refuses, recursive aliases included. """ try: return json.dumps(value, ensure_ascii=False, default=str) @@ -40,16 +43,6 @@ def _render_value(value) -> str: return repr(value) -def _render_key(key) -> str: - """Render a frontmatter key (the caller formats ``key: value`` lines). - - Keys are always strings in YAML frontmatter, so they do not need - the json.dumps structural pinning. Return them as-is so the answer - reads as ``- key: value`` rather than ``- "key": value``. - """ - return str(key) - - @R.register("frontmatter_read_step") class FrontmatterReadStep(BaseStep): """Read a Markdown file's frontmatter (YAML metadata only).""" @@ -96,7 +89,7 @@ class FrontmatterReadStep(BaseStep): return self.context.response.success = True header = f"Read frontmatter from {probed} ({len(meta)} key(s))" - rendered = [f"- {_render_key(key)}: {_render_value(value)}" for key, value in meta.items()] + rendered = [f"- {_render_value(key)}: {_render_value(value)}" for key, value in meta.items()] self.context.response.answer = f"{header}:\n" + "\n".join(rendered) if rendered else f"{header}: (no keys)" self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved}) self.logger.info(f"[{self.name}] path={path} keys={len(meta)}") diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py index f5afa475..a83b8982 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -208,7 +208,7 @@ async def test_read_answer_carries_the_frontmatter_itself(): store = await _make_store() resp = await _run(FrontmatterReadStep, store, path="notes/post") assert resp.success is True - assert resp.answer == 'Read frontmatter from notes/post.md (2 key(s)):\n- name: "n"\n- tags: ["a"]' + assert resp.answer == 'Read frontmatter from notes/post.md (2 key(s)):\n- "name": "n"\n- "tags": ["a"]' assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} await store.close() @@ -229,10 +229,10 @@ async def test_read_answer_preserves_value_structure_and_reports_empty(): assert resp.success is True assert resp.answer == ( "Read frontmatter from notes/rich.md (4 key(s)):\n" - '- name: "Cobalt"\n' - '- description: "line one line two"\n' - '- tags: ["a", "b"]\n' - '- nested: {"x": 1}' + '- "name": "Cobalt"\n' + '- "description": "line one line two"\n' + '- "tags": ["a", "b"]\n' + '- "nested": {"x": 1}' ) assert resp.metadata["frontmatter"] == { "name": "Cobalt", @@ -279,15 +279,16 @@ async def test_read_answer_tolerates_rich_yaml_types(): store = await _make_store() resp = await _run(FrontmatterReadStep, store, path="notes/types.md") assert resp.success is True - assert resp.answer == ( - "Read frontmatter from notes/types.md (6 key(s)):\n" - '- created: 2026-10-12\n' - '- schedule: {"1": "one", "label": "two"}\n' - "- tags: []\n" - '- aliases: ["a, b", "c"]\n' - '- command: "echo a b"\n' - '- description: "line one\\nline two"' - ) + expected_lines = [ + "Read frontmatter from notes/types.md (6 key(s)):", + '- "created": "2026-10-12"', + '- "schedule": {"1": "one", "label": "two"}', + '- "tags": []', + '- "aliases": ["a, b", "c"]', + '- "command": "echo a b"', + '- "description": "line one\\nline two"', + ] + assert resp.answer == "\n".join(expected_lines) assert resp.metadata["frontmatter"] == { "created": date(2026, 10, 12), "schedule": {1: "one", "label": "two"},