mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-04 07:30:21 +00:00
Merge 50bfb0f971 into 2767836b21
This commit is contained in:
commit
e7bc164c09
4 changed files with 216 additions and 6 deletions
|
|
@ -331,7 +331,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:
|
||||
|
|
|
|||
|
|
@ -583,7 +583,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,13 +1,17 @@
|
|||
"""``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 — 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).
|
||||
|
||||
``path`` is a path relative to the workspace.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
|
|
@ -18,6 +22,27 @@ from ..base_step import BaseStep
|
|||
from ...components import R
|
||||
|
||||
|
||||
def _render_value(value) -> str:
|
||||
"""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, 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)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
return repr(value)
|
||||
|
||||
|
||||
@R.register("frontmatter_read_step")
|
||||
class FrontmatterReadStep(BaseStep):
|
||||
"""Read a Markdown file's frontmatter (YAML metadata only)."""
|
||||
|
|
@ -71,6 +96,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 = [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.
|
|||
|
||||
from contextlib import chdir as temp_chdir
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
|
|
@ -246,3 +247,185 @@ 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_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),
|
||||
"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.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}'
|
||||
)
|
||||
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 = """---
|
||||
created: 2026-10-12
|
||||
schedule:
|
||||
1: one
|
||||
label: two
|
||||
tags: []
|
||||
aliases:
|
||||
- "a, b"
|
||||
- c
|
||||
command: "echo a b"
|
||||
description: |-
|
||||
line one
|
||||
line two
|
||||
---
|
||||
body
|
||||
"""
|
||||
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
|
||||
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"},
|
||||
"tags": [],
|
||||
"aliases": ["a, b", "c"],
|
||||
"command": "echo a b",
|
||||
"description": "line one\nline two",
|
||||
}
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_answer_distinguishes_newline_from_literal_backslash_n():
|
||||
"""Strings go through json.dumps, so an actual newline and a
|
||||
two-character ``"\\n"`` sequence render as different escape
|
||||
sequences. Review follow-up: pin the answer so this distinction
|
||||
survives future refactors.
|
||||
"""
|
||||
body = r"""---
|
||||
real_newline: "line one\nline two"
|
||||
literal_seq: "a\\nb"
|
||||
---
|
||||
body
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
_seed(Path(tmp), "notes/newline.md", body)
|
||||
store = await _make_store()
|
||||
resp = await _run(FrontmatterReadStep, store, path="notes/newline.md")
|
||||
assert resp.success is True
|
||||
assert '- "real_newline": "line one\\nline two"' in resp.answer
|
||||
# Literal two-character \n renders as a JSON-escaped backslash-n
|
||||
# (the JSON string contains a backslash + an n).
|
||||
assert '- "literal_seq": "a\\\\nb"' in resp.answer
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_answer_escapes_multiline_keys():
|
||||
"""Keys go through the same JSON renderer as values, so a multiline
|
||||
YAML key stays a single ``- "key": value`` entry instead of breaking
|
||||
the one-key-per-line layout. Review finding: a key like
|
||||
``first\\n- forged`` used to render as two lines and look like a
|
||||
second entry. The metadata keeps the original key.
|
||||
"""
|
||||
body = r"""---
|
||||
"first\n- forged": "value"
|
||||
---
|
||||
body
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
_seed(Path(tmp), "notes/keys.md", body)
|
||||
store = await _make_store()
|
||||
resp = await _run(FrontmatterReadStep, store, path="notes/keys.md")
|
||||
assert resp.success is True
|
||||
assert resp.answer == 'Read frontmatter from notes/keys.md (1 key(s)):\n- "first\\n- forged": "value"'
|
||||
assert resp.metadata["frontmatter"] == {"first\n- forged": "value"}
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_answer_mixed_mapping_key_types_render_via_json():
|
||||
"""An int 1 and a string "1" stay distinct in YAML and in the
|
||||
metadata, but the JSON-rendered answer collapses both keys to the
|
||||
string "1": the fragment textually keeps both entries, while
|
||||
re-parsing it as JSON keeps only the latter value. Documented
|
||||
presentation limitation — the structured metadata is the source of
|
||||
truth for key types.
|
||||
"""
|
||||
body = """---
|
||||
mixed:
|
||||
1: "number"
|
||||
"1": "string"
|
||||
---
|
||||
body
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
||||
_seed(Path(tmp), "notes/mixed_keys.md", body)
|
||||
store = await _make_store()
|
||||
resp = await _run(FrontmatterReadStep, store, path="notes/mixed_keys.md")
|
||||
assert resp.success is True
|
||||
# Both entries render textually; JSON's key coercion means a
|
||||
# re-parser sees only the last "1". The metadata keeps the types.
|
||||
assert '- "mixed": {"1": "number", "1": "string"}' in resp.answer
|
||||
assert resp.metadata["frontmatter"] == {
|
||||
"mixed": {1: "number", "1": "string"},
|
||||
}
|
||||
await store.close()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue