Skip to content

Commit a98cf9d

Browse files
Merge pull request #1135 from corbitsdev/cl-8310-0330-promptresidual-gpt-narrate-before-tools-after-grok-row
feat(prompt): add GPT narrate-before-tools family residual
2 parents 1a69ead + 2880019 commit a98cf9d

11 files changed

Lines changed: 309 additions & 29 deletions

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

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js";
44
describe("resolveModelFamilyPolicy", () => {
55
test("defaults are permissive for an unrecognized provider", () => {
66
const policy = resolveModelFamilyPolicy({
7-
providerName: "openai",
8-
model: "gpt-4.1",
7+
providerName: "unknown-provider",
8+
model: "unknown-model",
99
});
1010
expect(policy.family).toBe("default");
1111
expect(policy.applyGrokFinishBias).toBe(false);
@@ -55,8 +55,8 @@ describe("resolveModelFamilyPolicy", () => {
5555

5656
test("advertisedToolDeny is empty by default and never contains use_skill", () => {
5757
const leaf = resolveModelFamilyPolicy({
58-
providerName: "openai",
59-
model: "gpt-4.1",
58+
providerName: "unknown-provider",
59+
model: "unknown-model",
6060
orchestrator: false,
6161
});
6262
expect(leaf.advertisedToolDeny).toEqual([]);
@@ -124,12 +124,12 @@ describe("resolveModelFamilyPolicy", () => {
124124
orchestrator: true,
125125
});
126126
expect(orchestrator.promptResidual).toBeUndefined();
127-
// Default-family probe: anthropic/claude-sonnet-4 would hit the claude
128-
// row now, and the gpt row has NOT landed yet (#1135), so openai/gpt-4.1
129-
// is the probe that still resolves to the default family.
127+
// Default-family probe: anthropic/claude-sonnet-4 hits the claude row
128+
// and openai/gpt-4.1 hits the gpt row (#1135), so an unrecognized
129+
// provider is the probe that still resolves to the default family.
130130
const base = resolveModelFamilyPolicy({
131-
providerName: "openai",
132-
model: "gpt-4.1",
131+
providerName: "unknown-provider",
132+
model: "unknown-model",
133133
});
134134
expect(base.family).toBe("default");
135135
expect(base.promptResidual).toBeUndefined();
@@ -154,21 +154,22 @@ describe("resolveModelFamilyPolicy", () => {
154154
expect(orchestrator.promptResidual).toBeUndefined();
155155
});
156156

157-
// The gpt family row has NOT landed yet (#1135): openai/gpt-4.1 and
158-
// codex/gpt-5.1 are default-family probes here, asserting they resolve to
159-
// the default family with no residual. Grok keeps its CL-8297 tool-budget
157+
// The gpt family row has landed (#1135): openai/gpt-4.1 and codex/gpt-5.1
158+
// resolve to the gpt family with the narrate-before-tools residual, leaf
159+
// and orchestrator alike (no carve-out). Grok keeps its CL-8297 tool-budget
160160
// residual — the "no residual" claim below is default-family-only.
161-
test("gpt probes resolve to default with no residual; grok keeps its tool budget", () => {
161+
test("gpt probes resolve to gpt with the narrate residual; grok keeps its tool budget", () => {
162162
for (const input of [
163163
{ providerName: "openai", model: "gpt-4.1" },
164164
{ providerName: "codex", model: "gpt-5.1" },
165165
] as const) {
166-
const policy = resolveModelFamilyPolicy({
167-
...input,
168-
orchestrator: false,
169-
});
170-
expect(policy.family).toBe("default");
171-
expect(policy.promptResidual).toBeUndefined();
166+
for (const orchestrator of [false, true]) {
167+
const policy = resolveModelFamilyPolicy({ ...input, orchestrator });
168+
expect(policy.family).toBe("gpt");
169+
expect(policy.promptResidual).toContain(
170+
"Narrate before tools (GPT worker):",
171+
);
172+
}
172173
}
173174
const grok = resolveModelFamilyPolicy({
174175
providerName: "xai/default",
@@ -178,4 +179,24 @@ describe("resolveModelFamilyPolicy", () => {
178179
expect(grok.family).toBe("grok");
179180
expect(grok.promptResidual).toContain("Tool budget:");
180181
});
182+
test("gpt resolves its own family on permissive default thresholds (CL-8310)", () => {
183+
const gpt = resolveModelFamilyPolicy({
184+
providerName: "codex/default",
185+
model: "gpt-5.5",
186+
});
187+
const base = resolveModelFamilyPolicy({
188+
providerName: "anthropic",
189+
model: "claude-sonnet-4",
190+
});
191+
expect(gpt.family).toBe("gpt");
192+
// No eval characterization for gpt tool-only stretches yet: ship the
193+
// permissive default, no finish-bias, no discipline rules. The
194+
// narrate-before-tools residual is prompt-level (see prompts.ts), not a
195+
// threshold.
196+
expect(gpt.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt);
197+
expect(gpt.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs);
198+
expect(gpt.applyGrokFinishBias).toBe(false);
199+
expect(gpt.toolDisciplineRules).toBeUndefined();
200+
expect(gpt.advertisedToolDeny).toEqual([]);
201+
});
181202
});

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export interface ModelFamilyPolicy {
4141
/**
4242
* Provider-family residual appended once to the assembled leaf system
4343
* prompt (CL-8297). Tool-budget text for grok, the XML task_guidance block
44-
* for claude; the GPT seam stays unfilled until its lane lands (#1135).
44+
* for claude, the narrate-before-tools note for gpt (CL-8310, primary and
45+
* leaf alike).
4546
* Withheld from orchestrators and appended at the tail so it cannot
4647
* disturb the cached prompt prefix. Undefined for families that need none.
4748
*/
@@ -169,6 +170,33 @@ const CLAUDE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
169170
promptResidual: CLAUDE_TASK_GUIDANCE_NOTE,
170171
};
171172

173+
// Tiny narrate-before-tools residual for GPT workers (CL-8310): GPT-5.5 runs
174+
// showed 6–13 silent tool-only turns. Shared thrash harness + spawn contracts
175+
// do the structural work; this is only a narrate-before-tools nudge.
176+
// Deliberately not manage_tasks ceremony — that is CL-7769, not this text.
177+
// The text lives here (policy owns data); buildGptNarrateBeforeToolsNote
178+
// (prompts.ts) returns it verbatim so the prompt carries exactly one copy.
179+
// Served cells (astra/sol/terra/…) are never named here — CL-8265
180+
// characterizes them later.
181+
export const GPT_NARRATE_BEFORE_TOOLS_NOTE = [
182+
"Narrate before tools (GPT worker):",
183+
"- Before each tool call, write one short line saying what you are doing and why.",
184+
"- Never make back-to-back tool calls with no narration between them.",
185+
"- When the dispatch brief's done-definition is met, write the report envelope instead of making another tool call.",
186+
].join("\n");
187+
188+
// GPT (Codex / gpt-*) thresholds are provisional: we have no eval
189+
// characterization yet for how GPT behaves under tool-only stretches or
190+
// background-run stalls. Ship the permissive default rather than guessing at
191+
// a tightened number; the narrate-before-tools residual is prompt-level (see
192+
// GPT_NARRATE_BEFORE_TOOLS_NOTE above), not a threshold.
193+
const GPT_POLICY: Omit<ModelFamilyPolicy, "family"> = {
194+
...DEFAULT_POLICY,
195+
// Primary and leaf alike, so unlike the grok finish-bias there is no
196+
// orchestrator carve-out: the resolver below returns this as-is.
197+
promptResidual: GPT_NARRATE_BEFORE_TOOLS_NOTE,
198+
};
199+
172200
export function resolveModelFamilyPolicy(input: {
173201
providerName: string;
174202
model?: string;
@@ -204,6 +232,9 @@ export function resolveModelFamilyPolicy(input: {
204232
return orchestrator
205233
? { family, ...DEFAULT_POLICY }
206234
: { family, ...CLAUDE_POLICY };
235+
case "gpt":
236+
// Primary and leaf alike: no orchestrator carve-out.
237+
return { family, ...GPT_POLICY };
207238
default:
208239
return { family: "default", ...DEFAULT_POLICY };
209240
}

‎src/agent/prompt-sizes.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,11 @@ describe("grok tool-budget residual (CL-8297)", () => {
345345
test("default-family and orchestrator prompts carry no tool budget", () => {
346346
const defaultPrompt = assembleDirectorPrompt("builder", "default");
347347
expect(defaultPrompt).not.toContain("Tool budget:");
348-
// The default probe (openai/gpt-4.1) resolves to the default family, so
349-
// the default column carries no family residual at all — not the claude
350-
// task_guidance block either.
348+
// The default probe resolves to the default family, so the default
349+
// column carries no family residual — neither the claude task_guidance
350+
// block nor the gpt narrate-before-tools nudge.
351351
expect(defaultPrompt).not.toContain("<task_guidance>");
352+
expect(defaultPrompt).not.toContain("Narrate before tools (GPT worker):");
352353
expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain(
353354
"Tool budget:",
354355
);

‎src/agent/prompt-sizes.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = {
5757
};
5858

5959
const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" };
60-
// Default-family probe: openai/gpt-4.1 resolves to the default family (the
61-
// gpt row lands later in CL-8310), so the default column carries no residual.
60+
// Default-family probe: an unrecognized provider stays on the default
61+
// family no matter how many family rows land (claude/gpt already ship),
62+
// so the default column carries no residual.
6263
const DEFAULT_PROVIDER = {
63-
providerName: "openai",
64-
model: "gpt-4.1",
64+
providerName: "unknown-provider",
65+
model: "unknown-model",
6566
};
6667

6768
/** Families in the size table: default assembly vs Grok (+finish-bias note). */

‎src/agent/prompts.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
22
import {
33
buildChatSystemPrompt,
44
buildClaudeTaskGuidanceNote,
5+
buildGptNarrateBeforeToolsNote,
56
buildGrokLeafAntiThrashNote,
67
buildGuidelines,
78
buildPromptDisciplineBlock,
@@ -452,3 +453,89 @@ describe("claude XML task_guidance residual (provider residual, not a prompt for
452453
);
453454
});
454455
});
456+
457+
describe("gpt narrate-before-tools residual (CL-8310)", () => {
458+
it("is a 3-line narrate-before-tools note, not manage_tasks ceremony", () => {
459+
const note = buildGptNarrateBeforeToolsNote();
460+
expect(note).toContain("Narrate before tools (GPT worker):");
461+
expect(note).toMatch(/before.*tool call.*one short line/is);
462+
expect(note).toMatch(/no narration between them/i);
463+
expect(note).toContain("write the report envelope");
464+
expect(note.toLowerCase()).not.toContain("manage_tasks");
465+
});
466+
467+
it("appears exactly once on a gpt leaf prompt", () => {
468+
const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
469+
orchestrator: false,
470+
promptResidual: buildGptNarrateBeforeToolsNote(),
471+
});
472+
const note = buildGptNarrateBeforeToolsNote();
473+
expect(countOccurrences(prompt, note)).toBe(1);
474+
expect(prompt.trimEnd().endsWith(note)).toBe(true);
475+
});
476+
477+
it("appears exactly once on a gpt primary prompt", () => {
478+
const prompt = buildChatSystemPrompt(
479+
undefined,
480+
undefined,
481+
undefined,
482+
[],
483+
"orchestrator",
484+
undefined,
485+
undefined,
486+
{ promptResidual: buildGptNarrateBeforeToolsNote() },
487+
);
488+
const note = buildGptNarrateBeforeToolsNote();
489+
expect(countOccurrences(prompt, note)).toBe(1);
490+
});
491+
492+
it("is absent by default on both primary and leaf", () => {
493+
const leaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
494+
orchestrator: false,
495+
grokAntiThrash: false,
496+
});
497+
const primary = buildChatSystemPrompt(
498+
undefined,
499+
undefined,
500+
undefined,
501+
[],
502+
"orchestrator",
503+
);
504+
expect(leaf).not.toContain("Narrate before tools (GPT worker):");
505+
expect(primary).not.toContain("Narrate before tools (GPT worker):");
506+
});
507+
508+
it("is absent on grok and claude prompts", () => {
509+
const grokLeaf = buildSubAgentSystemPrompt(
510+
undefined,
511+
undefined,
512+
undefined,
513+
{
514+
orchestrator: false,
515+
grokAntiThrash: true,
516+
},
517+
);
518+
const claudeLeaf = buildSubAgentSystemPrompt(
519+
undefined,
520+
undefined,
521+
undefined,
522+
{
523+
orchestrator: false,
524+
grokAntiThrash: false,
525+
},
526+
);
527+
const claudePrimary = buildChatSystemPrompt(
528+
undefined,
529+
undefined,
530+
undefined,
531+
[],
532+
"orchestrator",
533+
);
534+
for (const prompt of [grokLeaf, claudeLeaf, claudePrimary]) {
535+
expect(prompt).not.toContain("Narrate before tools (GPT worker):");
536+
expect(prompt).not.toContain("Narrate before tools (GPT");
537+
}
538+
// The grok row keeps its own residual, untouched.
539+
expect(grokLeaf).toContain("Finish bias (xAI / Grok worker):");
540+
});
541+
});

‎src/agent/prompts.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "./worker-contract.js";
1414
import {
1515
CLAUDE_TASK_GUIDANCE_NOTE,
16+
GPT_NARRATE_BEFORE_TOOLS_NOTE,
1617
GROK_PROMPT_RESIDUAL,
1718
} from "./model-family-policy.js";
1819

@@ -459,6 +460,13 @@ export function buildChatSystemPrompt(
459460
sessionMode: SessionMode = "orchestrator",
460461
toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY,
461462
guidelineConfig?: GuidelineConfig,
463+
opts: {
464+
/**
465+
* Family policy residual (CL-8297) appended once at the tail so it
466+
* cannot disturb the cached prompt prefix. Unset for families with none.
467+
*/
468+
promptResidual?: string | undefined;
469+
} = {},
462470
): string {
463471
const sections = [
464472
baseSection(
@@ -477,6 +485,9 @@ export function buildChatSystemPrompt(
477485
if (extensions !== undefined && extensions.length > 0) {
478486
sections.push(...extensions);
479487
}
488+
if (opts.promptResidual !== undefined && opts.promptResidual.length > 0) {
489+
sections.push(opts.promptResidual);
490+
}
480491
return joinSections(sections);
481492
}
482493

@@ -536,6 +547,17 @@ export function buildClaudeTaskGuidanceNote(): string {
536547
return CLAUDE_TASK_GUIDANCE_NOTE;
537548
}
538549

550+
// Tiny residual for GPT workers (CL-8310): GPT-5.5/5.6-luna runs showed 6–13
551+
// silent tool-only turns. Shared thrash harness + spawn contracts do the
552+
// structural work; this is only a narrate-before-tools nudge. Deliberately
553+
// not manage_tasks ceremony — that is CL-7769, not this text.
554+
// Single source of truth is the GPT_NARRATE_BEFORE_TOOLS_NOTE block in
555+
// model-family-policy.ts (policy owns data); this returns that block verbatim
556+
// so the prompt carries one gpt residual with no line twice.
557+
export function buildGptNarrateBeforeToolsNote(): string {
558+
return GPT_NARRATE_BEFORE_TOOLS_NOTE;
559+
}
560+
539561
export function buildSubAgentSystemPrompt(
540562
extensions?: string[],
541563
env?: EnvironmentInfo,

‎src/exec/runner.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
733733
sessionMode,
734734
toolAvailability,
735735
skills: agentToolset.skills,
736+
providerName: config.providerName,
737+
model: config.model,
736738
})
737739
).systemPrompt;
738740

‎src/session/runtime-assembly.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { Compactor } from "@intx/types/runtime";
1515

1616
import { buildChatSystemPrompt } from "../agent/prompts.js";
1717
import type { GuidelineSubBlockId } from "../agent/prompts.js";
18+
import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js";
1819
import type { ToolAvailability } from "../agent/tool-search.js";
1920
import { gatherEnvironment } from "../agent/environment.js";
2021
import {
@@ -293,6 +294,10 @@ export interface SessionChatPromptArgs {
293294
// Guideline sub-block ids to drop (see GUIDELINE_SUB_BLOCK_IDS).
294295
// Omitted = full guidelines.
295296
promptSectionOmit?: readonly GuidelineSubBlockId[];
297+
// Active session provider/model, for family residuals on the primary
298+
// prompt (CL-8310: GPT narrate-before-tools). Omitted = no family residual.
299+
providerName?: string;
300+
model?: string;
296301
// Session-start snapshot from createAgentToolset. When provided, skip
297302
// rediscovery so the prompt listing and skill_search share one catalog.
298303
skills?: readonly SkillSummary[];
@@ -320,6 +325,19 @@ export async function loadSessionChatPrompt(
320325
...(args.systemPromptExtensions ?? []),
321326
...overrides.append,
322327
];
328+
// Family residual on the primary prompt (CL-8310): resolved from the model
329+
// family policy as an orchestrator — primaries dispatch rather than doing
330+
// the work directly, so grok/claude primaries stay untouched (their rows
331+
// withhold the residual from orchestrators) while the gpt row carries its
332+
// narrate-before-tools note primary and leaf alike.
333+
const { promptResidual } =
334+
args.providerName !== undefined
335+
? resolveModelFamilyPolicy({
336+
providerName: args.providerName,
337+
...(args.model !== undefined ? { model: args.model } : {}),
338+
orchestrator: true,
339+
})
340+
: { promptResidual: undefined };
323341
return {
324342
systemPrompt: buildChatSystemPrompt(
325343
extensions.length > 0 ? extensions : undefined,
@@ -331,6 +349,7 @@ export async function loadSessionChatPrompt(
331349
args.promptSectionOmit !== undefined
332350
? { omit: args.promptSectionOmit }
333351
: undefined,
352+
{ promptResidual },
334353
),
335354
skills,
336355
};

0 commit comments

Comments
 (0)