From 5b2bd3aea9c129860a970df27cf2195d316639d0 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:35:08 +0800 Subject: [PATCH 1/2] fix(frontmatter-read): put the frontmatter in the answer, not just a key count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `frontmatter_read` answered `Read frontmatter from (N key(s))` and left the frontmatter itself in `metadata["frontmatter"]`. MCP returns only `response.answer`, so the LLM that asked for a file's frontmatter learned how many keys exist and nothing else — the same shape of gap #379 describes for `list`, where the caller cannot act on the result. Render each key on its own line in the answer. Values are flattened to one line each so a multi-line description cannot break the line-per-key layout: lists join with commas and nested mappings render as compact JSON. An empty frontmatter says `(no keys)` rather than trailing off after the colon. The answer also names the probed path instead of the raw input, so a suffix-less request reports the `.md` file that was actually read and the caller can follow up with `read` without consulting metadata. Structured `metadata["frontmatter"]` is unchanged, so programmatic consumers see no difference. Towards #379. --- reme/steps/file_io/frontmatter_read.py | 27 +++++++++++++++- tests/unit/test_frontmatter_steps.py | 45 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 0b127986..d557b8ff 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -8,6 +8,7 @@ doesn't exist; otherwise ``{exists: true, frontmatter: {...}}``. ``path`` is a path relative to the workspace. """ +import json from pathlib import Path import frontmatter @@ -18,6 +19,28 @@ from ..base_step import BaseStep from ...components import R +def _render_value(value) -> str: + """Flatten one frontmatter value onto a single answer line.""" + if isinstance(value, (list, tuple)): + rendered = ", ".join(str(item) for item in value) + elif isinstance(value, dict): + rendered = json.dumps(value, ensure_ascii=False, sort_keys=True) + else: + rendered = str(value) + return " ".join(rendered.split()) + + +def _render_frontmatter(meta: dict) -> list[str]: + """Render each frontmatter key on its own ``- key: value`` line. + + ``frontmatter_read`` is served straight to the LLM, so the answer has to + carry the frontmatter itself; a key count alone leaves the caller with + nothing to act on. Values are flattened to one line each so a multi-line + description cannot break the line-per-key layout. + """ + return [f"- {key}: {_render_value(value)}" for key, value in meta.items()] + + @R.register("frontmatter_read_step") class FrontmatterReadStep(BaseStep): """Read a Markdown file's frontmatter (YAML metadata only).""" @@ -63,6 +86,8 @@ class FrontmatterReadStep(BaseStep): self.logger.info(f"[{self.name}] path={path} parse_error={exc!r}") return self.context.response.success = True - self.context.response.answer = f"Read frontmatter from {path} ({len(meta)} key(s))" + header = f"Read frontmatter from {probed} ({len(meta)} key(s))" + rendered = _render_frontmatter(meta) + 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 1ea9a6ef..af0e279a 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -192,3 +192,48 @@ async def test_explicit_md_path_has_no_resolved_path(): assert resp.success is True assert "resolved_path" not in resp.metadata await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_carries_the_frontmatter_itself(): + """The answer must contain the frontmatter, not just a key count. + + ``frontmatter_read`` is served straight to the LLM and MCP returns only + ``response.answer``, so a count alone leaves the caller with nothing to + act on. The suffix-less substitution is disclosed in the answer too. + """ + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), NOTE, BODY) + 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.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} + await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_flattens_values_and_reports_empty(): + """Multi-line, list and nested values stay on one line each; empty says so.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed( + Path(tmp), + "notes/rich.md", + "---\nname: Cobalt\ndescription: line one\n line two\ntags:\n- a\n- b\nnested:\n x: 1\n---\nbody\n", + ) + _seed(Path(tmp), "notes/empty.md", "---\n---\nbody\n") + store = await _make_store() + + resp = await _run(FrontmatterReadStep, store, path="notes/rich.md") + 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}' + ) + + resp = await _run(FrontmatterReadStep, store, path="notes/empty.md") + assert resp.answer == "Read frontmatter from notes/empty.md (0 key(s)): (no keys)" + assert resp.metadata["frontmatter"] == {} + await store.close() From 53a359d3e17f4183eea4dc1fdefb9efb71564238 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:45:28 +0800 Subject: [PATCH 2/2] docs(config): match the frontmatter_read description to the new answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job description still advertised "Read a file's frontmatter as a dict", but the answer is now rendered `key: value` lines — the dict only survives in metadata. An LLM choosing between `frontmatter_read` and a whole-file `read` would expect a dict it can index into and get prose instead. This is the wording-alignment point raised on #397, applied here before review rather than after. --- reme/config/default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 4b26a2f2..987c3d6e 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -501,7 +501,7 @@ jobs: frontmatter_read: backend: base - description: "Read a file's frontmatter as a dict." + description: "Read a file's frontmatter, one key: value per line." parameters: type: object properties: