Skip to content

Commit e5c2067

Browse files
committed
fix(sessions_spawn): silently strip ACP-only fields for runtime=subagent
Schema-following LLMs (notably gpt-5.4) auto-fill every advertised tool parameter, including streamTo and resumeSessionId, regardless of the runtime value they also set. Under runtime="subagent" the executor rejected those fields with a hard error, making subagent spawn effectively unusable for these models (see #43556, #56193, #63120, Since the subagent code path never reads either field, treat them as no-ops when runtime!=="acp" instead of failing. ACP semantics are unchanged: when runtime==="acp" the fields still flow through unmodified and the existing ACP spawn logic validates them. Tests updated to match the new silently-drop behavior.
1 parent c700bfc commit e5c2067

2 files changed

Lines changed: 24 additions & 29 deletions

File tree

src/agents/tools/sessions-spawn-tool.test.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ describe("sessions_spawn tool", () => {
328328
);
329329
});
330330

331-
it("rejects resumeSessionId without runtime=acp", async () => {
331+
it("silently drops resumeSessionId for runtime=subagent", async () => {
332332
const tool = createSessionsSpawnTool({
333333
agentSessionKey: "agent:main:main",
334334
});
@@ -338,9 +338,13 @@ describe("sessions_spawn tool", () => {
338338
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
339339
});
340340

341-
expect(JSON.stringify(result)).toContain("resumeSessionId is only supported for runtime=acp");
342-
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
341+
expect(JSON.stringify(result)).not.toContain("resumeSessionId is only supported");
343342
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
343+
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
344+
const [spawnArgs] = hoisted.spawnSubagentDirectMock.mock.calls[0] as [
345+
Record<string, unknown>,
346+
];
347+
expect(spawnArgs).not.toHaveProperty("resumeSessionId");
344348
});
345349

346350
it("rejects attachments for ACP runtime", async () => {
@@ -367,7 +371,7 @@ describe("sessions_spawn tool", () => {
367371
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
368372
});
369373

370-
it('rejects streamTo when runtime is not "acp"', async () => {
374+
it('silently drops streamTo when runtime is not "acp"', async () => {
371375
const tool = createSessionsSpawnTool({
372376
agentSessionKey: "agent:main:main",
373377
});
@@ -378,13 +382,13 @@ describe("sessions_spawn tool", () => {
378382
streamTo: "parent",
379383
});
380384

381-
expect(result.details).toMatchObject({
382-
status: "error",
383-
});
384-
const details = result.details as { error?: string };
385-
expect(details.error).toContain("streamTo is only supported for runtime=acp");
385+
expect(JSON.stringify(result)).not.toContain("streamTo is only supported");
386386
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
387-
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
387+
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
388+
const [spawnArgs] = hoisted.spawnSubagentDirectMock.mock.calls[0] as [
389+
Record<string, unknown>,
390+
];
391+
expect(spawnArgs).not.toHaveProperty("streamTo");
388392
});
389393

390394
it("keeps attachment content schema unconstrained for llama.cpp grammar safety", () => {

src/agents/tools/sessions-spawn-tool.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const SessionsSpawnToolSchema = Type.Object({
101101
resumeSessionId: Type.Optional(
102102
Type.String({
103103
description:
104-
'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.',
104+
'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Only applies when runtime="acp"; silently ignored for runtime="subagent". The agent replays conversation history via session/load instead of starting fresh.',
105105
}),
106106
),
107107
model: Type.Optional(Type.String()),
@@ -176,7 +176,13 @@ export function createSessionsSpawnTool(
176176
const label = readStringParam(params, "label") ?? "";
177177
const runtime = params.runtime === "acp" ? "acp" : "subagent";
178178
const requestedAgentId = readStringParam(params, "agentId");
179-
const resumeSessionId = readStringParam(params, "resumeSessionId");
179+
// ACP-only fields: schema-strict LLMs (e.g. gpt-5.4) auto-fill these on
180+
// every call regardless of the runtime they also set. The subagent code
181+
// path never consumes either field, so silently drop them when
182+
// runtime!=="acp" instead of failing the whole spawn. ACP semantics are
183+
// preserved: fields still flow through unchanged when runtime==="acp".
184+
const resumeSessionId =
185+
runtime === "acp" ? readStringParam(params, "resumeSessionId") : undefined;
180186
const modelOverride = readStringParam(params, "model");
181187
const thinkingOverrideRaw = readStringParam(params, "thinking");
182188
const cwd = readStringParam(params, "cwd");
@@ -185,7 +191,8 @@ export function createSessionsSpawnTool(
185191
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
186192
const expectsCompletionMessage = params.expectsCompletionMessage !== false;
187193
const sandbox = params.sandbox === "require" ? "require" : "inherit";
188-
const streamTo = params.streamTo === "parent" ? "parent" : undefined;
194+
const streamTo =
195+
runtime === "acp" && params.streamTo === "parent" ? "parent" : undefined;
189196
const lightContext = params.lightContext === true;
190197
if (runtime === "acp" && lightContext) {
191198
throw new Error("lightContext is only supported for runtime='subagent'.");
@@ -213,22 +220,6 @@ export function createSessionsSpawnTool(
213220

214221
const roleContext = requestedAgentId ? { role: requestedAgentId } : {};
215222

216-
if (streamTo && runtime !== "acp") {
217-
return jsonResult({
218-
status: "error",
219-
error: `streamTo is only supported for runtime=acp; got runtime=${runtime}`,
220-
...roleContext,
221-
});
222-
}
223-
224-
if (resumeSessionId && runtime !== "acp") {
225-
return jsonResult({
226-
status: "error",
227-
error: `resumeSessionId is only supported for runtime=acp; got runtime=${runtime}`,
228-
...roleContext,
229-
});
230-
}
231-
232223
if (runtime === "acp") {
233224
const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await loadAcpSpawnModule();
234225
if (Array.isArray(attachments) && attachments.length > 0) {

0 commit comments

Comments
 (0)