Skip to content

fix(llm): preserve opaque tool-call fields across OpenAI-compatible turns - #1233

Closed
basil-k-aji-dev wants to merge 5 commits into
alibaba:mainfrom
basil-k-aji-dev:fix/preserve-opaque-tool-call-fields
Closed

basil-k-aji-dev wants to merge 5 commits into
alibaba:mainfrom
basil-k-aji-dev:fix/preserve-opaque-tool-call-fields

Conversation

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor

Description

Some OpenAI-compatible providers attach opaque metadata to an assistant tool call that must be echoed unchanged on later turns. Vertex AI's OpenAI-compatible Gemini endpoint returns a thought signature under tool_calls[].extra_content.

mapOpenAIResponse mapped the SDK response onto OCR's ToolCall, which modelled only id, type and function, so everything else was dropped. buildOpenAIParams then rebuilt the assistant history without it and the provider rejected the next request with HTTP 400.

This breaks ordinary repository tools: turn one can call file_read, and turn two fails before the model can continue the review.

Change

Four properties the issue asks for, and how each is held:

  • Reserved fields stay ours. SetExtraFields overrides same-key fields, so id, type and function are stripped at capture time. A provider cannot use this channel to rewrite the identity of a tool call.
  • Malformed data is not forwarded. A payload that does not parse yields nil rather than a partial map. Forwarding half a payload is worse than dropping it, since the provider would reject the request either way.
  • Opaque data stays out of logs. The field is json:"-". Session logs already build an explicit id/name/arguments map in internal/session/history.go, so they were never a leak path, but the tag closes the struct-marshalling one.
  • Unknown fields survive nested. json.RawMessage keeps the subtree verbatim rather than round-tripping through a typed shape.

Only the OpenAI chat-completions path is touched. The Anthropic mapping builds ToolCall from typed tool_use blocks with no raw payload to carry, so it is unaffected.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional changes)
  • Documentation update
  • CI / Build / Tooling

How Has This Been Tested?

  • make test passes locally
  • Manual testing (described below)

Added four tests in internal/llm/native_turn_test.go, alongside the existing reasoning_content replay tests:

  • TestOpenAIChatCompletions_ReplaysOpaqueToolCallFieldsAcrossTurns — the end-to-end adapter regression the issue asks for. It maps a response carrying extra_content.google.thought_signature, rebuilds the assistant history, and asserts the nested subtree appears in the next request while id and function.name remain OCR's.
  • TestOpaqueToolCallFields_ReservedFieldsStayOurs — a response claiming id/type/function cannot capture them.
  • TestOpaqueToolCallFields_MalformedNotForwarded — empty, truncated, non-object and non-JSON payloads all yield nothing.
  • TestToolCall_OpaqueFieldsStayOutOfSerializedOutput — the opaque payload does not appear in marshalled output.

I verified the end-to-end test actually catches the defect rather than passing by construction. With the capture line removed it fails with exactly the behaviour from the report:

--- FAIL: TestOpenAIChatCompletions_ReplaysOpaqueToolCallFieldsAcrossTurns
    assistant tool-call history dropped extra_content:
    {"tool_calls":[{"id":"call_1","function":{"arguments":"{}","name":"file_read"},"type":"function"}],"role":"assistant"}

make check (license and english-check included) and the full package list pass.

One environment note, for accuracy: make test runs with -race, and the race detector cannot start on my arm64 machine (ThreadSanitizer: unsupported VMA range, Found 47 - Supported 48) for any package, on main as well as on this branch. I ran the same package list with LC_ALL=C go test -count=1; all packages pass. CI covers the -race path.

I do not have Vertex AI access, so the live 400 is not something I can reproduce first-hand. The tests are built on the provider-independent reproduction described in the issue.

Checklist

  • My code follows the project's coding style (go fmt, go vet)
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly (if applicable)
  • I have signed the CLA
  • I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM.

AI disclosure: developed with AI assistance (Claude Opus 5, via Claude Code). I reviewed the change and the reasoning myself, no commits are attributed to AI, and I will answer maintainer questions in my own words.

