feat(mcp): per-symbol adaptive codegraph_explore sizing (#569)

Sizes codegraph_explore to the answer, not the file count: shows the mechanism +
the exact methods you named in full (even buried in a large file) while collapsing
redundant interchangeable implementations to signatures. Adds uniqueness-aware
spare, per-symbol focused rendering of family files, all-tier test-file exclusion,
and named-method cluster survival in non-sibling god-files.

Validated A/B (Opus 4.8, 7-repo sweep): avg 25%% cheaper / 57%% fewer tokens / 23%%
faster / 62%% fewer tool calls. Django 9->23%% cheaper (0 reads), OkHttp 4->11%%
cheaper; gains across small/medium/large, inert repos unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-29 23:06:12 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f1b14f021b
commit b026e64b41
5 changed files with 226 additions and 111 deletions
+118 -48
View File
@@ -1924,8 +1924,8 @@ export class ToolHandler {
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
* dropping unrelated `OmsOrderService::list`.
*/
private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string> } {
const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>() };
private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string> } {
const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>() };
try {
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
@@ -1945,18 +1945,28 @@ export class ToolHandler {
const segPool = new Set<string>();
for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
const named = new Map<string, Node>();
// Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
// the whole graph). These are safe to SPARE a file on: the agent named THIS
// method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
// (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
// so naming it doesn't keep every backend variant full and flood the budget.
const uniqueNamedNodeIds = new Set<string>();
for (const t of tokens) {
const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
// ambiguous simple name keeps only candidates whose container is named.
const pick = cands.length <= 3
const specific = cands.length <= 3;
const pick = specific
? cands
: cands.filter((n) => {
const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
return !!container && segPool.has(container);
});
for (const n of pick.slice(0, 6)) named.set(n.id, n);
for (const n of pick.slice(0, 6)) {
named.set(n.id, n);
if (specific) uniqueNamedNodeIds.add(n.id);
}
if (named.size > 40) break;
}
if (named.size < 2) return EMPTY;
@@ -2004,7 +2014,7 @@ export class ToolHandler {
// must keep full source even if it's an off-spine polymorphic sibling — the
// agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
// as the mechanism, not as an interchangeable leaf. See the skeleton gate.
return { text: out.join('\n'), pathNodeIds: new Set(best.map((s) => s.node.id)), namedNodeIds: new Set(named.keys()) };
return { text: out.join('\n'), pathNodeIds: new Set(best.map((s) => s.node.id)), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
} catch {
return EMPTY;
}
@@ -2141,20 +2151,18 @@ export class ToolHandler {
);
};
// Tiny-tier hard-exclude: on small projects (`excludeLowValueFiles`
// budget flag), one slipped test/spec file dominates the per-file budget
// (cobra's `command_test.go` displaced `args.go` and contributed ~10KB of
// pure noise to "How does cobra parse commands?"). The sort-step
// deprioritization isn't enough at small N. Skip the hard-exclude when
// the query itself is about tests — that's the legitimate "explore the
// tests" case where the agent does want them.
if (budget.excludeLowValueFiles) {
// Hard-exclude test/spec files (ALL tiers, not just tiny). One slipped test
// file dominates the per-file budget on small repos (cobra's `command_test.go`
// displaced `args.go`) AND wastes budget on large ones (Django's
// `custom_lookups/tests.py` ate ~2.3 KB of the 28 KB cap, crowding out the
// SQLCompiler mechanism the agent then Read). A test file almost never answers
// an architecture question. Skip when the query itself is about tests — the
// legitimate "explore the tests" case — and only cut if ≥2 non-test candidates
// remain (else tests are the only signal for this area).
{
const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
if (!queryMentionsTests) {
const nonLow = relevantFiles.filter(([p]) => !isLowValue(p));
// Only apply the hard-filter if we still have at least 2 non-test
// candidates after the cut — otherwise the agent is asking about an
// area where tests are the only signal, and we should not strip them.
if (nonLow.length >= 2) {
relevantFiles = nonLow;
}
@@ -2322,49 +2330,90 @@ export class ToolHandler {
const fileLines = fileContent.split('\n');
const lang = group.nodes[0]?.language || '';
// Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): skeletonize a file
// (member signatures, bodies elided) when it is a redundant member of a
// polymorphic family. Skeletonize iff ALL hold:
// Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file
// to a per-symbol view when it's a redundant member of a polymorphic family.
// Engages iff ALL hold:
// 1. a flow spine exists,
// 2. no symbol in the file is on that spine (it's not the mechanism path),
// 3. it IS a polymorphic sibling (≥ MIN_SIBLINGS impls of a shared supertype),
// 4. it is NOT SPARED, where a file is spared iff the agent NAMED a callable
// in it (`getResponseWithInterceptorChain` → keep RealCall.kt full so the
// agent doesn't Read it back) UNLESS the file also DEFINES the family's
// supertype — a base+subclasses "family" file (Django's compiler.py) is
// huge and Read-anyway, so skeletonizing it FREES budget for the sibling
// files the agent would otherwise Read (it's the cheaper option, proven by
// A/B: sparing compiler.py cost MORE and Read MORE).
// Before condition 4, off-spine + sibling alone skeletonized RealCall.kt (it
// implements the 9-impl `Lockable` mixin), which the agent then Read back.
const namedInFile = group.nodes.some(n => flow.namedNodeIds.has(n.id));
const spared = namedInFile && !definesPolymorphicSupertype(group.nodes);
// 4. it is NOT SPARED, where a file is spared iff the agent named a
// (near-)UNIQUE callable in it (`getResponseWithInterceptorChain`, 1 def →
// keep RealCall.kt full) UNLESS the file DEFINES the family supertype (a
// base+subclasses "family" file like Django's compiler.py — collapse it).
// Uniqueness matters: `as_sql` has 110 defs across every Compiler/Expression
// subclass; naming it must NOT keep every backend variant + test file full
// and flood the budget. That's why the spare reads uniqueNamedNodeIds.
// Within a collapsed file the render is PER-SYMBOL (condition B): a method the
// agent NAMED or that's on the spine is shown with its FULL body (so the agent
// doesn't Read the file back for it — Django's SQLCompiler.execute_sql/as_sql);
// every other symbol is just its signature. So the base mechanism survives while
// the file's other ~80 symbols + the redundant subclasses collapse to one line each.
const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
const spared = spareNamed && !fileDefinesSuper;
if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
&& !group.nodes.some(n => flow.pathNodeIds.has(n.id))
&& isPolymorphicSibling(group.nodes)
&& !spared) {
const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
const syms = group.nodes
.filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
.sort((a, b) => a.startLine - b.startLine);
const seenLn = new Set<number>();
const skel: string[] = [];
for (const n of syms) {
// node.startLine can point at a decorator/annotation (@Throws, @Override,
// @objc), so scan forward a few lines for the line that actually NAMES the
// symbol — that's the signature the agent needs from a skeleton.
let lineNo = n.startLine;
for (let k = 0; k < 4; k++) {
if ((fileLines[n.startLine - 1 + k] || '').includes(n.name)) { lineNo = n.startLine + k; break; }
}
if (seenLn.has(lineNo)) continue;
seenLn.add(lineNo);
const sig = (fileLines[lineNo - 1] || '').trim();
if (sig) skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
// Pass 1: choose which symbols get a FULL body, by priority, greedily within
// a per-file body cap — so one huge family file can't body every named method
// and crowd out the other flow files (Django's query.py). A symbol earns a
// body if it's on-spine, or UNIQUELY named (`SQLCompiler.execute_sql`), or a
// co-named method WHEN this file DEFINES the family supertype (so the base
// `SQLCompiler.as_sql` body shows, but the 110 leaf `as_sql` overrides — and
// OkHttp's 5 `intercept`s if the agent names `intercept` — stay signatures).
const prio = (n: Node) => !CALLABLE_BODY.has(n.kind) ? 99
: flow.pathNodeIds.has(n.id) ? 0
: flow.uniqueNamedNodeIds.has(n.id) ? 1
: (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
const bodyCap = budget.maxCharsPerFile * 2;
const bodyIds = new Set<string>();
let bodyChars = 0;
for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
const sz = fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length;
if (bodyChars + sz > bodyCap && bodyIds.size > 0) continue;
bodyIds.add(n.id);
bodyChars += sz;
}
// Pass 2: render in line order — full body for chosen symbols, else the
// signature line (capped, with a "+N more" tail so the structure map of a
// god-file doesn't itself bloat the budget).
const skel: string[] = [];
let coveredUntil = 0; // skip symbols already inside an emitted body
let sigCount = 0, sigDropped = 0;
const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
for (const n of syms) {
if (n.startLine <= coveredUntil) continue;
if (bodyIds.has(n.id)) {
const end = Math.min(n.endLine, n.startLine + 220);
const body = fileLines.slice(n.startLine - 1, end).join('\n');
skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
coveredUntil = end;
} else {
// Elide the body, emit the signature. node.startLine can point at a
// decorator/annotation, so scan forward for the line that names the symbol.
let lineNo = n.startLine;
for (let k = 0; k < 4; k++) {
if ((fileLines[n.startLine - 1 + k] || '').includes(n.name)) { lineNo = n.startLine + k; break; }
}
if (lineNo <= coveredUntil) continue;
if (sigCount >= SIG_MAX) { sigDropped++; continue; }
const sig = (fileLines[lineNo - 1] || '').trim();
if (sig) { skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig); sigCount++; }
}
}
if (sigDropped > 0) skel.push(`… +${sigDropped} more (signatures elided)`);
if (skel.length > 0) {
const names = [...new Set(group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export').map(n => n.name))]
.slice(0, budget.maxSymbolsInFileHeader).join(', ');
lines.push(`#### ${filePath}${names} · skeleton (signatures only; Read for a full body)`, '', '```' + lang, skel.join('\n'), '```', '');
const tag = bodyIds.size > 0
? 'focused (the methods you named in full, the rest as signatures; Read for more)'
: 'skeleton (signatures only; Read for a full body)';
lines.push(`#### ${filePath}${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
totalChars += skel.join('\n').length + 120;
filesIncluded++;
continue;
@@ -2423,13 +2472,27 @@ export class ToolHandler {
// Alamofire is the canonical case: the `Session` class spans ~1,400
// lines). We want the granular symbols inside, not the envelope.
const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
.filter(n => n.startLine > 0 && n.endLine > 0)
// Cluster from this file's gathered nodes PLUS any callable the agent NAMED that
// lives here. Explore's relevance gather can miss a named method def in a huge
// non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237)
// was gathered only as call-reference edges, never as a def, so it formed no
// cluster and the agent Read it back. Inject named defs directly and rank them
// ABOVE connected/glue nodes (importance 9) so their cluster wins the per-file
// budget — the agent explicitly asked for these symbols.
const rangeNodes = new Map<string, Node>();
for (const n of group.nodes) if (n.startLine > 0 && n.endLine > 0) rangeNodes.set(n.id, n);
for (const id of flow.namedNodeIds) {
if (rangeNodes.has(id)) continue;
const n = cg.getNode(id);
if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n);
}
const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = [...rangeNodes.values()]
// Drop whole-file envelope nodes (containers covering >50% of the file).
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
.map(n => {
let importance = 1;
if (entryNodeIds.has(n.id)) importance = 10;
else if (flow.namedNodeIds.has(n.id)) importance = 9; // agent named it → keep its cluster
else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
else if (connectedToEntry.has(n.id)) importance = 3;
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
@@ -2529,6 +2592,13 @@ export class ToolHandler {
return a.span - b.span;
});
// Per-file budget is the SMALLER of the per-file cap and what's left of the
// total output cap — so selection (which ranks by importance) keeps the
// high-importance clusters and drops peripheral ones, instead of the
// downstream source-order trim slicing off whatever comes last in the file.
// That source-order slice is what cut Django's `_fetch_all` (L2237, importance
// 9 — agent-named) when query.py was the last of four big files to be emitted.
const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
const chosenIndices = new Set<number>();
let projectedChars = 0;
for (const rc of rankedClusters) {
@@ -2541,7 +2611,7 @@ export class ToolHandler {
projectedChars += sectionLen;
continue;
}
if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
if (projectedChars + sectionLen > fileBudget) continue;
chosenIndices.add(rc.idx);
projectedChars += sectionLen;
}