Skip to content

Streaming parallel tool calls: nil args crash when LiteLLM proxy returns multiple tool calls #1283

@mmuldoon-blue

Description

@mmuldoon-blue

Please update gptel first -- errors are often fixed by the time they're reported.

  • I have updated gptel to the latest commit and tested that the issue still exists

Bug Description

When a Claude model via a LiteLLM proxy returns parallel tool calls in a streaming response, tool calls with missing/unparseable arguments end up with nil args. This crashes any tool function that uses its arguments (e.g. gptel-agent--task crashes at (capitalize agent-type) with Wrong type argument: char-or-string-p, nil).

Backend

OpenAI/Azure

Steps to Reproduce

  1. Configure gptel with an OpenAI-compatible backend pointing to a LiteLLM proxy serving Claude models, with :stream t
  2. Use gptel-agent with a tool that has :confirm t and multiple required arguments (e.g. the Agent tool)
  3. Send a prompt that causes the model to invoke two parallel tool calls
  4. Accept the tool calls — the second call has nil for its args plist, crashing the tool function

Additional Context

Emacs version: 31 (macOS)
gptel version: 20260309.505
gptel-agent version: 20260308.2122
Backend: OpenAI-compatible (LiteLLM proxy → Claude us.anthropic.claude-sonnet-4-6), streaming enabled

Root cause: In gptel-openai.el line ~94-102, the streaming [DONE] handler's cl-loop silently returns nil for args when gptel--json-read-string fails to parse empty or malformed argument strings.

Validated fix: Change line 105 from when name to when (and name args) to skip tool calls with nil args:

(cl-loop
 for tool-call in tool-use
 for spec = (plist-get tool-call :function)
 for name = (plist-get spec :name)
 for args-str = (plist-get spec :arguments)
 for args = (or (ignore-errors (gptel--json-read-string args-str))
               (when (and args-str (not (string-empty-p args-str)))
                 (lwarn 'gptel :warning
                        "gptel: failed to parse tool call args for %s: %s"
                        name args-str)
                 nil))
 when (and name args)  ; skip entries missing name or args (streaming parse artifacts)
 collect (list :id (plist-get tool-call :id) :name name :args args)
 into call-specs
 finally (plist-put info :tool-use call-specs))

This fix has been tested locally and works—tool calls with nil args are now skipped silently instead of crashing the tool function.

Backtrace

Debugger entered--Lisp error: (wrong-type-argument char-or-string-p nil)
  capitalize(nil)
  #f(compiled-function () #<bytecode 0xea3893d3fc4a87b>)()
  funcall(#f(compiled-function () #<bytecode 0xea3893d3fc4a87b>))
  gptel-agent--task(#f(compiled-function (&rest args2) #<bytecode 0x1e113b6aa6de843b>) nil nil nil)
  apply(gptel-agent--task #f(compiled-function (&rest args2) #<bytecode 0x1e113b6aa6de843b>) (nil nil nil))
  gptel--accept-tool-calls(((#s(gptel-tool :function gptel-agent--task :name "Agent" ...) (:subagent_type "introspector" :description "Investigate Emacs errors" :prompt "...") #f(...))
                            (#s(gptel-tool :function gptel-agent--task :name "Agent" ...) nil #f(...))))
  gptel--inspect-accept-tool-calls()
  call-interactively(gptel--inspect-accept-tool-calls nil nil)
  command-execute(gptel--inspect-accept-tool-calls)

Note: the second tool call in the list has `nil` as its args plist (should be a plist like the first call).

Log Information

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions