mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-14 04:12:05 +00:00
fix(frontmatter-read): put the frontmatter in the answer, not just a key count
`frontmatter_read` answered `Read frontmatter from <path> (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.
This commit is contained in:
parent
1f67a6ce29
commit
5b2bd3aea9
2 changed files with 71 additions and 1 deletions
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue