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:
Agnik47 2026-08-28 22:25:19 +05:30 • committed by Mahesh Sanikommu
parent 44869502b1
commit b89ed824a6
2 changed files with 95 additions and 1 deletions

View file

@ -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 }
}

View file

@ -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"