Skip to content

Commit d4fd3b1

Browse files
committed
feat(prompt): add GPT narrate-before-tools family residual
Primary and leaf prompts carry a 3-line narrate-before-tools residual when the active session resolves to the gpt family (codex, gpt-*); grok and claude prompts are untouched and the residual appears exactly once. Fixes CL-8310
1 parent cd7fc55 commit d4fd3b1

9 files changed

Lines changed: 138 additions & 25 deletions

File tree

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

Lines changed: 20 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",

‎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/prompts.test.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,6 @@ describe("grok finish-bias residual gating (extends existing provider-family tes
373373
});
374374
});
375375

376-
<<<<<<< HEAD
377376
describe("promptResidual assembly (CL-8297)", () => {
378377
const TOOL_BUDGET =
379378
"Tool budget:\n" +
@@ -468,7 +467,7 @@ describe("gpt narrate-before-tools residual (CL-8310)", () => {
468467
it("appears exactly once on a gpt leaf prompt", () => {
469468
const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
470469
orchestrator: false,
471-
gptNarrateBeforeTools: true,
470+
promptResidual: buildGptNarrateBeforeToolsNote(),
472471
});
473472
const note = buildGptNarrateBeforeToolsNote();
474473
expect(countOccurrences(prompt, note)).toBe(1);
@@ -484,7 +483,7 @@ describe("gpt narrate-before-tools residual (CL-8310)", () => {
484483
"orchestrator",
485484
undefined,
486485
undefined,
487-
{ gptNarrateBeforeTools: true },
486+
{ promptResidual: buildGptNarrateBeforeToolsNote() },
488487
);
489488
const note = buildGptNarrateBeforeToolsNote();
490489
expect(countOccurrences(prompt, note)).toBe(1);

‎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
@@ -704,6 +704,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
704704
sessionMode,
705705
toolAvailability,
706706
skills: agentToolset.skills,
707+
providerName: config.providerName,
708+
model: config.model,
707709
})
708710
).systemPrompt;
709711

‎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
};

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe("isKimiLeafProvider", () => {
124124
});
125125

126126
describe("detectModelFamily", () => {
127-
test("detects grok, kimi, claude, and default", () => {
127+
test("detects grok, kimi, claude, gpt, and default", () => {
128128
expect(
129129
detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }),
130130
).toBe("grok");
@@ -142,6 +142,12 @@ describe("detectModelFamily", () => {
142142
).toBe("claude");
143143
expect(
144144
detectModelFamily({ providerName: "openai", model: "gpt-4.1" }),
145+
).toBe("gpt");
146+
expect(
147+
detectModelFamily({
148+
providerName: "unknown-provider",
149+
model: "unknown-model",
150+
}),
145151
).toBe("default");
146152
});
147153
});

‎src/subagent/provider-family.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses.js";
22
import { isXaiProviderName } from "../config/xai-providers.js";
3+
import { isCodexProviderName } from "../config/codex-providers.js";
34

45
/**
56
* True when the leaf inference path is xAI / Grok family.
@@ -49,8 +50,37 @@ export function isClaudeLeafProvider(input: {
4950
return false;
5051
}
5152

53+
/**
54+
* True when the inference path is the GPT family: a Codex provider name
55+
* (codex/ OAuth profiles, the codex-responses adapter, bare codex) or a
56+
* gpt-* model id on any provider. Served codex cells (astra/sol/terra/luna)
57+
* all match the generic gpt-* model shape — never name them here; CL-8265
58+
* characterizes cells later.
59+
*/
60+
export function isGptProvider(input: {
61+
providerName: string;
62+
model?: string;
63+
}): boolean {
64+
const name = input.providerName.toLowerCase();
65+
if (
66+
isCodexProviderName(name) ||
67+
name === "codex" ||
68+
name.includes("codex")
69+
)
70+
return true;
71+
if (input.model !== undefined && /^gpt-/i.test(input.model.trim()))
72+
return true;
73+
return false;
74+
}
75+
5276
/** Model families the shared directors branch on via ModelFamilyPolicy. */
53-
export type ModelFamily = "grok" | "kimi" | "muse" | "claude" | "default";
77+
export type ModelFamily =
78+
| "grok"
79+
| "kimi"
80+
| "muse"
81+
| "claude"
82+
| "gpt"
83+
| "default";
5484

5585
/**
5686
* Resolves a provider/model to a ModelFamily. Generalizes
@@ -66,6 +96,7 @@ export function detectModelFamily(input: {
6696
if (isKimiLeafProvider(input)) return "kimi";
6797
if (isMuseSparkLeafProvider(input)) return "muse";
6898
if (isClaudeLeafProvider(input)) return "claude";
99+
if (isGptProvider(input)) return "gpt";
69100
return "default";
70101
}
71102

‎src/tui/runner/session.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ export async function assembleTUISession(
447447
sessionMode: liveSessionMode,
448448
toolAvailability,
449449
skills: toolset.skills,
450+
providerName: config.providerName,
451+
model: config.model,
450452
});
451453

452454
const directorHolder: { instance?: ReturnType<typeof createChatDirector> } =

0 commit comments

Comments
 (0)