Skip to content
Open
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
15 changes: 14 additions & 1 deletion packages/tools/src/openai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
87 changes: 87 additions & 0 deletions packages/tools/src/tool-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
})

Expand Down Expand Up @@ -174,6 +180,87 @@ describe("memoryForget", () => {
})
})

describe("openai executeToolCall argument parsing", () => {
type ExecutorToolCall = Parameters<
ReturnType<typeof openAi.createToolCallExecutor>
>[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"
Expand Down
Loading