From c89b28a7e2751265f19e58aeab8fec73aa2c7d2d Mon Sep 17 00:00:00 2001 From: adarshh347 Date: Thu, 5 Nov 2026 15:00:43 +0530 Subject: [PATCH] feat(traverse): return readable traversal summaries --- docs/en/memory_search.md | 11 +++++ docs/zh/memory_search.md | 6 +++ reme/config/default.yaml | 2 +- reme/steps/index/traverse.py | 31 +++++++++++++- tests/unit/test_common_steps.py | 71 ++++++++++++++++++++++++++++++--- 5 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/en/memory_search.md b/docs/en/memory_search.md index c2a72d6b..065fd7fe 100644 --- a/docs/en/memory_search.md +++ b/docs/en/memory_search.md @@ -287,6 +287,17 @@ The `results` list is empty and `counts.returned` is `0`. An optional `response. The field also contains a human-readable `message`. Programmatic consumers should use `reason` to distinguish the outcomes. Normal hits omit `empty_result` and keep their existing answer format. +## Traverse return format + +`TraverseStep` returns a readable summary of reached nodes and directed edges in `response.answer`. Node lines include +available frontmatter names and concise descriptions; an unindexed path is marked as lacking indexed frontmatter while noting +that the file may still exist on disk. When no edges are reached within the requested depth and direction, the answer +says so explicitly. + +The complete structured `TraverseGraph` is available in `response.metadata.graph`. This changes the type of +`response.answer` from a graph dictionary to a string. Programmatic callers that parsed the old answer should migrate +to `response.metadata.graph` for seeds, direction, depth, nodes, and edges. + To show the explanation through the running service's CLI client, enable the existing metadata display option: ```bash diff --git a/docs/zh/memory_search.md b/docs/zh/memory_search.md index 520990ba..3f459a4d 100644 --- a/docs/zh/memory_search.md +++ b/docs/zh/memory_search.md @@ -264,6 +264,12 @@ Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是 该字段还包含便于阅读的 `message`。程序调用方应通过 `reason` 区分两种情况。 正常命中不包含 `empty_result`,正文格式保持原样。 +## Traverse 返回格式 + +`TraverseStep` 会在 `response.answer` 中返回易读的已到达节点和有向边摘要。节点行会显示可用的 frontmatter 名称和简短描述;未索引路径会标明尚无索引 frontmatter,并说明文件仍可能存在于磁盘。当请求的深度和方向内没有到达的边时,答案会明确说明。 + +完整结构化 `TraverseGraph` 保存在 `response.metadata.graph`。这会将 `response.answer` 从图字典改为字符串。原先解析旧答案的程序调用方应迁移到 `response.metadata.graph`,读取 seeds、方向、深度、节点和边。 + 通过 CLI 客户端调用已启动的服务时,可以开启已有的元数据显示选项查看原因: ```bash diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 985a7805..1a66b44e 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -409,7 +409,7 @@ jobs: traverse: backend: base description: >- - Return a bounded wikilink graph with frontend-ready nodes and directed edges. + Return a readable bounded wikilink summary in answer and the complete structured graph in metadata.graph. parameters: type: object properties: diff --git a/reme/steps/index/traverse.py b/reme/steps/index/traverse.py index 32de4a6d..0bbb2e46 100644 --- a/reme/steps/index/traverse.py +++ b/reme/steps/index/traverse.py @@ -29,6 +29,7 @@ class _TraversalLink: Adjacency = dict[str, list[_TraversalLink]] EdgeKey = tuple[str, str, str | None] +_MAX_DESCRIPTION_CHARS = 160 def _build_adjacency(nodes: list[FileNode]) -> tuple[Adjacency, Adjacency]: @@ -138,9 +139,34 @@ def _build_graph( ) +def _render_answer(graph: TraverseGraph) -> str: + """Render reached nodes and edges as a self-contained agent-readable summary.""" + lines = [ + f"Traverse results (seeds={', '.join(graph.seeds)}; depth={graph.depth}; direction={graph.direction})", + "Nodes:", + ] + for node in graph.nodes: + label = f" — {node.name}" if node.name else "" + brief_description = " ".join(node.description.split()) + if len(brief_description) > _MAX_DESCRIPTION_CHARS: + brief_description = f"{brief_description[: _MAX_DESCRIPTION_CHARS - 3].rstrip()}..." + description = f": {brief_description}" if brief_description else "" + availability = " (no indexed FileNode/frontmatter; file may still exist on disk)" if not node.indexed else "" + lines.append(f"[{node.depth}] {node.path}{label}{description}{availability}") + + lines.append("Edges:") + if graph.edges: + for edge in graph.edges: + target = f"{edge.target}#{edge.target_anchor}" if edge.target_anchor else edge.target + lines.append(f"{edge.source} -> {target} (depth={edge.depth})") + else: + lines.append("(no edges reached within requested depth and direction)") + return "\n".join(lines) + + @R.register("traverse_step") class TraverseStep(BaseStep): - """Return a bounded wikilink graph rooted at one or more workspace paths.""" + """Return a readable bounded traversal answer and its structured graph in metadata.""" async def execute(self): assert self.context is not None @@ -173,7 +199,8 @@ class TraverseStep(BaseStep): ) self.context.response.success = True - self.context.response.answer = graph.model_dump() + self.context.response.answer = _render_answer(graph) + self.context.response.metadata["graph"] = graph.model_dump() self.logger.info( f"[{self.name}] seeds={seeds!r} depth={max_depth} direction={direction} " f"nodes={len(graph.nodes)} edges={len(graph.edges)}", diff --git a/tests/unit/test_common_steps.py b/tests/unit/test_common_steps.py index 4f51131e..e343ced9 100644 --- a/tests/unit/test_common_steps.py +++ b/tests/unit/test_common_steps.py @@ -64,7 +64,11 @@ async def _make_store(nodes: list[FileNode]) -> LocalFileStore: def _edges(step) -> list[dict]: - return step.context.response.answer["edges"] + return step.context.response.metadata["graph"]["edges"] + + +def _graph(response) -> TraverseGraph: + return TraverseGraph.model_validate(response.metadata["graph"]) @pytest.mark.asyncio @@ -406,13 +410,14 @@ async def test_traverse_both_preserves_reciprocal_edge_directions(): ) step = traverse_mod.TraverseStep(file_store=store) response = await step(path="a.md", direction="both", depth=1) - graph = TraverseGraph.model_validate(response.answer) + graph = _graph(response) assert {(edge.source, edge.target) for edge in graph.edges} == { ("a.md", "b.md"), ("b.md", "a.md"), } - assert response.metadata == {} + assert response.answer.startswith("Traverse results (seeds=a.md; depth=1; direction=both)") + assert response.metadata["graph"] == graph.model_dump() await store.close() @@ -433,7 +438,7 @@ async def test_traverse_returns_frontmatter_and_unresolved_nodes(): ) step = traverse_mod.TraverseStep(file_store=store) response = await step(path="a.md", direction="forward", depth=1) - graph = TraverseGraph.model_validate(response.answer) + graph = _graph(response) nodes = {node.path: node for node in graph.nodes} assert graph.version == 1 @@ -449,6 +454,8 @@ async def test_traverse_returns_frontmatter_and_unresolved_nodes(): assert nodes["missing.md"].indexed is False assert nodes["missing.md"].depth == 1 assert graph.edges[0].target_anchor == "details" + assert "[0] a.md — Alpha: Root note" in response.answer + assert "[1] missing.md (no indexed FileNode/frontmatter; file may still exist on disk)" in response.answer await store.close() @@ -460,8 +467,62 @@ async def test_traverse_depth_zero_returns_only_seed_nodes(): store = await _make_store([_node("a.md", [("b.md", None)]), _node("b.md")]) step = traverse_mod.TraverseStep(file_store=store) response = await step(path="a.md", direction="forward", depth=0) - graph = TraverseGraph.model_validate(response.answer) + graph = _graph(response) assert [node.path for node in graph.nodes] == ["a.md"] assert graph.edges == [] + assert response.answer.endswith("(no edges reached within requested depth and direction)") + await store.close() + + +@pytest.mark.asyncio +async def test_traverse_answer_includes_labels_descriptions_and_reached_edges(): + """The answer is useful by itself while metadata retains the complete graph.""" + + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + store = await _make_store( + [ + _node( + "a.md", + [("b.md", None), ("c.md", "intro")], + name="Seed", + description="Root note", + ), + _node("b.md", name="Branch", description="First hop"), + _node("c.md", name="Child", description="Anchored result"), + ], + ) + step = traverse_mod.TraverseStep(file_store=store) + response = await step(path="a.md", direction="forward", depth=1) + graph = _graph(response) + + assert isinstance(response.answer, str) + assert "[0] a.md — Seed: Root note" in response.answer + assert "[1] b.md — Branch: First hop" in response.answer + assert "[1] c.md — Child: Anchored result" in response.answer + assert "a.md -> b.md (depth=1)" in response.answer + assert "a.md -> c.md#intro (depth=1)" in response.answer + assert graph.model_dump() == response.metadata["graph"] + assert graph.seeds == ["a.md"] + assert {(edge.source, edge.target, edge.target_anchor) for edge in graph.edges} == { + ("a.md", "b.md", None), + ("a.md", "c.md", "intro"), + } + await store.close() + + +@pytest.mark.asyncio +async def test_traverse_answer_bounds_and_normalizes_descriptions(): + """Long multiline frontmatter descriptions stay compact and readable in the answer.""" + + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + description = "Supported detail.\n" * 20 + store = await _make_store([_node("a.md", name="Seed", description=description)]) + step = traverse_mod.TraverseStep(file_store=store) + response = await step(path="a.md", direction="forward", depth=1) + node_line = response.answer.splitlines()[2] + + assert "\n" not in node_line + assert node_line.endswith("...") + assert len(node_line.split(": ", maxsplit=1)[1]) == 160 await store.close()