diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index b53e1569ef..fcb2a480c0 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -187,3 +187,21 @@ export function buildCodexDeveloperInstructions( deviceAvailable: tools.device === true, })}`; } + +/** + * Pylon's own instructions for a Codex thread, without Codex's mode template. + * Sent once on `thread/start` and `thread/resume`: Codex 0.153 no longer applies + * the developer instructions carried inside a turn's collaboration mode. + */ +export function buildCodexThreadInstructions( + toolsAvailable: boolean | T3CodeToolAvailability = true, +): string { + const tools = normalizeAvailability(toolsAvailable); + return buildRuntimeInstructions({ + harness: "Codex", + delegationAvailable: tools.delegation === true, + pairActive: tools.pair === true, + browserAvailable: tools.browser === true, + deviceAvailable: tools.device === true, + }); +} diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index bb5b9b5f28..5d9efaf070 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1035,6 +1035,64 @@ describe("openCodexThread", () => { }), ); + it.effect("sends Pylon's instructions when a thread is resumed and when it is started", () => + Effect.gen(function* () { + // Codex 0.153 ignores developer instructions carried inside a turn's + // collaboration mode, so without these the model never learns it runs in Pylon. + const payloads: Array<{ method: string; developerInstructions: unknown }> = []; + const record = (method: string, payload: object) => + payloads.push({ + method, + developerInstructions: + "developerInstructions" in payload ? payload.developerInstructions : undefined, + }); + const client = { + raw: { + request: ( + method: "thread/resume", + payload: CodexRpc.ClientRequestParamsByMethod["thread/resume"], + ) => { + record(method, payload); + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ); + }, + }, + request: ( + method: "thread/start", + payload: CodexRpc.ClientRequestParamsByMethod["thread/start"], + ) => { + record(method, payload); + return Effect.succeed(makeThreadOpenResponse("fresh-thread")); + }, + }; + const open = (developerInstructions: string | undefined) => + openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "stale-thread", + developerInstructions, + }); + + yield* open("Pylon"); + yield* open(undefined); + + NodeAssert.deepStrictEqual(payloads, [ + { method: "thread/resume", developerInstructions: "Pylon" }, + { method: "thread/start", developerInstructions: "Pylon" }, + { method: "thread/resume", developerInstructions: undefined }, + { method: "thread/start", developerInstructions: undefined }, + ]); + }), + ); + it.effect("propagates non-recoverable resume failures", () => Effect.gen(function* () { const client = { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 31d28d8236..a82f8eab81 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -51,6 +51,7 @@ import { resolveProviderHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions, type T3CodeToolAvailability, + buildCodexThreadInstructions, } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -583,10 +584,12 @@ function buildThreadStartParams(input: { readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly developerInstructions?: string | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); return { cwd: input.cwd, + ...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}), approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, @@ -761,6 +764,7 @@ export const openCodexThread = (input: { readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; readonly strictResume?: boolean; + readonly developerInstructions?: string | undefined; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -768,6 +772,7 @@ export const openCodexThread = (input: { runtimeMode: input.runtimeMode, model: input.requestedModel, serviceTier: input.serviceTier, + developerInstructions: input.developerInstructions, }); if (resumeThreadId === undefined) { @@ -2644,6 +2649,9 @@ export const makeCodexSessionRuntime = ( serviceTier: options.serviceTier, resumeThreadId, ...(options.strictResume ? { strictResume: true } : {}), + developerInstructions: buildCodexThreadInstructions( + configuredMcpToolAvailability(options.appServerArgs, options.mcpCapabilities), + ), }); if ( options.strictResume && diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index a21cce3793..7b81d7e681 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { buildCodexThreadInstructions } from "./CodexDeveloperInstructions.ts"; import { PAIR_LEAD_PROTOCOL, buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { @@ -141,3 +142,21 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("agent-device"); }); }); + +describe("the instructions a Codex thread starts with", () => { + it("are Pylon's own, for the tools this session has, without Codex's mode template", () => { + expect(buildCodexThreadInstructions({ browser: true, device: false, pair: true })).toBe( + buildRuntimeInstructions({ + harness: "Codex", + browserAvailable: true, + deviceAvailable: false, + delegationAvailable: false, + pairActive: true, + }), + ); + const none = buildCodexThreadInstructions(false); + expect(none).toContain("running in Pylon through the Codex harness"); + expect(none).not.toContain(""); + expect(none).not.toContain(""); + }); +}); diff --git a/docs/internals/delegation.md b/docs/internals/delegation.md index 7df50929e7..226b78cdbb 100644 --- a/docs/internals/delegation.md +++ b/docs/internals/delegation.md @@ -113,9 +113,11 @@ Two providers cannot lead and are refused by `pair_start` and by the clients, th executor. Antigravity offers no per-session control over its own subagents. Codex has the two flags above, but Codex 0.153.4 keeps all six `collaboration.*` tools with both off (checked with `codex exec -c features.multi_agent=false -c features.multi_agent_v2=false`), and in four live runs a -paired Codex lead spawned its own subagent on its own model and never called a pair tool. It also -reported seeing neither the pair protocol nor the `t3-code` tools, although the provider log shows -the server ready and the protocol sent in the turn's developer instructions; why is not yet known. +paired Codex lead spawned its own subagent on its own model and never called a pair tool. Part of +the cause was that Pylon's instructions did not reach Codex at all (see the Codex protocol traps in +[providers](providers.md)); its `t3-code` tools are deferred behind tool discovery, so a lead that is +never told about them does not find them. Codex stays refused until a paired run on a fresh thread +shows it briefing the executor with its collaboration tools still present. A Claude lead was verified end to end: `pair_handoff`, `pair_await`, the executor's own session writing the file, and the lead checking it. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index e39e4f4439..c60e294a17 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -85,6 +85,16 @@ no pending RPC response to send. Blocking questions still use the request/respon [decider](../../apps/server/src/orchestration/decider.ts) records an async answer and its user message together. +Codex takes Pylon's instructions twice. `thread/start` and `thread/resume` carry them as +`developerInstructions` ([`buildCodexThreadInstructions`](../../apps/server/src/provider/CodexDeveloperInstructions.ts)), +and each `turn/start` still carries them inside the collaboration mode's `developer_instructions` +together with Codex's own mode template. Only the first reaches the model on Codex 0.153.4, which +lists `collaboration_modes` as a removed feature: asked where it was running, a fresh thread answered +"the Codex application" without the thread-level copy and "Pylon through the Codex harness" with it. +The per-turn copy stays for older Codex versions. Whether `thread/resume` applies new instructions +to an existing conversation is not verified. Do not test delivery by asking a model to quote its +instructions; it answers "none" either way. Ask something only the instructions could tell it. + An async question can outlive the turn or a server restart. The engine reads that request's durable activity before resolving it because the in-memory command snapshot omits old activities. Do not infer that a request has disappeared merely because it is outside the recent window.