Repository navigation
Description
📋 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.Argsas 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
- Use the Go ADK OpenAI Chat Completions adapter with persistent session history and an object-validating backend such as oMLX/ollama.
- Have the agent call a zero-argument tool, such as
list_skills, with model-generated arguments"{}". - Allow the tool result and the task to complete.
- Send a follow-up message in the same context, causing a new task to load the persisted history. No suspend/resume is required.
- 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-genaiv1.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
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity
Ryounsk commentedon Sep 30, 2026
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.
fix(adk): guard nil tool-call args and coerce to empty object in Open…
alexdarbyshire commentedon Oct 6, 2026
Bug fixed with merge of #2852