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
49 changes: 43 additions & 6 deletions mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
(issue #161 producer/override-route amendments in that appendix).
v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A.
v3 empty-neighbors structural catalog: ``propose/completed/HINTS-V3-PROPOSE.md`` §3.1–3.3.
v4 non-empty neighbors success-path catalog: ``propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md``.
v4 success-path catalog: ``propose/completed/HINTS-V4-SUCCESS-PATH-PROPOSE.md``.
Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles.
"""

Expand Down Expand Up @@ -58,6 +58,11 @@

TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate"
TPL_FIND_SUCCESS_HANDLER = "handler: neighbors(['{id}'],'in',['EXPOSES'])"
TPL_FIND_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(['{id}'],'out',['HTTP_CALLS'])"
TPL_FIND_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(['{id}'],'out',['ASYNC_CALLS'])"

_FIND_SUCCESS_MAX_CHARS = 120

TPL_NEIGHBORS_WRONG_SUBJECT_KIND = (
"0 results — '{edge}' connects {src_kind} → {dst_kind}; "
Expand Down Expand Up @@ -394,6 +399,41 @@ def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
return pairs


def _find_is_page_full(payload: dict[str, Any], results: list[dict[str, Any]]) -> bool:
lim = payload.get("limit")
return (
lim is not None
and len(results) >= int(lim)
and payload.get("has_more_results") is True
)


def _append_find_success_hint(pairs: list[tuple[int, str]], text: str) -> None:
if text and len(text) <= _FIND_SUCCESS_MAX_CHARS:
pairs.append((PRIORITY_LEAF_FOLLOWUP, text))


def find_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
"""v4 non-empty find follow-ups (F1–F3); no graph I/O."""
if not payload.get("success"):
return []
results = list(payload.get("results") or [])
if not results or _find_is_page_full(payload, results):
return []
node_id = str(results[0].get("id") or "")
if not node_id:
return []
kind = str(payload.get("kind") or "")
pairs: list[tuple[int, str]] = []
if kind == "route":
_append_find_success_hint(pairs, TPL_FIND_SUCCESS_HANDLER.format(id=node_id))
elif kind == "client":
_append_find_success_hint(pairs, TPL_FIND_SUCCESS_HTTP_TARGETS.format(id=node_id))
elif kind == "producer":
_append_find_success_hint(pairs, TPL_FIND_SUCCESS_ASYNC_TARGETS.format(id=node_id))
return pairs


def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
for e in edges:
attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
Expand Down Expand Up @@ -503,12 +543,9 @@ def generate_hints(
lim = payload.get("limit")
if not results and _find_has_identifier_shaped_filter(kind, flt):
pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind)))
if (
lim is not None
and len(results) >= int(lim)
and payload.get("has_more_results") is True
):
if _find_is_page_full(payload, results) and lim is not None:
pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim))))
pairs.extend(find_success_hints(payload))
return finalize_hint_list(pairs)

if output_kind == "neighbors":
Expand Down
18 changes: 9 additions & 9 deletions plans/PLAN-HINTS-V4.md → plans/completed/PLAN-HINTS-V4.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Plan: HINTS-V4 (success-path road signs)

Status: **active (PR-A in progress)**. This plan implements
[`propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md`](../propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md)
Status: **completed** (PR-A [#175](https://github.com/HumanBean17/java-codebase-rag/pull/175), PR-B [#176](https://github.com/HumanBean17/java-codebase-rag/pull/176)). This plan implements
[`propose/completed/HINTS-V4-SUCCESS-PATH-PROPOSE.md`](../propose/completed/HINTS-V4-SUCCESS-PATH-PROPOSE.md)
(issue [#163](https://github.com/HumanBean17/java-codebase-rag/issues/163)).

Depends on: **NEIGHBORS-DOT-KEY-TRAVERSAL** landed ([#171](https://github.com/HumanBean17/java-codebase-rag/pull/171);
Expand Down Expand Up @@ -178,10 +178,10 @@ Implement **verbatim** names from the propose:

## Definition of done (PR-B)

- [ ] F1–F3 wired; page-full + empty-resolve behavior unchanged.
- [ ] Named find tests pass; optional S1 test if implemented.
- [ ] Appendix paragraph added to `HINTS-ROAD-SIGNS-PROPOSE.md`.
- [ ] Full test suite green; no ontology/README requirement unless reviewer asks for README mention.
- [x] F1–F3 wired; page-full + empty-resolve behavior unchanged.
- [x] Named find tests pass; optional S1 test if implemented (S1 deferred).
- [x] Appendix paragraph added to `HINTS-ROAD-SIGNS-PROPOSE.md`.
- [x] Full test suite green; no ontology/README requirement unless reviewer asks for README mention.

## Implementation step list

Expand Down Expand Up @@ -227,9 +227,9 @@ Implement **verbatim** names from the propose:

# Tracking

- `PR-A`: _landed (PR open)_
- `PR-B`: _pending_
- `PR-A`: _landed ([#175](https://github.com/HumanBean17/java-codebase-rag/pull/175))_
- `PR-B`: _landed ([#176](https://github.com/HumanBean17/java-codebase-rag/pull/176))_

## Cursor handoff

[`plans/CURSOR-PROMPTS-HINTS-V4.md`](./CURSOR-PROMPTS-HINTS-V4.md)
[`plans/completed/CURSOR-PROMPTS-HINTS-V4.md`](./CURSOR-PROMPTS-HINTS-V4.md)
13 changes: 11 additions & 2 deletions propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ The full catalog of hints emitted at v1. Each row is one template; multiple may
| `describe` (client node) | always | `declaring method: neighbors([{id}],'in',['DECLARES_CLIENT'])` |
| `find` | `len(results) == 0` and filter has an identifier-shaped value (e.g. `fqn_prefix`, `target_service`) | `no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup` |
| `find` | `len(results) >= limit` (page-full) | `result page full at {limit} — narrow filter or paginate` |
| `find` (route) | `len(results) > 0`, not page-full | `handler: neighbors(['{id}'],'in',['EXPOSES'])` — `{id}` = `results[0].id` (#163 v4) |
| `find` (client) | same, `kind == client` | `HTTP targets: neighbors(['{id}'],'out',['HTTP_CALLS'])` (#163 v4) |
| `find` (producer) | same, `kind == producer` | `async targets: neighbors(['{id}'],'out',['ASYNC_CALLS'])` (#163 v4) |
| `neighbors` | `len(results) == 0` and `len(edge_types) > 0` | `0 results — check if the requested edge_types apply to this kind` |
| `neighbors` | rows include `edge_type='DECLARES'` to method targets, **and** any of those methods has known `DECLARES_CLIENT` out in summary | (deferred — needs second-hop awareness; not in v1) |
| `search` | `len(results) == limit` **and** `(max_score - min_score) < 0.1 * max_score` (structural low-confidence signal — no absolute threshold). Requires `SearchOutput.limit` echo per §3.1 / §7.18. | `results look weak — narrow the query or try find(role=…)` |
Expand Down Expand Up @@ -138,8 +141,9 @@ This catalog is the v1 lock. Adding a new template requires a propose-doc amendm
| UC9 | Agent does `neighbors([class_id], 'out', ['DECLARES_CLIENT'])` and gets 0 (correctly, because DECLARES_CLIENT lives on methods) | neighbors | "0 results — check if the requested edge_types apply to this kind" |
| UC10 | Agent does `search`, gets a full page of hits all clustered within 10% of the top score (no dominant match) | search | "results look weak — narrow the query or try find(role=…)" |
| UC11 | Agent describes a leaf method (no rollups, no override axis) | describe | (no hints — clean output, agent has all it needs) |
| UC12 | Agent does `find` with successful match list | find | (no hints — page not full, results present) |
| UC13 | Agent does `neighbors` with results matching all requested edge_types | neighbors | (no hints — happy path) |
| UC12 | Agent does `find` on route, client, or producer with successful match list, not page-full | find | v4 success follow-up (F1–F3): handler / HTTP targets / async targets using `results[0].id` |
| UC12b | Agent does `find(kind=symbol)` with successful match list | find | (no v4 find hints — symbol kind out of F1–F3 scope) |
| UC13 | Agent does `neighbors` with homogeneous results at `offset==0`, single edge type | neighbors | v4 success follow-ups (N1a–N7) when triggers match; else fuzzy-strategy meta only |
| UC14 | Agent describes a class with `DECLARES.DECLARES_CLIENT` and `DECLARES.EXPOSES` both non-zero, plus `OVERRIDDEN_BY` (it's an interface) | describe | up to 3 hints — clients via members, routes via members, overriders if applicable |

15 realistic cases (UC6a + UC6b counted separately). The §2.5 cap is exercised by a dedicated test scenario in §6 (a hand-crafted record with > 5 firing conditions), not by a hypothetical UC — the UC re-walk is the design-validation move, the cap is a guardrail with its own test.
Expand Down Expand Up @@ -266,6 +270,9 @@ kind == producer, always → "declaring method: neighbors(['{id}'],'in',
# FindOutput
results==[] and filter has identifier-shaped value → "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
len(results) >= limit → "result page full at {limit} — narrow filter or paginate"
kind==route, len(results)>0, not page-full → "handler: neighbors(['{id}'],'in',['EXPOSES'])" # v4 #163
kind==client, len(results)>0, not page-full → "HTTP targets: neighbors(['{id}'],'out',['HTTP_CALLS'])" # v4 #163
kind==producer, len(results)>0, not page-full → "async targets: neighbors(['{id}'],'out',['ASYNC_CALLS'])" # v4 #163

# NeighborsOutput
results==[] and edge_types non-empty → "0 results — check if the requested edge_types apply to this kind"
Expand All @@ -280,6 +287,8 @@ File placement (`mcp_hints.py`), function decomposition, integration points in `

**Amendment (2026-05-16, issue #161 / PR #164)** — five describe templates for the producer axis and override-route rollup, symmetric with the client/route rows above: `DECLARES.DECLARES_PRODUCER`, `DECLARES_PRODUCER`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`, and `kind == producer` declaring-method hint. No ontology or re-index change.

**Amendment (v4 success-path, issue #163)** — (1) **Find:** non-page-full `find` on route/client/producer emits F1–F3 (`handler` / `HTTP targets` / `async targets`); `{id}` = `results[0].id` when multiple matches. (2) **Neighbors — second partial dot-key emission reversal:** non-empty success hints on type Symbol origins may recommend `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, and `DECLARES.EXPOSES` (matching describe rollups). v3 empty structural `neighbors` hints still never use dot-keys (`_filter_neighbors_dotkey_hints` applies to the empty branch only). `OVERRIDDEN_BY.*` dot-keys remain describe-only. No ontology or re-index change.

**What stayed unchanged from the first draft**

- §1 frame statement; §2 principles 1–8; §3.1 field shape; §3.2 generation contract; §5 "deliberately does NOT do" table; §8 risks table.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Status

**Status**: locked — not yet implemented. Tracks [issue #163](https://github.com/HumanBean17/java-codebase-rag/issues/163). Implementation plan: [`plans/PLAN-HINTS-V4.md`](../plans/PLAN-HINTS-V4.md) (planning PR [#174](https://github.com/HumanBean17/java-codebase-rag/pull/174)); Cursor prompts: [`plans/CURSOR-PROMPTS-HINTS-V4.md`](../plans/CURSOR-PROMPTS-HINTS-V4.md).
**Status**: completed (landed PRs [#175](https://github.com/HumanBean17/java-codebase-rag/pull/175) neighbors, [#176](https://github.com/HumanBean17/java-codebase-rag/pull/176) find). Tracks [issue #163](https://github.com/HumanBean17/java-codebase-rag/issues/163). Plan: [`plans/completed/PLAN-HINTS-V4.md`](../plans/completed/PLAN-HINTS-V4.md); Cursor prompts: [`plans/completed/CURSOR-PROMPTS-HINTS-V4.md`](../plans/completed/CURSOR-PROMPTS-HINTS-V4.md).

**Depends on (landed):** [NEIGHBORS-DOT-KEY-TRAVERSAL](./completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md) ([#171](https://github.com/HumanBean17/java-codebase-rag/pull/171)) — `neighbors` accepts `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` on type Symbol origins; describe rollup templates already prescribe those dot-keys.

Expand Down
95 changes: 95 additions & 0 deletions tests/test_mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,9 @@ def test_hints_neighbors_v2_declares_success_emits_dot_key_clients(kuzu_graph) -
mcp_hints.TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS,
{"id": "sym:com.example.bank.chat.Controller"},
),
(mcp_hints.TPL_FIND_SUCCESS_HANDLER, {"id": "route:svc:GET:/api/v1/chat"}),
(mcp_hints.TPL_FIND_SUCCESS_HTTP_TARGETS, {"id": "client:svc:feign:target:GET:/p"}),
(mcp_hints.TPL_FIND_SUCCESS_ASYNC_TARGETS, {"id": "producer:svc:kafka:topic:t"}),
],
)
def test_hints_all_v4_templates_under_120_chars(template: str, substitutions: dict[str, str]) -> None:
Expand Down Expand Up @@ -1159,6 +1162,98 @@ def test_hints_cap_same_priority_keeps_emission_order() -> None:
assert "e-meta" not in got


def _find_success_payload(
kind: str,
node_id: str,
*,
limit: int | None = None,
has_more_results: bool = False,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"success": True,
"kind": kind,
"results": [{"id": node_id, "kind": kind}],
"filter": {},
"offset": 0,
}
if limit is not None:
payload["limit"] = limit
payload["has_more_results"] = has_more_results
return payload


def test_hints_find_route_success_emits_handler() -> None:
rid = "route:svc:GET:/api/v1/chat"
payload = _find_success_payload("route", rid)
want = mcp_hints.TPL_FIND_SUCCESS_HANDLER.format(id=rid)
assert want in generate_hints("find", payload)


def test_hints_find_client_success_emits_http_calls() -> None:
cid = "client:svc:feign:target:GET:/p"
payload = _find_success_payload("client", cid)
want = mcp_hints.TPL_FIND_SUCCESS_HTTP_TARGETS.format(id=cid)
assert want in generate_hints("find", payload)


def test_hints_find_producer_success_emits_async_calls() -> None:
pid = "producer:svc:kafka:topic:t"
payload = _find_success_payload("producer", pid)
want = mcp_hints.TPL_FIND_SUCCESS_ASYNC_TARGETS.format(id=pid)
assert want in generate_hints("find", payload)


def test_hints_find_success_suppressed_when_page_full() -> None:
rid = "route:svc:GET:/api/v1/chat"
payload = _find_success_payload("route", rid, limit=1, has_more_results=True)
hints = generate_hints("find", payload)
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in hints
assert mcp_hints.TPL_FIND_SUCCESS_HANDLER.format(id=rid) not in hints


def test_hints_find_success_uses_first_result_id_when_multiple() -> None:
first = "route:svc:GET:/first"
second = "route:svc:GET:/second"
payload = _find_success_payload("route", first)
payload["results"] = [
{"id": first, "kind": "route"},
{"id": second, "kind": "route"},
]
want = mcp_hints.TPL_FIND_SUCCESS_HANDLER.format(id=first)
hints = generate_hints("find", payload)
assert want in hints
assert mcp_hints.TPL_FIND_SUCCESS_HANDLER.format(id=second) not in hints


def test_hints_find_symbol_success_emits_no_v4_followup() -> None:
sym_id = "sym:com.example.T"
payload = _find_success_payload("symbol", sym_id)
hints = generate_hints("find", payload)
v4_markers = (
mcp_hints.TPL_FIND_SUCCESS_HANDLER,
mcp_hints.TPL_FIND_SUCCESS_HTTP_TARGETS,
mcp_hints.TPL_FIND_SUCCESS_ASYNC_TARGETS,
)
assert not any(m.format(id=sym_id) in hints for m in v4_markers)


def test_hints_find_success_silent_when_first_result_missing_id() -> None:
payload = _find_success_payload("route", "route:unused")
payload["results"] = [{"kind": "route"}]
hints = generate_hints("find", payload)
assert not any("handler:" in h and "EXPOSES" in h for h in hints)


def test_hints_find_v2_route_success_emits_handler(kuzu_graph) -> None:
out = find_v2("route", {"path_prefix": "/api"}, graph=kuzu_graph, limit=500, offset=0)
assert out.success is True
assert out.results
rid = out.results[0].id
want = mcp_hints.TPL_FIND_SUCCESS_HANDLER.format(id=rid)
assert want in out.hints
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=500) not in out.hints


def test_hints_find_page_full_requires_has_more_results_flag() -> None:
full_page = {
"success": True,
Expand Down
Loading