mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
fix(tools): accept zero-argument OpenAI tool calls and reject non-object args
`getProfile`, `documentList` and `memoryForget` all declare `required: []`,
so a model may legitimately call them with no arguments at all. The OpenAI
API serialises that as `arguments: ""`, and `parseToolArguments` handed the
empty string straight to `JSON.parse`, so every no-argument call came back as
{"success":false,"error":"Invalid JSON arguments for getProfile"}
Those three tools were unreachable in their documented no-argument form.
The same gate also lets non-object JSON through. `"null"` parses cleanly and
then rejects in the destructuring parameter of every tool function --
`TypeError: Cannot destructure property 'containerTag' of 'object null'` --
which escapes `executeToolCall`, since it has no catch, and fails the whole
request. That is precisely the throw #1488 added this gate to contain. `"5"`
and `"\"text\""` are quieter but worse: they destructure to `undefined` and
call the API with no container tag at all.
Treat blank arguments as `{}`, and require the parsed value to be a non-null,
non-array object. Malformed JSON still returns the tool error #1488 added.
Adds eight regression tests. Six of them fail against the current
implementation -- two on the blank-argument path and four on the non-object
path, one carrying the raw TypeError. The two guard tests, malformed JSON and
an ordinary well-formed call, pass both before and after, so the behaviour
This commit is contained in:
parent
44869502b1
commit
b89ed824a6
2 changed files with 95 additions and 1 deletions
|
|
@ -571,8 +571,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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,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(profileRequest).toHaveBeenCalledTimes(1)
|
||||
expect(profileRequest).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(profileRequest).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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue