From 2d1f961cf6a21ffa9001965a2a8eb0863ce60623 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 21:04:48 -0700 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 2 ++ src/agent/director.ts | 1 + src/director.test.ts | 22 ++++++++++++++---- src/subagent/nudge-director.test.ts | 3 +++ src/subagent/nudge-director.ts | 3 +++ tests/unit/codex-responses-adapter.test.ts | 27 ++++++++++++++++++++++ 6 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e2e26cf..bfd4a2491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename labels. Overlay rows bind by an ask id minted at emit, not render-order index. A stale or empty accept fail-closes as unavailable rather than impersonating Reject; Escape still denies. +- After context compaction, ChatGPT Codex requests keep the operating prompt as + instructions. ## [0.3.18] - 2026-09-08 diff --git a/src/agent/director.ts b/src/agent/director.ts index 5c2f21ee8..f5f2b4aae 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -526,6 +526,7 @@ class ChatDirectorImpl extends DefaultDirector { ...action.options, tools, retryPolicy: action.options?.retryPolicy ?? this.retryPolicy, + systemPrompt: action.options?.systemPrompt ?? this._systemPrompt, }; if (this.inactivityTimeoutMs !== undefined) options.inactivityTimeoutMs = this.inactivityTimeoutMs; diff --git a/src/director.test.ts b/src/director.test.ts index 9ba4072e9..b9377b402 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -518,7 +518,10 @@ describe("chatDirector compaction", () => { } test("compacts at the tool.done pause once over threshold", async () => { - const director = chatDirectorWithContinuation(); + const director = createChatDirector("Corbits operating prompt", [], { + onTasksChange: () => {}, + requestContinuation: () => {}, + }); await director.decide(overThresholdToolTurn(), longState, mockCapabilities); const actions = actionsArray( await director.decide(makeToolDoneEvent("t1"), longState, mockCapabilities), @@ -535,6 +538,10 @@ describe("chatDirector compaction", () => { await director.decide(messageReceived(""), longState, mockCapabilities), ); expect(resumed.some((a) => a.type === "infer")).toBe(true); + const infer = resumed.find((a) => a.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); }); // CL-6910: `timeout`/`retryable` are owned entirely by the harness's own @@ -602,7 +609,10 @@ describe("chatDirector compaction", () => { test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => { let continuations = 0; - const director = chatDirectorWithContinuation(() => continuations++); + const director = createChatDirector("Corbits operating prompt", [], { + onTasksChange: () => {}, + requestContinuation: () => continuations++, + }); const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), ); @@ -615,6 +625,10 @@ describe("chatDirector compaction", () => { await director.decide(messageReceived(""), longState, mockCapabilities), ); expect(resumed.some((a) => a.type === "infer")).toBe(true); + const infer = resumed.find((a) => a.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); }); test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => { @@ -1038,7 +1052,7 @@ describe("transient nudges", () => { source: "test", }) as unknown as ReactorInboundEvent; - test("open-task nudge uses ephemeralTurns, not systemPrompt", async () => { + test("open-task nudge uses ephemeralTurns and keeps the stable system prompt", async () => { const director = createChatDirector("stable-base", [], { onTasksChange: () => {} }); await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities); const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities)); @@ -1050,6 +1064,6 @@ describe("transient nudges", () => { expect(options?.ephemeralTurns?.length ?? 0).toBeGreaterThan(0); const nudgeText = options?.ephemeralTurns?.[0]?.content?.find((b) => b.type === "text"); expect(nudgeText?.type === "text" ? nudgeText.text : "").toContain("tasks are still open"); - expect(options?.systemPrompt).toBeUndefined(); + expect(options?.systemPrompt).toBe("stable-base"); }); }); diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index ef166e19d..f7963d8ea 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -194,6 +194,7 @@ describe("SubAgentDirector tool failure recovery", () => { const resumed = inferAction(await director.decide(messageReceived(""), longState, caps)); const resumedTexts = ephemeralTexts(resumed); + expect(resumed.options?.systemPrompt).toBe("system"); expect(resumedTexts).toHaveLength(1); expect(resumedTexts?.[0]).toContain("A tool call failed"); @@ -260,6 +261,7 @@ describe("SubAgentDirector tool failure recovery", () => { const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); const resumedTexts = ephemeralTexts(resumed); + expect(resumed.options?.systemPrompt).toBe("system"); expect(resumedTexts).toHaveLength(1); expect(resumedTexts?.[0]).toContain("A tool call failed"); @@ -298,6 +300,7 @@ describe("SubAgentDirector tool failure recovery", () => { expect(continuations).toBe(1); const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); + expect(resumed.options?.systemPrompt).toBe("system"); expect(ephemeralTexts(resumed)).toBeUndefined(); }); }); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index b9b9810cd..a0eabec19 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -86,6 +86,7 @@ function isNonEmptyParentMessage(event: ReactorInboundEvent): boolean { export class SubAgentDirector extends DefaultDirector { private readonly compaction: CompactionGovernor; private readonly retryPolicy: RetryPolicy; + private readonly _systemPrompt: string; /** When true (CritiqueDirector), empty readCounts is not a successful complete. */ private readonly requireEvidence: boolean; private turnsCompleted = 0; @@ -177,6 +178,7 @@ export class SubAgentDirector extends DefaultDirector { retryPolicy: RetryPolicy = createCorbitsRetryPolicy(), ) { super(systemPrompt, toolDefinitions, {}); + this._systemPrompt = systemPrompt; this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); this.stallTimeoutMs = stallTimeoutMs; this.now = now; @@ -197,6 +199,7 @@ export class SubAgentDirector extends DefaultDirector { infer({ ...(options ?? {}), retryPolicy: options?.retryPolicy ?? this.retryPolicy, + systemPrompt: options?.systemPrompt ?? this._systemPrompt, }), }; // A real parent follow-up re-opens the brief; empty continuations do not. diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4faebfbcf..4d7025cda 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -8,6 +8,7 @@ import { CODEX_RESPONSES_PROVIDER, } from "../../src/provider/codex-responses-adapter.js"; import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js"; +import { COMPACTED_PREFIX } from "../../src/session/compactor.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; @@ -87,6 +88,32 @@ describe("codex-responses buildRequest", () => { expect(body["tool_choice"]).toBe("auto"); }); + test("sends compacted history with the system prompt as instructions and no developer item", () => { + const systemPrompt = "Corbits operating prompt"; + const turns: ConversationTurn[] = [ + userTurn(`${COMPACTED_PREFIX}\nPrior work summarized.`), + { role: "assistant", timestamp: 0, content: [{ type: "text", text: "ok" }] }, + userTurn("continue"), + ]; + const body = JSON.parse( + adapter().buildRequest(turns, "gpt-5-codex", { ...baseOptions, systemPrompt }).body, + ) as Record; + expect(body["instructions"]).toBe(systemPrompt); + const input = body["input"] as { + role?: string; + content?: { text?: string }[]; + }[]; + expect(input).toHaveLength(3); + expect(input[0]?.role).toBe("user"); + expect(input[1]?.role).toBe("assistant"); + expect(input[2]?.role).toBe("user"); + expect(input[0]?.content?.[0]?.text?.startsWith(COMPACTED_PREFIX)).toBe(true); + expect(input.every((item) => item.role !== "developer")).toBe(true); + expect(input.some((item) => item.content?.some((block) => block.text === systemPrompt))).toBe( + false, + ); + }); + test.each([undefined, "", "Corbits operating prompt"])( "preserves conversation order and system turns with systemPrompt %j", (systemPrompt) => { From 06261ef1ea578eacda8706b3e821edb930ba98f0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 10:51:40 -0700 Subject: [PATCH 2/2] Lock constructor prompt on abort recovery and share the helper --- src/director.test.ts | 31 +++++++++++----------- tests/unit/codex-responses-adapter.test.ts | 3 +++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/director.test.ts b/src/director.test.ts index b9377b402..54557d18c 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -510,18 +510,15 @@ describe("chatDirector compaction", () => { } as unknown as ReactorInboundEvent; } - function chatDirectorWithContinuation(onContinuation?: () => void) { - return createChatDirector("", [], { + function chatDirectorWithContinuation(systemPrompt: string, onContinuation?: () => void) { + return createChatDirector(systemPrompt, [], { onTasksChange: () => {}, requestContinuation: onContinuation ?? (() => {}), }); } test("compacts at the tool.done pause once over threshold", async () => { - const director = createChatDirector("Corbits operating prompt", [], { - onTasksChange: () => {}, - requestContinuation: () => {}, - }); + const director = chatDirectorWithContinuation("Corbits operating prompt"); await director.decide(overThresholdToolTurn(), longState, mockCapabilities); const actions = actionsArray( await director.decide(makeToolDoneEvent("t1"), longState, mockCapabilities), @@ -552,7 +549,7 @@ describe("chatDirector compaction", () => { // turn); it now falls through to the base director's terminal // checkpoint + reply instead of recovering. test("does not re-issue inference for a timeout already exhausted by the harness", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const timeout = { type: "inference.error", error: { category: "timeout", message: "request timed out" }, @@ -564,7 +561,7 @@ describe("chatDirector compaction", () => { }); test("recovers an internally aborted inference but keeps explicit abort terminal", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation("Corbits operating prompt"); const internalAbort = { type: "inference.error", error: { @@ -577,6 +574,10 @@ describe("chatDirector compaction", () => { await director.decide(internalAbort, longState, mockCapabilities), ); expect(recovered.some((action) => action.type === "infer")).toBe(true); + const infer = recovered.find((action) => action.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); const explicitAbort = { type: "abort", @@ -588,7 +589,7 @@ describe("chatDirector compaction", () => { }); test("does not auto-recover user-stop aborted inference errors", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const userStopAbort = { type: "inference.error", error: { @@ -609,10 +610,10 @@ describe("chatDirector compaction", () => { test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => { let continuations = 0; - const director = createChatDirector("Corbits operating prompt", [], { - onTasksChange: () => {}, - requestContinuation: () => continuations++, - }); + const director = chatDirectorWithContinuation( + "Corbits operating prompt", + () => continuations++, + ); const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), ); @@ -632,7 +633,7 @@ describe("chatDirector compaction", () => { }); test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); for (let i = 0; i < 2; i++) { const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), @@ -647,7 +648,7 @@ describe("chatDirector compaction", () => { }); test("chat posture is preserved: an idle turn never terminates the session", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const idle = actionsArray( await director.decide(textInferenceDone(10), longState, mockCapabilities), ); diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4d7025cda..0f16c16c4 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -88,6 +88,9 @@ describe("codex-responses buildRequest", () => { expect(body["tool_choice"]).toBe("auto"); }); + // Adapter mapping only: compacted history still uses instructions, not a + // developer item. ChatDirector tests own the lock that infer carries the + // constructor systemPrompt after compaction or recovery. test("sends compacted history with the system prompt as instructions and no developer item", () => { const systemPrompt = "Corbits operating prompt"; const turns: ConversationTurn[] = [