Skip to content

Commit f773241

Browse files
committed
feat(agent): add astra-only tool-evasion residual
1 parent 83c2aed commit f773241

5 files changed

Lines changed: 313 additions & 11 deletions

File tree

‎packages/prompt-variance/src/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,16 @@ export {
1010
type PromptVarianceFamily,
1111
type PromptVarianceRow,
1212
} from "./rows.js";
13+
14+
// Astra tool-evasion residual (CL-9027): forensics on the repro trace showed
15+
// evasion, not waste — the gpt-6-astra leaf re-issues the same effective tool
16+
// call with trivial argument deltas (path prefix, default offset/limit,
17+
// padding whitespace), so no exact tool fingerprint repeats 3x and the shared
18+
// threshold guard stays silent. This forbids that specific variation
19+
// (muse-rule precedent, CL-7869); no signature normalization ships in
20+
// first-party code. Named export rather than a family row: it travels the
21+
// ModelFamilyPolicy.promptResidual seam, composed over the gpt narrate note.
22+
export const astraResidual: string =
23+
"Tool discipline (gpt-6-astra worker):\n" +
24+
"- Never re-issue a tool call that repeats a prior call with only trivial argument changes (path prefix, offset, limit, whitespace).\n" +
25+
"- Never re-read a file you have already read this session.";

‎src/agent/model-family-policy.test.ts‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,200 @@ describe("resolveModelFamilyPolicy", () => {
200200
expect(gpt.toolDisciplineRules).toBeUndefined();
201201
expect(gpt.advertisedToolDeny).toEqual([]);
202202
});
203+
204+
describe("astra repro trace (CL-9027)", () => {
205+
// Minimal failing session-trace fixture: 8 consecutive tool-only turns
206+
// from a gpt-6-astra leaf (tool names + args + result sizes per turn).
207+
// Fingerprint and repeat-count semantics mirror
208+
// scripts/tool-fingerprint-forensics.ts (stableJson exact signatures,
209+
// largest exact-repeat count per period 1-6): the shared threshold guard
210+
// fires only on exact repeats, so a loop that varies trivial argument
211+
// details escapes it. That is evasion, not threshold-tolerated waste —
212+
// and the Step-3 residual forbids exactly this variation. The
213+
// near-identical grouping below is test-only forensics; no signature
214+
// normalization ships in first-party code.
215+
interface ReproTurn {
216+
tool: string;
217+
args: Record<string, unknown>;
218+
resultTokens: number;
219+
}
220+
const ASTRA_REPRO_TRACE: readonly ReproTurn[] = [
221+
{
222+
tool: "read",
223+
args: { path: "src/agent/prompts.ts" },
224+
resultTokens: 3200,
225+
},
226+
{
227+
tool: "read",
228+
args: { path: "./src/agent/prompts.ts" },
229+
resultTokens: 3200,
230+
},
231+
{
232+
tool: "read",
233+
args: { path: "src/agent/prompts.ts", offset: 1 },
234+
resultTokens: 3180,
235+
},
236+
{
237+
tool: "grep",
238+
args: { pattern: "narrate", path: "src/agent" },
239+
resultTokens: 420,
240+
},
241+
{
242+
tool: "read",
243+
args: { path: "src/agent/prompts.ts" },
244+
resultTokens: 3200,
245+
},
246+
{
247+
tool: "grep",
248+
args: { pattern: "narrate ", path: "src/agent" },
249+
resultTokens: 420,
250+
},
251+
{
252+
tool: "read",
253+
args: { path: "./src/agent/prompts.ts", limit: 2000 },
254+
resultTokens: 3200,
255+
},
256+
{
257+
tool: "read",
258+
args: { path: "src/agent/prompts.ts", offset: 0 },
259+
resultTokens: 3200,
260+
},
261+
];
262+
263+
function stableJson(value: unknown): string {
264+
if (value === null || typeof value !== "object")
265+
return JSON.stringify(value);
266+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
267+
const obj = value as Record<string, unknown>;
268+
const keys = Object.keys(obj).sort();
269+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(",")}}`;
270+
}
271+
272+
function fingerprint(turn: ReproTurn): string {
273+
return `${turn.tool}:${stableJson(turn.args)}`;
274+
}
275+
276+
function maxExactRepeats(fps: readonly string[]): number {
277+
let best = 1;
278+
for (let period = 1; period <= 6; period++) {
279+
for (let end = 1; end <= fps.length; end++) {
280+
const prefix = fps.slice(0, end);
281+
let i = prefix.length - 1;
282+
let j = i - period;
283+
let matched = 0;
284+
while (j >= 0 && prefix[i] === prefix[j]) {
285+
matched++;
286+
i--;
287+
j--;
288+
}
289+
const reps = Math.floor((matched + period) / period);
290+
if (reps > best) best = reps;
291+
}
292+
}
293+
return best;
294+
}
295+
296+
function evasionKey(turn: ReproTurn): string {
297+
const args = { ...turn.args };
298+
// The trivial deltas this trace varies: a leading ./ prefix, default
299+
// offset/limit values, and padding whitespace.
300+
if (typeof args.path === "string")
301+
args.path = args.path.replace(/^\.\//, "");
302+
if (args.offset === 0 || args.offset === 1) delete args.offset;
303+
if (typeof args.limit === "number") delete args.limit;
304+
if (typeof args.pattern === "string") args.pattern = args.pattern.trim();
305+
return `${turn.tool}:${stableJson(args)}`;
306+
}
307+
308+
function classifyAstraTrace(trace: readonly ReproTurn[]): string {
309+
if (maxExactRepeats(trace.map(fingerprint)) >= 3) return "waste";
310+
const groups = new Map<string, number>();
311+
for (const turn of trace) {
312+
const key = evasionKey(turn);
313+
groups.set(key, (groups.get(key) ?? 0) + 1);
314+
}
315+
return Math.max(...groups.values()) >= 3 ? "evasion" : "waste";
316+
}
317+
318+
test("classifies as evasion: no exact repeat trips the shared guard", () => {
319+
expect(ASTRA_REPRO_TRACE).toHaveLength(8);
320+
expect(maxExactRepeats(ASTRA_REPRO_TRACE.map(fingerprint))).toBeLessThan(
321+
3,
322+
);
323+
expect(classifyAstraTrace(ASTRA_REPRO_TRACE)).toBe("evasion");
324+
});
325+
326+
test("pins the offending pattern and the wasted turn/token delta", () => {
327+
const groups = new Map<string, number>();
328+
for (const turn of ASTRA_REPRO_TRACE) {
329+
const key = evasionKey(turn);
330+
groups.set(key, (groups.get(key) ?? 0) + 1);
331+
}
332+
// Six re-reads of one file plus two re-greps of one pattern, each run
333+
// differing only by a trivial argument delta.
334+
expect(groups.get('read:{"path":"src/agent/prompts.ts"}')).toBe(6);
335+
expect(groups.get('grep:{"path":"src/agent","pattern":"narrate"}')).toBe(
336+
2,
337+
);
338+
const wastedTokens = ASTRA_REPRO_TRACE.reduce(
339+
(sum, turn) => sum + turn.resultTokens,
340+
0,
341+
);
342+
expect(wastedTokens).toBe(20020);
343+
});
344+
});
345+
346+
test("astra resolves to astra with the composed residual; thresholds stay default (CL-9027)", () => {
347+
const base = resolveModelFamilyPolicy({
348+
providerName: "unknown-provider",
349+
model: "unknown-model",
350+
});
351+
const gpt = resolveModelFamilyPolicy({
352+
providerName: "openai",
353+
model: "gpt-5.6",
354+
});
355+
for (const orchestrator of [false, true]) {
356+
const astra = resolveModelFamilyPolicy({
357+
providerName: "codex/default",
358+
model: "gpt-6-astra",
359+
orchestrator,
360+
});
361+
expect(astra.family).toBe("astra");
362+
// Keeps the gpt narrate nudge and adds the evasion-specific rules.
363+
expect(astra.promptResidual).toContain(
364+
"Narrate before tools (GPT worker):",
365+
);
366+
expect(astra.promptResidual).toContain("trivial argument changes");
367+
expect(astra.promptResidual).not.toBe(gpt.promptResidual);
368+
// Evasion earns a forbidding residual, not tighter thresholds.
369+
expect(astra.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt);
370+
expect(astra.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs);
371+
expect(astra.applyGrokFinishBias).toBe(false);
372+
expect(astra.toolDisciplineRules).toBeUndefined();
373+
expect(astra.advertisedToolDeny).toEqual([]);
374+
}
375+
});
376+
377+
test("sol and generic gpt stay gpt with the byte-identical narrate residual", () => {
378+
const generic = resolveModelFamilyPolicy({
379+
providerName: "openai",
380+
model: "gpt-5.6",
381+
});
382+
for (const model of [
383+
"gpt-5.6-sol",
384+
"gpt-5.5",
385+
"gpt-5.6-luna",
386+
"gpt-5.6-terra",
387+
] as const) {
388+
for (const orchestrator of [false, true]) {
389+
const policy = resolveModelFamilyPolicy({
390+
providerName: "codex/default",
391+
model,
392+
orchestrator,
393+
});
394+
expect(policy.family).toBe("gpt");
395+
expect(policy.promptResidual).toBe(generic.promptResidual);
396+
}
397+
}
398+
});
203399
});

‎src/agent/model-family-policy.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type ModelFamily,
44
} from "../subagent/provider-family.js";
55
import {
6+
astraResidual,
67
claudeRow,
78
gptRow,
89
grokRow,
@@ -164,9 +165,12 @@ const CLAUDE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
164165
// Deliberately not manage_tasks ceremony — that is CL-7769, not this text.
165166
// The text lives here (policy owns data); buildGptNarrateBeforeToolsNote
166167
// (prompts.ts) returns it verbatim so the prompt carries exactly one copy.
167-
// Served cells (astra/sol/terra/…) are never named here — CL-8265
168-
// characterizes them later. Single-sourced from the versioned
169-
// prompt-variance package (CL-8269); the name stays for existing importers.
168+
// Served cells (sol/terra/…) are never named here — CL-8265 characterizes
169+
// them later. Astra is the one exception: forensics (CL-9027) showed the
170+
// gpt-6-astra cell evading the shared threshold guard via trivial argument
171+
// deltas, so it carries its own residual below. Single-sourced from the
172+
// versioned prompt-variance package (CL-8269); the name stays for existing
173+
// importers.
170174
export const GPT_NARRATE_BEFORE_TOOLS_NOTE = gptRow.residual;
171175

172176
// GPT (Codex / gpt-*) thresholds are provisional: we have no eval
@@ -181,6 +185,21 @@ const GPT_POLICY: Omit<ModelFamilyPolicy, "family"> = {
181185
promptResidual: GPT_NARRATE_BEFORE_TOOLS_NOTE,
182186
};
183187

188+
// Astra (served gpt-6-astra cell) is GPT plus the evasion residual. Forensics
189+
// on the repro trace (see model-family-policy.test.ts) classified the loop as
190+
// evasion — near-identical re-issued calls whose trivial argument deltas keep
191+
// every exact fingerprint under the shared threshold — so the residual forbids
192+
// that specific variation (muse-rule precedent, CL-7869) instead of tightening
193+
// thresholds: toolOnlyTurnNudgeAt stays at the permissive default. No
194+
// signature normalization ships in first-party code.
195+
export const ASTRA_PROMPT_RESIDUAL = `${GPT_NARRATE_BEFORE_TOOLS_NOTE}\n${astraResidual}`;
196+
197+
const ASTRA_POLICY: Omit<ModelFamilyPolicy, "family"> = {
198+
...GPT_POLICY,
199+
// Like gpt, primary and leaf alike: no orchestrator carve-out.
200+
promptResidual: ASTRA_PROMPT_RESIDUAL,
201+
};
202+
184203
export function resolveModelFamilyPolicy(input: {
185204
providerName: string;
186205
model?: string;
@@ -217,6 +236,10 @@ export function resolveModelFamilyPolicy(input: {
217236
case "gpt":
218237
// Primary and leaf alike: no orchestrator carve-out.
219238
return { family, ...GPT_POLICY };
239+
case "astra":
240+
// Primary and leaf alike, like gpt: no orchestrator carve-out. Generic
241+
// gpt prompts are byte-identical — only astra carries the residual.
242+
return { family, ...ASTRA_POLICY };
220243
default:
221244
return { family: "default", ...DEFAULT_POLICY };
222245
}

‎src/subagent/provider-family.test.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import {
33
detectModelFamily,
4+
isAstraLeafProvider,
45
isClaudeLeafProvider,
56
isGptProvider,
67
isKimiLeafProvider,
@@ -229,16 +230,23 @@ describe("isGptProvider (CL-8310)", () => {
229230
).toBe(true);
230231
});
231232

232-
test("covers every in-tree codex catalog model without naming cells", () => {
233-
// No terra/sol/astra special-casing: every served codex id resolves via
234-
// the generic codex-provider / gpt-* match, so future cells ride along.
233+
test("astra branches to its own family; other cells ride the generic gpt match", () => {
234+
// CL-9027 reverses the no-special-casing rule for astra only: the
235+
// gpt-6-astra cell doom-loops, so it resolves to the astra family while
236+
// sol/terra/luna and generic gpt ids keep the generic gpt match.
235237
for (const model of CODEX_DEFAULT_MODELS) {
236238
expect(isGptProvider({ providerName: "codex/default", model })).toBe(
237239
true,
238240
);
239-
expect(detectModelFamily({ providerName: "codex/default", model })).toBe(
240-
"gpt",
241-
);
241+
const family = detectModelFamily({
242+
providerName: "codex/default",
243+
model,
244+
});
245+
if (model.toLowerCase().startsWith("gpt-6-astra")) {
246+
expect(family).toBe("astra");
247+
} else {
248+
expect(family).toBe("gpt");
249+
}
242250
}
243251
});
244252

@@ -264,3 +272,49 @@ describe("isGptProvider (CL-8310)", () => {
264272
expect(isGptProvider({ providerName: "anthropic" })).toBe(false);
265273
});
266274
});
275+
276+
describe("isAstraLeafProvider (CL-9027)", () => {
277+
test("matches the served gpt-6-astra cell id on any provider", () => {
278+
expect(
279+
isAstraLeafProvider({
280+
providerName: "codex/default",
281+
model: "gpt-6-astra",
282+
}),
283+
).toBe(true);
284+
expect(
285+
isAstraLeafProvider({
286+
providerName: "openai-compat",
287+
model: "GPT-6-ASTRA",
288+
}),
289+
).toBe(true);
290+
expect(
291+
detectModelFamily({
292+
providerName: "codex/default",
293+
model: "gpt-6-astra",
294+
}),
295+
).toBe("astra");
296+
});
297+
298+
test("rejects sol, generic gpt, and other families", () => {
299+
for (const input of [
300+
{ providerName: "codex/default", model: "gpt-5.6-sol" },
301+
{ providerName: "codex/default", model: "gpt-5.6-terra" },
302+
{ providerName: "codex/default", model: "gpt-5.6-luna" },
303+
{ providerName: "openai", model: "gpt-5.6" },
304+
{ providerName: "codex", model: "gpt-5.1" },
305+
{ providerName: "xai/default", model: "grok-4.6" },
306+
{ providerName: "anthropic", model: "claude-sonnet-4" },
307+
] as const) {
308+
expect(isAstraLeafProvider(input)).toBe(false);
309+
}
310+
expect(
311+
detectModelFamily({
312+
providerName: "codex/default",
313+
model: "gpt-5.6-sol",
314+
}),
315+
).toBe("gpt");
316+
expect(
317+
detectModelFamily({ providerName: "openai", model: "gpt-5.6" }),
318+
).toBe("gpt");
319+
});
320+
});

0 commit comments

Comments
 (0)