feat(mcp): adaptive codegraph_explore sizing — skeletonize redundant polymorphic siblings (#564)

codegraph_explore now skeletonizes off-spine, redundant members of a polymorphic
family (OkHttp's interceptor chain, Django's SQLCompiler family) to signatures
instead of shipping every full body, while keeping the dispatch mechanism, the
orchestrator/base, and any method the agent named in full. Sizes the response to
the answer rather than the budget cap, so interface-heavy flows stop costing more
than plain grep/read. Default on; CODEGRAPH_ADAPTIVE_EXPLORE=0 disables.

Gate: off-spine + >=3-impl sibling + not-spared, where spared = the agent named a
callable in the file UNLESS the file defines the family's supertype (a huge
base+subclasses file is Read-anyway, so skeletonizing frees explore budget).

Validated headless A/B (Opus 4.8): both former README cost outliers flipped —
OkHttp and Django went from costlier-than-native to cheaper; full 7-repo average
22%% cheaper / 47%% fewer tokens / 20%% faster / 50%% fewer tool calls, every repo
cost-positive, inert repos unchanged. 7-case regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-29 14:42:00 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f58de8a391
commit f1b14f021b
5 changed files with 819 additions and 59 deletions
+135 -7
View File
@@ -239,6 +239,22 @@ function exploreLineNumbersEnabled(): boolean {
return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
}
/**
* Adaptive explore sizing (default ON). `codegraph_explore` skeletonizes OFF-SPINE
* polymorphic-sibling files — a file whose class is one of ≥3 interchangeable
* implementations of a shared interface (e.g. OkHttp's `: Interceptor` classes) —
* to class + member signatures (bodies elided), keeping the on-spine exemplar full.
* This sizes the response to the answer instead of the budget cap on sibling-heavy
* flows (OkHttp interceptor-chain explore 28.5k→16.6k, ~28% cheaper than native
* search, reads flat). It is PROVABLY INERT elsewhere: distinct pipeline steps (no
* ≥3-implementer supertype, e.g. Excalidraw's `renderStaticScene`) and on-spine
* files keep full source — output is byte-identical to shipped on excalidraw /
* tokio / django / vscode / gin. Set `CODEGRAPH_ADAPTIVE_EXPLORE=0` to disable.
*/
function adaptiveExploreEnabled(): boolean {
return process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== 'false';
}
/**
* Prefix each line of a source slice with its 1-based line number, matching
* the Read tool's `cat -n` convention (number + tab) so the agent treats it
@@ -1908,7 +1924,8 @@ export class ToolHandler {
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
* dropping unrelated `OmsOrderService::list`.
*/
private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
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>() };
try {
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
@@ -1921,7 +1938,7 @@ export class ToolHandler {
.map((t) => t.replace(FILE_EXT, '').trim())
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
)].slice(0, 16);
if (tokens.length < 2) return '';
if (tokens.length < 2) return EMPTY;
// Pool of name SEGMENTS (Class + method from every token) used to
// disambiguate an ambiguous SIMPLE name: keep a candidate only if its
// CONTAINER class is itself named in the query.
@@ -1942,7 +1959,7 @@ export class ToolHandler {
for (const n of pick.slice(0, 6)) named.set(n.id, n);
if (named.size > 40) break;
}
if (named.size < 2) return '';
if (named.size < 2) return EMPTY;
const MAX_HOPS = 7;
let best: Array<{ node: Node; edge: Edge | null }> | null = null;
// BFS the full call graph (incl. synth edges) from each named seed, but
@@ -1974,7 +1991,7 @@ export class ToolHandler {
chain.reverse();
if (!best || chain.length > best.length) best = chain;
}
if (!best || best.length < 3) return '';
if (!best || best.length < 3) return EMPTY;
const out = ['## Flow (call path among the symbols you queried)', ''];
for (let i = 0; i < best.length; i++) {
const step = best[i]!;
@@ -1982,9 +1999,14 @@ export class ToolHandler {
out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
}
out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
return out.join('\n');
// namedNodeIds = every callable the agent explicitly named (a superset of
// the spine). A file holding one is something the agent asked to SEE, so it
// 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()) };
} catch {
return '';
return EMPTY;
}
}
@@ -2217,6 +2239,63 @@ export class ToolHandler {
}
// Step 4: Read contiguous file sections
// Compute the flow spine once — used both to prepend the Flow section (below)
// and to gate adaptive source sizing: files on the spine get full source,
// off-spine peers skeletonize.
const flow = this.buildFlowFromNamedSymbols(cg, query);
// Polymorphic-sibling detector for adaptive sizing. A class that implements/
// extends a supertype shared by >= MIN_SIBLINGS classes is one of many
// INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
// showing one + the rest as signatures is enough), as opposed to a DISTINCT
// pipeline step (Excalidraw's `renderStaticScene`, which shares no supertype and
// must stay full or the agent loses real content). Only off-spine sibling files
// skeletonize; distinct steps and on-spine files keep full source. Cache
// supertype→(has ≥N implementers) so this stays a handful of edge queries.
const MIN_SIBLINGS = 3;
const siblingSuper = new Map<string, boolean>();
const isPolymorphicSibling = (nodes: Node[]): boolean => {
for (const n of nodes) {
for (const e of cg.getOutgoingEdges(n.id)) {
if (e.kind !== 'implements' && e.kind !== 'extends') continue;
let many = siblingSuper.get(e.target);
if (many === undefined) {
many = cg.getIncomingEdges(e.target)
.filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
siblingSuper.set(e.target, many);
}
if (many) return true;
}
}
return false;
};
// A file that DEFINES a polymorphic supertype (a class/interface with ≥
// MIN_SIBLINGS implementers) AND co-locates its subclasses is a redundant
// "family" file — Django's compiler.py holds `SQLCompiler` + its 4 subclasses
// (SQLInsert/Update/Delete/AggregateCompiler) in 2,266 lines. Such files are
// huge and read-anyway, so they should STILL skeletonize even when the agent
// named a method in them: a full one eats ~6.5K of the explore budget (Django
// is pinned at the 28K cap, truncating), starving the sibling files the agent
// then Reads. This flag OVERRIDES the named-callable spare below — it does NOT
// by itself spare a file. (OkHttp's RealCall implements the `Lockable` mixin
// but defines no ≥3-impl supertype, so the named spare keeps it full.)
const superMany = new Map<string, boolean>();
const definesPolymorphicSupertype = (nodes: Node[]): boolean => {
for (const n of nodes) {
if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct'
&& n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias') continue;
let many = superMany.get(n.id);
if (many === undefined) {
many = cg.getIncomingEdges(n.id)
.filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
superMany.set(n.id, many);
}
if (many) return true;
}
return false;
};
lines.push('### Source Code');
lines.push('');
lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
@@ -2243,6 +2322,55 @@ 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:
// 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);
if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
&& !group.nodes.some(n => flow.pathNodeIds.has(n.id))
&& isPolymorphicSibling(group.nodes)
&& !spared) {
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);
}
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'), '```', '');
totalChars += skel.join('\n').length + 120;
filesIncluded++;
continue;
}
}
// Whole-small-file rule: if a relevant file is small enough to afford,
// return it ENTIRELY instead of clustering. Clustering exists to tame
// god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
@@ -2542,7 +2670,7 @@ export class ToolHandler {
// maxOutputChars (observed 30k against a 28k tier cap). A fat explore
// payload persists in the agent's context and is re-read as cache-input
// on every subsequent turn, so the overrun is paid many times over.
const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
const output = flow.text + lines.join('\n');
if (output.length > budget.maxOutputChars) {
const cut = output.slice(0, budget.maxOutputChars);
const lastNewline = cut.lastIndexOf('\n');