Related Issues

Closes #947

…urns

Some providers attach metadata to an assistant tool call that must be
echoed unchanged on the next request. Vertex AI's Gemini endpoint returns
a thought signature under extra_content and rejects the following request
with HTTP 400 when it is missing, so a review died on its second turn.

Capture the tool-call fields OCR does not model and replay them via the
SDK's SetExtraFields. id, type and function stay owned by OCR, malformed
payloads are dropped rather than forwarded, and the opaque data is
json:"-" so it stays out of review and session output.

Fixes alibaba#947
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 1 issue(s) in this PR.

  • ✅ Successfully posted inline: 1 comment(s)

Comment thread internal/llm/client.go Outdated
Comment on lines +288 to +290
if !json.Valid(v) {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
The json.Valid(v) check is redundant here. When json.Unmarshal decodes into map[string]json.RawMessage, the decoder must fully parse each value to locate token boundaries — if any value were invalid JSON, the Unmarshal on line 280 would have returned an error. This means every json.Valid call re-parses bytes that are already guaranteed valid, adding unnecessary CPU cost proportional to the number and size of extra fields on every response mapping.

Removing this check is safe and eliminates the redundant work:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 468f90a.

I checked the claim before acting on it rather than assuming: json.Unmarshal into map[string]json.RawMessage has to scan each value to find its boundaries, so a malformed inner value fails at the Unmarshal on line 280 and never reaches the loop. Confirmed against {"a":{invalid}}, {"a":{"b":}}, {"a":tru} and {"a":} — all error at Unmarshal.

Removed the len(v) == 0 guard in the same commit, since it is dead for the same reason: a value in a valid document is always at least one byte (null is four). Left a comment on the Unmarshal saying retained values are valid by construction, so the next reader does not re-add either check.

The malformed-payload behaviour the issue asks for is unchanged — it comes from the Unmarshal error path, and TestOpaqueToolCallFields_MalformedNotForwarded still covers it.

@pdrivom

pdrivom commented Sep 14, 2026

Copy link
Copy Markdown

Independent verification on a second Gemini surface: the AI Studio endpoint https://generativelanguage.googleapis.com/v1beta/openai (the built-in gemini provider), model gemini-3.8-flash. This PR's description cites Vertex AI — the same break and the same fix hold here.

Minimal repro against the endpoint, no OCR involved. Turn 1 returns a tool call carrying extra_content.google.thought_signature (348 bytes in this run). Replaying that tool call with only id / type / function:

HTTP 400 INVALID_ARGUMENT
Function call is missing a thought_signature in functionCall parts. This is required for
tools to work correctly, and missing thought_signature may lead to degraded model
performance. Additional data, function call `default_api:code_search` , position 2.

Re-sending the identical request with extra_content restored: HTTP 200.

OCR, same repository and diff both times — one 20-line Python file with a planted SQL injection, a ZeroDivisionError and an unused import:

  • v1.12.0 release binary: util.py: rejected by provider (HTTP 400) -> failed, 1 tool calls, 4 439 tokens, all 1 file review(s) failed. The first file_read is enough to end the run, so no tool-using review can complete on this provider.
  • This PR built from 468f90a: Review complete: 3 finding(s), all three plants found with sensible severities (security · critical on the injection), 49 206 tokens. go test ./internal/llm/... passes.

Two notes that may be useful:

  • The signature is not always small. On ordinary tool calls it was 250–350 bytes, but on the turn where the model emitted its review comments it was ~15 KB. It rides in every subsequent request, so token reports for tool-using reviews will rise against v1.12.0 — expected, but easy to misread as a regression.
  • json:"-" on the field looks right and costs nothing for resume: ResumeState is a file-fingerprint checkpoint index rather than a conversation replay, so nothing needs the signature to survive into the session JSONL.

Related: #1042 is what makes this hard to diagnose in the first place — the 400 surfaces with an empty body, so the message quoted above never reaches the user.

Disclosure: these verification runs were driven with an AI coding assistant (Claude Opus 5). Every output quoted above is from a real run against the live endpoint.

mbullington added a commit to withremyinc/open-code-review that referenced this pull request Sep 15, 2026
@subaru-ye

Copy link
Copy Markdown

I tested commit 468f90a with a small local SSE server, and it looks like the streaming path is still missing this metadata.

The test makes two requests. The first streamed response returns a tool call with extra_content.google.thought_signature. The server rejects the second request with HTTP 400 unless that value is sent back unchanged.

I tried putting extra_content in both the first tool-call chunk and a later chunk. Both cases fail on the current PR with missing thought_signature, while the non-streaming path works.

It looks like the field gets lost in openai.ChatCompletionAccumulator before mapOpenAIResponse sees the completed response. I confirmed this by temporarily saving choice.Delta.ToolCalls[*].JSON.ExtraFields before AddChunk and attaching it back by choice and tool index afterward. The same tests then passed with the race detector enabled.

Could we cover the streaming path in this PR as well? I have the focused test and a small patch ready if that helps.

Gnoot01 added a commit to modsutd-community/modsutd that referenced this pull request Sep 16, 2026
Gemini's OpenAI-compatible endpoint rejects a follow-up turn that does not
echo the opaque fields it attached to a tool call, so every review this
provider ran ended as a failed subtask and the work fell through to Groq and
then OpenAI. alibaba/open-code-review#1233 fixes it in internal/llm/client.go
and is not in a release.

Pinning that pull request through uses: would change nothing. The action is
composite and installs the npm package, a launcher that downloads a published
binary, so a fork of the action runs the identical engine. The job builds the
engine instead and prepends it to the path, where it answers ocr ahead of the
shim npm installs. The action, including its own install step, is untouched.

Both new steps continue on error, so a build that breaks degrades to the
published binary rather than taking the provider chain down. Base commit and
fix commit are pinned by sha: the fix sits on a fork branch that can be
force-pushed.

Delete both steps once npm ships a version past 1.12.4 carrying the fix.
The non-streaming capture reads tc.RawJSON() on the completed response.
ChatCompletionAccumulator builds its ChatCompletion field by field and
never sets raw JSON on the assembled message or its tool calls, so on the
streaming path that string is empty and opaqueToolCallFields returns nil:
Vertex AI's thought signature was still dropped and the next request still
rejected with HTTP 400.

Capture the unmodelled fields from the tool-call delta instead, where they
still exist, keyed by choice and tool index and merged across chunks since
a provider may attach the metadata to any chunk of the call. Reattach after
the accumulated response is mapped. JSON.ExtraFields holds only what the
SDK does not model, so the stream's own "index" framing never reaches the
next request, and the reserved filter still applies so a provider cannot
rewrite a tool call's identity on either path.

Reported by @subaru-ye on alibaba#1233.

Co-authored-by: subaru-ye <178248423+subaru-ye@users.noreply.github.com>
basil-k-aji-dev added a commit to basil-k-aji-dev/open-code-review that referenced this pull request Sep 17, 2026
The non-streaming capture reads tc.RawJSON() on the completed response.
ChatCompletionAccumulator builds its ChatCompletion field by field and
never sets raw JSON on the assembled message or its tool calls, so on the
streaming path that string is empty and opaqueToolCallFields returns nil:
Vertex AI's thought signature was still dropped and the next request still
rejected with HTTP 400.

Capture the unmodelled fields from the tool-call delta instead, where they
still exist, keyed by choice and tool index and merged across chunks since
a provider may attach the metadata to any chunk of the call. Reattach after
the accumulated response is mapped. JSON.ExtraFields holds only what the
SDK does not model, so the stream's own "index" framing never reaches the
next request, and the reserved filter still applies so a provider cannot
rewrite a tool call's identity on either path.

Reported by @subaru-ye on alibaba#1233.
@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@subaru-ye You're right, and thanks for the precise diagnosis — it saved me the search. Reproduced both of your cases and fixed on the streaming path in a0c9ef1.

Reproduction. A local SSE server, extra_content.google.thought_signature on the tool call, replayed through NewToolCallMessage -> buildOpenAIParams. Both placements you describe fail on 468f90a:

--- FAIL: .../metadata_on_the_chunk_that_opens_the_call
    streamed tool-call history dropped extra_content:
    {"tool_calls":[{"id":"call_1","function":{"arguments":"{}","name":"file_read"},"type":"function"}],"role":"assistant"}
--- FAIL: .../metadata_on_a_later_chunk
    (identical)

Root cause — one step sharper than "lost in the accumulator". I probed the SDK types directly. The delta does carry it:

DELTA tc raw={"index":0,"id":"call_1",...,"extra_content":{"google":{"thought_signature":"sig"}}}
   DELTA ExtraFields["extra_content"] = {"google":{"thought_signature":"sig"}}
ACCUM tc raw=""
ACCUM message raw=""

ChatCompletionAccumulator synthesizes its ChatCompletion field by field and never sets raw JSON on the assembled message or its tool calls. So RawJSON() is "", and opaqueToolCallFields returns nil on its first line — the data is gone before any filtering is reached. Worth noting for anyone tempted by a narrower fix: the accumulated ChatCompletionMessageToolCallUnion.JSON has no ExtraFields map at all (only ID/Function/Type/Custom), so there is no post-accumulation path to the value. Capturing from the delta, as you did, is the only place it can be done.

The fix, matching the reasoning_content precedent already in that loop:

  • Capture delta.JSON.ExtraFields before AddChunk, keyed by choice index and tool index, merged across chunks — which is what makes your second case work.
  • Reattach after mapOpenAIResponse, in the existing per-choice loop that already reattaches reasoning.
  • JSON.ExtraFields holds only what the SDK does not model, so the stream's own "index" framing is excluded for free rather than being echoed back as provider metadata. The test asserts "index" does not appear in the replayed message.
  • The reserved-field filter still applies, so the "a provider cannot rewrite a tool call's identity" invariant now holds identically on both paths. If a provider ever emitted tool calls whose delta index disagreed with the accumulator's slice position, the mismatch is dropped rather than attached to the wrong call — a signature on the wrong call is rejected as surely as none.

Test added as TestOpenAIChatCompletions_ReplaysOpaqueToolCallFieldsFromStream, two subtests for your two placements. Fails 2/2 on the parent commit, passes on a0c9ef1; go test ./... green across the repo.

One honest caveat: I could not run -race here. This machine is aarch64 and ThreadSanitizer refuses to start (unsupported VMA range: Found 47 - Supported 48), so your race-detector run is the only one that exists — if you would re-run it against a0c9ef1 I would appreciate it.

Please do still post your focused test. If it covers a placement mine misses I would rather carry yours than assume two cases are the whole space, and you found this in the first place.

@lizhengfeng101 this now covers streaming as well as the non-streaming path, so #947 should be fully closed by it rather than half. Happy to split the streaming commit into its own PR if you would prefer to review them separately.

@subaru-ye

Copy link
Copy Markdown

Verified a0c9ef1 on Windows/amd64 with the race detector (gcc via MSYS2): go test -race ./internal/llm/ passes, including both TestOpenAIChatCompletions_ReplaysOpaqueToolCallFieldsFromStream subtests, and go test ./... is green across all 23 packages. That's the race run you asked for — my earlier one was against my local patch; this one is against your commit.

I also ran my focused test against this commit. The full HTTP round-trip cases (non-streaming / metadata on the opening chunk / metadata on a later chunk) all pass. One case behaves differently from my local patch, though: when the delta that opens a tool call carries a negative tool index, the capture is keyed by that raw index (-1) and never attached, so any provider fields on that chunk are lost:

{"choices":[{"index":0,"delta":{"tool_calls":[{"index":-1,"id":"call_1","type":"function","function":{"name":"file_read","arguments":"{}"},"extra_content":{"google":{"thought_signature":"sig"}},"provider_flag":true}]}},{"index":0,"delta":{"tool_calls":[{"index":0,"extra_content":{"google":{"thought_signature":"replacement"}}}]},"finish_reason":"tool_calls"}]}

The replayed request carries only extra_content: {"replacement"} — the signature and provider_flag from the opening chunk are dropped, because the capture buckets by delta.Index while the attach loop only reads bucket 0. My patch clamped the index to 0 before keying. Real providers may never send a negative index, so this may be fine as-is — just flagging it since the opening chunk is exactly where a signature would sit. Happy to share the focused test if it would be useful.

The capture keyed by the delta's raw index while the attach loop reads the
accumulator's slice position. Those agree only after clamping: the SDK maps
a negative index to 0, with the comment that "the API may send -1 for
single tool calls". A signature on the chunk that opens a single call
therefore landed in bucket -1 and was never attached - the one chunk where
providers are most likely to put it.

Key by the same clamped index the accumulator uses, so capture and attach
address the same call.

Reported by @subaru-ye on alibaba#1233.

Co-authored-by: subaru-ye <178248423+subaru-ye@users.noreply.github.com>
basil-k-aji-dev added a commit to basil-k-aji-dev/open-code-review that referenced this pull request Sep 18, 2026
The capture keyed by the delta's raw index while the attach loop reads the
accumulator's slice position. Those agree only after clamping: the SDK maps
a negative index to 0, with the comment that "the API may send -1 for
single tool calls". A signature on the chunk that opens a single call
therefore landed in bucket -1 and was never attached - the one chunk where
providers are most likely to put it.

Key by the same clamped index the accumulator uses, so capture and attach
address the same call.

Reported by @subaru-ye on alibaba#1233.
@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@subaru-ye Thank you for the race run — and you found a real bug, not an edge case I can wave off.

I went to check whether a negative index is something providers actually send, and the answer is in the SDK's own accumulator:

// Clamp negative indices to 0 since the API may send -1 for single tool calls
toolIndex := clampToZero(deltaTool.Index)
choice.Message.ToolCalls = expandToFit(choice.Message.ToolCalls, toolIndex)

So your clamp is not a defensive guess, it is the accumulator's documented rule — and "single tool calls" is precisely the common case. My capture keyed by the raw delta.Index while the attach loop reads the accumulator's slice position, and those two agree only after clamping. Confirmed with a probe on your payload: the SDK merges the -1 and 0 deltas into one call at position 0 with id=call_1, while my code filed them as two separate buckets and attached only bucket 0. The comment I wrote about position and index agreeing was simply wrong for the one chunk where a signature is most likely to sit.

Fixed in 8b4f040 — key by the same clamped index the accumulator uses. Added your payload as a third subtest, negative tool index on the chunk that opens the call; it fails with the raw index and passes with the clamp:

--- FAIL: .../negative_tool_index_on_the_chunk_that_opens_the_call
    streamed tool-call history dropped extra_content:
    {"tool_calls":[{"id":"call_1","function":{"arguments":"{}","name":"file_read"},"type":"function"}],"role":"assistant"}

go test ./... green across the repo. The race detector still can't run on this machine (aarch64, ThreadSanitizer unsupported VMA range), so 8b4f040 is unverified under -race on my side — if you have one more run in you I'd be grateful, though the change is a key computation with no new sharing.

Please do post your focused test. Two of my three subtests now come from you, which is a decent argument that your case coverage is better than mine.

@lizhengfeng101 no rush on your side — this is settling rather than churning, and I'd rather it land correct than fast.

@subaru-ye

Copy link
Copy Markdown

Verified 8b4f040 on Windows/amd64 with the race detector: go test -race ./internal/llm/ passes, including the new negative-index subtest, and go test ./... is green across the repo.

@Qiyuanqiii Qiyuanqiii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for continuing to refine this — I went through the current head (8b4f040) again, including the non-streaming and streaming paths and the follow-up fixes from the discussion.

The implementation looks solid to me at this point. In particular, the streaming metadata capture, later-chunk handling, and the -1 tool index fix now line up well with openai-go's accumulator behavior. The reserved-field filtering also makes the generic ExtraFields approach reasonably contained, and the independent race-test verification is reassuring. I don't see an obvious code-level blocker in the current revision.

One small hardening thought, not something I would block on: ExtraFields is a map[string]json.RawMessage, while NewToolCallMessage and internal/session.copyMessages still copy ToolCall values shallowly. That means those copies share the same map. I couldn't find a current path that mutates it after capture, so this looks safe with the existing immutable-use pattern, but a deep-copy helper/test could make that assumption explicit in the future.

There is also now some overlap with #1393. I don't think the two PRs are strict duplicates: this PR takes the more general approach of preserving arbitrary opaque tool-call fields, while #1393 takes a narrower extra_content approach and also adds provider-error diagnostics and session persistence.

Because those are genuinely different design choices, I don't think it would be fair to decide between them simply based on which PR lands first. I'd be interested in maintainer guidance on which abstraction the project prefers, and whether the generic approach here should remain the direction for #947 or be reconciled with #1393 in some other way.

From my side, though, the current #1233 implementation itself looks technically sound.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@subaru-ye a question about attribution on this PR, separate from the scope discussion.

Both of the streaming commits exist because of your work. a0c9ef1 fixed a gap you found and reproduced; 8b4f040 fixed the negative-index bug you found, and the payload from your comment is the third subtest in it, near enough verbatim. You also ran the race detector on both, twice, on hardware I do not have.

That is co-authorship, and the commits should say so rather than leaving it in a thread. If you are willing, I would like to add:

Co-authored-by: subaru-ye <YOUR-ID+subaru-ye@users.noreply.github.com>

Two things I would rather ask than assume. First, whether you want it at all — some people prefer not to be on commits they did not write. Second, which address: the trailer only attaches to an account if the email matches one GitHub knows, so I would rather you gave me the one you want than have me guess an id and produce a line that credits nobody.

If you would rather not, that is fine and I will leave the commits alone; the thread already records what you did. And if a maintainer lands #1371 or #1393 instead of this PR, the same offer stands for whoever carries the change — @Moon-Wrecker has already said they would add trailers for both of us.

No rush, and no need to answer if the scope question is settled first.

@subaru-ye

Copy link
Copy Markdown

Yes please, I'd like the attribution.

Co-authored-by: subaru-ye <178248423+subaru-ye@users.noreply.github.com>

@basil-k-aji-dev
basil-k-aji-dev force-pushed the fix/preserve-opaque-tool-call-fields branch from 8b4f040 to 8f8701f Compare September 19, 2026 10:59
@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

Done — added to both commits, which are the two your work produced:

d9d727c fix(llm): preserve opaque tool-call fields on the streaming path
8f8701f fix(llm): clamp the streamed tool index when capturing opaque fields
   Co-authored-by: subaru-ye <178248423+subaru-ye@users.noreply.github.com>

Only the messages changed; git diff 8b4f040 HEAD is empty, so the trees are identical and nothing needs re-reviewing. GitHub resolves the trailer — the commit pages now render both of us rather than leaving it as text.

Thank you for the address rather than making me guess an id. A trailer whose email GitHub does not recognise renders as plain text and credits nobody, which is a quiet way to lose exactly the attribution it is meant to record.

The offer still stands for whoever carries this if the maintainers land #1371 or #1393 instead — @Moon-Wrecker has already said they would add trailers for both of us, and @AyushDubey23 has posted theirs on that thread.

Raised in review by @Qiyuanqiii. ExtraFields is a map, and both copy sites —
NewToolCallMessage and session.copyMessages — copied ToolCall values with a
plain slice copy, so every copy shared the one map and the byte slices in it.

Nothing mutates the map after mapOpenAIResponse fills it, so this was safe.
But safe by a convention held in two packages with nothing to enforce it: a
future write through any copy would reach the original, and the failure would
surface as a tool call carrying another turn's signature rather than as
anything local to the writer.

CloneToolCalls copies the map and the values inside it. The map is small and
tool calls are few, so making the type safe by construction costs less than
keeping the convention correct. Tests pin both halves — a new key written
through the clone, and an in-place overwrite of the shared backing array.
@basil-k-aji-dev
basil-k-aji-dev force-pushed the fix/preserve-opaque-tool-call-fields branch from 6af5822 to 058d0cb Compare September 19, 2026 12:06
@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@Qiyuanqiii thank you — and the shallow-copy note was worth acting on, so I have.

You were right on every point. Both sites copy shallowly:

// NewToolCallMessage
copy(tc, toolCalls)
// session.copyMessages
ToolCalls: append([]llm.ToolCall(nil), m.ToolCalls...),

and the only write to a tool call's ExtraFields is in mapOpenAIResponse, before any copy exists. So it is safe today — but safe by a convention held in two packages with nothing to enforce it, and the failure mode if someone later writes through a copy is a tool call carrying another turn's signature, which would surface nowhere near the code that caused it.

Fixed in 058d0cb with CloneToolCalls, used by both sites. It copies the map and the values inside it — copying only the map would still share every backing array, so an in-place overwrite would reach the original.

Two tests pin it, and both fail against the previous revision:

--- FAIL: TestCloneToolCalls_SharesNothingMutable
    the copies share one map: a write through the clone reached the original

The first writes a new key through the clone; the second overwrites the shared byte slice in place. A third checks nil and empty input stay nil, so a tool call with no opaque fields does not gain an empty map.

go test ./... is green across the repo.

On the overlap with #1393, I agree with your framing and have nothing to add beyond what I wrote there: the generic abstraction is the one I would argue for, the persistence choice in #1393 is the better one and I would drop mine, and which PR lands is entirely the maintainers' call. @AyushDubey23 reached the same conclusion on that thread. I would rather the Gemini bug were fixed this week than that this is the PR that fixes it.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

@NanaseInori review

@NanaseInori NanaseInori left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the latest head (058d0cb). The remaining hardening concern from my previous review has now been addressed cleanly.

CloneToolCalls deep-copies both the ExtraFields map and each json.RawMessage backing byte slice, and it is used by both NewToolCallMessage and internal/session.copyMessages. The new mutation tests make the intended no-aliasing property explicit rather than relying on an immutable-use convention.

The earlier streaming fixes also remain sound: opaque fields are captured before ChatCompletionAccumulator drops them, merged across chunks, keyed with the same negative-index clamping behavior as the SDK, and guarded against attaching metadata to the wrong tool-call ID. Reserved fields remain owned by OCR, while provider-specific fields are replayed generically.

I don't see a remaining code-level blocker in this revision. The overlap with #1393 is now a maintainer-level question of scope/merge sequencing rather than a correctness objection to this implementation.

This review was conducted by NanaseInori's review bot, using the model GPT-5.6 Sol. If you need a human review, please manually @NanaseInori.

@lizhengfeng101

Copy link
Copy Markdown
Contributor

Thanks for this, @basil-k-aji-dev — and sorry for the overlap.

Closing as superseded by #1393, which has now been merged and resolves #947. It preserves the provider's extra_content on the tool call, keeps it out of review/session output (json:"-"), and replays it verbatim on the next OpenAI-compatible turn — the same round trip your PR fixes.

The one design difference: your version preserves every unmodeled tool-call field through a generic map[string]json.RawMessage with reserved-field protection, whereas the merged PR preserves the specific extra_content key. For the known case (Gemini thought signatures) they behave identically, so we went with the narrower version.

If you later hit a provider that stashes required metadata under a different key, that generalization would be a welcome follow-up — please open a fresh PR for it and reference this discussion. Thanks again for the careful work.

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.

fix(llm): preserve opaque tool-call fields across OpenAI-compatible turns

6 participants