2 Achegas 4f8782cbe5 ... e5897d0334

Autor SHA1 Mensaxe Data
  Colby McHenry e5897d0334 feat: remove reasoning offload / CodeGraph AI managed reasoning feature hai 2 días
  Colby McHenry e7d9f8c6fa feat(explore): surface interface/registry dispatch boundaries and window oversize spine methods hai 2 días
Modificáronse 6 ficheiros con 304 adicións e 177 borrados
  1. 0 1
      CHANGELOG.md
  2. 0 38
      README.md
  3. 108 1
      __tests__/dynamic-boundaries.test.ts
  4. 3 3
      __tests__/mcp-tool-allowlist.test.ts
  5. 0 105
      src/bin/codegraph.ts
  6. 193 29
      src/mcp/tools.ts

+ 0 - 1
CHANGELOG.md

@@ -12,7 +12,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ### New Features
 
 - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
-- Optional **reasoning offload** for `codegraph_explore` (off by default). Point CodeGraph at any OpenAI-compatible reasoning model you bring — Cerebras, OpenAI, a local vLLM or Ollama — and `codegraph_explore` hands the source it retrieved to that model and returns a tight, cited answer instead of a wall of source, so your agent's main context gets the answer in far fewer tokens. Turn it on with `codegraph offload set-endpoint <url> --model <model> --key-env <ENV>` (or the `CODEGRAPH_OFFLOAD_*` env vars), and `codegraph offload status` / `codegraph offload disable` manage it. Your API key is never written to disk (the config stores the *name* of the env var to read it from), nothing but the retrieved context and your question leaves your machine, and it silently falls back to normal local output on any error so it can never break a call.
 - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
 - C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.
 - Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`.

+ 0 - 38
README.md

@@ -598,44 +598,6 @@ add a negation — `!vendor/`. The defaults apply uniformly, so committing a
 dependency or build directory doesn't force it into the graph; the `.gitignore`
 negation is the explicit opt-in.
 
-## Reasoning offload (bring your own model)
-
-**Optional, off by default.** Normally `codegraph_explore` returns the verbatim
-source it retrieved and your agent reasons over it. With reasoning offload, that
-source is instead handed to a reasoning model **you** point at, which returns a
-tight, cited answer — so your agent's main context gets the answer, not a wall of
-source. You trade one network round-trip for far fewer main-context tokens.
-
-Point it at **any** OpenAI-compatible endpoint with your own key — Cerebras,
-OpenAI, a local vLLM or Ollama, anything. Nothing but the assembled context + your
-question leaves your machine, and your API key is **never written to disk** (the
-config stores the *name* of an env var; the key is read from it at call time).
-
-```bash
-# Enable — URL ends in /v1; the key is read from the named env var at call time
-codegraph offload set-endpoint https://api.cerebras.ai/v1 \
-    --model gpt-oss-120b --key-env CEREBRAS_API_KEY
-
-codegraph offload status     # show the current endpoint / model / key source
-codegraph offload disable    # turn it back off
-```
-
-Restart your editor/agent session afterward so running MCP servers pick it up.
-Everything is also settable by env (these override the saved config — handy for
-CI): `CODEGRAPH_OFFLOAD_URL`, `_MODEL`, `_KEY`, `_EFFORT` (`low`|`medium`|`high`),
-`_STYLE` (`plain`|`report`).
-
-A few things worth knowing:
-
-- **Quality tracks the model you choose.** The synthesis prompt is correctness-first
-  (it leads with a `Coverage: full / partial / not found` verdict and cites
-  `file:line` for every claim, so answers stay verifiable), but a weak endpoint can
-  still be confidently wrong. It's designed and validated against `gpt-oss-120b`-class
-  models at low temperature.
-- **It's strictly degradable.** Any failure — no endpoint, network error, timeout,
-  empty answer — silently falls back to returning the local source. The offload can
-  never break a call.
-
 ## Telemetry
 
 CodeGraph collects **anonymous usage statistics** — which tools and commands get

+ 108 - 1
__tests__/dynamic-boundaries.test.ts

@@ -8,7 +8,7 @@
  * showing nothing. Deterministic, query-time only, no graph mutation, and a
  * fully connected flow must never produce the section.
  */
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
 import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
@@ -16,6 +16,19 @@ import CodeGraph from '../src/index';
 import { ToolHandler } from '../src/mcp/tools';
 import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
 
+// These suites assert on the RAW codegraph_explore output (the Flow / boundary
+// sections). The managed reasoning-offload, when configured on the dev machine
+// (~/.codegraph/config.json `{"offload":{"managed":true}}`), REPLACES that output
+// with a remote Cerebras synthesis — so the structural assertions only hold with
+// the offload off. Disable it for this file so the suite is hermetic regardless
+// of machine config, then restore.
+let _prevOffloadDisable: string | undefined;
+beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
+afterAll(() => {
+  if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
+  else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
+});
+
 // ---------------------------------------------------------------------------
 // Unit: the scanner
 // ---------------------------------------------------------------------------
@@ -297,3 +310,97 @@ describe('codegraph_explore — dynamic boundaries', () => {
     expect(text).toContain('handle_save');
   });
 });
+
+// ---------------------------------------------------------------------------
+// Integration: interface/registry dispatch (a named method has many impls)
+// ---------------------------------------------------------------------------
+
+describe('codegraph_explore — interface dispatch', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  const setup = async (files: Record<string, string>, include: string[]) => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-'));
+    const src = path.join(testDir, 'src');
+    fs.mkdirSync(src, { recursive: true });
+    for (const [name, content] of Object.entries(files)) {
+      fs.writeFileSync(path.join(src, name), content);
+    }
+    cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
+    await cg.indexAll();
+    handler = new ToolHandler(cg);
+  };
+
+  afterEach(() => {
+    if (cg) cg.destroy();
+    if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  // 9 classes implement INodeType, each with execute(); a runtime registry lookup
+  // dispatches to one. The agent names the static entry + `execute`, which can't
+  // resolve to a single impl — the boundary IS the answer.
+  const nodeFamily = (n: number) => {
+    const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter'];
+    return [
+      'export interface INodeType { execute(): unknown; }',
+      ...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`),
+    ].join('\n');
+  };
+  const engine = [
+    "import { registry } from './registry';",
+    'export class WorkflowExecute {',
+    '  processRunExecutionData() { return this.runNode(); }',
+    '  runNode() { return this.executeNode(); }',
+    '  executeNode() {',
+    "    const nodeType = registry.get('http');",
+    '    return nodeType.execute();',
+    '  }',
+    '}',
+  ].join('\n');
+  const registry = [
+    "import type { INodeType } from './nodes';",
+    'class Registry {',
+    '  private m: Record<string, INodeType> = {};',
+    '  get(k: string): INodeType { return this.m[k]!; }',
+    '}',
+    'export const registry = new Registry();',
+  ].join('\n');
+
+  it('announces the interface, the TRUE implementer count, and sample targets', async () => {
+    await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
+    const text = res.content[0].text as string;
+
+    expect(text).toContain('## Interface dispatch (a named method has many implementations)');
+    expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
+    // a couple of concrete targets, with file:line
+    expect(text).toMatch(/\b\w+Node\.execute` \(/);
+    // never steer to Read
+    expect(text).not.toMatch(/\buse Read\b/i);
+  });
+
+  it('stays SILENT on a fully connected flow with no polymorphic family', async () => {
+    await setup({
+      'pipeline.ts': [
+        'export function stepOne() { return stepTwo(); }',
+        'export function stepTwo() { return stepThree(); }',
+        'export function stepThree() { return 3; }',
+      ].join('\n'),
+    }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
+    const text = res.content[0].text as string;
+    expect(text).toContain('## Flow');
+    expect(text).not.toContain('## Interface dispatch');
+  });
+
+  it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
+    await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
+    const text = res.content[0].text as string;
+    expect(text).not.toContain('## Interface dispatch');
+  });
+});

+ 3 - 3
__tests__/mcp-tool-allowlist.test.ts

@@ -20,9 +20,9 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
   it('exposes ONLY codegraph_explore by default when unset', () => {
     delete process.env[ENV];
     // The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one
-    // tool that earns its place (verbatim source grouped by file, plus the reasoned
-    // flow map under the offload). node/search/callers/callees/impact/files/status
-    // stay defined and executable but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
+    // tool that earns its place (verbatim source grouped by file).
+    // node/search/callers/callees/impact/files/status stay defined and executable
+    // but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
     expect(listed()).toEqual(['codegraph_explore']);
   });
 

+ 0 - 105
src/bin/codegraph.ts

@@ -36,10 +36,6 @@ import { installFatalHandlers } from './fatal-handler';
 import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
 import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
-import { writeOffloadConfig, resolveOffload } from '../reasoning/config';
-import { writeOffloadToken } from '../reasoning/credentials';
-import { startDeviceLogin, pollForToken, openBrowser } from '../reasoning/login';
-import { fetchUsage } from '../reasoning/reasoner';
 
 // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
 async function loadCodeGraph(): Promise<typeof import('../index')> {
@@ -1352,107 +1348,6 @@ program
     });
   });
 
-/**
- * codegraph login / logout — managed reasoning (CodeGraph AI).
- *
- * `login` runs a browser device-authorization flow against the CodeGraph dashboard,
- * mints the account's metered org token, and stores it (managed offload on). When
- * signed in, codegraph_explore reasons over its assembled source via the managed
- * gateway instead of returning the raw source dump. `logout` clears it.
- *
- * Bring-your-own endpoint is configured via the CODEGRAPH_OFFLOAD_URL /
- * CODEGRAPH_OFFLOAD_KEY / CODEGRAPH_OFFLOAD_MODEL env vars (see ../reasoning/config).
- */
-program
-  .command('login')
-  .description('Sign in to CodeGraph AI for managed reasoning — opens your browser to authorize')
-  .option('--no-browser', "Don't auto-open the browser; just print the URL to visit")
-  .action(async (opts: { browser?: boolean }) => {
-    try {
-      const start = await startDeviceLogin();
-      const url = start.verification_uri_complete ?? start.verification_uri;
-      info('To authorize, open this URL in your browser:');
-      info(`  ${url}`);
-      info(`and confirm the code:  ${start.user_code}`);
-      if (opts.browser !== false) await openBrowser(url);
-      info('Waiting for authorization…  (Ctrl-C to cancel)');
-      const token = await pollForToken(start.device_code, start.interval ?? 5, start.expires_in ?? 600);
-      writeOffloadConfig({ managed: true });
-      writeOffloadToken(token);
-      success('Signed in to CodeGraph AI — managed reasoning is on.');
-      try {
-        const usage = await fetchUsage();
-        if (usage) {
-          // Mirror `codegraph usage`'s precedence: a comped/internal account is
-          // flagged `unlimited` (often with remaining:0 when no allowance is set),
-          // so check that before the numeric balance or it reads "0 remaining".
-          if (usage.banned) warn('  Account suspended — contact support.');
-          else if (usage.unlimited) info('  credits: unlimited');
-          else if (typeof usage.remaining === 'number')
-            info(`  credits: ${usage.remaining.toLocaleString()} remaining`);
-        }
-      } catch {
-        /* balance is best-effort */
-      }
-      info('  Restart your editor/agent session for running MCP servers to pick it up.');
-    } catch (err) {
-      error(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
-    }
-  });
-
-program
-  .command('logout')
-  .description('Sign out of CodeGraph AI (clears the saved token and turns off managed reasoning)')
-  .action(() => {
-    writeOffloadToken(null);
-    writeOffloadConfig(null);
-    success('Signed out of CodeGraph AI.');
-  });
-
-/**
- * codegraph usage — show the CodeGraph AI balance + recent usage (the server is the source of
- * truth; this just pings /v1/usage with the stored token). Degrades quietly when signed out or
- * unreachable — managed reasoning is optional, so this command never errors hard.
- */
-program
-  .command('usage')
-  .description('Show your CodeGraph AI balance and recent usage')
-  .action(async () => {
-    const cfg = resolveOffload();
-
-    if (!cfg.apiKey) {
-      if (cfg.url && cfg.managed) {
-        info('Signed out of CodeGraph AI. Run `codegraph login` to sign in.');
-      } else {
-        info('Not signed in to CodeGraph AI — codegraph_explore runs locally.');
-        info('Run `codegraph login` to use managed reasoning with your credits.');
-      }
-      return;
-    }
-
-    const usage = await fetchUsage();
-    if (!usage) {
-      if (cfg.managed) warn('Could not reach CodeGraph AI to read your balance — try again in a moment.');
-      else info(`Reasoning offload: your own endpoint (${cfg.url}) — no CodeGraph AI balance to show.`);
-      info('  (codegraph_explore still works locally regardless.)');
-      return;
-    }
-
-    success('CodeGraph AI');
-    if (usage.banned) warn('  Account suspended — contact support.');
-    else if (usage.unlimited) info('  Balance:  unlimited');
-    else if (typeof usage.remaining === 'number')
-      info(`  Balance:  ${usage.remaining.toLocaleString()} credits  ($${(usage.remaining / 100_000).toFixed(2)})`);
-    if (usage.plan) info(`  Plan:     ${usage.plan === 'payg' ? 'pay-as-you-go' : usage.plan}`);
-    if (typeof usage.tokensLast30 === 'number' || typeof usage.callsLast30 === 'number')
-      info(`  30 days:  ${(usage.callsLast30 ?? 0).toLocaleString()} explores · ${(usage.tokensLast30 ?? 0).toLocaleString()} tokens`);
-    // Only a subscription allowance resets each period; pay-as-you-go credits don't expire, so there's
-    // nothing to renew. Show a reset date only when there's an actual recurring allowance.
-    if (usage.periodEnd && (usage.allowance ?? 0) > 0)
-      info(`  Allowance resets: ${new Date(usage.periodEnd).toISOString().slice(0, 10)}`);
-  });
-
 /**
  * codegraph serve
  */

+ 193 - 29
src/mcp/tools.ts

@@ -29,7 +29,6 @@ import {
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
 import { isGeneratedFile } from '../extraction/generated-detection';
 import { scanDynamicDispatch } from './dynamic-boundaries';
-import { isOffloadEnabled, synthesizeOffload } from '../reasoning/reasoner';
 
 /**
  * An expected, recoverable "codegraph can't serve this" condition — most
@@ -635,10 +634,9 @@ export function getStaticTools(): ToolDefinition[] {
 /**
  * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
  * — the single tool that reliably earns its place: one capped call returns the
- * verbatim source of the relevant symbols grouped by file (and, with the offload,
- * a reasoned flow map over that source). Every other tool is a narrower slice of
- * what explore already does, and presence itself steers mis-picks, so they are no
- * longer LISTED to agents.
+ * verbatim source of the relevant symbols grouped by file. Every other tool is a
+ * narrower slice of what explore already does, and presence itself steers
+ * mis-picks, so they are no longer LISTED to agents.
  *
  * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
  * status) remain fully functional — handlers stay, the library API and CLI are
@@ -1547,8 +1545,11 @@ 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>; uniqueNamedNodeIds: Set<string> } {
-    const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>() };
+  private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string>; spineCallSites: Map<string, number> } {
+    // spineCallSites: for each spine node, the line where it CALLS the next hop —
+    // lets the source assembler window an oversize spine method (e.g. n8n's 962-line
+    // processRunExecutionData) to the call site instead of dumping the whole body.
+    const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
     try {
       const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
       // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
@@ -1578,8 +1579,13 @@ export class ToolHandler {
       // the dynamic-boundary scan (a token is covered when ANY of its nodes
       // lands on the main chain — overloads off the chain don't count against).
       const tokenNodes = new Map<string, string[]>();
+      // token → its full same-name callable family (before the container filter).
+      // A LARGE family that fails to connect on the chain is a polymorphic
+      // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
+      const tokenFamily = new Map<string, Node[]>();
       for (const t of tokens) {
         const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
+        tokenFamily.set(t, cands);
         // A qualified or otherwise-specific name (<=3 hits) keeps all; an
         // ambiguous simple name keeps only candidates whose container is named.
         const specific = cands.length <= 3;
@@ -1607,7 +1613,7 @@ export class ToolHandler {
         const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
         if (!boundaries) return EMPTY;
         const text = boundaries + '> Full source for these symbols is below.\n';
-        return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
+        return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
       }
       const MAX_HOPS = 7;
       let best: Array<{ node: Node; edge: Edge | null }> | null = null;
@@ -1642,6 +1648,14 @@ export class ToolHandler {
       }
       const hasMain = !!best && best.length >= 3;
       const pathIds = new Set((best ?? []).map((s) => s.node.id));
+      // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
+      // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
+      // the assembler window an oversize spine method to the call instead of dumping it.
+      const spineCallSites = new Map<string, number>();
+      if (best) for (let i = 0; i < best.length - 1; i++) {
+        const ln = best[i + 1]?.edge?.line;
+        if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
+      }
 
       // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
       // asked about did not fully connect: some token resolved to nodes but
@@ -1673,6 +1687,28 @@ export class ToolHandler {
         }
       }
 
+      // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
+      // polymorphism). A method the agent NAMED that resolves to a large same-name
+      // family AND did not land on the main chain is almost always a runtime
+      // dispatch (plugin/strategy/handler interface): the concrete target is chosen
+      // at runtime from N implementations, so no single static edge is the answer.
+      // The body-scan above can't see this — `nodeType.execute()` is textually an
+      // ordinary call; the polymorphism lives in the graph (implements edges), so
+      // detect it there. Fires ONLY for an uncovered named token; a connected flow
+      // stays silent.
+      let polyText = '';
+      {
+        const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
+        const polyCands: Array<{ token: string; family: Node[] }> = [];
+        for (const [t, fam] of tokenFamily) {
+          if (fam.length < POLY_MIN_FAMILY) continue;
+          const ids = tokenNodes.get(t) || [];
+          if (ids.some((id) => pathIds.has(id))) continue; // covered by the flow — silent
+          polyCands.push({ token: t, family: fam });
+        }
+        if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
+      }
+
       // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
       // symbol — the indirect hops an agent would otherwise grep/Read to
       // reconstruct ("where do the appended `validators` actually run?"). The
@@ -1704,7 +1740,7 @@ export class ToolHandler {
         }
       }
 
-      if (!hasMain && synthLines.length === 0 && !boundaryText) return EMPTY;
+      if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
       const out: string[] = [];
       if (hasMain) {
         out.push('## Flow (call path among the symbols you queried)', '');
@@ -1725,13 +1761,14 @@ export class ToolHandler {
         );
       }
       if (boundaryText) out.push(boundaryText);
+      if (polyText) out.push(polyText);
       out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
       // 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: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
+      return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites };
     } catch {
       return EMPTY;
     }
@@ -1793,6 +1830,93 @@ export class ToolHandler {
     ].join('\n');
   }
 
+  /**
+   * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
+   * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
+   * an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
+   *
+   * A method the agent named that resolves to a large same-name family whose
+   * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
+   * the concrete target is chosen at runtime from N implementations, so no single
+   * static edge is "the answer" — the implementations ARE the continuations. We
+   * announce the supertype, its TRUE implementer count, and a few concrete targets,
+   * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
+   * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
+   *
+   * Robust to FTS sampling bias: the same-name family is a capped FTS sample that
+   * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
+   * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
+   * 611 implementers vs a handful). So candidate supertypes are ranked by their
+   * TRUE graph-wide implementer count, NOT their frequency in the sample.
+   */
+  private buildPolymorphicBoundaries(cg: CodeGraph, candidates: Array<{ token: string; family: Node[] }>, named: Map<string, Node>): string {
+    const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
+    const MIN_IMPL = 8;     // a supertype needs >= this many implementers to count as "polymorphic"
+    const MIN_SUPPORT = 2;  // >= this many sampled definers must share the supertype (ties it to the token)
+    const SAMPLE = 40;      // family members inspected per token
+    const MAX_NOTES = 3;
+    const rel = (p: string) => p.replace(/\\/g, '/');
+    const containerOf = (m: Node): Node | null => {
+      try { const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains'); return ce ? cg.getNode(ce.source) : null; }
+      catch { return null; }
+    };
+    const notes: string[] = [];
+    const seenSuper = new Set<string>();
+    for (const { token, family } of candidates) {
+      if (notes.length >= MAX_NOTES) break;
+      // supertype id → how many sampled definers share it + a few example definers
+      const supers = new Map<string, { node: Node; count: number; targets: Node[] }>();
+      for (const m of family.slice(0, SAMPLE)) {
+        const container = containerOf(m);
+        if (!container || !CLASSY.has(container.kind)) continue;
+        let sups: Node[] = [];
+        try {
+          sups = cg.getOutgoingEdges(container.id)
+            .filter((e) => e.kind === 'implements' || e.kind === 'extends')
+            .map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
+            .filter((n): n is Node => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
+        } catch { /* no supertypes — free function or unresolved */ }
+        for (const s of sups) {
+          const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
+          e.count++;
+          if (e.targets.length < 6) e.targets.push(m);
+          supers.set(s.id, e);
+        }
+      }
+      // Pick the supertype with the most TRUE implementers (graph-wide), among
+      // those genuinely shared by the token's definers.
+      let best: { node: Node; impl: number; targets: Node[] } | null = null;
+      for (const { node, count, targets } of supers.values()) {
+        if (count < MIN_SUPPORT) continue;
+        let impl = 0;
+        try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; }
+        catch { /* leave 0 — gated out below */ }
+        if (impl < MIN_IMPL) continue;
+        if (!best || impl > best.impl) best = { node, impl, targets };
+      }
+      if (!best || seenSuper.has(best.node.id)) continue;
+      seenSuper.add(best.node.id);
+      const namedNames = new Set([...named.values()].map((n) => n.name));
+      const eg = best.targets.slice(0, 4).map((m) => {
+        const cont = containerOf(m);
+        const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
+        const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
+        return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
+      });
+      const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
+      notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
+    }
+    if (notes.length === 0) return '';
+    return [
+      '## Interface dispatch (a named method has many implementations)',
+      '',
+      ...notes,
+      '',
+      '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
+      '',
+    ].join('\n');
+  }
+
   /**
    * Shortlist candidate runtime targets for a dispatch key surfaced by
    * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
@@ -2721,7 +2845,7 @@ export class ToolHandler {
         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()]
+      const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: 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 => {
@@ -2730,7 +2854,12 @@ export class ToolHandler {
           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 };
+          // On the rendered call-path spine? That IS the flow answer — its cluster
+          // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
+          // processRunExecutionData, the named flow ENTRY at L1562, is a large
+          // low-density method that lost the budget to denser blocks and got cut, so
+          // the agent Read it back — the very thing explore exists to prevent).
+          return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
         });
 
       // Add edge source locations in this file — captures template references
@@ -2748,7 +2877,7 @@ export class ToolHandler {
           // Look up target name from subgraph first, fall back to edge kind
           const targetNode = subgraph.nodes.get(edge.target);
           const targetName = targetNode?.name ?? edge.kind;
-          ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
+          ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
         }
       }
 
@@ -2757,13 +2886,15 @@ export class ToolHandler {
       if (ranges.length === 0) continue;
 
       const gapThreshold = budget.gapThreshold;
-      const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
+      const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
       let current = {
         start: ranges[0]!.start,
         end: ranges[0]!.end,
         symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
         score: ranges[0]!.importance,
         maxImportance: ranges[0]!.importance,
+        hasSpine: ranges[0]!.spine,
+        spineCallLine: ranges[0]!.spineCallLine,
       };
 
       for (let i = 1; i < ranges.length; i++) {
@@ -2773,6 +2904,8 @@ export class ToolHandler {
           current.symbols.push(`${r.name}(${r.kind})`);
           current.score += r.importance;
           current.maxImportance = Math.max(current.maxImportance, r.importance);
+          current.hasSpine = current.hasSpine || r.spine;
+          current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
         } else {
           clusters.push(current);
           current = {
@@ -2781,6 +2914,8 @@ export class ToolHandler {
             symbols: [`${r.name}(${r.kind})`],
             score: r.importance,
             maxImportance: r.importance,
+            hasSpine: r.spine,
+            spineCallLine: r.spineCallLine,
           };
         }
       }
@@ -2795,16 +2930,40 @@ export class ToolHandler {
       // get tail-trimmed with a marker.
       const contextPadding = 3;
       const withLineNumbers = exploreLineNumbersEnabled();
-      const buildSection = (c: { start: number; end: number }): string => {
+      // Language-neutral separator (no `//` — not a comment in Python, Ruby,
+      // etc.). With line numbers on, the line-number jump also signals the gap.
+      const GAP_MARKER = '\n\n... (gap) ...\n\n';
+      // An oversize spine method (the call path runs THROUGH a god-method — n8n's
+      // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
+      // plus the signature head, NOT dumped whole. Without this the cluster is too big
+      // for any per-file cap and gets dropped, so the agent Reads the method back —
+      // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
+      // the spine's call still appears in context.
+      const OVERSIZE_SPINE_LINES = 200;
+      const SPINE_WINDOW = 28; // lines each side of the next-hop call site
+      const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => {
+        if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
+          const call = c.spineCallLine;
+          const winStart = Math.max(c.start, call - SPINE_WINDOW);
+          const winEnd = Math.min(c.end, call + SPINE_WINDOW);
+          const parts: string[] = [];
+          // Signature head, only when it sits clearly above the window (else the
+          // window already covers the method opening).
+          const headEnd = Math.min(c.start + 4, winStart - 2);
+          if (headEnd >= c.start) {
+            const head = fileLines.slice(c.start - 1, headEnd).join('\n');
+            parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
+          }
+          const win = fileLines.slice(winStart - 1, winEnd).join('\n');
+          parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
+          return parts.join(GAP_MARKER);
+        }
         const startIdx = Math.max(0, c.start - 1 - contextPadding);
         const endIdx = Math.min(fileLines.length, c.end + contextPadding);
         const slice = fileLines.slice(startIdx, endIdx).join('\n');
         // startIdx is 0-based, so the slice's first line is line startIdx + 1.
         return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
       };
-      // Language-neutral separator (no `//` — not a comment in Python, Ruby,
-      // etc.). With line numbers on, the line-number jump also signals the gap.
-      const GAP_MARKER = '\n\n... (gap) ...\n\n';
 
       // Rank clusters for inclusion under the per-file cap. Entry-point
       // clusters come first: a cluster containing a query entry point
@@ -2819,6 +2978,11 @@ export class ToolHandler {
       const rankedClusters = clusters
         .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
         .sort((a, b) => {
+          // Spine clusters first — the rendered call path IS the flow answer, so it
+          // outranks any denser block of peripheral declarations (a low-density entry
+          // method must not lose the budget to them). Within spine / within non-spine,
+          // the existing importance → density → score → span order holds.
+          if (a.c.hasSpine !== b.c.hasSpine) return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
           if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
           const densityA = a.c.score / a.span;
           const densityB = b.c.score / b.span;
@@ -2834,6 +2998,11 @@ export class ToolHandler {
       // 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));
+      // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
+      // path is the answer), but bounded — at most ~2.5× the per-file cap and never
+      // past what's left of the total output cap — so a pathological long in-file
+      // spine can't run away or starve co-flow files entirely.
+      const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
       const chosenIndices = new Set<number>();
       let projectedChars = 0;
       for (const rc of rankedClusters) {
@@ -2846,7 +3015,12 @@ export class ToolHandler {
           projectedChars += sectionLen;
           continue;
         }
-        if (projectedChars + sectionLen > fileBudget) continue;
+        // A spine cluster (the rendered call path) is the flow answer — include it
+        // past the per-file budget up to the spine ceiling; non-spine clusters obey
+        // the normal per-file budget.
+        const fits = projectedChars + sectionLen <= fileBudget;
+        const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING;
+        if (!fits && !spineFits) continue;
         chosenIndices.add(rc.idx);
         projectedChars += sectionLen;
       }
@@ -2977,16 +3151,6 @@ export class ToolHandler {
     // externalize territory.
     const output = flow.text + lines.join('\n');
 
-    // Reasoning offload (opt-in, bring-your-own endpoint): when configured, hand
-    // the assembled source + the query to a reasoning model and return its
-    // synthesized answer instead of the raw source dump. Reasons over the FULL
-    // assembled context (pre-truncation). Strictly degradable — any failure
-    // returns null and we fall through to returning the local source below.
-    if (isOffloadEnabled()) {
-      const synthesized = await synthesizeOffload({ query, context: output });
-      if (synthesized) return this.textResult(synthesized);
-    }
-
     const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
     if (output.length > hardCeiling) {
       // Cut at a FILE-SECTION boundary (the last `#### ` header before the