mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-29 06:36:43 +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
0efc7b9d33
commit
2604c45653
2 changed files with 92 additions and 1 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,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, {
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue