diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1ce23cb63..64bf1d573 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -553,8 +553,21 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool } function parseToolArguments(argumentsJson: string) { + // getProfile, documentList and memoryForget all declare `required: []`, so a model + // may legitimately call them with no arguments. OpenAI serialises that as `""`, + // which is "no arguments" rather than malformed JSON — parse it as `{}`. + const source = argumentsJson?.trim() || "{}" + try { - return { success: true as const, value: JSON.parse(argumentsJson) } + const value = JSON.parse(source) + + // `"null"`, `"5"` and `"[]"` parse cleanly, then throw in the destructuring + // parameter of every tool function — the throw this gate exists to contain. + 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 136a19bea..777c553c3 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -6,12 +6,14 @@ const documentsDelete = vi.fn() const documentsList = vi.fn() const searchExecute = vi.fn() const clientAdd = vi.fn() +const clientProfile = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { search = { execute: searchExecute } add = clientAdd + profile = clientProfile documents = { delete: documentsDelete, list: documentsList, @@ -42,6 +44,10 @@ beforeEach(() => { }) searchExecute.mockReset() clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) + clientProfile.mockReset().mockResolvedValue({ + profile: { static: ["likes tea"], dynamic: [] }, + searchResults: undefined, + }) vi.unstubAllGlobals() }) @@ -174,6 +180,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, { + containerTags: ["user_1"], + }) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(true) + expect(clientProfile).toHaveBeenCalledTimes(1) + expect(clientProfile).toHaveBeenCalledWith({ containerTag: "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(clientProfile).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(searchExecute).not.toHaveBeenCalled() + }) + + it("still passes well-formed arguments through", async () => { + searchExecute.mockResolvedValue({ results: [{ id: "mem_1" }] }) + const execute = openAi.createToolCallExecutor(API_KEY, { + containerTags: ["user_1"], + }) + + const result = JSON.parse( + await execute( + toolCall( + "searchMemories", + JSON.stringify({ informationToGet: "tea", limit: 3 }), + ), + ), + ) + + expect(result.success).toBe(true) + expect(searchExecute).toHaveBeenCalledWith( + expect.objectContaining({ q: "tea", limit: 3 }), + ) + }) +}) + describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const CUSTOM_ID = "memories_prefs_txt"