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
7 changes: 7 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,13 @@ 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 leading
system and developer metadata, including developer `additional_tools`, and before conversational
input. Stateful `previous_response_id` continuations reuse tagged guidance only when it matches the latest
tagged item in their trusted replay prefix. Other generated guidance is reused when an exact generated
developer item exists in that prefix. When guidance changes, leading tool protocol stays first and
the replacement is inserted before current conversational input.
Comment thread
Wibias marked this conversation as resolved.

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
22 changes: 22 additions & 0 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,17 +324,33 @@ export function parseRequest(body: unknown): OcxParsedRequest {
// synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
let compactionRequest = false;
let contextCompactionBoundary = false;
let continuationConversationMessageIndex: number | undefined;

if (typeof data.instructions === "string" && data.instructions.length > 0) {
systemPrompt.push(data.instructions);
}

if (typeof data.input === "string") {
if (data.previous_response_id) continuationConversationMessageIndex = messages.length;
messages.push({ role: "user", content: data.input, timestamp: now });
} else if (data.input) {
for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) {
const item = data.input[inputIndex];
const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
const itemRole = (item as { role?: string }).role;
// Raw protocol items do not map one-to-one onto context messages. Capture the boundary while
// both representations are available so later metadata can stay before conversation in both.
if (
data.previous_response_id
&& inputIndex >= replayedInputPrefixLength
&& continuationConversationMessageIndex === undefined
&& (
effectiveType === "agent_message"
|| (effectiveType === "message" && (itemRole === "user" || itemRole === "assistant"))
)
) {
continuationConversationMessageIndex = messages.length;
}

if (effectiveType === "compaction_trigger") {
compactionRequest = true;
Expand Down Expand Up @@ -614,6 +630,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
}
}
}
if (data.previous_response_id && continuationConversationMessageIndex === undefined) {
continuationConversationMessageIndex = messages.length;
}

const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
const loadedTools = buildTools(loadedToolSpecs) ?? [];
Expand Down Expand Up @@ -683,6 +702,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
options,
_rawBody: body,
...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
...(continuationConversationMessageIndex !== undefined
? { _continuationConversationMessageIndex: continuationConversationMessageIndex }
: {}),
...(webSearch ? { _webSearch: webSearch } : {}),
...(imageGen ? { _imageGeneration: imageGen } : {}),
...(textFormat ? { _structuredOutput: true } : {}),
Expand Down
91 changes: 76 additions & 15 deletions src/server/responses/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,32 +396,93 @@ 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;
}

function isConversationalItem(item: unknown): boolean {
if (!isRecord(item)) return false;
if (item.type === "agent_message") return true;
const type = item.type ?? (typeof item.role === "string" ? "message" : undefined);
return type === "message" && (item.role === "user" || item.role === "assistant");
}

function statefulRawInsertionIndex(items: readonly unknown[], replayPrefixLen: number): number {
for (let index = replayPrefixLen; index < items.length; index += 1) {
if (isConversationalItem(items[index])) return index;
}
const last = items[items.length - 1];
return isRecord(last) && last.type === "compaction_trigger"
? items.length - 1
: items.length;
}

export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void {
const raw = parsed._rawBody as { input?: unknown } | undefined;
const rawInput = raw && Array.isArray(raw.input) ? raw.input : undefined;
const replayPrefixLen = rawInput
? Math.min(parsed._replayPrefixLen ?? 0, rawInput.length)
: 0;
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))) {
if (rawInput) {
const replayPrefix = rawInput.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() });
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);
const statefulContinuation = parsed.previousResponseId !== undefined;
const message = { role: "developer" as const, content: text, timestamp: Date.now() };
const statefulRawIndex = statefulContinuation && rawInput
? statefulRawInsertionIndex(rawInput, replayPrefixLen)
: undefined;

