Skip to content

Commit 2ae31ec

Browse files
HumanBean17claude
andauthored
implement trace tool v2: tree output, bidirectional traversal (#260)
* implement trace tool v2: tree output, configurable collapse, bidirectional traversal Replace flat edges/paths output with nested tree format. Add configurable collapse_roles, collapse_min_chain_length, direction="both" for bidirectional traversal with shared visited set, min_result_nodes retry, and source-relative fan-out ranking. Breaking API change: TraceEdge/TracePath replaced by TreeNode/RankedLeaf. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address PR review feedback for trace tool v2 - Remove duplicate id() call in collapse logic - Remove dead bidirectional advisory blocks - Consolidate triple tree walk to single pass in mcp_hints.py - Make REPOSITORY source-relative priority distinct from SERVICE - Add test for seed with zero matching edges Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: emit min_result_nodes advisory even when retry improves results The retry acceptance check used `or` which silently accepted improved- but-still-below-target results without emitting the advisory. Split into separate checks so the advisory fires whenever below target. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: move trace tool v2 proposal and plan to completed Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9657e7b commit 2ae31ec

9 files changed

Lines changed: 1405 additions & 650 deletions

File tree

docs/AGENT-GUIDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Prefer **`resolve` → `describe(id=…)`** over **`describe(fqn=…)`** when an
178178
| Handler for route | route id | `neighbors(ids, "in", ["EXPOSES"])` |
179179
| Who implements interface T? | type symbol id | `neighbors(ids, "in", ["IMPLEMENTS"])` |
180180
| Who injects type T? | type symbol id | `neighbors(ids, "in", ["INJECTS"])` |
181-
| Impact / "what breaks if I change X"? | `trace` or `neighbors` loop | `trace(id, "in", ["CALLS","OVERRIDES"], max_depth=3)` or loop `neighbors` `in` with `CALLS`, `INJECTS` |
181+
| Impact / "what breaks if I change X"? | `trace` with `direction="both"` or `neighbors` loop | `trace(id, "both", ["CALLS","OVERRIDES"], max_depth=3)` or loop `neighbors` `in` with `CALLS`, `INJECTS` |
182182

183183
**Rules of thumb:**
184184

@@ -190,9 +190,11 @@ Prefer **`resolve` → `describe(id=…)`** over **`describe(fqn=…)`** when an
190190

191191
#### `trace`
192192

193-
Multi-hop BFS traversal with server-side pruning. Returns structured paths, a node dict, and traversal stats. Use when the question implies a path or chain (3+ hops), needs to cross a service boundary, or a `neighbors` loop has exceeded 2 hops without converging. Args: `ids` (string or array), **`direction`**, **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (1–5, default 3), `max_paths` (default 20), `max_nodes_discovered` (100–2000, default 500), `filter` (hard gate `NodeFilter`), `edge_filter` (CALLS edge attribute filtering), `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (per-node edge limit, default 5), `collapse_trivial` (collapse wrapper chains, default true), `include_unresolved` (interleave unresolved call sites).
193+
Multi-hop BFS traversal with server-side pruning. Returns nested tree structure, a node dict, and traversal stats. Use when the question implies a path or chain (3+ hops), needs to cross a service boundary, or a `neighbors` loop has exceeded 2 hops without converging. Args: `ids` (string or array), **`direction`** (`in` | `out` | `both`), **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (1–5, default 3), `max_paths` (default 20), `max_nodes_discovered` (100–2000, default 500), `filter` (hard gate `NodeFilter`), `edge_filter` (CALLS edge attribute filtering), `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (per-node edge limit, default 5), `collapse_trivial` (collapse wrapper chains, default true), `collapse_roles` (roles to collapse as trivial intermediates, default `["OTHER"]`; only effective when `collapse_trivial=true`), `collapse_min_chain_length` (minimum chain length for collapse, default 1), `include_unresolved` (interleave unresolved call sites), `cross_service` (continue BFS through HTTP_CALLS/ASYNC_CALLS boundaries), `min_result_nodes` (minimum result nodes target; retries with doubled `fan_out_cap` if below target, default 0).
194194

195-
Returns `TraceOutput` with `nodes` (dict of `NodeRef`), `edges` (list of `TraceEdge` with `hop`, `parent_edge_id`, `collapsed`, `cross_service_boundary`), `paths` (ranked root-to-leaf), and `stats` (budget, pruning counts). Cross-service edges (`HTTP_CALLS`, `ASYNC_CALLS`) are boundary signals — BFS stops at the service boundary and includes the downstream node for the agent to continue with a separate `trace` call.
195+
Returns `TraceOutput` with `nodes` (dict of `NodeRef`), `tree` (nested `TreeNode` list — one per seed; each `TreeNode` has `id`, `edge_from_parent` with `direction`, `edge_type`, `hop`, `confidence`, `cross_service_boundary`, `attrs`; `children` (nested TreeNodes); `collapsed` and `collapsed_intermediates` for collapsed edges), `ranked_leaves` (scored leaf nodes with `node_id`, `depth`, `leaf_role`, `score`, sorted descending by score, capped at `max_paths`), and `stats` (budget, pruning counts). Cross-service edges (`HTTP_CALLS`, `ASYNC_CALLS`) are boundary signals — BFS stops at the service boundary unless `cross_service=true`.
196+
197+
**`direction="both"`**: runs bidirectional traversal (out then in) with a shared visited set. Tree contains children from both directions; `edge_from_parent.direction` distinguishes them. Use for impact analysis ("who depends on X and what does X call?") in one call.
196198

197199
**`trace` vs `neighbors`:** Use `neighbors` for single-hop adjacency (full unfiltered result). Use `trace` for multi-hop path questions, impact analysis, or when `neighbors` returns high fan-out (>8 CALLS edges).
198200

mcp_hints.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -517,13 +517,25 @@ def _high_fanout_trace_hint(origin_id: str, calls_n: int) -> _StructuredHint:
517517
)
518518

