Skip to content

Commit 0d4e186

Browse files
committed
Restore the system prompt after context compaction
Compaction resume inferred without the constructor prompt, so Codex requests dropped instructions. Default systemPrompt at the infer rewrite layer so the operating prompt survives; explicit values still win.
1 parent 6ea5969 commit 0d4e186

6 files changed

Lines changed: 59 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- After context compaction, ChatGPT Codex requests keep the operating prompt as
19+
instructions.
20+
1421
## [0.3.18] - 2026-09-08
1522

1623
### Added

src/agent/director.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ class ChatDirectorImpl extends DefaultDirector {
526526
...action.options,
527527
tools,
528528
retryPolicy: action.options?.retryPolicy ?? this.retryPolicy,
529+
systemPrompt: action.options?.systemPrompt ?? this._systemPrompt,
529530
};
530531
if (this.inactivityTimeoutMs !== undefined)
531532
options.inactivityTimeoutMs = this.inactivityTimeoutMs;

src/director.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,10 @@ describe("chatDirector compaction", () => {
518518
}
519519

520520
test("compacts at the tool.done pause once over threshold", async () => {
521-
const director = chatDirectorWithContinuation();
521+
const director = createChatDirector("Corbits operating prompt", [], {
522+
onTasksChange: () => {},
523+
requestContinuation: () => {},
524+
});
522525
await director.decide(overThresholdToolTurn(), longState, mockCapabilities);
523526
const actions = actionsArray(
524527
await director.decide(makeToolDoneEvent("t1"), longState, mockCapabilities),
@@ -535,6 +538,10 @@ describe("chatDirector compaction", () => {
535538
await director.decide(messageReceived(""), longState, mockCapabilities),
536539
);
537540
expect(resumed.some((a) => a.type === "infer")).toBe(true);
541+
const infer = resumed.find((a) => a.type === "infer");
542+
const options: ExtendedInferenceOptions | undefined =
543+
infer?.type === "infer" ? infer.options : undefined;
544+
expect(options?.systemPrompt).toBe("Corbits operating prompt");
538545
});
539546

540547
// CL-6910: `timeout`/`retryable` are owned entirely by the harness's own
@@ -602,7 +609,10 @@ describe("chatDirector compaction", () => {
602609

603610
test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => {
604611
let continuations = 0;
605-
const director = chatDirectorWithContinuation(() => continuations++);
612+
const director = createChatDirector("Corbits operating prompt", [], {
613+
onTasksChange: () => {},
614+
requestContinuation: () => continuations++,
615+
});
606616
const actions = actionsArray(
607617
await director.decide(overflowError(), longState, mockCapabilities),
608618
);
@@ -615,6 +625,10 @@ describe("chatDirector compaction", () => {
615625
await director.decide(messageReceived(""), longState, mockCapabilities),
616626
);
617627
expect(resumed.some((a) => a.type === "infer")).toBe(true);
628+
const infer = resumed.find((a) => a.type === "infer");
629+
const options: ExtendedInferenceOptions | undefined =
630+
infer?.type === "infer" ? infer.options : undefined;
631+
expect(options?.systemPrompt).toBe("Corbits operating prompt");
618632
});
619633

620634
test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => {
@@ -1038,7 +1052,7 @@ describe("transient nudges", () => {
10381052
source: "test",
10391053
}) as unknown as ReactorInboundEvent;
10401054

1041-
test("open-task nudge uses ephemeralTurns, not systemPrompt", async () => {
1055+
test("open-task nudge uses ephemeralTurns and keeps the stable system prompt", async () => {
10421056
const director = createChatDirector("stable-base", [], { onTasksChange: () => {} });
10431057
await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
10441058
const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
@@ -1050,6 +1064,6 @@ describe("transient nudges", () => {
10501064
expect(options?.ephemeralTurns?.length ?? 0).toBeGreaterThan(0);
10511065
const nudgeText = options?.ephemeralTurns?.[0]?.content?.find((b) => b.type === "text");
10521066
expect(nudgeText?.type === "text" ? nudgeText.text : "").toContain("tasks are still open");
1053-
expect(options?.systemPrompt).toBeUndefined();
1067+
expect(options?.systemPrompt).toBe("stable-base");
10541068
});
10551069
});

src/subagent/nudge-director.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ describe("SubAgentDirector tool failure recovery", () => {
194194

195195
const resumed = inferAction(await director.decide(messageReceived(""), longState, caps));
196196
const resumedTexts = ephemeralTexts(resumed);
197+
expect(resumed.options?.systemPrompt).toBe("system");
197198
expect(resumedTexts).toHaveLength(1);
198199
expect(resumedTexts?.[0]).toContain("A tool call failed");
199200

@@ -260,6 +261,7 @@ describe("SubAgentDirector tool failure recovery", () => {
260261

261262
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
262263
const resumedTexts = ephemeralTexts(resumed);
264+
expect(resumed.options?.systemPrompt).toBe("system");
263265
expect(resumedTexts).toHaveLength(1);
264266
expect(resumedTexts?.[0]).toContain("A tool call failed");
265267

@@ -298,6 +300,7 @@ describe("SubAgentDirector tool failure recovery", () => {
298300
expect(continuations).toBe(1);
299301

300302
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
303+
expect(resumed.options?.systemPrompt).toBe("system");
301304
expect(ephemeralTexts(resumed)).toBeUndefined();
302305
});
303306
});

src/subagent/nudge-director.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function isNonEmptyParentMessage(event: ReactorInboundEvent): boolean {
8686
export class SubAgentDirector extends DefaultDirector {
8787
private readonly compaction: CompactionGovernor;
8888
private readonly retryPolicy: RetryPolicy;
89+
private readonly _systemPrompt: string;
8990
/** When true (CritiqueDirector), empty readCounts is not a successful complete. */
9091
private readonly requireEvidence: boolean;
9192
private turnsCompleted = 0;
@@ -177,6 +178,7 @@ export class SubAgentDirector extends DefaultDirector {
177178
retryPolicy: RetryPolicy = createCorbitsRetryPolicy(),
178179
) {
179180
super(systemPrompt, toolDefinitions, {});
181+
this._systemPrompt = systemPrompt;
180182
this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions);
181183
this.stallTimeoutMs = stallTimeoutMs;
182184
this.now = now;
@@ -197,6 +199,7 @@ export class SubAgentDirector extends DefaultDirector {
197199
infer({
198200
...(options ?? {}),
199201
retryPolicy: options?.retryPolicy ?? this.retryPolicy,
202+
systemPrompt: options?.systemPrompt ?? this._systemPrompt,
200203
}),
201204
};
202205
// A real parent follow-up re-opens the brief; empty continuations do not.

tests/unit/codex-responses-adapter.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
CODEX_RESPONSES_PROVIDER,
99
} from "../../src/provider/codex-responses-adapter.js";
1010
import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js";
11+
import { COMPACTED_PREFIX } from "../../src/session/compactor.js";
1112
import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference";
1213
import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime";
1314

@@ -87,6 +88,32 @@ describe("codex-responses buildRequest", () => {
8788
expect(body["tool_choice"]).toBe("auto");
8889
});
8990

91+
test("sends compacted history with the system prompt as instructions and no developer item", () => {
92+
const systemPrompt = "Corbits operating prompt";
93+
const turns: ConversationTurn[] = [
94+
userTurn(`${COMPACTED_PREFIX}\nPrior work summarized.`),
95+
{ role: "assistant", timestamp: 0, content: [{ type: "text", text: "ok" }] },
96+
userTurn("continue"),
97+
];
98+
const body = JSON.parse(
99+
adapter().buildRequest(turns, "gpt-5-codex", { ...baseOptions, systemPrompt }).body,
100+
) as Record<string, unknown>;
101+
expect(body["instructions"]).toBe(systemPrompt);
102+
const input = body["input"] as {
103+
role?: string;
104+
content?: { text?: string }[];
105+
}[];
106+
expect(input).toHaveLength(3);
107+
expect(input[0]?.role).toBe("user");
108+
expect(input[1]?.role).toBe("assistant");
109+
expect(input[2]?.role).toBe("user");
110+
expect(input[0]?.content?.[0]?.text?.startsWith(COMPACTED_PREFIX)).toBe(true);
111+
expect(input.every((item) => item.role !== "developer")).toBe(true);
112+
expect(input.some((item) => item.content?.some((block) => block.text === systemPrompt))).toBe(
113+
false,
114+
);
115+
});
116+
90117
test.each([undefined, "", "Corbits operating prompt"])(
91118
"preserves conversation order and system turns with systemPrompt %j",
92119
(systemPrompt) => {

0 commit comments

Comments
 (0)