diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 5069a615..9bc593b0 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -458,8 +458,18 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool } function parseToolArguments(argumentsJson: string) { + // OpenAI sends "" for tools called with no arguments. + const source = argumentsJson?.trim() || "{}" + try { - return { success: true as const, value: JSON.parse(argumentsJson) } + const value = JSON.parse(source) + + // "null", "5" and "[]" parse but would throw when destructured. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { success: false as const } + } + + return { success: true as const, value } } catch { return { success: false as const } } diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 1ec5c0ea..0283fcc3 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -341,6 +341,87 @@ describe("memoryForget", () => { }) }) +describe("openai executeToolCall argument parsing", () => { + type ExecutorToolCall = Parameters< + ReturnType + >[0] + + function toolCall(name: string, args: string) { + return { + id: "call_1", + type: "function", + function: { name, arguments: args }, + } as ExecutorToolCall + } + + // getProfile, documentList and memoryForget declare `required: []`, so the model + // is allowed to call them with no arguments. OpenAI serialises that as "". + it.each([ + "", + " ", + ])("runs a zero-argument tool when arguments are %p", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY, { + namespace: "user_1", + }) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(true) + expect(sdk.profile).toHaveBeenCalledTimes(1) + expect(sdk.profile.mock.calls[0]?.[0]).toBe("user_1") + }) + + // These parse cleanly, so the JSON guard lets them through to a destructuring + // parameter that rejects — the throw the guard exists to contain. + it.each([ + "null", + "5", + "[]", + '"text"', + ])("rejects non-object arguments %p as a tool result rather than throwing", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for getProfile/) + expect(sdk.profile).not.toHaveBeenCalled() + }) + + it("still reports malformed JSON as a tool error", async () => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse( + await execute(toolCall("searchMemories", "{not json")), + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for searchMemories/) + expect(sdk.search).not.toHaveBeenCalled() + }) + + it("still passes well-formed arguments through", async () => { + const execute = openAi.createToolCallExecutor(API_KEY, { + namespace: "user_1", + }) + + const result = JSON.parse( + await execute( + toolCall( + "searchMemories", + JSON.stringify({ informationToGet: "tea", limit: 3 }), + ), + ), + ) + + expect(result.success).toBe(true) + expect(sdk.search).toHaveBeenCalledWith( + "user_1", + expect.objectContaining({ query: "tea", limit: 3 }), + ) + }) +}) + describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const FILE_ID = "memories_prefs_txt"