Skip to content

Commit 91c9d9d

Browse files
HumanBean17claude
andauthored
add trace hints and skill integration (PR-TRACE-3) (#248)
* add trace hints and skill integration (PR-TRACE-3) - Extend generate_hints with output_kind="trace" and four hint templates (budget hit, pruned drilldown, cross-service boundary, high fanout trace) - Add trace recommendation to neighbors/describe high-fanout hints - Update SKILL.md preamble, decision tree, and tool reference with trace - Add "trace" to StructuredHint.tool Literal in mcp_v2.py - Add 5 new tests (4 hint unit tests + 1 integration test) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * address PR-TRACE-3 review feedback - Extract _high_fanout_trace_hint() helper (review point 1) - Use user's prune_roles in budget-hit hint if supplied (point 2) - Include from_id in cross-service hint reason (point 3) - Split stats guard so cross-service hints work without stats (point 4) - Add clarifying comment for fan_out_cap=0 in integration test (point 5) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ab08b7a commit 91c9d9d

5 files changed

Lines changed: 271 additions & 7 deletions

File tree

mcp_hints.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ def _extract_other_ids(results: list[dict[str, Any]]) -> list[str]:
133133
LABEL_WRONG_SUBJECT_KIND = "wrong subject kind"
134134
LABEL_WRONG_DIRECTION = "wrong direction"
135135
LABEL_TYPE_LEVEL_REQUERY = "type-level requery"
136+
LABEL_BUDGET_HIT = "budget hit"
137+
LABEL_PRUNED_DRILLDOWN = "pruned drilldown"
138+
LABEL_CROSS_SERVICE_BOUNDARY = "cross-service boundary"
139+
LABEL_HIGH_FANOUT_TRACE = "high fanout trace"
136140

137141
# §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta.
138142
PRIORITY_DECLARES_TYPE_ROLLUP = 4
@@ -501,8 +505,95 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct
501505
return out
502506

503507

508+
def _high_fanout_trace_hint(origin_id: str, calls_n: int) -> _StructuredHint:
509+
"""Shared trace recommendation for high CALLS fan-out (used by neighbors and describe paths)."""
510+
return _StructuredHint(
511+
"trace",
512+
{"ids": [origin_id], "direction": "out", "edge_types": ["CALLS"],
513+
"prune_roles": ["DTO", "EXCEPTION", "UTILITY"], "fan_out_cap": 5},
514+
True, PRIORITY_LEAF_FOLLOWUP,
515+
LABEL_HIGH_FANOUT_TRACE,
516+
f"{calls_n} CALLS — consider trace for a pruned multi-hop view",
517+
)
518+
519+
520+
def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHint], list[tuple[int, str]]]:
521+
"""Structured hints and advisories for trace output."""
522+
struct_pairs: list[_StructuredHint] = []
523+
advisories: list[tuple[int, str]] = []
524+
525+
stats = payload.get("stats")
526+
edges = list(payload.get("edges") or [])
527+
528+
# Cross-service boundary hints don't require stats — guard stats-dependant hints separately.
529+
if isinstance(stats, dict):
530+
# (a) Budget hit hint.
531+
if stats.get("budget_hit"):
532+
n = int(stats.get("total_nodes_discovered") or 0)
533+
advisories.append((
534+
PRIORITY_META,
535+
f"trace hit the node discovery budget ({n} nodes). "
536+
"Results are partial. Increase max_depth or add prune_roles and re-run.",
537+
))
538+
# Preserve user's prune_roles if they supplied custom ones.
539+
prune_roles = list(payload.get("prune_roles") or ["DTO", "EXCEPTION", "UTILITY"])
540+
struct_pairs.append(_StructuredHint(
541+
"trace",
542+
{"ids": list(payload.get("seed_ids") or []),
543+
"direction": str(payload.get("direction") or "out"),
544+
"edge_types": list(payload.get("edge_types") or []),
545+
"prune_roles": prune_roles},
546+
False, PRIORITY_META,
547+
LABEL_BUDGET_HIT,
548+
f"budget hit at {n} nodes — results are incomplete",
549+
))
550+
551+
# (b) Pruned / collapsed edges drill-down hint.
552+
pruned_count = (
553+
int(stats.get("nodes_pruned_role") or 0)
554+
+ int(stats.get("nodes_pruned_fan_out") or 0)
555+
+ int(stats.get("edges_collapsed_trivial") or 0)
556+
)
557+
if pruned_count > 0 or any(e.get("collapsed") for e in edges):
558+
advisories.append((
559+
PRIORITY_META,
560+
f"trace pruned {pruned_count} edges. Use neighbors(id, direction, edge_types) on specific nodes for full detail.",
561+
))
562+
struct_pairs.append(_StructuredHint(
563+
"neighbors",
564+
{"ids": [], "direction": str(payload.get("direction") or "out"), "edge_types": list(payload.get("edge_types") or [])},
565+
False, PRIORITY_META,
566+
LABEL_PRUNED_DRILLDOWN,
567+
f"trace pruned {pruned_count} edges — use neighbors on specific nodes for detail",
568+
))
569+
570+
# (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+
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"
578+
advisories.append((
579+
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}",
590+
))
591+
592+
return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
593+
594+
504595
def generate_hints(
505-
output_kind: Literal["search", "find", "describe", "neighbors", "resolve"],
596+
output_kind: Literal["search", "find", "describe", "neighbors", "resolve", "trace"],
506597
payload: dict[str, Any],
507598
) -> tuple[list[_StructuredHint], list[str]]:
508599
"""Return structured hints and advisories for a success-only MCP v2 payload dict.
@@ -685,6 +776,8 @@ def generate_hints(
685776
LABEL_HIGH_FANOUT,
686777
f"{calls_n} CALLS — noisy axes are callee_declaring_role and per-call-site multiplicity",
687778
))
779+
# Trace recommendation for high fan-out neighbors results.
780+
struct_meta.append(_high_fanout_trace_hint(origin_id, calls_n))
688781
# Unresolved sites advisory
689782
unresolved = int(payload.get("unresolved_count") or 0)
690783
if unresolved > 0:
@@ -925,8 +1018,16 @@ def generate_hints(
9251018
LABEL_HIGH_FANOUT,
9261019
f"method has {_out_count(edge_summary, 'CALLS')} CALLS — consider filtering",
9271020
))
1021+
struct_pairs.append(_high_fanout_trace_hint(node_id, _out_count(edge_summary, "CALLS")))
9281022
# Advisory for many CALLS
9291023
advisories.append((PRIORITY_LEAF_FOLLOWUP, "many CALLS — consider filtering by target microservice"))
9301024
return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
9311025

9321026
return ([], [])
1027+
1028+
if output_kind == "trace":
1029+
if not payload.get("success"):
1030+
return ([], [])
1031+
return _trace_structured_hints(payload)
1032+
1033+
return ([], [])

mcp_v2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def _role_axes_mutually_exclusive(self) -> EdgeFilter:
178178

179179
class StructuredHint(BaseModel):
180180
label: str = ""
181-
tool: Literal["search", "find", "describe", "neighbors", "resolve"]
181+
tool: Literal["search", "find", "describe", "neighbors", "trace", "resolve"]
182182
args: dict[str, Any]
183183
actionable: bool = True
184184
reason: str = ""

skills/explore-codebase/SKILL.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: explore-codebase
3-
description: Complete operating manual for the java-codebase-rag MCP tools (search, find, describe, neighbors, resolve). Use this skill whenever you need to explore a Java codebase — locate symbols, trace call chains, find routes, walk cross-service boundaries, or answer any "where is X", "who calls Y", "what does Z depend on" question. Self-contained: includes edge taxonomy, NodeFilter reference, decision tree, argument shapes, recovery playbook, and navigation patterns. No external files needed.
3+
description: Complete operating manual for the java-codebase-rag MCP tools (search, find, describe, neighbors, trace, resolve). Use this skill whenever you need to explore a Java codebase — locate symbols, trace call chains, find routes, walk cross-service boundaries, or answer any "where is X", "who calls Y", "what does Z depend on" question. Self-contained: includes edge taxonomy, NodeFilter reference, decision tree, argument shapes, recovery playbook, and navigation patterns. No external files needed.
44
---
55

66
# /explore-codebase — Codebase navigation via the java-codebase-rag MCP
@@ -11,7 +11,7 @@ Any time you need to understand structure in an indexed Java codebase: locating
1111

1212
## Tools
1313

14-
`search`, `find`, `describe`, `neighbors`, `resolve`.
14+
`search`, `find`, `describe`, `neighbors`, `trace`, `resolve`.
1515

1616
## Node kinds
1717

@@ -47,15 +47,15 @@ Trust the indexed brownfield row over a framework-only reading of the source.
4747

4848
1. **Locate**`resolve` for identifier-shaped strings; `search` for natural language or code fragments; `find` for structured `NodeFilter` discovery.
4949
2. **Inspect**`describe(id)` for the full record and `edge_summary` (per-label `in`/`out` counts).
50-
3. **Walk**`neighbors` in a loop with explicit **`direction`** and **`edge_types`**. Multi-hop traces are **your** reasoning, not a separate tool.
50+
3. **Walk**`neighbors` for single-hop adjacency; `trace` for multi-hop BFS with pruning (fan-out control, role-based pruning, cross-service boundaries).
5151

5252
## Forced reasoning preamble (every tool call)
5353

5454
Before each MCP call, output one short line:
5555

5656
```
57-
Q-class: <semantic | structured | inspect | walk>
58-
Pick: <search|find|describe|neighbors|resolve> Why: <≤8 words>
57+
Q-class: <semantic | structured | inspect | walk | trace>
58+
Pick: <search|find|describe|neighbors|trace|resolve> Why: <≤8 words>
5959
```
6060

6161
Then use real JSON shapes (see below). If the call fails or returns nothing useful, use the **Recovery playbook** — do not thrash.
@@ -189,6 +189,10 @@ Prefer **`resolve` -> `describe(id=...)`** over **`describe(fqn=...)`** when an
189189
| Who implements interface T? | type symbol id | `neighbors(ids, "in", ["IMPLEMENTS"])` |
190190
| Where is T injected | type symbol id | `neighbors(ids, "in", ["INJECTS"])` |
191191
| Impact / "what breaks if I change X"? | no magic tool | loop `neighbors` `in` with `CALLS`, `INJECTS`, ... until bounded |
192+
| "What happens when route R is called?" | `find(kind="route")` then `trace(route_id, "out", ["EXPOSES","CALLS"], max_depth=4)` | `describe` on key nodes |
193+
| "Impact of changing method M" | `resolve` / `find` then `trace(id, "in", ["CALLS","OVERRIDES"], max_depth=3)` | `describe` on callers |
194+
| "Trace from X to database" | `trace(id, "out", ["CALLS"], max_depth=4, prune_roles=["DTO","EXCEPTION"])` | `neighbors` for pruned detail |
195+
| "What calls this across services?" | `trace(id, "out", ["CALLS","HTTP_CALLS","ASYNC_CALLS"], max_depth=5)` | `trace` on downstream route_id if needed |
192196

193197
**Rules of thumb:**
194198

@@ -229,6 +233,19 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, ... on cross-
229233

230234
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). `attrs.resolved=false` on `CALLS` rows means known-receiver-external (JDK/Spring) callees. **`include_unresolved=True`** (CALLS + `direction=out` only) interleaves unresolved sites with resolved `CALLS`. **`dedup_calls=True`** collapses identical `(origin, callee)` `CALLS` to one row with `call_site_lines`. Optional **`edge_filter`** projects before pagination: `min_confidence`; `include_strategies` / `exclude_strategies` (mutually exclusive); `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles` (`["OTHER"]` also drops known-external rows).
231235

236+
### `trace`
237+
238+
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).
239+
240+
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`.
241+
242+
**`TraceEdge`**: `from_id`, `to_id`, `edge_type`, `hop` (BFS depth), `parent_edge_id` (nullable), `collapsed` (bool), `collapsed_intermediates` (list of node ids), `cross_service_boundary` (bool), `attrs`.
243+
244+
**When to use `trace` vs `neighbors`:**
245+
- Use `neighbors` for single-hop adjacency where you want the full unfiltered result.
246+
- Use `trace` for multi-hop path questions (3+ hops), impact analysis, cross-service boundary discovery, or when `neighbors` returns high fan-out (>8 CALLS edges).
247+
- After `trace`, use `neighbors` or `describe` on specific nodes for detail the trace pruned or collapsed.
248+
232249
## Ontology glossary
233250

234251
**Roles (`filter.role` / `exclude_roles`):** `CONTROLLER`, `SERVICE`, `REPOSITORY`, `COMPONENT`, `CONFIG`, `ENTITY`, `CLIENT`, `MAPPER`, `DTO`, `OTHER`.

tests/test_mcp_hints.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,3 +1307,97 @@ def test_advisories_char_cap() -> None:
13071307
_, advisories = generate_hints("neighbors", payload)
13081308
for a in advisories:
13091309
assert len(a) <= 200, f"advisory too long: {a!r}"
1310+
1311+
1312+
# ---------------------------------------------------------------------------
1313+
# PR-TRACE-3 tests: trace hints + neighbors high-fanout trace mention
1314+
# ---------------------------------------------------------------------------
1315+
1316+
1317+
def test_hint_trace_budget_hit() -> None:
1318+
"""generate_hints('trace', {stats.budget_hit=True}) returns budget hit advisory."""
1319+
struct, advisories = generate_hints("trace", {
1320+
"success": True,
1321+
"stats": {
1322+
"budget_hit": True,
1323+
"total_nodes_discovered": 500,
1324+
"nodes_after_pruning": 120,
1325+
},
1326+
"edges": [],
1327+
"nodes": {},
1328+
"seed_ids": ["sym:com.example.Svc#run()"],
1329+
"direction": "out",
1330+
"edge_types": ["CALLS"],
1331+
})
1332+
assert any("budget" in a.lower() for a in advisories), f"no budget advisory in {advisories}"
1333+
budget_hints = [h for h in struct if h.label == "budget hit"]
1334+
assert budget_hints, f"no budget hit structured hint in {[h.label for h in struct]}"
1335+
1336+
1337+
def test_hint_trace_pruned_edges() -> None:
1338+
"""generate_hints('trace', {stats with pruning}) returns drill-down hint."""
1339+
struct, advisories = generate_hints("trace", {
1340+
"success": True,
1341+
"stats": {
1342+
"budget_hit": False,
1343+
"nodes_pruned_role": 5,
1344+
"nodes_pruned_fan_out": 3,
1345+
"edges_collapsed_trivial": 2,
1346+
"total_nodes_discovered": 100,
1347+
},
1348+
"edges": [],
1349+
"nodes": {},
1350+
"seed_ids": ["sym:a"],
1351+
"direction": "out",
1352+
"edge_types": ["CALLS"],
1353+
})
1354+
assert any("pruned" in a.lower() for a in advisories), f"no pruned advisory in {advisories}"
1355+
drilldown = [h for h in struct if h.label == "pruned drilldown"]
1356+
assert drilldown, f"no pruned drilldown hint in {[h.label for h in struct]}"
1357+
1358+
1359+
def test_hint_trace_cross_service_boundary() -> None:
1360+
"""generate_hints('trace', {edges with cross_service_boundary=True}) returns cross-service hint."""
1361+
struct, advisories = generate_hints("trace", {
1362+
"success": True,
1363+
"stats": {
1364+
"budget_hit": False,
1365+
"total_nodes_discovered": 10,
1366+
"nodes_pruned_role": 0,
1367+
"nodes_pruned_fan_out": 0,
1368+
"edges_collapsed_trivial": 0,
1369+
},
1370+
"edges": [
1371+
{
1372+
"from_id": "client:svc:PaymentClient",
1373+
"to_id": "route:payment-service:/api/payments:POST",
1374+
"cross_service_boundary": True,
1375+
"attrs": {"confidence": 0.85, "strategy": "URI_PATH_MATCH"},
1376+
},
1377+
],
1378+
"nodes": {
1379+
"route:payment-service:/api/payments:POST": {"id": "route:payment-service:/api/payments:POST", "kind": "route"},
1380+
},
1381+
"seed_ids": ["sym:a"],
1382+
"direction": "out",
1383+
"edge_types": ["CALLS", "HTTP_CALLS"],
1384+
})
1385+
assert any("cross-service" in a.lower() for a in advisories), f"no cross-service advisory in {advisories}"
1386+
xs_hints = [h for h in struct if h.label == "cross-service boundary"]
1387+
assert xs_hints, f"no cross-service boundary hint in {[h.label for h in struct]}"
1388+
assert xs_hints[0].tool == "trace"
1389+
assert xs_hints[0].actionable is True
1390+
1391+
1392+
def test_hint_neighbors_high_fanout_mentions_trace() -> None:
1393+
"""generate_hints('neighbors', {many CALLS}) includes a trace recommendation."""
1394+
payload = _neighbors_hint_payload(
1395+
[_success_edge(_symbol_other(f"sym:callee{i}"), edge_type="CALLS") for i in range(12)],
1396+
requested_edge_types=["CALLS"],
1397+
)
1398+
payload["calls_row_count"] = 12
1399+
struct, _ = generate_hints("neighbors", payload)
1400+
trace_hints = [h for h in struct if h.tool == "trace" and h.label == "high fanout trace"]
1401+
assert trace_hints, f"no trace fanout hint in {[(h.tool, h.label) for h in struct]}"
1402+
assert trace_hints[0].actionable is True
1403+
assert "prune_roles" in trace_hints[0].args

tests/test_mcp_trace.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,3 +948,55 @@ async def test_trace_tool_description_mentions_six_tools(mcp_server) -> None:
948948
assert "describe" in instructions
949949
assert "neighbors" in instructions
950950
assert "resolve" in instructions
951+
952+
953+
# ---------------------------------------------------------------------------
954+
# PR-TRACE-3 tests: cross-service integration + hint verification
955+
# ---------------------------------------------------------------------------
956+
957+
958+
def test_trace_bank_chat_cross_service_http_flow(kuzu_graph: KuzuGraph) -> None:
959+
"""Integration: trace from a bank-chat method through HTTP_CALLS; verify cross-service boundary + hints."""
960+
# Find a method that declares a client (has cross-service path).
961+
seed_id = _find_method_with_declares_client(kuzu_graph)
962+
if seed_id is None:
963+
pytest.skip("No method with DECLARES_CLIENT in fixture")
964+
965+
out = trace_v2(
966+
ids=seed_id,
967+
direction="out",
968+
edge_types=["CALLS", "HTTP_CALLS"],
969+
max_depth=4,
970+
fan_out_cap=0, # No cap — include all edges for cross-service verification.
971+
graph=kuzu_graph,
972+
)
973+
assert out.success is True
974+
975+
# Verify cross-service boundary edges exist.
976+
xs_edges = [e for e in out.edges if e.cross_service_boundary]
977+
if xs_edges:
978+
# Cross-service boundary edges should stop at the route.
979+
for xe in xs_edges:
980+
assert xe.edge_type in ("HTTP_CALLS", "ASYNC_CALLS")
981+
assert xe.to_id in out.nodes
982+
# No further edges from the downstream node.
983+
downstream_edges = [e for e in out.edges if e.from_id == xe.to_id]
984+
assert len(downstream_edges) == 0
985+
986+
# Verify hint generation works on the trace output.
987+
from mcp_hints import generate_hints
988+
trace_payload = {
989+
"success": out.success,
990+
"stats": out.stats.model_dump(),
991+
"edges": [e.model_dump() for e in out.edges],
992+
"nodes": {nid: n.model_dump() for nid, n in out.nodes.items()},
993+
"seed_ids": out.seed_ids,
994+
"direction": out.direction,
995+
"edge_types": out.edge_types,
996+
}
997+
struct, advisories = generate_hints("trace", trace_payload)
998+
# Cross-service boundary hints should fire if xs_edges exist.
999+
if xs_edges:
1000+
assert any("cross-service" in a.lower() for a in advisories), (
1001+
f"expected cross-service advisory, got: {advisories}"
1002+
)

0 commit comments

Comments
 (0)