Skip to content

Commit 4c3fb76

Browse files
HumanBean17claude
andcommitted
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 f202ce9 commit 4c3fb76

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
@@ -190,7 +190,7 @@ def _role_axes_mutually_exclusive(self) -> EdgeFilter:
190190

191191
class StructuredHint(BaseModel):
192192
label: str = ""
193-
tool: Literal["search", "find", "describe", "neighbors", "resolve"]
193+
tool: Literal["search", "find", "describe", "neighbors", "trace", "resolve"]
194194
args: dict[str, Any]
195195
actionable: bool = True
196196
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

@@ -29,15 +29,15 @@ Test/build/CI files, git history — read directly. `CALLS` is static analysis o
2929

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

3434
## Forced reasoning preamble (every tool call)
3535

3636
Before each MCP call, output one short line:
3737

3838
```
39-
Q-class: <semantic | structured | inspect | walk>
40-
Pick: <search|find|describe|neighbors|resolve> Why: <≤8 words>
39+
Q-class: <semantic | structured | inspect | walk | trace>
40+
Pick: <search|find|describe|neighbors|trace|resolve> Why: <≤8 words>
4141
```
4242

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

179183
**Rules of thumb:**
180184

@@ -210,6 +214,19 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, ... on cross-
210214

211215
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). `attrs.resolved=false` means the callee is external (JDK/Spring) — not a missing symbol. **`include_unresolved=True`** (CALLS + `direction=out` only) interleaves unresolved call sites with resolved `CALLS` (`row_kind` discriminator); **mutually exclusive with `edge_filter`**. **`dedup_calls=True`** collapses identical `(origin, callee)` pairs 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). **Note:** `filter.role` filters the neighbor node, not the callee's declaring type — use `edge_filter.callee_declaring_role` for callee stereotype filtering.
212216

217+
### `trace`
218+
219+
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).
220+
221+
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`.
222+
223+
**`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`.
224+
225+
**When to use `trace` vs `neighbors`:**
226+
- Use `neighbors` for single-hop adjacency where you want the full unfiltered result.
227+
- 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).
228+
- After `trace`, use `neighbors` or `describe` on specific nodes for detail the trace pruned or collapsed.
229+
213230
## Ontology glossary
214231

215232
**Roles** (`filter.role` / `exclude_roles`):

tests/test_mcp_hints.py

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