Skip to content

Commit 90457dd

Browse files
fix(mcp): honesty signals for zero-exact search and filename-only get_symbol (#770)
search_codebase on an identifier query now reports exact_match (true/false) with a note when no indexed symbol matches exactly, so the agent does not anchor on a fuzzy neighbour that merely ranks first. get_symbol resolves a filename-only or partial-path id (answer.py::get_answer) via a suffix rung on the file path, mirroring get_context's basename ladder, and returns path-qualified suggestions on a total miss instead of a bare not-found.
1 parent 23087bf commit 90457dd

4 files changed

Lines changed: 234 additions & 1 deletion

File tree

packages/server/src/repowise/server/mcp_server/tool_search.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from repowise.server.mcp_server._meta import build_meta as _build_meta
2626
from repowise.server.mcp_server.tool_search_symbols import (
27+
_qual_norm,
2728
search_paths_single,
2829
search_symbols_single,
2930
)
@@ -63,6 +64,41 @@ def _embedded_identifiers(query: str) -> list[str]:
6364
return _IDENT_TOKEN_RE.findall(query)
6465

6566

67+
def _identifier_candidates(query: str, mode: str) -> list[str]:
68+
"""Identifier tokens the query is asking after, for the exact-match signal.
69+
70+
A single-token query IS the identifier (symbol mode); a natural-language
71+
query carrying identifiers (hybrid mode) exposes them the same way
72+
``_resolve_mode`` used to route here. Concept/path queries name none.
73+
"""
74+
if mode == "symbol":
75+
q = query.strip()
76+
return [q] if q else []
77+
if mode == "hybrid":
78+
return _embedded_identifiers(query)
79+
return []
80+
81+
82+
def _has_exact_symbol(candidates: list[str], symbols: list[dict]) -> bool:
83+
"""True when some returned symbol's name/qualified-name equals a candidate.
84+
85+
Reuses the scorer's separator-normalisation so an agent's ``Class.method``
86+
matches a ``Class::method`` qualified_name in the index. This is the score
87+
cliff made explicit: an exact hit and a fuzzy neighbour look identical in
88+
the result list otherwise, and the agent anchors on whatever ranks first.
89+
"""
90+
if not candidates or not symbols:
91+
return False
92+
wanted = {c.strip().lower() for c in candidates if c.strip()}
93+
wanted |= {_qual_norm(c) for c in candidates if c.strip()}
94+
for s in symbols:
95+
name = (s.get("name") or "").strip().lower()
96+
qn = _qual_norm(s.get("qualified_name"))
97+
if (name and name in wanted) or (qn and qn in wanted):
98+
return True
99+
return False
100+
101+
66102
# Decision records are short, dense title-statements; they win cosine
67103
# similarity against long file-page embeddings on any query containing
68104
# design nouns ("store", "SQLite", "cap", "prune") and crowd file pages
@@ -561,6 +597,24 @@ async def _structured_search(
561597
"mode": mode,
562598
"_meta": _build_meta(repository=repository, targets=_result_paths(results)),
563599
}
600+
# Exact-match honesty: an identifier-shaped query whose target names no
601+
# indexed symbol still returns fuzzy neighbours. Say so, or the agent
602+
# anchors on a wrong hit that looks authoritative (their Alamofire
603+
# 44-overload read-spiral). Emit the boolean either way; a note only when
604+
# there is no exact hit to distinguish from the fuzz.
605+
candidates = _identifier_candidates(query, mode)
606+
if candidates:
607+
exact = _has_exact_symbol(candidates, symbols)
608+
response["exact_match"] = exact
609+
if not exact:
610+
shown = ", ".join(repr(c) for c in candidates[:3])
611+
response["note"] = (
612+
f"No indexed symbol exactly matches {shown}. The results are "
613+
"fuzzy neighbours ranked by token overlap — confirm a hit names "
614+
"what you meant before relying on it. If you expected an exact "
615+
"symbol, recheck spelling/casing, or Grep the literal name for "
616+
"an exhaustive usage sweep."
617+
)
564618
if grep_hint and not results:
565619
response["grep_hint"] = grep_hint
566620
return response

packages/server/src/repowise/server/mcp_server/tool_symbol.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
1. Exact match on WikiSymbol.symbol_id (the canonical "{path}::{name}" key)
2525
2. Exact match on (file_path, qualified_name) — supports class.method form
2626
3. Exact match on (file_path, name) — supports unqualified names
27+
4. Suffix match — a bare filename or partial path ("answer.py::get_answer")
28+
resolves against any file whose path ends with that segment, on the leaf
29+
name; mirrors get_context's basename ladder. A total miss returns
30+
``suggestions`` (real path-qualified ids) instead of a bare "not found".
2731
2832
The tool also resolves **omission refs**: a ``symbol_id`` of the form
2933
``"repowise#<12-hex>"`` (from a ``[repowise#<ref>: ...]`` truncation marker)
@@ -44,7 +48,7 @@
4448
from pathlib import Path
4549
from typing import Any
4650

47-
from sqlalchemy import select
51+
from sqlalchemy import or_, select
4852

4953
from repowise.core.persistence.database import get_session
5054
from repowise.core.persistence.models import WikiSymbol
@@ -448,9 +452,61 @@ async def _resolve_symbol(session, repo_id: str, symbol_id: str) -> list[WikiSym
448452
if rows:
449453
return _order_candidates(rows, file_path)
450454

455+
# 4. Suffix file-path match — the caller passed a bare filename or partial
456+
# path ("answer.py::get_answer") instead of the full indexed path.
457+
# Resolve against any file whose path ends with that segment on a "/"
458+
# boundary, on the bare leaf name. Mirrors get_context's basename ladder
459+
# so both tools accept the same shorthand instead of a dead "not found".
460+
if file_path and name:
461+
from repowise.server.mcp_server.tool_context.targets import _escape_like
462+
463+
esc = _escape_like(file_path.strip("/").replace("\\", "/"))
464+
bare = _bare_name(name)
465+
res = await session.execute(
466+
select(WikiSymbol).where(
467+
WikiSymbol.repository_id == repo_id,
468+
WikiSymbol.name == bare,
469+
or_(
470+
WikiSymbol.file_path == file_path.strip("/").replace("\\", "/"),
471+
WikiSymbol.file_path.like(f"%/{esc}", escape="\\"),
472+
),
473+
)
474+
)
475+
rows = list(res.scalars().all())
476+
if rows:
477+
return _order_candidates(rows, file_path)
478+
451479
return []
452480

453481

482+
async def _symbol_suggestions(session, repo_id: str, symbol_id: str, exclude_spec) -> list[str]:
483+
"""Concrete symbol_ids to retry when a lookup misses entirely.
484+
485+
The caller usually had the right leaf name but a wrong or partial path.
486+
Match that name across every file and hand back real ids (path-qualified)
487+
the agent can pass straight back to get_symbol — a bare "not found" would
488+
otherwise send it to get_context or a whole-file Read.
489+
"""
490+
_, name = _parse_symbol_id(symbol_id)
491+
if not name:
492+
return []
493+
bare = _bare_name(name)
494+
res = await session.execute(
495+
select(WikiSymbol.symbol_id, WikiSymbol.file_path)
496+
.where(WikiSymbol.repository_id == repo_id, WikiSymbol.name == bare)
497+
.limit(20)
498+
)
499+
out: list[str] = []
500+
seen: set[str] = set()
501+
for sid, fpath in res.all():
502+
if sid and sid not in seen and not is_excluded(fpath, exclude_spec):
503+
seen.add(sid)
504+
out.append(sid)
505+
if len(out) >= 5:
506+
break
507+
return out
508+
509+
454510
def _read_file_text(repo_path: Path, file_path: str) -> str | None:
455511
"""Read a repo file's live text, or None when unreadable/outside the root."""
456512
abs_path = (repo_path / file_path).resolve()
@@ -696,6 +752,22 @@ async def get_symbol(
696752
targets=[file_part],
697753
),
698754
}
755+
async with get_session(ctx.session_factory) as session:
756+
suggestions = await _symbol_suggestions(session, repository.id, symbol_id, exclude_spec)
757+
if suggestions:
758+
return {
759+
"symbol_id": symbol_id,
760+
"error": (
761+
f"Symbol not found: {symbol_id!r}. A symbol with this name "
762+
"exists at the path(s) below — retry with one of these "
763+
"exact symbol_ids."
764+
),
765+
"suggestions": suggestions,
766+
"_meta": _build_meta(
767+
timing_ms=(time.perf_counter() - t0) * 1000,
768+
repository=repository,
769+
),
770+
}
699771
return {
700772
"symbol_id": symbol_id,
701773
"error": (

tests/unit/server/mcp/test_search.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,57 @@ async def test_plain_english_query_gets_no_hint(self, setup_mcp):
510510

511511
result = await search_codebase("authentication flow for the service")
512512
assert "grep_hint" not in result
513+
514+
515+
class TestExactMatchSignal:
516+
"""An identifier query says whether any hit is an EXACT symbol match, so
517+
the agent doesn't anchor on a fuzzy neighbour that ranks first (finding 1)."""
518+
519+
def test_has_exact_symbol_matches_name(self):
520+
from repowise.server.mcp_server.tool_search import _has_exact_symbol
521+
522+
syms = [{"name": "get_answer", "qualified_name": "get_answer"}]
523+
assert _has_exact_symbol(["get_answer"], syms)
524+
assert not _has_exact_symbol(["get_answerX"], syms)
525+
assert not _has_exact_symbol([], syms)
526+
assert not _has_exact_symbol(["get_answer"], [])
527+
528+
def test_has_exact_symbol_normalizes_separators(self):
529+
# An agent's "Class.method" must match a "Class::method" qualified_name.
530+
from repowise.server.mcp_server.tool_search import _has_exact_symbol
531+
532+
syms = [{"name": "method", "qualified_name": "Class::method"}]
533+
assert _has_exact_symbol(["Class.method"], syms)
534+
535+
def test_identifier_candidates_by_mode(self):
536+
from repowise.server.mcp_server.tool_search import _identifier_candidates
537+
538+
assert _identifier_candidates("AuthService", "symbol") == ["AuthService"]
539+
assert _identifier_candidates("where is AuthService defined", "hybrid") == ["AuthService"]
540+
assert _identifier_candidates("rate limiting", "concept") == []
541+
542+
@pytest.mark.asyncio
543+
async def test_exact_hit_sets_true_and_no_note(self, setup_mcp):
544+
from repowise.server.mcp_server import search_codebase
545+
546+
result = await search_codebase("AuthService", mode="symbol")
547+
assert result["exact_match"] is True
548+
assert "note" not in result
549+
550+
@pytest.mark.asyncio
551+
async def test_fuzzy_only_sets_false_with_note(self, setup_mcp):
552+
# "AuthServiceXyz" token-overlaps AuthService (a hit) but matches no
553+
# symbol exactly — the signal must fire even though results are non-empty.
554+
from repowise.server.mcp_server import search_codebase
555+
556+
result = await search_codebase("AuthServiceXyz", mode="symbol")
557+
assert result["results"], "fuzzy neighbour should still be returned"
558+
assert result["exact_match"] is False
559+
assert "exactly matches" in result.get("note", "")
560+
561+
@pytest.mark.asyncio
562+
async def test_concept_query_gets_no_signal(self, setup_mcp):
563+
from repowise.server.mcp_server import search_codebase
564+
565+
result = await search_codebase("authentication flow for the service")
566+
assert "exact_match" not in result

tests/unit/server/mcp/test_symbol_ambiguity_and_format.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,59 @@ async def test_unambiguous_lookup_is_unchanged_shape(setup_mcp, repo_on_disk, se
134134
assert " 10\t return x + 1" in result["source"]
135135

136136

137+
async def _add_alpha_row(session):
138+
from sqlalchemy import select
139+
140+
from repowise.core.persistence.models import Repository, WikiSymbol
141+
142+
repo = (await session.execute(select(Repository))).scalars().first()
143+
session.add(
144+
WikiSymbol(
145+
id="alpha1",
146+
repository_id=repo.id,
147+
file_path="pkg/mod.py",
148+
symbol_id="pkg/mod.py::alpha",
149+
name="alpha",
150+
qualified_name="alpha",
151+
kind="function",
152+
signature="def alpha(x)",
153+
start_line=9,
154+
end_line=10,
155+
language="python",
156+
)
157+
)
158+
await session.flush()
159+
160+
161+
@pytest.mark.asyncio
162+
async def test_filename_only_id_resolves_via_suffix(setup_mcp, repo_on_disk, session):
163+
# A bare filename ("mod.py::alpha") must resolve to the full indexed path
164+
# via the suffix ladder, not dead-end on "not found" (finding 2).
165+
from repowise.server.mcp_server import get_symbol
166+
167+
await _add_alpha_row(session)
168+
result = await get_symbol("mod.py::alpha")
169+
170+
assert result.get("error") is None
171+
assert result["symbol_id"] == "pkg/mod.py::alpha"
172+
assert result["verified"] is True
173+
assert " 9\tdef alpha(x):" in result["source"]
174+
175+
176+
@pytest.mark.asyncio
177+
async def test_total_miss_returns_symbol_id_suggestions(setup_mcp, repo_on_disk, session):
178+
# A wrong path with a real leaf name returns retryable path-qualified ids
179+
# instead of a bare "not found".
180+
from repowise.server.mcp_server import get_symbol
181+
182+
await _add_alpha_row(session)
183+
result = await get_symbol("nope/wrong.py::alpha")
184+
185+
assert "source" not in result
186+
assert result["suggestions"] == ["pkg/mod.py::alpha"]
187+
assert "retry" in result["error"].lower()
188+
189+
137190
@pytest.mark.asyncio
138191
async def test_range_read_source_is_numbered(setup_mcp, repo_on_disk):
139192
from repowise.server.mcp_server import get_symbol

0 commit comments

Comments
 (0)