Skip to content
Draft
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
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/guides/sub-agent-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ The dashboard's **Sub-agent delegation** controls three related settings:
`multiAgentGuidanceEnabled` defaults to on and is the master switch for opencodex-authored guidance
on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text.

For array-form stateless Responses requests, opencodex places generated guidance after the leading
developer metadata and before conversational input. Stateful `previous_response_id` continuations
reuse an exact item from their trusted replay prefix instead of adding another copy.
Comment on lines +54 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the system-prefix rule.

Line 54 says that generated guidance follows leading developer metadata. injectDeveloperMessage also places guidance after leading system messages. This can cause users to expect different ordering for array-form requests with system metadata.

Proposed documentation fix
-For array-form stateless Responses requests, opencodex places generated guidance after the leading
-developer metadata and before conversational input. Stateful `previous_response_id` continuations
+For array-form stateless Responses requests, opencodex places generated guidance after the leading
+system and developer metadata, including developer `additional_tools`, and before conversational input. Stateful `previous_response_id` continuations
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
For array-form stateless Responses requests, opencodex places generated guidance after the leading
developer metadata and before conversational input. Stateful `previous_response_id` continuations
reuse an exact item from their trusted replay prefix instead of adding another copy.
For array-form stateless Responses requests, opencodex places generated guidance after the leading
system and developer metadata, including developer `additional_tools`, and before conversational input. Stateful `previous_response_id` continuations
reuse an exact item from their trusted replay prefix instead of adding another copy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/sub-agent-surface.md` around lines 54 - 56,
Update the documentation around the array-form stateless Responses request
ordering to explicitly state that generated guidance is inserted after leading
system and developer metadata, before conversational input. Keep the existing
stateful previous_response_id replay-prefix behavior unchanged.

Source: Path instructions


These are instructions to the main agent, not a proxy-side spawn router. On v2, a full-history fork
inherits the parent model and rejects model or effort overrides. Guidance therefore tells Codex to
use `fork_turns: "none"` (or a positive partial turn count such as `"3"`) when passing `model` or
Expand Down
62 changes: 50 additions & 12 deletions src/server/responses/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,32 +378,70 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function isGeneratedDeveloperItem(item: unknown, text: string): boolean {
if (!isRecord(item) || item.type !== "message" || item.role !== "developer") return false;
if (!Array.isArray(item.content) || item.content.length !== 1) return false;
function generatedDeveloperText(item: unknown): string | undefined {
if (!isRecord(item) || item.type !== "message" || item.role !== "developer") return undefined;
if (!Array.isArray(item.content) || item.content.length !== 1) return undefined;
const [part] = item.content;
return isRecord(part) && part.type === "input_text" && part.text === text;
return isRecord(part) && part.type === "input_text" && typeof part.text === "string"
? part.text
: undefined;
}

function isGeneratedDeveloperItem(item: unknown, text: string): boolean {
return generatedDeveloperText(item) === text;
}

function isDeveloperPrefixItem(item: unknown): boolean {
if (!isRecord(item)) return false;
if (item.type === "additional_tools") return item.role === "developer";
const type = item.type ?? (typeof item.role === "string" ? "message" : undefined);
return type === "message" && (item.role === "system" || item.role === "developer");
}

function leadingDeveloperPrefixLength(items: readonly unknown[]): number {
let index = 0;
while (index < items.length && isDeveloperPrefixItem(items[index])) index += 1;
return index;
}

export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void {
const raw = parsed._rawBody as { input?: unknown } | undefined;
const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] };
if (raw && Array.isArray(raw.input)) {
const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, raw.input.length);
if (raw.input.slice(0, replayPrefixLen).some(item => isGeneratedDeveloperItem(item, text))) {
const replayPrefix = raw.input.slice(0, replayPrefixLen);
const taggedGuidance = text.startsWith("<multi_agent_mode>") && text.endsWith("</multi_agent_mode>");
const lastTaggedGuidance = taggedGuidance
? replayPrefix.map(generatedDeveloperText)
.filter(item => item?.startsWith("<multi_agent_mode>") && item.endsWith("</multi_agent_mode>"))
.at(-1)
: undefined;
if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) {
return;
}
}

parsed.context.messages.push({ role: "developer", content: text, timestamp: Date.now() });
const statefulContinuation = parsed.previousResponseId !== undefined;
const message = { role: "developer" as const, content: text, timestamp: Date.now() };

// A previous_response_id delta can begin with a tool result, and changed replayed guidance must
// remain earlier than its replacement. Stateless requests can keep guidance in the prefix.
if (statefulContinuation) {
parsed.context.messages.push(message);
} else {
const prefixLen = parsed.context.messages.findIndex(item => item.role !== "developer");
parsed.context.messages.splice(prefixLen < 0 ? parsed.context.messages.length : prefixLen, 0, message);
}

if (raw && Array.isArray(raw.input)) {
// compaction_trigger must remain the final input item (codex-rs + ChatGPT backend both
// validate this). Insert the developer message BEFORE the trigger when present.
const last = raw.input[raw.input.length - 1];
if (last && typeof last === "object" && (last as { type?: string }).type === "compaction_trigger") {
raw.input.splice(raw.input.length - 1, 0, devItem);
if (statefulContinuation) {
const last = raw.input[raw.input.length - 1];
const index = isRecord(last) && last.type === "compaction_trigger"
? raw.input.length - 1
: raw.input.length;
raw.input.splice(index, 0, devItem);
} else {
raw.input.push(devItem);
raw.input.splice(leadingDeveloperPrefixLength(raw.input), 0, devItem);
}
}
}
105 changes: 91 additions & 14 deletions tests/multi-agent-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,28 +822,35 @@ describe("injectDeveloperMessage", () => {
&& (part as Record<string, unknown>).text === text;
}).length;

test("appends to both the parsed messages and the raw passthrough input", () => {
const parsed = parsedFixture({ reasoning: "max" });
test("inserts after leading developer metadata and before conversation", () => {
const parsed = parseRequest({
model: "gpt-5.5",
input: [
{ type: "message", role: "system", content: [{ type: "input_text", text: "system" }] },
{ type: "message", role: "developer", content: [{ type: "input_text", text: "native mode" }] },
{ type: "additional_tools", role: "developer", tools: [] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "work" }] },
],
});
injectDeveloperMessage(parsed, "hello there");
const last = parsed.context.messages.at(-1)!;
expect(last.role).toBe("developer");
expect(last.content).toBe("hello there");

expect(parsed.context.systemPrompt).toEqual(["system"]);
expect(parsed.context.messages.map(message => message.role)).toEqual(["developer", "developer", "user"]);
expect(parsed.context.messages[1]!.content).toBe("hello there");
const rawInput = (parsed._rawBody as { input: unknown[] }).input;
expect(rawInput.at(-1)).toEqual({
type: "message",
role: "developer",
content: [{ type: "input_text", text: "hello there" }],
});
expect(rawInput[2]).toMatchObject({ type: "additional_tools", role: "developer" });
expect(rawInput[3]).toEqual(generatedItem("hello there"));
expect(rawInput[4]).toMatchObject({ type: "message", role: "user" });
});

test("string raw input is left alone", () => {
const parsed = parsedFixture({ reasoning: "max", rawInput: "plain" });
injectDeveloperMessage(parsed, "note");
expect((parsed._rawBody as { input: unknown }).input).toBe("plain");
expect(parsed.context.messages.at(-1)!.content).toBe("note");
expect(parsed.context.messages[0]!.content).toBe("note");
});

test("inserts BEFORE compaction_trigger so it stays the final input item", () => {
test("inserts before conversation while compaction_trigger stays final", () => {
const parsed = parsedFixture({ reasoning: "max" });
const rawBody = parsed._rawBody as { input: unknown[] };
rawBody.input = [
Expand All @@ -853,11 +860,81 @@ describe("injectDeveloperMessage", () => {
injectDeveloperMessage(parsed, "guidance text");
const input = rawBody.input;
expect(input).toHaveLength(3);
expect((input[1] as { type: string }).type).toBe("message");
expect((input[1] as { role: string }).role).toBe("developer");
expect((input[0] as { type: string }).type).toBe("message");
expect((input[0] as { role: string }).role).toBe("developer");
expect((input[1] as { role: string }).role).toBe("user");
expect((input[2] as { type: string }).type).toBe("compaction_trigger");
});

test("consecutive stateless requests keep one stale or fresh guidance item before conversation", async () => {
const dir = codexHomeFixture(V2_ON);
catalogFixture(dir, [{
slug: "anthropic/claude-sonnet-5",
efforts: ["low", "medium", "high", "xhigh"],
multiAgentVersion: "v2",
}]);
const fixture = parsedFixture({ reasoning: "medium" });
const cases = [
["stale", await multiAgentGuidanceText(
fixture,
{ injectionModel: "anthropic/claude-sonnet-5" },
{ collectCatalogState: () => ({ state: "stale" }) },
)],
["fresh", await multiAgentGuidanceText(fixture, {
injectionModel: "anthropic/claude-sonnet-5",
})],
] as const;

expect(cases[0][1]).toContain("The model catalog changed");
expect(cases[1][1]).toContain("Preferred sub-agent");
for (const [label, text] of cases) {
for (const content of ["first", "second"]) {
const parsed = parsedFixture({
reasoning: "medium",
rawInput: [{ type: "message", role: "user", content }],
});
injectDeveloperMessage(parsed, text!);
const rawInput = (parsed._rawBody as { input: unknown[] }).input;
expect(countExact(rawInput, text!), label).toBe(1);
expect(rawInput).toEqual([generatedItem(text!), { type: "message", role: "user", content }]);
}
}
});

test("keeps an unexpanded previous_response_id tool delta first", () => {
const parsed = parsedFixture({
reasoning: "max",
rawInput: [{ type: "function_call_output", call_id: "call_1", output: "ok" }],
});
parsed.previousResponseId = "resp_remote";
injectDeveloperMessage(parsed, guidance);

const rawInput = (parsed._rawBody as { input: unknown[] }).input;
expect(rawInput[0]).toMatchObject({ type: "function_call_output", call_id: "call_1" });
expect(rawInput[1]).toEqual(generatedItem());
expect(parsed.context.messages.at(-1)).toMatchObject({ role: "developer", content: guidance });
});

test("stateful guidance changes remain latest across stale-fresh-stale transitions", () => {
const stale = "<multi_agent_mode>stale</multi_agent_mode>";
const fresh = "<multi_agent_mode>fresh</multi_agent_mode>";
const parsed = parsedFixture({ rawInput: [generatedItem(stale), { role: "user", content: "work" }] });
parsed.previousResponseId = "resp_1";
parsed._replayPrefixLen = 2;
injectDeveloperMessage(parsed, fresh);

const replay = parsedFixture({ rawInput: [
...(parsed._rawBody as { input: unknown[] }).input,
{ role: "assistant", content: "done" },
] });
replay.previousResponseId = "resp_2";
replay._replayPrefixLen = 4;
injectDeveloperMessage(replay, fresh);
expect((replay._rawBody as { input: unknown[] }).input).toHaveLength(4);
injectDeveloperMessage(replay, stale);
expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(stale));
});

test("exact-guidance predicate rejects every near-match replay-prefix shape (#326)", () => {
const nearMatches: Array<[string, unknown]> = [
["non-record item", null],
Expand Down
2 changes: 1 addition & 1 deletion tests/responses-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ describe("Responses previous_response_id state", () => {
const parsed2 = parseRequest(request2);
expect(parsed2._replayPrefixLen).toBe(3);
const request2Input = (request2 as { input: Array<Record<string, unknown>> }).input;
expect(request2Input[1]).toMatchObject({ role: "developer" });
expect(request2Input[0]).toMatchObject({ role: "developer" });
expect(request2Input[2]).toMatchObject({ type: "function_call" });
injectDeveloperMessage(parsed2, guidance);
expect(countRawGuidance(request2)).toBe(1);
Expand Down
Loading