// A previous_response_id delta can begin with tool/protocol items. Keep those first, then place
// changed guidance before the current conversation. Stateless requests keep guidance in the prefix.
if (statefulContinuation) {
const index = Math.min(
parsed._continuationConversationMessageIndex ?? parsed.context.messages.length,
parsed.context.messages.length,
);
parsed.context.messages.splice(index, 0, 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 (rawInput) {
if (statefulContinuation) {
rawInput.splice(statefulRawIndex!, 0, devItem);
} else {
raw.input.push(devItem);
rawInput.splice(leadingDeveloperPrefixLength(rawInput), 0, devItem);
}
}
}
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface OcxParsedRequest {
_rawBody?: unknown;
/** Number of leading raw input items restored from local previous_response_id state. */
_replayPrefixLen?: number;
/** Parsed-message index before the first conversational item in a continuation's current delta. */
_continuationConversationMessageIndex?: number;
/** True when the proxy expanded a previous_response_id request into a full input replay. */
_previousResponseInputExpanded?: boolean;
/** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
Expand Down
161 changes: 147 additions & 14 deletions tests/multi-agent-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,28 +887,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 @@ -918,11 +925,137 @@ 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 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 text = await multiAgentGuidanceText(
fixture,
{
injectionModel: "anthropic/claude-sonnet-5",
},
{ collectCatalogState: () => ({ state: "fresh" }) },
);

expect(text).toContain("Preferred sub-agent");
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!)).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("inserts changed stateful guidance before an ordinary new user delta", () => {
const guidanceA = "<multi_agent_mode>A</multi_agent_mode>";
const guidanceB = "<multi_agent_mode>B</multi_agent_mode>";
const rawInput = [
generatedItem(guidanceA),
{ type: "message", role: "user", content: "previous turn" },
{ type: "message", role: "assistant", content: "done" },
{ type: "message", role: "user", content: "current turn" },
];
const parsed = parseRequest({ model: "gpt-5.5", input: rawInput });
parsed.previousResponseId = "resp_1";
parsed._replayPrefixLen = 3;
parsed._continuationConversationMessageIndex = 3;

injectDeveloperMessage(parsed, guidanceB);

expect(rawInput).toEqual([
generatedItem(guidanceA),
{ type: "message", role: "user", content: "previous turn" },
{ type: "message", role: "assistant", content: "done" },
generatedItem(guidanceB),
{ type: "message", role: "user", content: "current turn" },
]);
expect(parsed.context.messages.map(message => message.role)).toEqual([
"developer",
"user",
"assistant",
"developer",
"user",
]);
});

test("keeps leading stateful protocol items before changed guidance and conversation", () => {
const rawInput = [
{ type: "function_call_output", call_id: "call_1", output: "ok" },
{ type: "message", role: "user", content: "current turn" },
];
const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_remote" });

injectDeveloperMessage(parsed, guidance);

expect(rawInput).toEqual([
{ type: "function_call_output", call_id: "call_1", output: "ok" },
generatedItem(),
{ type: "message", role: "user", content: "current turn" },
]);
expect(parsed.context.messages.map(message => message.role)).toEqual(["toolResult", "developer", "user"]);
});

test("keeps raw and parsed stateful placement aligned across reconstructed compaction history", () => {
const rawInput = [
{ type: "message", role: "user", content: "current turn" },
{ type: "compaction", encrypted_content: "ocx1:c3VtbWFyeQ==" },
];
const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_remote" });

injectDeveloperMessage(parsed, guidance);

expect(rawInput[0]).toEqual(generatedItem());
expect(parsed.context.messages.map(message => message.role)).toEqual(["developer", "user", "user"]);
});

test("stateful guidance dedup uses the latest tagged item across A-B-A transitions", () => {
const guidanceA = "<multi_agent_mode>A</multi_agent_mode>";
const guidanceB = "<multi_agent_mode>B</multi_agent_mode>";
const parsed = parsedFixture({ rawInput: [generatedItem(guidanceA), { role: "user", content: "work" }] });
parsed.previousResponseId = "resp_1";
parsed._replayPrefixLen = 2;
injectDeveloperMessage(parsed, guidanceB);

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

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