Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/server/src/provider/CodexDeveloperInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
58 changes: 58 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<runtime_info>Pylon</runtime_info>");
yield* open(undefined);

NodeAssert.deepStrictEqual(payloads, [
{ method: "thread/resume", developerInstructions: "<runtime_info>Pylon</runtime_info>" },
{ method: "thread/start", developerInstructions: "<runtime_info>Pylon</runtime_info>" },
{ method: "thread/resume", developerInstructions: undefined },
{ method: "thread/start", developerInstructions: undefined },
]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { resolveProviderHomePath } from "../../pathExpansion.ts";
import {
buildCodexDeveloperInstructions,
type T3CodeToolAvailability,
buildCodexThreadInstructions,
} from "../CodexDeveloperInstructions.ts";
const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -761,13 +764,15 @@ export const openCodexThread = (input: {
readonly serviceTier: CodexServiceTier | undefined;
readonly resumeThreadId: string | undefined;
readonly strictResume?: boolean;
readonly developerInstructions?: string | undefined;
}): Effect.Effect<typeof CodexThreadResumeMetadata.Type, CodexErrors.CodexAppServerError> => {
const resumeThreadId = input.resumeThreadId;
const startParams = buildThreadStartParams({
cwd: input.cwd,
runtimeMode: input.runtimeMode,
model: input.requestedModel,
serviceTier: input.serviceTier,
developerInstructions: input.developerInstructions,
});

if (resumeThreadId === undefined) {
Expand Down Expand Up @@ -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 &&
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/RuntimeInstructions.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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("<pylon_browser>");
expect(none).not.toContain("<pylon_pair>");
});
});
8 changes: 5 additions & 3 deletions docs/internals/delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions docs/internals/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading