Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Prefer **`resolve` → `describe(id=…)`** over **`describe(fqn=…)`** when an
| Handler for route | route id | `neighbors(ids, "in", ["EXPOSES"])` |
| Who implements interface T? | type symbol id | `neighbors(ids, "in", ["IMPLEMENTS"])` |
| Who injects type T? | type symbol id | `neighbors(ids, "in", ["INJECTS"])` |
| 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` |
| 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` |

**Rules of thumb:**

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

#### `trace`

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).
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).

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.
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`.

**`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.

**`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).

Expand Down
45 changes: 33 additions & 12 deletions mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,13 +517,25 @@ def _high_fanout_trace_hint(origin_id: str, calls_n: int) -> _StructuredHint:
)


def _walk_tree_nodes(tree: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Flatten a tree structure into a list of all nodes (for iteration)."""
result: list[dict[str, Any]] = []
stack = list(tree)
while stack:
node = stack.pop()
result.append(node)
for child in node.get("children") or []:
stack.append(child)
return result


def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHint], list[tuple[int, str]]]:
"""Structured hints and advisories for trace output."""
"""Structured hints and advisories for trace output (v2 tree format)."""
struct_pairs: list[_StructuredHint] = []
advisories: list[tuple[int, str]] = []

stats = payload.get("stats")
edges = list(payload.get("edges") or [])
tree = list(payload.get("tree") or [])

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

# (c) Cross-service boundary hint (no stats dependency).
xs_edges = [e for e in edges if e.get("cross_service_boundary")]
if xs_edges:
# When cross_service=True, BFS already continued through boundaries.
# Emit a lighter informational advisory instead of an action hint.
# Walk tree in a single pass: build parent map and collect cross-service boundary nodes.
xs_edges_final: list[tuple[str, str, str | None]] = [] # (from_id, to_id, confidence)
stack: list[tuple[dict[str, Any], str | None]] = [(n, None) for n in tree]
while stack:
node, parent_id = stack.pop()
nid = str(node.get("id") or "")
efp = node.get("edge_from_parent")
if isinstance(efp, dict) and efp.get("cross_service_boundary"):
attrs = efp.get("attrs") if isinstance(efp.get("attrs"), dict) else {}
confidence = attrs.get("confidence")
xs_edges_final.append((parent_id or "", nid, confidence))
for child in node.get("children") or []:
stack.append((child, nid))

if xs_edges_final:
was_seamless = bool(payload.get("cross_service"))
if was_seamless:
advisories.append((
PRIORITY_META,
f"trace crossed {len(xs_edges)} service boundary(ies).",
f"trace crossed {len(xs_edges_final)} service boundary(ies).",
))
else:
for xe in xs_edges[:3]:
to_id = str(xe.get("to_id") or "")
from_id = str(xe.get("from_id") or "")
confidence = xe.get("attrs", {}).get("confidence") if isinstance(xe.get("attrs"), dict) else None
for from_id, to_id, confidence in xs_edges_final[:3]:
conf_str = f"confidence={confidence}" if confidence is not None else "low confidence"
advisories.append((
PRIORITY_META,
Expand Down
Loading
Loading