519519

520+
def _walk_tree_nodes(tree: list[dict[str, Any]]) -> list[dict[str, Any]]:
521+
"""Flatten a tree structure into a list of all nodes (for iteration)."""
522+
result: list[dict[str, Any]] = []
523+
stack = list(tree)
524+
while stack:
525+
node = stack.pop()
526+
result.append(node)
527+
for child in node.get("children") or []:
528+
stack.append(child)
529+
return result
530+
531+
520532
def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHint], list[tuple[int, str]]]:
521-
"""Structured hints and advisories for trace output."""
533+
"""Structured hints and advisories for trace output (v2 tree format)."""
522534
struct_pairs: list[_StructuredHint] = []
523535
advisories: list[tuple[int, str]] = []
524536

525537
stats = payload.get("stats")
526-
edges = list(payload.get("edges") or [])
538+
tree = list(payload.get("tree") or [])
527539

528540
# Cross-service boundary hints don't require stats — guard stats-dependant hints separately.
529541
if isinstance(stats, dict):
@@ -554,7 +566,8 @@ def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHi
554566
+ int(stats.get("nodes_pruned_fan_out") or 0)
555567
+ int(stats.get("edges_collapsed_trivial") or 0)
556568
)
557-
if pruned_count > 0 or any(e.get("collapsed") for e in edges):
569+
has_collapsed = any(n.get("collapsed") for n in _walk_tree_nodes(tree))
570+
if pruned_count > 0 or has_collapsed:
558571
advisories.append((
559572
PRIORITY_META,
560573
f"trace pruned {pruned_count} edges. Use neighbors(id, direction, edge_types) on specific nodes for full detail.",
@@ -568,21 +581,29 @@ def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHi
568581
))
569582

570583
# (c) Cross-service boundary hint (no stats dependency).
571-
xs_edges = [e for e in edges if e.get("cross_service_boundary")]
572-
if xs_edges:
573-
# When cross_service=True, BFS already continued through boundaries.
574-
# Emit a lighter informational advisory instead of an action hint.
584+
# Walk tree in a single pass: build parent map and collect cross-service boundary nodes.
585+
xs_edges_final: list[tuple[str, str, str | None]] = [] # (from_id, to_id, confidence)
586+
stack: list[tuple[dict[str, Any], str | None]] = [(n, None) for n in tree]
587+
while stack:
588+
node, parent_id = stack.pop()
589+
nid = str(node.get("id") or "")
590+
efp = node.get("edge_from_parent")
591+
if isinstance(efp, dict) and efp.get("cross_service_boundary"):
592+
attrs = efp.get("attrs") if isinstance(efp.get("attrs"), dict) else {}
593+
confidence = attrs.get("confidence")
594+
xs_edges_final.append((parent_id or "", nid, confidence))
595+
for child in node.get("children") or []:
596+
stack.append((child, nid))
597+
598+
if xs_edges_final:
575599
was_seamless = bool(payload.get("cross_service"))
576600
if was_seamless:
577601
advisories.append((
578602
PRIORITY_META,
579-
f"trace crossed {len(xs_edges)} service boundary(ies).",
603+
f"trace crossed {len(xs_edges_final)} service boundary(ies).",
580604
))
581605
else:
582-
for xe in xs_edges[:3]:
583-
to_id = str(xe.get("to_id") or "")
584-
from_id = str(xe.get("from_id") or "")
585-
confidence = xe.get("attrs", {}).get("confidence") if isinstance(xe.get("attrs"), dict) else None
606+
for from_id, to_id, confidence in xs_edges_final[:3]:
586607
conf_str = f"confidence={confidence}" if confidence is not None else "low confidence"
587608
advisories.append((
588609
PRIORITY_META,

0 commit comments

Comments
 (0)