Merge pull request #1527 from colbymchenry/bugfix/CG-38

CG-38: guarantee an agent-named symbol renders, wherever it sits
This commit is contained in:
Colby Mchenry
2026-08-07 13:19:25 -05:00
committed by GitHub
13 changed files with 4778 additions and 43 deletions
+138 -31
View File
@@ -2542,6 +2542,14 @@ export class ToolHandler {
// fed only to the dynamic-dispatch-links scan below.
const dynNamed = new Map<string, Node>();
const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
// Nodes resolved from a SHAPE-PRECISE token (camelCase / PascalCase /
// snake_case / qualified) — the same test the gather path uses. It is the
// difference between "the agent named this symbol" and "an ordinary English
// word in a prose question collided with a callable", and it is what makes
// the narrative-less return below safe (see `identityOnly`).
const isPreciseToken = (x: string) =>
/[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
const preciseNamedIds = new Set<string>();
const hasHeuristicEdge = (id: string): boolean =>
[...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
for (const t of tokens) {
@@ -2560,9 +2568,11 @@ export class ToolHandler {
});
const kept = pick.slice(0, 6);
tokenNodes.set(t, kept.map((n) => n.id));
const precise = isPreciseToken(t);
for (const n of kept) {
named.set(n.id, n);
if (specific) uniqueNamedNodeIds.add(n.id);
if (precise) preciseNamedIds.add(n.id);
}
// Same token, non-callable synth endpoints (capped, precision-gated on an
// actual heuristic edge so plain config constants never qualify).
@@ -2575,6 +2585,7 @@ export class ToolHandler {
if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
if (hasHeuristicEdge(n.id)) {
dynNamed.set(n.id, n);
if (precise) preciseNamedIds.add(n.id);
tokenDyn++;
}
if (dynNamed.size >= 12 || tokenDyn >= 4) break;
@@ -2606,6 +2617,35 @@ export class ToolHandler {
}
return synthLines;
};
/**
* No narrative to print but the agent still NAMED symbols, and their
* identity is a separate output from the prose (CG-38).
*
* `namedNodeIds` is not decoration: downstream it injects the named def into
* the file's cluster ranges and ranks it importance 9, which is the whole
* mechanism behind "a symbol the agent named renders" (the assembler's
* named-def injection). Returning EMPTY here threw that away whenever the
* named symbols happened not to form a call chain two sibling closures in
* one factory (`queueMessage` / `flushQueuedMessages`, neither calling the
* other) produce no chain, no synth hop and no dispatch boundary, so BOTH
* defs lost importance 9 and the file rendered from its head instead: the
* agent got the `QueuedMessage` interface at L70 and had to Read the file
* for the functions at L1087/L1102 it had asked for by name.
*
* Restricted to SHAPE-PRECISE tokens. With a narrative present the prose is
* itself corroboration that the resolution was right, so that path keeps
* every named id as before; with nothing corroborating it, only an
* unambiguous symbol reference may promote an English word in a prose
* question that happens to exact-match a callable must not earn importance 9.
* Same distinction, same test, as the gather path's `isPreciseToken`.
*/
const identityOnly = () => (preciseNamedIds.size === 0 ? EMPTY : {
text: '',
pathNodeIds: new Set<string>(),
namedNodeIds: new Set<string>(preciseNamedIds),
uniqueNamedNodeIds: new Set<string>([...uniqueNamedNodeIds].filter((id) => preciseNamedIds.has(id))),
spineCallSites: new Map<string, number>(),
});
if (named.size < 2) {
// <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
// edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
@@ -2614,7 +2654,7 @@ export class ToolHandler {
// dynamic-dispatch site that EXPLAINS a half-connected flow.
const synthLines = collectSynthLinks(null);
const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
if (synthLines.length === 0 && !boundaries) return EMPTY;
if (synthLines.length === 0 && !boundaries) return identityOnly();
const out: string[] = [];
if (synthLines.length) out.push(
'**Dynamic-dispatch links among your symbols**',
@@ -2729,7 +2769,7 @@ export class ToolHandler {
hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null
);
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return identityOnly();
const out: string[] = [];
if (hasMain) {
out.push('**Flow (call path among the symbols you queried)**', '');
@@ -4996,6 +5036,19 @@ export class ToolHandler {
* keeps every rule that matters: only whole symbol ranges are emitted, so a
* body is never cut, and the members are chosen by the same importance the
* cluster ranking uses. Returns null when nothing needed shrinking.
*
* `sizeOf` measures the RAW source span, while the render adds
* `contextPadding` around every block and a line-number prefix to every
* line so this over-keeps (measured ~60% under on a 1,414-line file:
* 16.5K accounted, 26.3K rendered). That is deliberate, not an oversight:
* `bound()` clamps the result to the ceiling exactly, so the slack costs no
* bytes, and making the estimate exact instead measured WORSE it stops at
* the last member that fits whole, and the released bytes carry forward to
* lower-ranked files (payroll-go's `runPayrollCycleAll` body lost its
* `s.store.Upsert` call to a rank-5 file). What the slack must NOT do is
* decide WHICH members survive: that is the ceiling trim's job, and CG-38 is
* why that trim now protects the named spans instead of cutting in source
* order. See `docs/benchmarks/explore-tail-render-cg38.md`.
*/
const shrinkCluster = (c: ExploreCluster, cap: number): SectionPart[] | null => {
if (c.members.length < 2) return null;
@@ -5088,45 +5141,81 @@ export class ToolHandler {
* it or re-send it. Below that floor the part is simply dropped unless
* nothing has been emitted at all, where the floor wins over the ceiling
* because an empty section is the one outcome worse than an oversize one.
*
* `focusLines` are the lines this trim must not lose: the spine's next-hop
* call site (CG-30) and every definition the agent NAMED inside the cluster
* (CG-38). The head fill is source-ordered, so a named def in the TAIL of a
* large file is otherwise always the first thing an over-ceiling render
* drops the one span the agent asked for by name, cut in favour of
* head-of-file filler it did not ask for. The full-ceiling fill is tried
* FIRST and the 60% hold-back applies only when a focus line is actually
* left uncovered, so a cluster whose head already reaches its focus keeps
* the whole ceiling for source.
*/
const windowToCeiling = (
parts: ReadonlyArray<SectionPart>,
ceiling: number,
focusLine?: number,
focusLines: ReadonlyArray<number> = [],
): SectionPart[] => {
const emit: ExploreLineRange[] = [];
const inParts = (line: number) =>
parts.some((p) => line >= p.range.start && line <= p.range.end);
const needFocus = typeof focusLine === 'number' && focusLine > 0 && inParts(focusLine);
// Hold room back for the call site so the head window can't eat all of it.
const headRoom = needFocus ? Math.floor(ceiling * 0.6) : ceiling;
let used = 0;
for (const p of parts) {
const join = emit.length > 0 ? GAP_MARKER.length : 0;
if (used + join + p.text.length <= headRoom) {
emit.push(p.range);
used += join + p.text.length;
continue;
const focus = [...new Set(focusLines)]
.filter((l) => typeof l === 'number' && l > 0 && inParts(l))
.sort((a, b) => a - b);
/** Source-ordered fill of whole parts, the overrunning one cut to a head window. */
const fill = (room: number): { emit: ExploreLineRange[]; used: number } => {
const emit: ExploreLineRange[] = [];
let used = 0;
for (const p of parts) {
const join = emit.length > 0 ? GAP_MARKER.length : 0;
if (used + join + p.text.length <= room) {
emit.push(p.range);
used += join + p.text.length;
continue;
}
const first = emit.length === 0;
const win = headWindowOf(
p.range, Math.max(0, room - used - join), first ? MIN_WINDOW_LINES : 0);
if (win && (first || win.end - win.start + 1 >= MIN_WINDOW_LINES)) {
emit.push(win);
used += join + renderSpan(win).length;
}
break;
}
const first = emit.length === 0;
const win = headWindowOf(
p.range, Math.max(0, headRoom - used - join), first ? MIN_WINDOW_LINES : 0);
if (win && (first || win.end - win.start + 1 >= MIN_WINDOW_LINES)) {
emit.push(win);
used += join + renderSpan(win).length;
}
break;
return { emit, used };
};
let { emit, used } = fill(ceiling);
const reached = () => (emit.length ? emit[emit.length - 1]!.end : 0);
if (focus.some((l) => l > reached())) {
// Hold room back for the focus windows so the head can't eat all of it.
({ emit, used } = fill(Math.floor(ceiling * 0.6)));
}
const last = emit[emit.length - 1];
if (needFocus && (!last || focusLine! > last.end)) {
const host = parts.find((p) => focusLine! >= p.range.start && focusLine! <= p.range.end)!;
const lo = Math.max(host.range.start, focusLine! - SPINE_WINDOW, last ? last.end + 1 : 0);
const hi = Math.min(host.range.end, focusLine! + SPINE_WINDOW);
const win = centeredWindowOf(
focusLine!, lo, hi, Math.max(0, ceiling - used - GAP_MARKER.length));
// What is left is SPLIT between the uncovered focus lines rather than
// handed to them in order. Greedy-in-source-order reproduces the very bug
// this guards: on a prose query resolving four focus lines, the two
// earliest took the whole reserve and `flushQueuedMessages` at L1102 —
// named in the question — was dropped again. A skipped or undersized
// window returns its share to the pool for the ones after it.
let covered = reached();
let room = Math.max(0, ceiling - used);
const pending = focus.filter((l) => l > covered);
for (let i = 0; i < pending.length; i++) {
const line = pending[i]!;
if (line <= covered) continue; // an earlier window already reached it
const share = Math.floor(room / (pending.length - i)) - GAP_MARKER.length;
if (share <= 0) continue;
const host = parts.find((p) => line >= p.range.start && line <= p.range.end)!;
const lo = Math.max(host.range.start, line - SPINE_WINDOW, covered + 1);
const hi = Math.min(host.range.end, line + SPINE_WINDOW);
const win = centeredWindowOf(line, lo, hi, share);
// Same sliver floor as the head window — a two-line peek at the call
// site teaches the next call's dedup to shred the block around it.
if (win && win.end - win.start + 1 >= MIN_WINDOW_LINES) emit.push(win);
if (!win || win.end - win.start + 1 < MIN_WINDOW_LINES) continue;
emit.push(win);
const cost = GAP_MARKER.length + renderSpan(win).length;
used += cost;
room -= cost;
covered = win.end;
}
// Never empty: a section with no source sends the agent to Read.
if (emit.length === 0 && parts.length > 0) {
@@ -5138,6 +5227,24 @@ export class ToolHandler {
.map((r) => ({ range: r, text: renderSpan(r) }));
};
/**
* The lines a ceiling trim of this cluster must not lose: the spine's
* next-hop call site, and the definition line of every member the agent
* NAMED or that is a query entry point (importance >= 9). Capped, because
* each one costs a window and too many turn a section into confetti; the
* most important come first, source order within a tier so the windows read
* top-down.
*/
const MAX_FOCUS_LINES = 6;
const focusLinesOf = (c: ExploreCluster): number[] => {
const named = c.members
.filter((m) => m.importance >= 9)
.sort((a, b) => b.importance - a.importance || a.start - b.start)
.slice(0, MAX_FOCUS_LINES)
.map((m) => m.start);
return c.spineCallLine ? [c.spineCallLine, ...named] : named;
};
/**
* One cluster's final parts: built, shrunk if it overruns `cap`, then
* passed through the session history (CG-18).
@@ -5165,7 +5272,7 @@ export class ToolHandler {
if (!Number.isFinite(ceiling) || sectionText(r.parts).length <= ceiling) return r;
// Windows are subsets of spans dedupeSpans already cleared, so the record
// still only ever claims source that was actually sent.
const parts = windowToCeiling(r.parts, ceiling, c.spineCallLine);
const parts = windowToCeiling(r.parts, ceiling, focusLinesOf(c));
return { parts, covered: r.covered, shrunk: true };
};
if (sectionText(base.parts).length <= cap) {