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
39 changes: 38 additions & 1 deletion src/responses/function-call-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
101 changes: 101 additions & 0 deletions tests/responses/responses-function-tool-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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<typeof declaration> }> = [
{ 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 => {
Expand Down Expand Up @@ -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 {
Expand Down
Loading