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
41 changes: 25 additions & 16 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
if (namespace) tool.namespace = namespace;
out.push(tool);
};
const pushCustom = (t: Record<string, unknown>, namespace?: string) => {
// Freeform custom tools are lowered to a single string `input` because chat models cannot
// emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
// tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
// routed models that the nested helper name is itself a callable top-level tool.
const inputDescription = t.name === "apply_patch"
? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope."
: "Raw freeform input for this tool.";
const tool: OcxTool = {
name: t.name as string,
description: (t.description as string) ?? "",
parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] },
freeform: true,
};
if (namespace) tool.namespace = namespace;
out.push(tool);
};
for (const t of tools) {
if (!isObj(t)) continue;
if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) {
Expand All @@ -178,27 +195,19 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
if (t.type === "function" && typeof t.name === "string") {
pushFn(t);
} else if (t.type === "namespace" && Array.isArray(t.tools)) {
// MCP tools arrive grouped under a namespace tool; flatten the inner function tools so
// chat-completions models receive them (round-trip restores the namespace in the bridge).
const ns = typeof t.name === "string" ? t.name : undefined;
// Codex 0.147 groups its ordinary client tools under the reserved `functions` namespace,
// including freeform custom tools such as code-mode `exec`. Those children are still
// top-level Responses tools, so flatten them without a namespace. Other namespace groups
// are MCP-style and keep their namespace for round-trip routing.
const builtinFunctions = t.name === "functions";
const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined;
for (const inner of t.tools as unknown[]) {
if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns);
else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner);
}
}
else if (t.type === "custom" && typeof t.name === "string") {
// Freeform custom tools are lowered to a single string `input` because chat models cannot
// emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
// tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
// routed models that the nested helper name is itself a callable top-level tool.
const inputDescription = t.name === "apply_patch"
? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope."
: "Raw freeform input for this tool.";
out.push({
name: t.name,
description: (t.description as string) ?? "",
parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] },
freeform: true,
});
pushCustom(t);
}
else if (t.type === "tool_search") {
// Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools).
Expand Down
34 changes: 34 additions & 0 deletions tests/responses-tool-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { parseRequest } from "../src/responses/parser";
import { cursorRequestUsesCodeMode } from "../src/adapters/cursor/tool-definitions";
import type { AdapterEvent } from "../src/types";
import { jsonItemTypes, jsonToolItems, streamedView } from "./helpers/responses-conformance";

Expand Down Expand Up @@ -64,6 +65,39 @@ describe("Responses Lite additional_tools declaration merge", () => {
expect(parsed.context.tools?.find(tool => tool.name === "search")?.namespace).toBe("github");
});

it("flattens Codex 0.147 built-in functions and preserves nested custom exec", () => {
const parsed = parseRequest(request([
{
type: "additional_tools",
role: "developer",
tools: [
{
type: "namespace",
name: "functions",
tools: [
{ type: "custom", name: "exec", description: "Run JavaScript with nested helpers." },
{ type: "function", name: "wait", parameters: { type: "object", properties: {} } },
],
},
{
type: "namespace",
name: "collaboration",
tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object", properties: {} } }],
},
],
},
]));

const exec = parsed.context.tools?.find(tool => tool.name === "exec");
const wait = parsed.context.tools?.find(tool => tool.name === "wait");
const spawn = parsed.context.tools?.find(tool => tool.name === "spawn_agent");
expect(exec).toMatchObject({ name: "exec", freeform: true });
expect(exec?.namespace).toBeUndefined();
expect(wait?.namespace).toBeUndefined();
expect(spawn?.namespace).toBe("collaboration");
expect(cursorRequestUsesCodeMode(parsed.context.tools)).toBe(true);
});

it("preserves wire order across multiple additional_tools groups", () => {
const parsed = parseRequest(request([
{ type: "additional_tools", role: "developer", tools: [fnTool] },
Expand Down
Loading