This commit is contained in:
Adarsh 2026-11-07 01:27:30 +00:00 • committed by GitHub
commit dba7e31360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 113 additions and 8 deletions

View file

@ -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

View file

@ -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

View file

@ -414,7 +414,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:

View file

@ -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)}",

View file

@ -66,7 +66,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
@ -435,13 +439,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()
@ -462,7 +467,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
@ -478,6 +483,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()
@ -489,8 +496,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()