Skip to content

[BUG] Zero-argument tool calls serialize as "null" after session reload in OpenAI-shaped adapters #2851

Closed

Description

@alexdarbyshire

📋 Prerequisites

  • I have searched the existing issues to avoid creating a duplicate
  • By submitting this issue, you agree to follow our Code of Conduct
  • I am using the latest version of the software
  • I have tried to clear cache/cookies or used incognito mode (if ui-related)
  • I can consistently reproduce this issue

🎯 Affected Service(s)

App Service

🚦 Impact/Severity

Blocker

🐛 Bug Description

Zero-argument tool succeeds within one task, fail on follow-up task after history is reloaded

  • Affects inference backends which require function call arguments to be an object
  • OpenAI adapters serializes nil FunctionCall.Args as the string "null" instead of "{}"
  • Observable on agent substrate suspend/resume (which triggers replay path)

Underlying JSON reprex: googleapis/go-genai#920. Filed upstream issue proposing omitempty be replaced with omitzero

Related persistence-related nil-argument problem addressed for Bedrock in #2222

🔄 Steps To Reproduce

  1. Use the Go ADK OpenAI Chat Completions adapter with persistent session history and an object-validating backend such as oMLX/ollama.
  2. Have the agent call a zero-argument tool, such as list_skills, with model-generated arguments "{}".
  3. Allow the tool result and the task to complete.
  4. Send a follow-up message in the same context, causing a new task to load the persisted history. No suspend/resume is required.
  5. The reloaded empty arguments are nil and the adapter serializes them as "null" on replay.

The underlying transformation is:

model arguments "{}"
  → non-nil empty FunctionCall.Args map
  → persistence omits args (json:"args,omitempty")
  → reload restores Args as nil
  → json.Marshal(nil map) produces null
  → provider arguments string "null"

🤔 Expected Behavior

Zero-argument tool call replayed with "arguments":"{}", including after session reload.

📱 Actual Behavior

Zero-argument tool call replayed with "arguments":"null"

Inference backend oMLX responds with HTTP Status code 422: Unprocessable content

💻 Environment

  • kagent: affected code confirmed at 90151e9833a8c26ad8a0598320132ef484ed8249.
  • Dependencies at that revision: ADK-Go v2.2.0; go-genai v1.69.0.
  • Runtime: Go ADK, OpenAI Chat Completions, persistent SQLite session history, Substrate.

🔧 CLI Bug Report

No response

🔍 Additional Context

adk-go guard in their implementation: https://github.com/google/adk-go/blob/f7e16e0226d8a255e39543df2fdfc0e15041de51/model/openaimodel/request.go#L259-L261

Claude generated analysis of behaviour in selection of inference backends

Full case matrix
  • (a) the literal string "null" (4 JSON-string characters spelling the word null) — this is
    kagent's actual bug shape, and the one the correction below is about
  • (b) JSON null (the field itself is null, not a string)
  • (c) an unparsable garbage string
  • (d) an empty string ""
  • (e) the field omitted entirely
Engine (a) "null" string (b) JSON null (c) garbage string (d) empty string "" (e) omitted Never rejects?
oMLX 422, diagnostic message 422, diagnostic message 422, diagnostic message 200, coerced to {} 200, coerced to {} No
Ollama 400 (JSON-unmarshal into object fails) 400 (type mismatch at outer decode) 400 400 (empty string fails json.Unmarshal) 400 (zero-value "" same as empty) No — rejects everything not a valid JSON object
vLLM 200, silently coerced to {} 200, silently coerced to {} 200, coerced to {} (warning logged) 200, coerced to {} 200, coerced to {} Yes — never rejects
Aphrodite Engine (vLLM fork) 200, coerced to {} (warning) 200, coerced to {} 200, coerced to {} (warning) 200, coerced to {} 200, coerced to {} Yes — never rejects for arguments content
llama.cpp (llama-server) passed through verbatim as the string "null" re-serialized to the text "null", effectively same as (a) passed through verbatim, unvalidated passed through as "" hard reject (exception → ~400) Only rejects on omission
LocalAI passed through untouched no-op, decodes to Go zero value "" passed through untouched passed through untouched zero value "", no error Yes — no validation at all found
KoboldCpp parsed to Python None, stored, no error left as None untouched left as the original garbage string (try/except swallow) left as "" untouched left absent/None Yes — silent best-effort, never rejects, never coerces to {}
mlx-lm (mlx_lm.server) parsed to Python None via unguarded json.loads, no error left untouched (falsy, short-circuits) uncaught JSONDecodeError — likely surfaces as 500-class failure, not a clean 4xx left untouched (falsy) left absent Effectively yes for known-bad shapes, but crashes ungracefully on garbage rather than either rejecting cleanly or coercing
SGLang ValueError raised (strict path) — likely 400 passed through as None, not validated (skips the str-only guard) ValueError raised — likely 400 ValueError raised (empty string fails json parse) passed through as None/absent, no error No — partial: rejects string-typed bad JSON, silently lets non-string None/absent through
Hugging Face TGI passed through as Value::String("null"), untouched passed through as Value::Null, untouched passed through if JSON-string-quoted; breaks outer request JSON if not, unrelated failure mode passed through as Value::String("") 422/400 — but only because the field itself is non-Optional, not because of content validation Only rejects on omission (structural, not content)
TabbyAPI (ExLlamaV2) parsed to Python None via json.loads, silently kept, no warning 422 at Pydantic (field typed str, JSON null fails type check) kept as original raw string, warning logged, no reject kept as "", warning logged, no reject 422 at Pydantic (required field missing) Partial — only structural type mismatches (b, e) reject; content-level malformation (a, c, d) never does
OpenAI (hosted) 200 — measured live, direct and via OpenRouter passthrough 400 "expected a string, but got null instead" — measured live 200 (case H, "not json") — measured live 200 (case D) — measured live 400 "Missing required parameter" — measured live No — rejects on wrong type (b) or absence (e) only; content of a string value is never inspected (a, c, d all pass, plus raw-object case G → 400)
OpenRouter (hosted), route to OpenAI backend 200 — identical to direct OpenAI, confirmed via provider: "OpenAI" in response 400, identical to direct OpenAI 200, identical 200, identical 400, identical Same as OpenAI — OpenRouter passes through unmodified on this route, confirmed byte-for-byte
OpenRouter (hosted), route to non-OpenAI backend (measured via meta-llama/llama-3.3-70b-instruct → DeepInfra/Together) 200 200 — OpenRouter's own gateway normalizes this away before forwarding not separately tested not separately tested 200 — normalized away Yes for cases actually tested (a, b, e, and raw-object G) — OpenRouter's gateway smooths these over for non-OpenAI backends regardless of what that backend would do directly
Unsloth N/A N/A N/A N/A N/A Ships no OpenAI-compatible server of its own; its docs point users at vLLM, llama.cpp's llama-server, or Ollama to serve exported models — behavior is entirely inherited from whichever of those serves the model

*edit - remove citations

📋 Logs

📷 Screenshots

No response

🙋 Are you willing to contribute?

  • I am willing to submit a PR to fix this issue

Activity

  1. Ryounsk commented on Sep 30, 2026

    @Ryounsk

    I have a focused fix ready for this. It normalizes nil function-call arguments to an empty JSON object at the provider boundary for OpenAI Chat Completions, OpenAI Responses, and SAP AI Core, reusing the same rule for the existing Bedrock path. I will open the PR with converter regression tests shortly.

  2. alexdarbyshire commented on Oct 6, 2026

    @alexdarbyshire
    ContributorAuthor

    Bug fixed with merge of #2852

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions