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:
co-authored by
Claude Opus 4.8
parent
f58de8a391
commit
f1b14f021b
+135
-7
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user