Skip to content

Zero-argument tool calls persist arguments: "" and wedge the conversation permanently #57

Closed

Description

@dennisimoo

Summary

A tool call that takes no arguments is stored with arguments: "". "" is not valid JSON, so when that turn is replayed as history the upstream rejects the whole request:

{"error":{"code":null,"message":"`arguments` must be valid JSON",
 "param":"arguments","type":"invalid_request_error"}}

Codex surfaces this as stream disconnected before completion: {…}.

The critical part is that it does not recover. The bad value is conversation history, so every subsequent turn replays it and fails identically. With CODEX_RELAY_HISTORY_STORE=disk it survives restarts too. One zero-argument tool call ends the session for good.

Seen on codex-relay 0.5.5 against Meta's /chat/completions (muse-spark-1.2), but nothing in the path is provider-specific — any upstream that validates arguments as JSON will do this.

Cause

src/stream.rs, accumulating streamed tool calls:

337:  arguments: String::new(),                                       // starts empty
350:  if let Some(a) = &f.arguments { entry.arguments.push_str(a); }  // only appends when sent

A tool with no parameters never sends an arguments delta, so the accumulator stays "". That empty string is then written out verbatim in two places:

599:  "arguments": &tc.arguments,     // response.output_item.done → Codex stores it
670:  "function": { "name": &tc.name, "arguments": &tc.arguments }   // session store

Neither defaults to "{}". Line 632 shows the empty case was already anticipated one branch earlier — the delta emission is guarded with else if !tc.arguments.is_empty() — but the item and the store are not.

On the replay side, src/translate.rs:177:

cur.get("arguments").and_then(Value::as_str).unwrap_or("{}").to_string()

unwrap_or("{}") only fires when the field is absent or not a string. "" is a perfectly good &str, so the default is skipped and "" goes upstream. src/translate.rs:733 has the same unwrap_or("{}") on the inbound direction.

Suggested fix

Default the empty accumulator at the point it becomes an item, so both the Codex-facing item and the session store carry valid JSON:

let arguments = if tc.arguments.is_empty() { "{}" } else { &tc.arguments };

applied at stream.rs:599 and stream.rs:670.

Hardening translate.rs:177 / :733 to validate rather than only null-check would also catch any other route to a non-JSON arguments string:

cur.get("arguments")
    .and_then(Value::as_str)
    .filter(|a| serde_json::from_str::<Value>(a).is_ok())
    .unwrap_or("{}")

Repro sketch

  1. Point codex-relay at a provider that validates arguments (Meta does; OpenRouter and DeepSeek appear more lenient).
  2. Give the model a zero-parameter tool.
  3. Let it call that tool, then take one more turn so the call is replayed as history.
  4. The second request fails with invalid_request_error on param: "arguments", and every retry after it fails the same way.

Note

0.5.6 does not change this — its changes are the double-bar DSML dialect, the n=1 guard, reasoning_effort, and think.rs. I checked both versions for any from_str on the replay path; the only one is custom_tool_input (translate.rs:488), which also falls back to the raw string.

Happy to send a PR if the approach above looks right to you.

Activity

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

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions