Skip to content

Commit 3434843

Browse files
HumanBean17claude
andcommitted
add cross_service flag to trace tool for seamless boundary traversal (#256)
Agents expected trace to cross service boundaries but couldn't control it. Add `cross_service: bool = False` parameter — when True, BFS continues through HTTP_CALLS/ASYNC_CALLS boundaries into downstream services. EXPOSES is auto-added as scaffolding (exempt from fan_out_cap) so Route -> Handler is followed automatically. Default preserves existing boundary-stop behavior. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f6007ac commit 3434843

5 files changed

Lines changed: 170 additions & 28 deletions

File tree

mcp_hints.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -570,24 +570,33 @@ def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHi
570570
# (c) Cross-service boundary hint (no stats dependency).
571571
xs_edges = [e for e in edges if e.get("cross_service_boundary")]
572572
if xs_edges:
573-
for xe in xs_edges[:3]:
574-
to_id = str(xe.get("to_id") or "")
575-
from_id = str(xe.get("from_id") or "")
576-
confidence = xe.get("attrs", {}).get("confidence") if isinstance(xe.get("attrs"), dict) else None
577-
conf_str = f"confidence={confidence}" if confidence is not None else "low confidence"
573+
# When cross_service=True, BFS already continued through boundaries.
574+
# Emit a lighter informational advisory instead of an action hint.
575+
was_seamless = bool(payload.get("cross_service"))
576+
if was_seamless:
578577
advisories.append((
579578
PRIORITY_META,
580-
f"Cross-service boundary: {from_id} -> {to_id} ({conf_str}). "
581-
f"Use trace('{to_id}', 'out', ['EXPOSES','CALLS'], max_depth=4) to continue in the downstream service, "
582-
f"or describe('{to_id}') for route details.",
583-
))
584-
struct_pairs.append(_StructuredHint(
585-
"trace",
586-
{"ids": [to_id], "direction": "out", "edge_types": ["EXPOSES", "CALLS"], "max_depth": 4},
587-
True, PRIORITY_LEAF_FOLLOWUP,
588-
LABEL_CROSS_SERVICE_BOUNDARY,
589-
f"cross-service boundary: {from_id} -> {to_id}",
579+
f"trace crossed {len(xs_edges)} service boundary(ies).",
590580
))
581+
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
586+
conf_str = f"confidence={confidence}" if confidence is not None else "low confidence"
587+
advisories.append((
588+
PRIORITY_META,
589+
f"Cross-service boundary: {from_id} -> {to_id} ({conf_str}). "
590+
f"Use trace('{to_id}', 'out', ['EXPOSES','CALLS'], max_depth=4) to continue in the downstream service, "
591+
f"or describe('{to_id}') for route details.",
592+
))
593+
struct_pairs.append(_StructuredHint(
594+
"trace",
595+
{"ids": [to_id], "direction": "out", "edge_types": ["EXPOSES", "CALLS"], "max_depth": 4},
596+
True, PRIORITY_LEAF_FOLLOWUP,
597+
LABEL_CROSS_SERVICE_BOUNDARY,
598+
f"cross-service boundary: {from_id} -> {to_id}",
599+
))
591600

592601
return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
593602

mcp_trace.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ def trace_v2(
498498
fan_out_cap: int = 5,
499499
collapse_trivial: bool = True,
500500
include_unresolved: bool = False,
501+
cross_service: bool = False,
501502
graph: KuzuGraph | None = None,
502503
) -> TraceOutput:
503504
"""Multi-hop BFS traversal with pruning."""
@@ -601,6 +602,12 @@ def trace_v2(
601602
# Determine if cross-service detection is active.
602603
cross_service_active = bool(set(edge_types) & _CROSS_SERVICE_EDGE_TYPES)
603604

605+
# Effective scaffolding set: when cross_service=True, EXPOSES is also scaffolding
606+
# so Route -> Handler is followed automatically in downstream services.
607+
effective_scaffolding = _SCAFFOLDING_EDGE_TYPES
608+
if cross_service:
609+
effective_scaffolding = _SCAFFOLDING_EDGE_TYPES | frozenset({"EXPOSES"})
610+
604611
# BFS state.
605612
visited: set[str] = set(seed_ids)
606613
frontier: list[str] = list(seed_ids)
@@ -639,7 +646,7 @@ def trace_v2(
639646
query_edge_types = list(edge_types)
640647
# Cross-service: also query scaffolding edges when cross-service is active.
641648
if cross_service_active:
642-
for scaffold_et in _SCAFFOLDING_EDGE_TYPES:
649+
for scaffold_et in effective_scaffolding:
643650
if scaffold_et not in query_edge_types:
644651
query_edge_types.append(scaffold_et)
645652

@@ -672,7 +679,7 @@ def trace_v2(
672679

673680
for row in src_rows:
674681
et = str(row.get("edge_type") or "")
675-
if et in _SCAFFOLDING_EDGE_TYPES:
682+
if et in effective_scaffolding:
676683
scaffolding_rows.append(row)
677684
else:
678685
signal_rows.append(row)
@@ -703,7 +710,7 @@ def trace_v2(
703710
edge_type = str(row.get("edge_type") or "")
704711

705712
# --- Cross-service boundary detection ---
706-
if edge_type in _SCAFFOLDING_EDGE_TYPES and cross_service_active:
713+
if edge_type in effective_scaffolding and cross_service_active:
707714
# Follow scaffolding edge to Client/Producer node.
708715
# Record the scaffolding edge and include the node.
709716
try:
@@ -791,9 +798,16 @@ def trace_v2(
791798
edges.append(cross_edge)
792799
edge_id_map[cross_edge_id] = cross_edge
793800
visited.add(cross_target_id)
794-
# Do NOT add downstream node to frontier — boundary-stop.
795-
796-
# Do NOT add Client/Producer to frontier either.
801+
# Track incoming edge for downstream node.
802+
if cross_target_id not in node_to_incoming_edge_id:
803+
node_to_incoming_edge_id[cross_target_id] = cross_edge_id
804+
# When cross_service=True, add downstream node to frontier
805+
# so BFS continues into the downstream service.
806+
if cross_service:
807+
new_frontier.add(cross_target_id)
808+
809+
# Do NOT add Client/Producer to frontier — its cross-service
810+
# edges were already queried inline above.
797811
continue
798812

799813
# --- Standard edge processing ---

server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -587,8 +587,8 @@ async def resolve(
587587
"are exempt. `collapse_trivial` merges wrapper chains (A→B→C where B is trivial). "
588588
"Result: `nodes` dict (id → NodeRef), `edges` list with BFS metadata (hop, parent_edge_id, "
589589
"collapsed, cross_service_boundary), ranked `paths` (root-to-leaf), and `stats` with pruning counts. "
590-
"Cross-service boundary: BFS records the cross-service edge and includes the downstream Route/Producer "
591-
"in `nodes` but stops the frontier — the agent decides whether to continue."
590+
"Cross-service boundary: by default BFS stops at service boundaries. "
591+
"Set `cross_service=True` to continue traversal through HTTP_CALLS/ASYNC_CALLS boundaries."
592592
),
593593
)
594594
async def trace(
@@ -628,6 +628,10 @@ async def trace(
628628
default=False,
629629
description="Include UnresolvedCallSite edges (CALLS out only)",
630630
),
631+
cross_service: bool = Field(
632+
default=False,
633+
description="Continue BFS through service boundaries (HTTP_CALLS/ASYNC_CALLS). Default: stop at boundaries.",
634+
),
631635
) -> mcp_trace.TraceOutput:
632636
return await asyncio.to_thread(
633637
mcp_trace.trace_v2,
@@ -643,6 +647,7 @@ async def trace(
643647
fan_out_cap if fan_out_cap is not None else 5,
644648
collapse_trivial,
645649
include_unresolved,
650+
cross_service,
646651
None,
647652
)
648653

skills/explore-codebase/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Prefer **`resolve` → `describe(id=…)`** over **`describe(fqn=…)`** when an
183183
| "What happens when route R is called?" | `find(kind="route")` then `trace(route_id, "out", ["EXPOSES","CALLS"], max_depth=4)` | `describe` on key nodes |
184184
| "Impact of changing method M" | `resolve` / `find` then `trace(id, "in", ["CALLS","OVERRIDES"], max_depth=3)` | `describe` on callers |
185185
| "Trace from X to database" | `trace(id, "out", ["CALLS"], max_depth=4, prune_roles=["DTO","EXCEPTION"])` | `neighbors` for pruned detail |
186-
| "What calls this across services?" | `trace(id, "out", ["CALLS","HTTP_CALLS","ASYNC_CALLS"], max_depth=5)` | `trace` on downstream route_id if needed |
186+
| "What calls this across services?" | `trace(id, "out", ["CALLS","HTTP_CALLS","ASYNC_CALLS"], max_depth=5, cross_service=True)` | `describe` on downstream routes |
187187

188188
**Rules of thumb:**
189189

@@ -229,7 +229,7 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross-
229229

230230
### `trace`
231231

232-
Multi-hop BFS with pruning. Args: `ids` (string or list), **`direction`**, **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (default 3, clamped 1–5), `max_paths` (default 20), `max_nodes_discovered` (default 500, clamped 100–2000), optional `filter` (NodeFilter), optional `edge_filter` (CALLS only), optional `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (default 5, scaffolding edges exempt), `collapse_trivial` (default true), `include_unresolved` (default false).
232+
Multi-hop BFS with pruning. Args: `ids` (string or list), **`direction`**, **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (default 3, clamped 1–5), `max_paths` (default 20), `max_nodes_discovered` (default 500, clamped 100–2000), optional `filter` (NodeFilter), optional `edge_filter` (CALLS only), optional `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (default 5, scaffolding edges exempt), `collapse_trivial` (default true), `include_unresolved` (default false), `cross_service` (default false — set true to continue BFS through HTTP_CALLS/ASYNC_CALLS boundaries into downstream services).
233233

234234
Returns `TraceOutput`: `success`, `seed_ids`, `direction`, `edge_types`, `actual_depth`, `nodes` (dict of id→NodeRef), `edges` (list of `TraceEdge`), `paths` (list of `TracePath`), `stats` (`TraceStats`), `advisories`.
235235

tests/test_mcp_trace.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -923,9 +923,123 @@ def test_trace_cross_service_boundary_stops(kuzu_graph: KuzuGraph) -> None:
923923
assert len(downstream_edges) == 0
924924

925925

926-
# ---------------------------------------------------------------------------
927-
# PR-TRACE-2 tests: MCP tool registration
928-
# ---------------------------------------------------------------------------
926+
def test_trace_cross_service_seamless_http(kuzu_graph: KuzuGraph) -> None:
927+
"""cross_service=True: BFS continues through HTTP_CALLS boundary into downstream service."""
928+
seed_id = _find_method_with_declares_client(kuzu_graph)
929+
if seed_id is None:
930+
pytest.skip("No method with DECLARES_CLIENT in fixture")
931+
932+
out = trace_v2(
933+
ids=seed_id,
934+
direction="out",
935+
edge_types=["CALLS", "HTTP_CALLS"],
936+
max_depth=5,
937+
fan_out_cap=0,
938+
cross_service=True,
939+
graph=kuzu_graph,
940+
)
941+
assert out.success is True
942+
943+
# Should have cross-service boundary edges.
944+
xs_edges = [e for e in out.edges if e.cross_service_boundary]
945+
if not xs_edges:
946+
pytest.skip("No cross-service edges in result")
947+
948+
for xe in xs_edges:
949+
assert xe.edge_type in ("HTTP_CALLS", "ASYNC_CALLS")
950+
# Downstream node should be in nodes.
951+
assert xe.to_id in out.nodes
952+
953+
# Key difference from boundary-stop: downstream Route should have edges FROM it
954+
# (EXPOSES to handler, then CALLS from handler) because BFS continued through.
955+
for xe in xs_edges:
956+
downstream_edges = [e for e in out.edges if e.from_id == xe.to_id]
957+
# At least one edge (EXPOSES to handler) should exist from the downstream Route.
958+
if downstream_edges:
959+
exposes_edges = [e for e in downstream_edges if e.edge_type == "EXPOSES"]
960+
assert len(exposes_edges) >= 1, (
961+
f"Expected EXPOSES edges from {xe.to_id}, got: {[e.edge_type for e in downstream_edges]}"
962+
)
963+
964+
965+
def test_trace_cross_service_seamless_async(kuzu_graph: KuzuGraph) -> None:
966+
"""cross_service=True: BFS continues through ASYNC_CALLS boundary into downstream service."""
967+
seed_id = _find_method_with_declares_producer(kuzu_graph)
968+
if seed_id is None:
969+
pytest.skip("No method with DECLARES_PRODUCER in fixture")
970+
971+
out = trace_v2(
972+
ids=seed_id,
973+
direction="out",
974+
edge_types=["CALLS", "ASYNC_CALLS"],
975+
max_depth=5,
976+
fan_out_cap=0,
977+
cross_service=True,
978+
graph=kuzu_graph,
979+
)
980+
assert out.success is True
981+
982+
xs_edges = [e for e in out.edges if e.cross_service_boundary]
983+
if not xs_edges:
984+
pytest.skip("No cross-service edges in result")
985+
986+
for xe in xs_edges:
987+
assert xe.edge_type in ("HTTP_CALLS", "ASYNC_CALLS")
988+
assert xe.to_id in out.nodes
989+
990+
991+
def test_trace_cross_service_seamless_respects_budget(kuzu_graph: KuzuGraph) -> None:
992+
"""cross_service=True still respects max_nodes_discovered budget."""
993+
seed_id = _find_method_with_declares_client(kuzu_graph)
994+
if seed_id is None:
995+
pytest.skip("No method with DECLARES_CLIENT in fixture")
996+
997+
out = trace_v2(
998+
ids=seed_id,
999+
direction="out",
1000+
edge_types=["CALLS", "HTTP_CALLS"],
1001+
max_depth=5,
1002+
max_nodes_discovered=100,
1003+
fan_out_cap=0,
1004+
cross_service=True,
1005+
graph=kuzu_graph,
1006+
)
1007+
assert out.success is True
1008+
# Budget may or may not have been hit depending on graph size,
1009+
# but if it was, the stats should reflect it.
1010+
if out.stats.budget_hit:
1011+
assert out.stats.total_nodes_discovered >= 100
1012+
1013+
1014+
def test_trace_cross_service_seamless_exposes_as_scaffolding(kuzu_graph: KuzuGraph) -> None:
1015+
"""EXPOSES edges from downstream Routes are exempt from fan_out_cap when cross_service=True."""
1016+
seed_id = _find_method_with_declares_client(kuzu_graph)
1017+
if seed_id is None:
1018+
pytest.skip("No method with DECLARES_CLIENT in fixture")
1019+
1020+
# Use fan_out_cap=1 — very tight, but EXPOSES should still come through.
1021+
out = trace_v2(
1022+
ids=seed_id,
1023+
direction="out",
1024+
edge_types=["CALLS", "HTTP_CALLS"],
1025+
max_depth=5,
1026+
fan_out_cap=1,
1027+
cross_service=True,
1028+
graph=kuzu_graph,
1029+
)
1030+
assert out.success is True
1031+
1032+
xs_edges = [e for e in out.edges if e.cross_service_boundary]
1033+
if xs_edges:
1034+
# Even with fan_out_cap=1, EXPOSES edges from downstream Routes should appear
1035+
# (they're scaffolding, exempt from cap).
1036+
for xe in xs_edges:
1037+
downstream_edges = [e for e in out.edges if e.from_id == xe.to_id]
1038+
if downstream_edges:
1039+
exposes_edges = [e for e in downstream_edges if e.edge_type == "EXPOSES"]
1040+
assert len(exposes_edges) >= 1, (
1041+
f"EXPOSES should be exempt from fan_out_cap, but got: {[e.edge_type for e in downstream_edges]}"
1042+
)
9291043

9301044

9311045
async def test_trace_registered_as_mcp_tool(mcp_server) -> None:

0 commit comments

Comments
 (0)