Skip to content

[fix]: accept object-valued tool_search_call arguments on Responses streaming path - #4644

Merged
akshaydeo merged 1 commit into
maximhq:devfrom
devonpmack:fix/responses-stream-tool-arguments-object
Jun 23, 2026
Merged

[fix]: accept object-valued tool_search_call arguments on Responses streaming path#4644
akshaydeo merged 1 commit into
maximhq:devfrom
devonpmack:fix/responses-stream-tool-arguments-object

Conversation

@devonpmack

@devonpmack devonpmack commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

When a model streams tool calls over the OpenAI Responses API, tool_search_call items serialize their arguments as a JSON object instead of the JSON string that function_call items use.

tool_search_call items are emitted whenever the request enables OpenAI's tool_search tool (deferred tool discovery) — which Codex (codex_cli_rs) enables by default. The streamed item (response.output_item.added / .done) decodes into ResponsesMessage, whose embedded ResponsesToolMessage.Arguments is a *string. An object value therefore fails to decode:

Failed to parse stream response: Mismatch type string with value object
  ... "status":"in_progress","arguments":{},"call_id":"ca...
  ... "status":"completed","arguments":{"query":"..."} ...

HandleOpenAIResponsesStreaming logs this at warn and continues, so the item is silently dropped mid-stream. Strict streaming clients never receive it and hang on a half-open stream until their own idle watchdog fires — no error is surfaced.

This reproduces on the latest release (maximhq/bifrost:v1.5.16).

Root cause

  • Regular function_call items serialize arguments as a string — these decode fine.
  • tool_search_call items serialize arguments as an object: {} while in_progress, and e.g. {"query":"...","limit":10} when completed.
  • The object form appears when the request includes OpenAI's tool_search tool. Codex enables this by default for deferred tool discovery.

Runnable reproduction

From a local maximhq/bifrost checkout, run this command. It imports the local checkout and feeds three Responses stream frames through the same parser path Bifrost uses (schemas.Unmarshal into BifrostResponsesStreamResponse).

REPO="$PWD"
TMP="$(mktemp -d)"
cd "$TMP"

go mod init bifrost-tool-search-repro >/dev/null
go mod edit -require github.com/maximhq/bifrost/core@v0.0.0
go mod edit -replace github.com/maximhq/bifrost/core="$REPO/core"

cat > main.go <<'GO'
package main

import (
  "fmt"
  "os"

  "github.com/maximhq/bifrost/core/schemas"
)

var frames = []struct {
  label string
  raw   string
}{
  {
    "function_call args as string",
    `{"type":"response.output_item.done","output_index":1,"sequence_number":1,"item":{"id":"fc_1","type":"function_call","status":"completed","name":"grafana_query","call_id":"call_function","arguments":"{\"query\":\"rate(http_requests_total[5m])\"}"}}`,
  },
  {
    "tool_search_call args as empty object",
    `{"type":"response.output_item.added","output_index":1,"sequence_number":4,"item":{"id":"tsc_1","type":"tool_search_call","status":"in_progress","arguments":{},"call_id":"call_tool_search","execution":"client"}}`,
  },
  {
    "tool_search_call args as object",
    `{"type":"response.output_item.done","output_index":1,"sequence_number":5,"item":{"id":"tsc_1","type":"tool_search_call","status":"completed","arguments":{"query":"observability logs","limit":10},"call_id":"call_tool_search","execution":"client"}}`,
  },
}

func main() {
  failed := false
  for _, frame := range frames {
    var resp schemas.BifrostResponsesStreamResponse
    if err := schemas.Unmarshal([]byte(frame.raw), &resp); err != nil {
      fmt.Printf("FAIL: %s: %v\n", frame.label, err)
      failed = true
      continue
    }
    fmt.Printf("OK:   %s -> %s\n", frame.label, *resp.Item.Arguments)
  }
  if failed {
    os.Exit(1)
  }
}
GO

go mod tidy >/dev/null
go run .

Before this fix

Run the repro against dev / v1.5.16 and the tool_search_call frames fail with the same parse error seen in production:

OK:   function_call args as string -> {"query":"rate(http_requests_total[5m])"}
FAIL: tool_search_call args as empty object: Mismatch type string with value object ... "arguments":{},"call_id":"ca...
FAIL: tool_search_call args as object: Mismatch type string with value object ... "arguments":{"query":"observability logs"...
exit status 1

After this fix

Run the same repro on this PR branch and all frames parse. Object-valued arguments are preserved as stringified JSON:

OK:   function_call args as string -> {"query":"rate(http_requests_total[5m])"}
OK:   tool_search_call args as empty object -> {}
OK:   tool_search_call args as object -> {"query":"observability logs","limit":10}

Optional live OpenAI shape check

The deterministic repro above does not require credentials. To verify the upstream shape directly, send a Responses request that includes a tool_search tool. OpenAI streams tool_search_call frames like this:

event: response.output_item.added
data: {"item":{"type":"tool_search_call","status":"in_progress","arguments":{}, ...}}

event: response.output_item.done
data: {"item":{"type":"tool_search_call","status":"completed","arguments":{"query":"...","limit":10}, ...}}

That object-valued arguments field is what the old parser rejected.

Type of Change

  • Bug fix

Affected Packages

  • core/schemas/responses.go
  • core/schemas/responses_test.go
  • core/changelog.md

Changes Made

  • Added ResponsesMessage.UnmarshalJSON that shadows arguments as raw JSON, decodes the rest of the item normally, then normalizes arguments to the canonical stringified-JSON form (a JSON object is preserved as its raw JSON text; a JSON string is kept as-is). Every downstream consumer that reads *Arguments as stringified JSON keeps working unchanged.
  • Added responsesToolArgumentsToString helper.
  • Added regression tests covering: string arguments (unchanged), object arguments (normalized), empty object {}, an object inside a streamed response.output_item.done event, and real tool_search_call frames captured from api.openai.com.

Testing

  • Unit tests added/updated
  • Tests passing locally
go test ./schemas/ -run TestResponsesMessageToolCallArguments -v

Checklist

  • Code follows the project's code style
  • Tests are passing locally
  • Commit message follows the format: [type]: description
  • Changelog updated (core/changelog.md)

Related Issues

Relates to the broader streaming tool-call serialization issues (e.g. #3443, #3475); this one is the OpenAI Responses path receiving tool_search_call.arguments as an object rather than a string.

Made with Cursor

@CLAassistant

CLAassistant commented Jun 23, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a custom UnmarshalJSON method on ResponsesMessage that normalizes tool/function-call arguments from either a JSON string or a JSON object into the canonical *string representation. A helper responsesToolArgumentsToString performs the conversion. Tests and a changelog entry accompany the fix.

Changes

Responses API tool-call arguments normalization

Layer / File(s) Summary
Custom UnmarshalJSON and normalization helper
core/schemas/responses.go, core/changelog.md
ResponsesMessage.UnmarshalJSON reads arguments as raw JSON bytes and converts them to the canonical *string form whether the provider emits a JSON string or a JSON object; responsesToolArgumentsToString performs the conversion. Changelog documents the fix.
Tests for argument normalization
core/schemas/responses_test.go
TestResponsesMessageToolCallArguments adds subtests for string arguments, object arguments, empty object, streamed response.output_item.done events, and tool_search_call frames, verifying decode normalization and marshal round-trips.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • danpiths
  • akshaydeo
  • TejasGhatte

Poem

🐇 A hop through the JSON maze,
Where objects wore a string's disguise—
I sniffed the raw bytes, rewrote the phrase,
And normalized the tool's reply.
No more dropped calls mid-stream! Hip-hops to tidy skies. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: accepting object-valued tool_search_call arguments on the Responses streaming path, which directly addresses the core problem solved by this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, root cause, reproduction steps, and solution with clear before/after outcomes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…treaming path

OpenAI's Responses API normally serializes function_call `arguments` as a JSON
string, but some models (e.g. gpt-5.x) and the native websocket /responses
transport emit them as a JSON object. The embedded ResponsesToolMessage.Arguments
field is a *string, so an object value made the stream chunk decode fail with
"Mismatch type string with value object", silently dropping the tool call
mid-stream and hanging streaming clients until their own idle watchdog fired.

ResponsesMessage now has a custom UnmarshalJSON that shadows `arguments` as raw
JSON, decodes the rest of the item normally, then stores the canonical
stringified-JSON form (object preserved as its raw JSON text).

Affected packages:
- core/schemas/responses.go - ResponsesMessage.UnmarshalJSON + helper
- core/schemas/responses_test.go - regression tests (string + object forms, streamed item)
- core/changelog.md

Co-authored-by: Cursor <cursoragent@cursor.com>
@devonpmack
devonpmack force-pushed the fix/responses-stream-tool-arguments-object branch from 9056c30 to e488546 Compare June 23, 2026 17:11
@devonpmack devonpmack changed the title [fix]: accept object-valued tool call arguments on Responses streaming path [fix]: accept object-valued tool_search_call arguments on Responses streaming path Jun 23, 2026
@devonpmack
devonpmack marked this pull request as ready for review June 23, 2026 17:14
@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

Safe to merge — the fix is narrowly scoped to a custom UnmarshalJSON that only intercepts ResponsesMessage, correctly uses the Alias pattern to avoid recursion, and leaves all callers unchanged.

The core implementation is sound: field shadowing is correctly applied, the null/empty guard is in place, and five distinct test scenarios cover both the happy path and real production frames. The only imperfection is that the map-iteration test loop uses t.Fatalf, which means a failure on the first frame silently skips the second — so the safety net is slightly weaker than it looks.

The test loop in core/schemas/responses_test.go (the 'real tool_search_call frames' sub-test) deserves a quick glance to confirm both frames are actually exercised on failure.

Important Files Changed

Filename Overview
core/schemas/responses.go Adds UnmarshalJSON on ResponsesMessage that shadows arguments as json.RawMessage to accept both string (function_call) and object (tool_search_call) values, then normalises to a string before handing off downstream. Alias pattern is correctly applied to avoid infinite recursion; field shadowing, null-check, and nil-ResponsesToolMessage guard are all sound.
core/schemas/responses_test.go Comprehensive regression tests cover string args, object args, empty object, wrapped stream events, and real api.openai.com frames. Minor: the map-iteration loop uses t.Fatalf, so a failure on the first frame silently skips the second.
core/changelog.md One-line changelog entry correctly describing the fix; no issues.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant OAI as OpenAI SSE stream
    participant H as HandleOpenAIResponsesStreaming
    participant U as ResponsesMessage.UnmarshalJSON (new)
    participant D as Downstream consumers

    OAI->>H: "response.output_item.added {arguments:{}}"
    H->>U: Unmarshal into BifrostResponsesStreamResponse.Item
    Note over U: Shadow arguments as RawMessage<br/>Decode rest via Alias<br/>responsesToolArgumentsToString → "{}"
    U->>H: "Arguments *string = ptr("{}")"
    H->>D: publish item (no longer silently dropped)

    OAI->>H: "response.output_item.done {arguments:{query:...,limit:10}}"
    H->>U: Unmarshal into BifrostResponsesStreamResponse.Item
    Note over U: Shadow arguments as RawMessage<br/>responsesToolArgumentsToString → raw JSON string
    U->>H: "Arguments *string = ptr("{\"query\":...}")"
    H->>D: publish completed item (previously hung client)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant OAI as OpenAI SSE stream
    participant H as HandleOpenAIResponsesStreaming
    participant U as ResponsesMessage.UnmarshalJSON (new)
    participant D as Downstream consumers

    OAI->>H: "response.output_item.added {arguments:{}}"
    H->>U: Unmarshal into BifrostResponsesStreamResponse.Item
    Note over U: Shadow arguments as RawMessage<br/>Decode rest via Alias<br/>responsesToolArgumentsToString → "{}"
    U->>H: "Arguments *string = ptr("{}")"
    H->>D: publish item (no longer silently dropped)

    OAI->>H: "response.output_item.done {arguments:{query:...,limit:10}}"
    H->>U: Unmarshal into BifrostResponsesStreamResponse.Item
    Note over U: Shadow arguments as RawMessage<br/>responsesToolArgumentsToString → raw JSON string
    U->>H: "Arguments *string = ptr("{\"query\":...}")"
    H->>D: publish completed item (previously hung client)
Loading

Comments Outside Diff (1)

  1. core/schemas/responses_test.go, line 249-263 (link)

    P2 t.Fatalf in map range stops remaining frames from being tested

    The "real tool_search_call frames" sub-test iterates over a map and calls t.Fatalf on the first failing frame. If the "in_progress" frame fails, the "completed" frame is never exercised (and vice versa). Use t.Errorf (optionally with continue) so every frame is checked and all failures are surfaced in a single test run. This is especially important here because the two frames exercise meaningfully different states — an empty object {} vs. a populated one.

Reviews (1): Last reviewed commit: "[fix]: core - accept object-valued tool ..." | Re-trigger Greptile

@akshaydeo
akshaydeo merged commit 00f0583 into maximhq:dev Jun 23, 2026
6 checks passed
akshaydeo pushed a commit that referenced this pull request Jun 24, 2026
…treaming path (#4644)

OpenAI's Responses API normally serializes function_call `arguments` as a JSON
string, but some models (e.g. gpt-5.x) and the native websocket /responses
transport emit them as a JSON object. The embedded ResponsesToolMessage.Arguments
field is a *string, so an object value made the stream chunk decode fail with
"Mismatch type string with value object", silently dropping the tool call
mid-stream and hanging streaming clients until their own idle watchdog fired.

ResponsesMessage now has a custom UnmarshalJSON that shadows `arguments` as raw
JSON, decodes the rest of the item normally, then stores the canonical
stringified-JSON form (object preserved as its raw JSON text).

Affected packages:
- core/schemas/responses.go - ResponsesMessage.UnmarshalJSON + helper
- core/schemas/responses_test.go - regression tests (string + object forms, streamed item)
- core/changelog.md

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants