mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-27 06:15:20 +00:00
fix(frontmatter-read): never fail a valid YAML read on rendering
Review on #564 found two rendering regressions plus boundary losses: - json.dumps(..., sort_keys=True) raised on datetime.date values (YAML timestamps) and on mixed-type mapping keys, turning a parseable frontmatter into a failed job with empty metadata - the base revision read those files fine. - Comma-joining lists lost item boundaries (["a, b", "c"] read back as one ambiguous string), [] was indistinguishable from an empty string, and the whitespace collapse also mangled spaces inside serialized values. _render_value is now a single tolerant renderer: bare strings keep their text but have line breaks escaped; everything else goes through json.dumps(ensure_ascii=False, default=str) (no key sort) so collections stay structured and YAML-only types serialize; anything JSON still cannot represent falls back to repr. Keys are rendered through the same path so a multiline key cannot break the one-key-per-line layout either. The structured metadata contract is untouched. Also aligns the benchmark.yaml job description with the textual answer (default.yaml already says 'one key: value per line') and the module docstring now distinguishes the textual answer from the structured payload in metadata. Regression coverage: nested dates, mixed mapping keys, empty lists, comma-containing items, significant whitespace, and a literal block scalar with a real newline.
This commit is contained in:
parent
53a359d3e1
commit
33d44b8132
3 changed files with 85 additions and 26 deletions
|
|
@ -327,7 +327,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:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""``frontmatter_read_step`` — return the frontmatter dict of a markdown file.
|
||||
"""``frontmatter_read_step`` — surface a markdown file's frontmatter.
|
||||
|
||||
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. Returns ``{exists: false}`` when the target
|
||||
doesn't exist; otherwise ``{exists: true, frontmatter: {...}}``.
|
||||
you want both at once. The answer is textual — ``- key: value`` per
|
||||
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).
|
||||
|
||||
``path`` is a path relative to the workspace.
|
||||
"""
|
||||
|
|
@ -20,25 +22,22 @@ 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())
|
||||
"""Render one frontmatter key or value on a single answer line.
|
||||
|
||||
|
||||
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.
|
||||
``frontmatter_read`` is served straight to the LLM, so the answer has
|
||||
to carry the frontmatter itself. Collections keep their JSON shape so
|
||||
list boundaries, empty collections, and nested structure stay visible;
|
||||
``default=str`` covers YAML-only types such as dates, and skipping key
|
||||
sort keeps mixed-type mapping keys loadable. Bare strings are the one
|
||||
exception — quoting every scalar would drown the answer in noise — but
|
||||
their line breaks are escaped so the one-key-per-line layout survives.
|
||||
"""
|
||||
return [f"- {key}: {_render_value(value)}" for key, value in meta.items()]
|
||||
if isinstance(value, str):
|
||||
return value.replace("\r", "\\r").replace("\n", "\\n")
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
return repr(value)
|
||||
|
||||
|
||||
@R.register("frontmatter_read_step")
|
||||
|
|
@ -87,7 +86,7 @@ class FrontmatterReadStep(BaseStep):
|
|||
return
|
||||
self.context.response.success = True
|
||||
header = f"Read frontmatter from {probed} ({len(meta)} key(s))"
|
||||
rendered = _render_frontmatter(meta)
|
||||
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)}")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ probed path in not-found errors instead of the caller's raw input.
|
|||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -207,14 +208,14 @@ 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()
|
||||
|
||||
|
||||
@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."""
|
||||
async def test_read_answer_preserves_value_structure_and_reports_empty():
|
||||
"""Multi-line values stay on one line each; lists keep their shape; empty says so."""
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
_seed(
|
||||
Path(tmp),
|
||||
|
|
@ -225,15 +226,74 @@ async def test_read_answer_flattens_values_and_reports_empty():
|
|||
store = await _make_store()
|
||||
|
||||
resp = await _run(FrontmatterReadStep, store, path="notes/rich.md")
|
||||
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"
|
||||
'- tags: ["a", "b"]\n'
|
||||
'- nested: {"x": 1}'
|
||||
)
|
||||
assert resp.metadata["frontmatter"] == {
|
||||
"name": "Cobalt",
|
||||
"description": "line one line two",
|
||||
"tags": ["a", "b"],
|
||||
"nested": {"x": 1},
|
||||
}
|
||||
|
||||
resp = await _run(FrontmatterReadStep, store, path="notes/empty.md")
|
||||
assert resp.success is True
|
||||
assert resp.answer == "Read frontmatter from notes/empty.md (0 key(s)): (no keys)"
|
||||
assert resp.metadata["frontmatter"] == {}
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_answer_tolerates_rich_yaml_types():
|
||||
"""Rendering must never turn a parseable frontmatter into a failed job.
|
||||
|
||||
Regression test for the review findings: YAML dates (``datetime.date``)
|
||||
crashed ``json.dumps``, mixed mapping keys crashed ``sort_keys``, and
|
||||
comma-joined lists / collapsed whitespace lost value boundaries. All of
|
||||
these stay successful reads with the structured ``metadata`` untouched.
|
||||
"""
|
||||
rich = (
|
||||
"---\n"
|
||||
"created: 2026-10-12\n"
|
||||
"schedule:\n"
|
||||
" 1: one\n"
|
||||
" label: two\n"
|
||||
"tags: []\n"
|
||||
"aliases:\n"
|
||||
' - "a, b"\n'
|
||||
" - c\n"
|
||||
'command: "echo a b"\n'
|
||||
"description: |-\n"
|
||||
" line one\n"
|
||||
" line two\n"
|
||||
"---\n"
|
||||
"body\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
_seed(Path(tmp), "notes/types.md", rich)
|
||||
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"
|
||||
)
|
||||
assert resp.metadata["frontmatter"] == {
|
||||
"created": date(2026, 10, 12),
|
||||
"schedule": {1: "one", "label": "two"},
|
||||
"tags": [],
|
||||
"aliases": ["a, b", "c"],
|
||||
"command": "echo a b",
|
||||
"description": "line one\nline two",
|
||||
}
|
||||
await store.close()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue