diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index c4888cf1f7..fc26675dc1 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -33,6 +33,38 @@ function namespaceOf(value: unknown): string | undefined { return typeof value === "string" && value !== "functions" ? value : undefined; } +/** + * Repair one provider spelling drift for Codex's flat shell bridge. + * + * This is schema-bound rather than a general alias: `input` is also a legitimate + * argument name for arbitrary caller and MCP tools. Only the exact bare + * `exec_command` declaration can establish that its sole string input has one faithful + * reading as the required string `cmd` member. + */ +function repairExecCommandInput( + argumentsText: string, + schema: FunctionCallRepairSchema, +): string { + if (schema.namespace !== undefined || schema.name !== "exec_command") return argumentsText; + const parameters = schema.parameters; + if (!isObject(parameters) || parameters.type !== "object" + || !Array.isArray(parameters.required) || !parameters.required.includes("cmd") + || !isObject(parameters.properties) || !isObject(parameters.properties.cmd) + || parameters.properties.cmd.type !== "string") return argumentsText; + let parsed: unknown; + try { + parsed = JSON.parse(argumentsText); + } catch { + return argumentsText; + } + if (!isObject(parsed)) return argumentsText; + const keys = Object.keys(parsed); + if (keys.length !== 1 || keys[0] !== "input" || typeof parsed.input !== "string") { + return argumentsText; + } + return JSON.stringify({ cmd: parsed.input }); +} + function selectorAllows( selector: unknown, lowered: unknown, @@ -131,7 +163,12 @@ function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed if (unsafe) return item; } catch { return item; } } - const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + const integerRepaired = coerceIntegerToolArguments( + raw || "{}", + schema.parameters, + schema.namespace ? undefined : schema.name, + ); + const argumentsText = repairExecCommandInput(integerRepaired, schema); return argumentsText === raw ? item : { ...item, arguments: argumentsText }; } diff --git a/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md new file mode 100644 index 0000000000..8640b675e4 --- /dev/null +++ b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md @@ -0,0 +1,12 @@ +# ADR-0098 — decision recorded under "Schema-bound flat shell repair" + +- Contract owner: [transports/responses.md](../transports/responses.md#schema-bound-flat-shell-repair) + +## Decision record + +- 목적과 의도: Let an unambiguous routed `exec_command` wrapper reach the Codex shell bridge without weakening tool identity or argument validation. +- 기존 구현 및 제약 조건: Freeform code-mode tools already unwrap `input`, but a genuinely declared flat function must keep its name and Codex validates it against required `cmd`. +- 검토한 주요 대안: Rename the call to code-mode `exec`; rewrite every function's `input`; add a schema-bound repair at authoritative function-call completion. +- 선택한 방식: Repair only the exact bare declaration and exact one-member string payload when the current-turn schema requires string `cmd`. +- 다른 대안 대신 이 방식을 선택한 이유: Tool names and arbitrary `input` fields are caller-owned; the original schema is the only authority that makes the representation change deterministic. +- 장점, 단점 및 영향: Buffered, streamed-completion, and replay paths converge on valid `cmd` arguments; previews and ambiguous or namespaced shapes stay untouched. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b4399c97ba..f1fd790353 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -125,6 +125,16 @@ Function-call wrappers around freeform bodies are restored by is recoverable because the wrapper is otherwise unusable; two alternate fields are ambiguous and therefore remain untouched. Foreign freeform grammars never receive that compatibility rewrite. +#### Schema-bound flat shell repair + +Completed Responses function calls have one separate schema-bound flat-shell repair. When the +exact bare `exec_command` declaration requires a string `cmd`, a provider result containing only +the string member `{ "input": "..." }` is rewritten to `{ "cmd": "..." }`; the call name is not +changed. Namespaced tools, additional or conflicting members, non-string values, malformed JSON, +partial streaming previews, and schemas that do not prove this exact contract remain byte-exact. + +> Decision record: [ADR-0098](../decisions/ADR-0098-schema-bound-exec-command-input-repair.md) + Progressive preview for those wrappers is decoded by `src/responses/progressive-freeform-input.ts` in both the adapter-event bridge and routed function-call restoration, over the classification in `src/responses/freeform-wrapper-scan.ts`. diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 599e9cd9fa..7577ac1559 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -211,6 +211,77 @@ describe("pure function completion repair", () => { expect(repairFunctionCalls(missing, schemas).value).toBe(missing); }); + test("repairs the exact flat exec_command input wrapper into its required cmd", () => { + const shell = { + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + additionalProperties: false, + }, + }; + const map = collectFunctionCallRepairSchemas({ tools: [shell] }); + const call = item('{"input":"printf hello"}', { name: "exec_command" }); + expect(repairFunctionCalls(call, map).value) + .toEqual({ ...call, arguments: '{"cmd":"printf hello"}' }); + + const reserved = item('{"input":"printf hello"}', { + name: "exec_command", + namespace: "functions", + }); + expect(repairFunctionCalls(reserved, map).value) + .toEqual({ ...reserved, arguments: '{"cmd":"printf hello"}' }); + }); + + test("exec_command input repair stays fail-closed outside the exact schema and payload", () => { + const declaration = (parameters: Record, namespace?: string) => ({ + type: "function", + name: "exec_command", + ...(namespace ? { namespace } : {}), + parameters, + }); + const canonical = { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }; + const cases: Array<{ payload: string; tool?: ReturnType }> = [ + { payload: '{"cmd":"already canonical"}' }, + { payload: '{"input":"one","cwd":"/tmp"}' }, + { payload: '{"input":7}' }, + { payload: '{"input":"one","cmd":"two"}' }, + { payload: '{"input":"one"', }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, required: [] }) }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, properties: { cmd: { type: "number" } } }) }, + ]; + for (const entry of cases) { + const tool = entry.tool ?? declaration(canonical); + const map = collectFunctionCallRepairSchemas({ tools: [tool] }); + const namespace = tool.namespace; + const call = item(entry.payload, { + name: namespace ? `${namespace}__exec_command` : "exec_command", + ...(namespace ? { namespace } : {}), + }); + expect(repairFunctionCalls(call, map).value).toBe(call); + } + + // A namespace is declared by its group, not by a `namespace` property on a + // top-level function. Exercise the actual remote identity so this proves the + // bare-only repair guard rather than passing because schema lookup missed. + const remoteMap = collectFunctionCallRepairSchemas({ tools: [{ + type: "namespace", + name: "remote", + tools: [declaration(canonical)], + }] }); + const remoteCall = item('{"input":"one"}', { + name: "exec_command", + namespace: "remote", + }); + expect(repairFunctionCalls(remoteCall, remoteMap).value).toBe(remoteCall); + }); + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { @@ -269,6 +340,36 @@ describe("native function completion SSE", () => { } finally { rewrite.dispose?.(); budget.dispose(); } }); + test("repairs exec_command input only at authoritative SSE completion boundaries", () => { + const map = collectFunctionCallRepairSchemas({ tools: [{ + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }] }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(map); + const shellItem = item("", { name: "exec_command", status: "in_progress" }); + try { + const added = frame("response.output_item.added", { output_index: 0, item: shellItem }); + const delta = frame("response.function_call_arguments.delta", { + item_id: "fc_one", + delta: '{"input":"printf hello"}', + }); + expect(rewrite(added)).toEqual([added]); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { + item_id: "fc_one", + arguments: '{"input":"printf hello"}', + })); + expect(payload(done[0]!)).toMatchObject({ arguments: '{"cmd":"printf hello"}' }); + } finally { + rewrite.dispose?.(); + } + }); + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); try {