Skip to content

fix(model/openaimodel): stop replaying prior-turn reasoning to the API - #1395

Merged
wojtas000 merged 17 commits into
google:mainfrom
hanorik:fix/openaimodel-replayed-thoughts
Sep 23, 2026
Merged

wojtas000 merged 17 commits into
google:mainfrom
hanorik:fix/openaimodel-replayed-thoughts

Conversation

@hanorik

@hanorik hanorik commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

model/openaimodel reports model reasoning to the caller as thought parts and
then sends those parts straight back to the API on the next turn as ordinary
assistant text. convertContents matched every part on part.Text != "" with
no guard on part.Thought, and nothing upstream strips thought parts. The
reasoning accumulated into the same textParts buffer as the real answer and
flushed with it into a single assistant message, so nothing separated the
model's private scratchpad from what it had actually said, and the following
turn was answered against it. A turn that produced only reasoning replayed as an
assistant message the model never sent.

Solution

Drop replayed reasoning rather than send it. The Responses API takes it back
only as an input item referencing the id of the item that produced it, and ADK
carries no such ids, so there is no correct way to replay it; adk-python's
Responses backend drops it for the same reason. Text and call/response are now
read independently rather than as arms of one switch, because a single part can
carry both — choosing between them either put the reasoning on the wire or
dropped the call and stranded its response in callTracker. A thought-marked
call or response therefore survives while its reasoning text does not.

Two adjacent holes close with it. A thought carrying only a signature used to
reach the default arm and fail the whole conversion, and is now dropped, since
a Responses request has nowhere to put one either way; and a part marked as a
thought that carries media or code is still rejected as an unsupported part
rather than vanishing from the request unannounced. A request left empty by the
drop now reports that, instead of ErrNoContents as though the caller had sent
nothing. No exported API changes — the only new declaration is the unexported
replayedReasoning.


Update after review.

Rebased onto main; the conflict was newMessage's signature, which #1427 changed under this branch.

A part carrying an unsendable field — media, code, server-side tool traffic — is now rejected wherever it appears, because the previous check keyed on what the part contributed: reasoning text riding on an image matched no arm at all and the image left the request unannounced, and so did the same image riding on ordinary text or on a function call. unsupportedPayload runs once per part before anything is emitted and asks the opposite question — is anything set that convertContents does not account for — so the field is named in the error, unsupported content part: InlineData, whatever it travels with.

replayedReasoning is stated the same way rather than as a list of the fields that disqualify a part, which had omitted ToolCall, ToolResponse and AudioTranscription and dropped them silently; a field genai adds later is now reported rather than vanishing, and a reflection walk over genai.Part holds both predicates to it.

An unsupported role is reported again on a turn whose only text was a thought — nothing buffers, so flushText returned before normalizeRole ever ran.

A part that contributed nothing now says it carries nothing to send, rather than borrowing the unsupported-part wording it no longer fits.

The package doc no longer claims without qualification that reasoning is never sent back on a later turn: in a multi-agent run the shared flow rebuilds a peer's event as plain user text with the marker unset, so a peer's reasoning still arrives as ordinary content, which is not this package's to fix.

The unbounded run loop raised in review is closed on main by #1290 and needs nothing here — driving runner.Run against a stub that only ever reasons ends after 10 calls with the request body flat at 382 bytes, where before the drop it grew until the provider refused it.

A second round found three more, all of them mine rather than the original commit's.

The empty-request wrap keyed on the request having contents rather than on a part having been dropped, so a Content carrying no parts reported that every part was dropped when none had been, and turned the bare ErrNoContents into a wrapped one — err == ErrNoContents flipped true to false for a request that behaves exactly as it did before this branch. convertContents now reports whether it dropped anything and the wrap follows that, so a request that arrived empty, blank text included, still returns the bare sentinel.

The restored role check keyed on the part having text, so it ran for a thought carrying reasoning and not for one carrying only the marker or a bare signature — which main rejected as an unsupported part and this branch had made silent. It now runs wherever a part is dropped.

Pairing is enforced where it previously was not, which is the widest behavior change here and belongs in the note below rather than buried: text riding on a function response that pairs with nothing, or on a call with no name, used to be discarded because the text arm matched first and the call or response was never read.

A third round, from the review on baff7e4b, closed the two claims the description overstated and the four smaller notes.

err == ErrNoContents now survives a blank thought. {Text: " ", Thought: true} was being wrapped: the drop is real, but the text would have been skipped downstream anyway, so the drop is not what emptied the request. The flag is set only for reasoning carrying text the model would otherwise have seen, which also gives the bare sentinel back to a bare thought and a lone signature.

The rejection for a part that reaches nothing keeps the unsupported content part prefix the single message used before this branch, so a caller string-matching that phrase still matches the shapes that now land there — an empty part, and one carrying only VideoMetadata, MediaResolution or PartMetadata.

The comment claiming a part is read field by field now says what the code does: text is read independently of a call or a response, but a call and a response on the same part remain arms of one switch and the response is dropped, exactly as on main.

The package doc claimed a single exception to reasoning never being replayed. There are three — a peer agent's reply folded into context, a compacted transcript, and a memory recall all render reasoning as ordinary text upstream of any model package — so it now says that instead of naming one.

Behavioral changes. A 16-shape corpus was run through the public API against main and this branch and diffed outcome-by-outcome; 11 shapes differ. Nothing that previously reached the OpenAI API stops reaching it, and no shape that succeeded on the wire now fails.

Input shape main this PR
{Text:"scratch", Thought:true} alone succeeded — the reasoning was sent (the bug) ErrNoContents, wrapped, naming the drop
unsendable field (FileData, ToolCall, AudioTranscription, CodeExecutionResult, InlineData, ExecutableCode, ToolResponse) riding on text or a call succeeded, field silently discarded error naming the field
bare {Thought:true}, lone ThoughtSignature unsupported content part bare ErrNoContents
empty part, or only VideoMetadata / MediaResolution / PartMetadata unsupported content part *genai.Part unsupported content part: carries nothing to send
everything else in the corpus, incl. blank text, a call and response on one part, a signature or VideoMetadata riding on text — unchanged

errors.Is(err, ErrNoContents) holds throughout. err == ErrNoContents holds for every request that was empty on arrival; it is false only where the drop suppressed text that would otherwise have been sent, a shape that returned no error at all on main. No exported identifier changes.

Known gaps, deliberately not fixed here.

  • A call and a response on the same part still emit the call and drop the response silently. Identical to main, nothing in the tree builds that part; the comment no longer implies otherwise.
  • The two rejection messages carry no sentinel, so a caller can only string-match them. main had none either. An ErrUnsupportedContentPart sentinel would be an exported-API addition and wants a maintainer's call first.
  • ConvertForeignEvent (internal/llminternal) rebuilds a peer's reasoning as plain user text, so it still reaches every model package. Out of scope here and worth its own issue, as the review notes.
  • flattenContentText (the system-instruction path) has no thought filter. No in-repo caller can put a marked thought part there; noted as defense-in-depth rather than a live leak.

Testing Plan.

  • go build -mod=readonly work
  • go test -race -mod=readonly -count=1 -shuffle=on work
  • golangci-lint run and go mod tidy -diff — clean in both modules
  • Sixteen guards reverted individually and the suite re-run; every one is caught by a named test. The three added this round: resetting the drop flag per content block, and removing the blank-text skip from newMessage and from newOutputMessage.
  • Live OpenAI e2e suite (adk-openai-e2e), which is the only evidence that exists for this package — it ships no httprr cassettes, so its unit tests assert serialization against expectation and nothing in CI proves OpenAI accepts the payload:
adk-go under test: pr-1395 / a3de3b50
openai-go:         v3.54.0
model:             gpt-5.4-nano
29 scenarios PASS, 0 FAIL          Full suite cost: $0.00152

Scenario 16 is held out: it is written against the unmerged #1392 and does not compile against main either. The same suite on main (a6114727) is likewise 29/0, so this branch is at parity.

Review loop. Three rounds of a six-lens panel (security, api-compat, correctness, testing, idioms, parity). Round 1: one blocking correctness finding (an unsendable field riding on text or a call escaped the rejection) and two blocking testing gaps. Round 2: four testing gaps and three over-length comments; one blocking correctness claim — that a dropped ThoughtSignature should wrap the sentinel — was disputed and adjudicated in favour of the current rule by an independent pass, on the grounds that a signature has nowhere to go in a Responses request under any circumstance. Round 3: clean.

@hanorik
hanorik requested review from a team and karolpiotrowicz August 23, 2026 22:32
@karolpiotrowicz

Copy link
Copy Markdown
Contributor

The drop is the right call, and the conversion holds where it matters: no part marked as a thought contributes text to any emitted item, on any role and in any position, and a thought-marked call or response still survives with its pairing intact. Exercising it turned up one consequence worth a decision before this lands, and three smaller things after it.

The run loop stops terminating on a reasoning-only turn. base_flow.go:120 deliberately does not end the run when the model's last turn is all thought parts — it calls the model again — and nothing bounds that path. MaxLLMCalls occurs exactly twice in the tree, as a field at agent/live.go:48 and an assignment at runtime.go:304, and is never read anywhere. Until now each retry replayed the previous turn's reasoning, so the request grew and a real endpoint eventually refused it. With request.go:128 dropping those parts, the request is byte-identical every iteration, so nothing ends the run at all.

Driving runner.Run against a stub that always answers with reasoning only, and rejects an over-budget body with context_length_exceeded the way the real API does:

main    0a51e15   model calls: 6
                  request body sizes: [196 487 592 697 802 907]
                  outcome: TERMINATED BY PROVIDER ERROR: 400 context_length_exceeded

branch  9dd6a6f   model calls: 40
                  request body sizes: [196 382 382 382 ... 382]
                  outcome: STOPPED BY TEST CAP - did not terminate on its own
the test those two runs come from — drop it in model/openaimodel and run go test -run TestBackstop -v ./model/openaimodel/
package openaimodel_test

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"google.golang.org/genai"

	"google.golang.org/adk/v2/agent"
	"google.golang.org/adk/v2/agent/llmagent"
	"google.golang.org/adk/v2/model/openaimodel"
	"google.golang.org/adk/v2/runner"
	"google.golang.org/adk/v2/session"
)

const reasoningOnly = `{
  "id":"resp_1","object":"response","created_at":1,"status":"incomplete","model":"test-model",
  "incomplete_details":{"reason":"max_output_tokens"},
  "output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"still thinking about it"}]}],
  "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2,"output_tokens_details":{"reasoning_tokens":1}}
}`

func TestBackstop(t *testing.T) {
	const budget = 900 // bytes of request body, standing in for a token budget

	var sizes []int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		sizes = append(sizes, len(b))
		if len(b) > budget {
			w.WriteHeader(http.StatusBadRequest)
			io.WriteString(w, `{"error":{"message":"maximum context length exceeded","type":"invalid_request_error","code":"context_length_exceeded"}}`)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		io.WriteString(w, reasoningOnly)
	}))
	defer srv.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	m, _ := openaimodel.NewModel(ctx, "test-model", &openaimodel.ClientConfig{APIKey: "test", BaseURL: srv.URL})
	a, _ := llmagent.New(llmagent.Config{Name: "assistant", Model: m, Instruction: "be helpful"})
	r, _ := runner.New(runner.Config{AppName: "backstop", Agent: a,
		SessionService: session.InMemoryService(), AutoCreateSession: true})

	events, outcome := 0, ""
	for _, err := range r.Run(ctx, "u", "s", genai.NewContentFromText("q", genai.RoleUser), agent.RunConfig{}) {
		if err != nil {
			outcome = "TERMINATED BY PROVIDER ERROR: " + err.Error()
			break
		}
		if events++; events >= 40 {
			outcome = "STOPPED BY TEST CAP - did not terminate on its own"
			break
		}
	}
	t.Logf("model calls: %d", len(sizes))
	t.Logf("request body sizes: %v", sizes)
	t.Logf("outcome: %s", outcome)
}

The bound this removes was the bug's own symptom, so preserving it is not an option and the drop is not what is wrong here. It is worth saying plainly how narrow the trigger is, because it decides how urgent the cap is. The turn has to consist only of thought parts, and on the default configuration that cannot happen: openaimodel never sets Reasoning on the request, so a real gpt-5-nano call truncated by max_output_tokens returns one reasoning item with empty content and empty summary, response.go:107 returns ErrNoTextOrToolContent, and the run ends after a single call. The same call with "reasoning":{"summary":"auto"} returns two summary entries and no message item, which response.go:100 maps to all-thought parts and does spin. That is reachable today only by passing the option through the escape hatch at openai.go:70.

So the missing cap belongs in base_flow.go:120 rather than in this package, and I would land this first and track the cap separately rather than hold a confidentiality fix behind it.

A thought-marked part that also carries media or code vanishes without the error. The rejection arm at request.go:152 is gated on part.Text == "", so a part with reasoning text riding on an image matches no arm at all — the text is suppressed by request.go:128, the call and response arms do not apply, and part.Text == "" is false. The comment at request.go:188-189 says such a part goes on "to be converted, or rejected as unsupported, on their merits", which holds only while the part has no text, and request_test.go:304 covers just that variant. The media is dropped on main too, so nothing regresses — what is new is the promise to reject it.

{Thought: true, Text: "scratch", InlineData: &genai.Blob{MIMEType: "image/png", Data: []byte{1}}}
// no error, one input item emitted, the image gone. Same for FileData, ExecutableCode, CodeExecutionResult.

{Thought: true, InlineData: &genai.Blob{MIMEType: "image/png", Data: []byte{1}}}
// openai: unsupported content part *genai.Part   <- the only variant the table covers

replayedReasoning does not cover every genai.Part field, so three more shapes drop silently. request.go:190-192 names six fields and omits ToolCall, ToolResponse and AudioTranscription, along with VideoMetadata, MediaResolution and PartMetadata, which carry nothing sendable on their own. A part marked as a thought carrying any of the first three returns true from the predicate and disappears, where main returned openai: unsupported content part. Nothing in the repo populates those fields today, so this is about the next genai bump rather than about now. Inverting the polarity, so the predicate is true only when text and signature are the only things set, would make a newly added field fail loudly by default instead of quietly.

An invalid role stops being reported when a turn's only text is a thought. Nothing buffers, so flushText returns before normalizeRole ever runs.

{Role: "assistant", Parts: []*genai.Part{{Text: "x"}}}
// main and this branch: openai: unsupported role "assistant"

{Role: "assistant", Parts: []*genai.Part{{Text: "x", Thought: true}}}
// main:        openai: unsupported role "assistant"
// this branch: nil

The package doc promises more than the package can deliver. doc.go:24-29 says reasoning "is not sent back on a later turn", unqualified. In a multi-agent run a peer's event goes through ConvertForeignEvent at contents_processor.go:108, which rebuilds each text part as a brand-new &genai.Part{Text: fmt.Sprintf("[%s] said: %s", ...)} at contents_processor.go:580-582 with Thought left at its zero value, so the guard passes it straight through as user text. That rebuild is shared flow code and out of scope here — one qualifying clause in the paragraph would keep the doc true in the meantime.

hanorik and others added 4 commits September 3, 2026 12:15
Review of google#1395 found three shapes the drop got wrong and one sentence of
package doc it made untrue.

A thought carrying reasoning text on top of an image or code matched no arm
at all: the text was suppressed, the call and response arms did not apply,
and the rejection arm was gated on part.Text == "", which was false. The
media left the request with no error, against the promise the same change
had just made. The arm now keys on whether the part contributed text rather
than on whether it had any, so suppressing the text cannot suppress the
rejection with it.

replayedReasoning listed the fields that disqualified a part, which left
ToolCall, ToolResponse and AudioTranscription out and dropped them silently
where main reported an unsupported part. The test is inverted: clear the
reasoning, its signature, and the metadata that only qualifies media held in
another field, and a part is droppable when what remains is the zero value.
A field added by a later genai release is then rejected by default instead
of vanishing, and a field walk over genai.Part in the tests keeps that
honest.

An unsupported role stopped being reported when a turn's only text was a
thought: nothing buffered, so flushText returned before normalizeRole ran.
The role is now checked wherever text is dropped, so the error is raised
identically with the marker on or off.

The doc paragraph claimed reasoning is never sent back on a later turn,
unqualified. In a multi-agent run the shared flow rebuilds a peer's event as
plain user text with the thought marker unset, so a peer's reasoning still
arrives as ordinary content; the paragraph now says so and says it is not
this package's to fix.

The unbounded run loop the review also raised is closed on main by google#1290,
which bounds consecutive thought-only turns; driving runner.Run against a
stub that only ever reasons now ends after 10 calls with the request body
flat at 382 bytes, where before this change it grew until the provider
refused it.
…art carries

Review of the previous commit found the rejection still keyed on what the
part contributed, which only moved the hole. A thought carrying text on top
of an image was reported, but the same image riding on ordinary text, or on
a function call, still matched a sendable arm first and left the request
with no error at all. That predates this branch, and it is what made the
promise the branch had just written down untrue.

The check is now made once per part, before anything is emitted, and asks
the opposite question: not "which fields disqualify this part" but "is
anything set that convertContents does not account for". Text, the thought
marker and its signature, a call, a response, and the three metadata fields
that only qualify media carried elsewhere are cleared from a copy; whatever
is left over is named in the error. So an image reaches the caller as
"unsupported content part: InlineData" no matter what it travels with, and a
field added to genai.Part by a later release is reported rather than
silently dropped, because it is unaccounted for by construction.

replayedReasoning now shares that test instead of restating it, and the arm
that used to serve both jobs is left with the one it is for: a part that
contributed nothing and is not reasoning worth dropping now says it carries
nothing to send, rather than borrowing the unsupported-part wording.

On the tests: describeInput rendered an assistant input message and an
assistant output message identically, so the table could not have seen a
replayed assistant turn go out as "input_text" — the shape the Responses API
rejects. They now render as in/ and out/. The genai.Part field walk is
stated against the accounted-for list and fails loudly on an unexported
field instead of panicking, and it runs against unsupportedPayload as well
as the drop. Two tests drive the public GenerateContent surface, blocking
and streaming, asserting on the bytes that leave the process: the reasoning
is absent and the answer is not, and a request the drop empties never
reaches the model.

Each of the seven guards was reverted individually and the suite re-run;
every one is caught by a named test.
…just the predicate

Second review round. The field walk asserted what unsupportedPayload
returns, which says nothing about whether convertContents passes it on: a
mutation that swallowed the error for one field would leave the walk green.
Each generated case now also builds the request and requires the field's
name in the error, so the unit and the caller are held to the same answer.

An empty part had no case either. buildContentsDefault filters those out
upstream, so it only reaches convertContents from a caller assembling
contents itself, which is exactly why the contract is worth pinning: it is
reported, not quietly skipped.

The "carries nothing to send" error is built with errors.New rather than a
verbless fmt.Errorf, matching errors.go.
@wojtas000
wojtas000 force-pushed the fix/openaimodel-replayed-thoughts branch from 9dd6a6f to ee104ef Compare September 3, 2026 14:19
@wojtas000

wojtas000 commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Thanks — all four landed, and the fifth needed nothing. Rebased onto main (the conflict was newMessage's signature, changed by #1427) and pushed as three commits on top of the original.

  • The missing cap — closed on main by fix(llminternal): bound consecutive thought-only turns in the base flow #1290 (base_flow.go:120), and your backstop test on the rebased branch now reports model calls: 10, sizes [196 382 382 …], terminating on its own.
  • Thought-marked media — the rejection keyed on what the part contributed, not just on part.Text == "", so an image on ordinary text or on a call vanished too (on main as well); unsupportedPayload now runs once per part before anything is emitted and names the field whatever it travels with.
  • Uncovered genai.Part fields — inverted as you suggested, so ToolCall, ToolResponse, AudioTranscription and anything genai adds later are reported rather than dropped, with a reflection walk holding both predicates to it.
  • The unreported role — the role is now checked wherever text is dropped, so a thought-only turn returns unsupported role "assistant" exactly as it does with the marker off.
  • The doc — qualified in place: a peer's reasoning still arrives as ordinary content, and the paragraph now says so and says it is not this package's to fix.

Two more fell out of the rest of the review:

  • A part that contributed nothing now says it carries nothing to send, rather than borrowing the unsupported-part wording it no longer fits.
  • describeInput rendered an assistant input message and an output message identically, so the table was blind to a replayed turn going out as input_text; they render as in/ and out/ now.

Verified: each of the seven guards reverted individually and the suite re-run, every one caught by a named test; gates green in both modules; and exercised live on gpt-5.4-nano through the public surface, blocking and streaming, where the failing scenarios are identical to main's (the unrelated ThinkingConfig gap #1392 covers).

One behavior change worth your eye, also in the PR body: a part carrying an unsendable field alongside sendable text or a call now errors instead of building a request with that field silently discarded — it never reached the API either way, but a caller who was getting a 200 now gets an error.

wojtas000 and others added 2 commits September 3, 2026 16:44
… on the drop

Review found the two of them gated on the wrong thing.

The wrap keyed on the request having contents, so a Content carrying no
parts at all reported "every part was dropped as replayed reasoning or
blank text" when nothing had been dropped, and turned the bare sentinel
into a wrapped one — err == ErrNoContents flipped from true to false for
a request that behaves exactly as it did before this branch.
convertContents now reports whether it dropped anything and the wrap
follows that, so the bare sentinel survives for a request that arrived
empty, blank text included, and the message no longer has to hedge about
which of the two emptied it.

The role check keyed on the part having text, so it ran for a thought
carrying reasoning and not for one carrying only the marker or a bare
signature — which main rejected as an unsupported part, and this branch
had made silent. It now runs wherever a part is dropped, so an
unsupported role is reported on any of the three.

Also pinned, because neither the drop nor the pairing was ever the
subject of a test here: text riding on a function response that pairs
with nothing, and on a call with no name. Both used to be discarded
because the text arm matched first, and are reported now that a part's
fields are read independently.
@wojtas000

wojtas000 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Second pass over my own follow-up caught three more, all introduced by it rather than by the original commit. Pushed as e5fa75d.

  • Undisclosed break: unpaired calls and responses — text riding on a FunctionResponse that pairs with nothing, or on a nameless FunctionCall, used to be discarded because the text arm matched first, and now errors; that is the same independent read of a part's fields that lets a well-formed call riding on text survive, so it stays, but it is now disclosed in the body and covered by two cases (orphan, nameless).
  • Wrong message and sentinel identity — the empty-request wrap keyed on the request having contents rather than on a part having been dropped, so Contents: [{Role:"user"}] claimed every part was dropped when none had been and flipped err == ErrNoContents from true to false; convertContents now reports the drop and the wrap follows it, so a request empty on arrival keeps the bare sentinel.
  • Role check gated too narrowly — it ran only when the part had text, so a bare thought or a lone signature on an invalid role passed silently where main had rejected it; it now runs wherever a part is dropped, covered both ways (thought, signature).

Gates green in both modules, the ten guards each reverted individually and caught by a named test, and the live suite's failures are identical to main's (the unrelated ThinkingConfig gap #1392 covers).

@karolpiotrowicz karolpiotrowicz 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.

All five landed, and I re-ran the one that mattered most rather than taking it on trust. Driving runner.Run against a stub that only ever reasons, with the same body-size budget as before: this branch makes 10 model calls with the request body flat at 382 bytes and ends on its own, where main before the drop grew the body every turn until the stub refused it. The bound is maxConsecutiveThoughtOnlyTurns at base_flow.go:120, so the cap question is closed on main and needs nothing here.

the two runs, from the same test I posted in August
before the fix (main)   model calls: 6
                        request body sizes: [196 509 636 763 890 1017]
                        outcome: TERMINATED BY ERROR: 400 context_length_exceeded

with the PR            model calls: 10
                        request body sizes: [196 382 382 382 382 382 382 382 382 382]
                        outcome: RAN TO COMPLETION ON ITS OWN

The other four check out against a run rather than a reading. I built ~25 part shapes and put each through buildOpenAIParams at both revisions: an image or a ToolCall or an AudioTranscription riding on text now errors naming the field where main dropped it silently, a bare thought or a lone signature on an invalid role now reports the role where main reported an unsupported part, and go doc -all differs from main by exactly the qualified paragraph. Two shapes are worth calling out because they show the inversion earning its keep beyond what I asked for: a part carrying reasoning text, a call and an image had both the image and the call dropped on main, and a well-formed call riding on text used to be discarded, which then orphaned its own response and failed the whole conversion — that shape now converts correctly.

Nothing here blocks. Two sentences in the description claim more than the code does, and both are a clause away from being true.

err == ErrNoContents does not survive a blank thought. The description says the identity holds "for every request that was empty on arrival, including blank/whitespace-only text". For {Text: " ", Thought: true} it does not: main returns the bare sentinel, this branch returns the wrapped one. The code is behaving exactly as request.go:54-64 says it will, since a part genuinely was dropped as reasoning — it is the sentence that is wrong, and the fix is to drop "including blank/whitespace-only text" or to trim before setting the flag.

&model.LLMRequest{Contents: []*genai.Content{modelTurn(&genai.Part{Text: "   ", Thought: true})}}
// main:        err == ErrNoContents  true
// this branch: err == ErrNoContents  false   (errors.Is still true)

"Reported by name, never silently discarded" is not true of the four accounted qualifier fields. ThoughtSignature, VideoMetadata, MediaResolution and PartMetadata are zeroed at request.go:242-249, so a part carrying one alongside text is converted with the field gone, and a part carrying one alone gets content part carries nothing to send — which names nothing and is not quite true either, since the part does carry something. Both are identical to main, so nothing regresses. I would narrow the claim to fields outside the accounted set rather than change the code, since three of those four genuinely have nowhere to go in a Responses item.

Four smaller things are on the lines they belong to, all of them non-blocking.

One thing outside this PR that the doc paragraph is right to flag: ConvertForeignEvent at contents_processor.go:596-599 rebuilds a peer's text part with the marker unset, so a sub-agent's reasoning still reaches every model package as ordinary user text. Worth its own issue against internal/llminternal rather than holding this.

Gates are green on both modules apart from telemetry, which fails the same way on main in the same sandbox because it dials the metadata server. No exported surface moves.

Comment thread model/openaimodel/request.go Outdated
Comment thread model/openaimodel/request.go Outdated
Comment thread model/openaimodel/request_test.go
Comment thread model/openaimodel/request_test.go
Review found the empty-request wrap firing for a part whose reasoning text
was only whitespace. The drop is real there, but the text would have been
skipped by newMessage anyway, so the drop is not what emptied the request —
and wrapping turned err == ErrNoContents from true to false for a shape that
behaves exactly as it does on main. The flag is now set only for reasoning
that carried text the model would otherwise have seen, which also gives the
bare sentinel back to a bare thought and a lone signature.

The rejection for a part that reaches nothing keeps the unsupported-content-part
prefix the single message used before this branch, so a caller string-matching
on that phrase still matches the shapes that now land there — an empty part,
and one carrying only VideoMetadata, MediaResolution or PartMetadata.

The comment above the payload check claimed a part is read field by field.
Text is, against a call or a response, but a call and a response on the same
part are still arms of one switch and the response is dropped, as on main. The
comment now says what the code does rather than what the phrase implies.

Two test gaps the review named: the identity table gains the blank-reasoning
case the claim turns on, plus the bare thought and lone signature, and the
genai.Part field walk now puts each unsendable field on text, on reasoning
text and on a function call rather than only on a part of its own, since the
riding shape is the one this change exists to fix.
…the doc

Review round found the flag's accumulation untested: writing it as an
assignment rather than a conditional set passed the whole suite, so a blank
thought following a real one could have talked the emptied-request report
back down. Two cases pin it, within a turn and across turns, and one more
pins the blank-skip the trim rule leans on — that a blank text item is
dropped beside a real one rather than only when it is alone.

The package doc claimed a single exception to reasoning never being sent
back. There are three: a peer agent's reply folded into context, a compacted
transcript, and a memory recall all render reasoning as ordinary text
upstream of any model package. It now says that rather than naming one.
…across turns

Second review round. The across-turns case put the real thought in the last
turn, so a flag reset per content block would have passed it; the real turn
now comes first, which is the order that catches it. The blank skip was
pinned only on the assistant path through newOutputMessage, leaving the user
path through newMessage untested both beside real text and on its own.

The field walk asserted the error contains the field name, which a shorter
name that prefixes a longer one would satisfy; it now asserts the suffix.

Three doc comments ran past the two-sentence limit this package keeps.
@wojtas000

wojtas000 commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Both description claims fixed, and the four line notes are answered on their threads.

  • err == ErrNoContents and the blank thought — took the trim rather than the wording. The flag is set only when the drop suppressed text the model would otherwise have seen, so {Text: " ", Thought: true} keeps the bare sentinel; a bare thought and a lone signature now do too, which main answered with unsupported content part. The identity table pins all of it.
  • "Reported by name" and the four qualifier fields — narrowed the claim as you suggested rather than touching the code, since three of the four genuinely have nowhere to go. The wrap message no longer hedges about blank text either, because it can now only mean the one thing.
  • ConvertForeignEvent — agreed, its own issue. Your paragraph also undersold it: compaction renders "<author> (thought): …" into the summarizer transcript, and memory ingests thought text unfiltered, so the doc paragraph now says three routes render reasoning as ordinary text upstream instead of naming one.

Since baff7e4b: three rounds of the six-lens panel. Round 1 found the rejection still escapable when an unsendable field rode on text or a call; rounds 2 and 3 were tests and comment length. One lens wanted a dropped ThoughtSignature to wrap the sentinel — I kept the bare one, on the grounds that a signature has nowhere to go in a Responses request under any circumstance, so the drop deprives the model of nothing; an independent pass agreed.

Gates green in both modules.

karolpiotrowicz
karolpiotrowicz previously approved these changes Sep 8, 2026

@karolpiotrowicz karolpiotrowicz 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.

Both description claims from the last round are closed, and I re-ran the one that mattered: {Text: " ", Thought: true} now returns the bare ErrNoContents on this branch exactly as it does on main, and so do a bare thought and a lone signature. Taking the trim over the wording was the better of the two options I offered, since it fixes the behavior instead of documenting it. The four line notes are answered on their threads. adk-python's Responses backend does drop replayed reasoning for the reason the description gives, at _openai_responses_llm.py:307-323, dispatched from :436-438. Nothing here blocks.

Two sentences in the description are contradicted by measurement. Both are a clause away from being true and neither needs a code change.

"No shape that succeeded on the wire now fails" is falsified by the table two rows above it. Putting the corpus through the conversion at the merge base and at this branch, three shapes move from a built request to a hard error: an InlineData blob riding on plain text, a ToolCall riding on text, and text riding on a FunctionResponse that pairs with nothing. Each of those returned a 200 on main with the field quietly discarded. The table already says so for the first two — "succeeded, field silently discarded" becomes "error naming the field" — and then the prose says the opposite. Narrowing it to shapes whose payload actually reached the provider would keep the claim you can defend.

"Sixteen guards reverted individually ... every one is caught by a named test" does not hold for one of them. Deleting part.Text != "" || from request.go:155 leaves go test ./model/openaimodel/ green, and changes what a caller sees through the public surface:

req := &model.LLMRequest{Contents: []*genai.Content{{
	Role: "assistant",
	Parts: []*genai.Part{{
		Thought:      true,
		Text:         "scratch",
		FunctionCall: &genai.FunctionCall{Name: "lookup", ID: "c1"},
	}},
}}}
for _, err := range llm.GenerateContent(ctx, req, false) { ... }

// this branch:   err = openai: unsupported role "assistant"   provider calls: 0
// guard deleted: err = <nil>                                  provider calls: 1

With the guard gone the request reaches the provider under a role the package cannot send. main errors on that shape too, so this is a coverage hole rather than a regression — but it is the line that keeps the role check honest when reasoning text rides on a call, which is the case the round-2 fix was for. The four role cases at request_test.go:503-542 all use parts carrying nothing but reasoning, and thought_text_riding_on_a_call uses modelTurn, so nothing exercises the disjunct. The shape is unreachable from ADK itself, which only ever builds user and model roles, so this is about the claim rather than about a user hitting it.

One small thing, and I would leave it alone if the churn is not worth it. The wrapped message at request.go:56-61 says "every part was dropped as replayed reasoning" for a request where only some part was: [{Text: "scratch", Thought: true}, {Text: " "}] gets the wrap, and the blank sibling was buffered and then skipped downstream, not dropped as reasoning. Diagnostic wording only, and rewording it costs two pinned test expectations.

One thing to leave alone. The two guards in replayedReasoning that look unobservable — the signature-on-an-answer early return, and the unsupportedPayload conjunct — are reachable only through the predicate's own tests, but removing either breaks TestReplayedReasoning_EveryUnaccountedFieldDisqualifies, which is the executable form of the forward-compatibility rule we settled last round.

Gates are green on both modules, the merge into current main is clean, and no exported surface moves. Two open PRs of yours, #1392 and #1389, conflict with this one on the same three files — whichever lands first, the other needs a rebase.

@karolpiotrowicz
karolpiotrowicz dismissed their stale review September 8, 2026 06:18

Superseded by the approving review. The items this review named are verified closed at the current head.

Brings in google#1392, which landed while this branch was in review. The only
conflict is doc.go, where both add a paragraph at the same anchor: google#1392's
table of how every GenerateContentConfig field is treated, and this
branch's account of why reasoning is not replayed. They describe different
things, so both are kept, the landed table first and this branch's
paragraph after it. go doc renders both.

Nothing else conflicts, and the overlap is narrower than the shared file
list suggests. Both branches rewrote request.go, but in disjoint
functions — this one in convertContents, replayedReasoning and
unsupportedPayload, google#1392 in applyGenerationConfig and the tables it
drives — and no test function in request_test.go or openai_test.go was
touched by both, so neither side's cases inherit a field the other added.

Checked rather than assumed: every function each side added to request.go
is present; the reflection guards on both sides still run and pass
(TestReplayedReasoning_EveryUnaccountedFieldDisqualifies,
TestEveryUnsupportedHTTPOptionFieldIsDriven and the three accounting
tests); and neutering replayedReasoning or rejectUnsupportedConfigFields
each turns the package red, so the merge left both sides driven.

@karolpiotrowicz karolpiotrowicz 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.

Nothing blocks. I re-checked the branch merged with current main (d64e113), and the drop still holds there: no reasoning text reaches any request body, blocking or streaming, and a function call with its output, an answer and a refusal all replay intact on the turns after them.

The fix matters more now than when this was opened. At the time openaimodel never asked for reasoning, so a history carrying thought parts needed the raw-options escape hatch. Since #1392, ThinkingConfig{IncludeThoughts: true} sends reasoning.summary=auto (request.go:536-537), the summaries come back as thought parts (response.go:184), and on main the next turn sends them to the API as assistant text. Two turns through runner.Run against a stub show it:

main without this PR:   turn 2 replays the scratchpad: true
main with this PR:      turn 2 replays the scratchpad: false
the test — drop it in model/openaimodel and run go test -run TestThinkingSummaryReplay -v ./model/openaimodel/
package openaimodel_test

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"google.golang.org/genai"

	"google.golang.org/adk/v2/agent"
	"google.golang.org/adk/v2/agent/llmagent"
	"google.golang.org/adk/v2/model/openaimodel"
	"google.golang.org/adk/v2/runner"
	"google.golang.org/adk/v2/session"
)

func TestThinkingSummaryReplay(t *testing.T) {
	const turn = `{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"m",
	  "output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"PRIVATE-SCRATCHPAD"}]},
	            {"type":"message","id":"msg_1","role":"assistant","status":"completed",
	             "content":[{"type":"output_text","text":"4","annotations":[]}]}],
	  "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
	var bodies []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		bodies = append(bodies, string(b))
		w.Header().Set("Content-Type", "application/json")
		io.WriteString(w, turn)
	}))
	defer srv.Close()

	ctx := context.Background()
	m, _ := openaimodel.NewModel(ctx, "m", &openaimodel.ClientConfig{APIKey: "k", BaseURL: srv.URL})
	a, _ := llmagent.New(llmagent.Config{Name: "assistant", Model: m,
		GenerateContentConfig: &genai.GenerateContentConfig{
			ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true}}})
	r, _ := runner.New(runner.Config{AppName: "app", Agent: a,
		SessionService: session.InMemoryService(), AutoCreateSession: true})

	for _, q := range []string{"what is 2+2?", "and 3+3?"} {
		for _, err := range r.Run(ctx, "u", "s", genai.NewContentFromText(q, genai.RoleUser), agent.RunConfig{}) {
			if err != nil {
				t.Fatal(err)
			}
		}
	}
	t.Logf("turn 1 asks for summaries: %v", strings.Contains(bodies[0], `"reasoning":{"summary":"auto"}`))
	t.Logf("turn 2 replays the scratchpad: %v", strings.Contains(bodies[1], "PRIVATE-SCRATCHPAD"))
}

The two hand-resolved conflicts in doc.go keep both sides: the generation-config table and the strict-tools paragraph from main are intact, and the reasoning paragraph follows them once.

One non-blocking thing. The live e2e run in the Testing Plan was made on an earlier head (a3de3b5) with openai-go v3.54.0, and main has since moved to openai-go v3.61.0 and genai v1.71.0. A re-run on the current head would keep that section describing what actually merges.

@wojtas000
wojtas000 merged commit f9ce16e into google:main Sep 23, 2026
14 checks passed
wojtas000 added a commit to wojtas000/adk-go that referenced this pull request Sep 24, 2026
…model-subpackages

Brings in main through google#1395, which changed request.go after this branch split it. Its reasoning drop and unsupported-payload check are applied to internal/responses/request.go, and its tests merged into internal/responses under the renamed identifiers. The request corpus is identical on both branches.
tonydzi added a commit to tonydzi/adk-go that referenced this pull request Sep 24, 2026
…a/FileData parts

convertContents only handled Text, FunctionCall and FunctionResponse parts;
any genai.Part.InlineData or FileData hit the default branch and failed with
"openai: unsupported content part *genai.Part".

Convert both into the Responses API's input_image / input_file content items,
mirroring adk-python's _inline_data_part_to_response_content and
_file_data_part_to_response_content:

- InlineData: image MIME -> input_image with a data:<mime>;base64,<...> URL;
  anything else -> input_file carrying the same data URL in file_data.
- FileData: image MIME -> input_image with the URI passed through; a URI
  shaped like an OpenAI file id (file-...) -> input_file via FileID; any
  other URI -> input_file via FileURL.
- A message mixing text and media is emitted as a single message whose
  content list preserves part order, replacing the text-only accumulator.
- A replayed assistant turn carrying media now fails loudly instead of
  silently dropping it, since the output-message content type is text-only.

Rebased onto main, which has since landed google#1395, google#1614, google#1616, google#1389, google#1392
and google#1359 in this package. The conflict was semantic rather than textual, so
the resolution is spelled out here:

- convertContents keeps main's (items, droppedReasoning, error) signature and
  its unsupportedPayload guard; the text accumulator it flushes is the ordered
  content list this change introduced.
- Media is read independently of text, the way main already reads text
  independently of a call, and is appended after the text of the same part so
  the content list keeps the order the parts arrived in.
- unsupportedPayload now zeroes InlineData and FileData: it reports fields the
  package cannot send, and it can send these two now. accountedForFields, the
  restated spec in the test, gains the same two entries.
- replayedReasoning no longer treats a thought-marked part carrying media as
  reasoning that can be dropped, because dropping it would lose content.
- The carries-nothing arm accounts for media, so a part holding only an image
  is not reported as carrying nothing to send.

Four cases in TestBuildOpenAIParams_DropsReplayedThoughts pinned the previous
contract, that media is unsendable, and now pin the new one. Three become
positive cases; media on a model turn stays a rejection, but for the narrower
reason that an output message carries output_text only, so the message
changes. TestBuildOpenAIParams_UnsupportedPart keeps main's ExecutableCode
body, with its t.Fatalf text corrected from "inline data part" to the part it
actually exercises.

Fixes google#1333

Assisted-by: Claude Code/claude-opus-5[1m]
Machine: MacBook-Anton
Account: a
Operator: robot:git-s29-anchor-ladder
Signed-off-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com>
kdroste-google pushed a commit to kdroste-google/adk-go that referenced this pull request Sep 29, 2026
google#1395)

* fix(model/openaimodel): stop replaying prior-turn reasoning to the API

* fix(model/openaimodel): close the review gaps in the reasoning drop

Review of google#1395 found three shapes the drop got wrong and one sentence of
package doc it made untrue.

A thought carrying reasoning text on top of an image or code matched no arm
at all: the text was suppressed, the call and response arms did not apply,
and the rejection arm was gated on part.Text == "", which was false. The
media left the request with no error, against the promise the same change
had just made. The arm now keys on whether the part contributed text rather
than on whether it had any, so suppressing the text cannot suppress the
rejection with it.

replayedReasoning listed the fields that disqualified a part, which left
ToolCall, ToolResponse and AudioTranscription out and dropped them silently
where main reported an unsupported part. The test is inverted: clear the
reasoning, its signature, and the metadata that only qualifies media held in
another field, and a part is droppable when what remains is the zero value.
A field added by a later genai release is then rejected by default instead
of vanishing, and a field walk over genai.Part in the tests keeps that
honest.

An unsupported role stopped being reported when a turn's only text was a
thought: nothing buffered, so flushText returned before normalizeRole ran.
The role is now checked wherever text is dropped, so the error is raised
identically with the marker on or off.

The doc paragraph claimed reasoning is never sent back on a later turn,
unqualified. In a multi-agent run the shared flow rebuilds a peer's event as
plain user text with the thought marker unset, so a peer's reasoning still
arrives as ordinary content; the paragraph now says so and says it is not
this package's to fix.

The unbounded run loop the review also raised is closed on main by google#1290,
which bounds consecutive thought-only turns; driving runner.Run against a
stub that only ever reasons now ends after 10 calls with the request body
flat at 382 bytes, where before this change it grew until the provider
refused it.

* fix(model/openaimodel): reject unsendable payload whatever else the part carries

Review of the previous commit found the rejection still keyed on what the
part contributed, which only moved the hole. A thought carrying text on top
of an image was reported, but the same image riding on ordinary text, or on
a function call, still matched a sendable arm first and left the request
with no error at all. That predates this branch, and it is what made the
promise the branch had just written down untrue.

The check is now made once per part, before anything is emitted, and asks
the opposite question: not "which fields disqualify this part" but "is
anything set that convertContents does not account for". Text, the thought
marker and its signature, a call, a response, and the three metadata fields
that only qualify media carried elsewhere are cleared from a copy; whatever
is left over is named in the error. So an image reaches the caller as
"unsupported content part: InlineData" no matter what it travels with, and a
field added to genai.Part by a later release is reported rather than
silently dropped, because it is unaccounted for by construction.

replayedReasoning now shares that test instead of restating it, and the arm
that used to serve both jobs is left with the one it is for: a part that
contributed nothing and is not reasoning worth dropping now says it carries
nothing to send, rather than borrowing the unsupported-part wording.

On the tests: describeInput rendered an assistant input message and an
assistant output message identically, so the table could not have seen a
replayed assistant turn go out as "input_text" — the shape the Responses API
rejects. They now render as in/ and out/. The genai.Part field walk is
stated against the accounted-for list and fails loudly on an unexported
field instead of panicking, and it runs against unsupportedPayload as well
as the drop. Two tests drive the public GenerateContent surface, blocking
and streaming, asserting on the bytes that leave the process: the reasoning
is absent and the answer is not, and a request the drop empties never
reaches the model.

Each of the seven guards was reverted individually and the suite re-run;
every one is caught by a named test.

* test(model/openaimodel): prove the rejection reaches the caller, not just the predicate

Second review round. The field walk asserted what unsupportedPayload
returns, which says nothing about whether convertContents passes it on: a
mutation that swallowed the error for one field would leave the walk green.
Each generated case now also builds the request and requires the field's
name in the error, so the unit and the caller are held to the same answer.

An empty part had no case either. buildContentsDefault filters those out
upstream, so it only reaches convertContents from a caller assembling
contents itself, which is exactly why the contract is worth pinning: it is
reported, not quietly skipped.

The "carries nothing to send" error is built with errors.New rather than a
verbless fmt.Errorf, matching errors.go.

* fix(model/openaimodel): key the empty-request wrap and the role check on the drop

Review found the two of them gated on the wrong thing.

The wrap keyed on the request having contents, so a Content carrying no
parts at all reported "every part was dropped as replayed reasoning or
blank text" when nothing had been dropped, and turned the bare sentinel
into a wrapped one — err == ErrNoContents flipped from true to false for
a request that behaves exactly as it did before this branch.
convertContents now reports whether it dropped anything and the wrap
follows that, so the bare sentinel survives for a request that arrived
empty, blank text included, and the message no longer has to hedge about
which of the two emptied it.

The role check keyed on the part having text, so it ran for a thought
carrying reasoning and not for one carrying only the marker or a bare
signature — which main rejected as an unsupported part, and this branch
had made silent. It now runs wherever a part is dropped, so an
unsupported role is reported on any of the three.

Also pinned, because neither the drop nor the pairing was ever the
subject of a test here: text riding on a function response that pairs
with nothing, and on a call with no name. Both used to be discarded
because the text arm matched first, and are reported now that a part's
fields are read independently.

* fix(model/openaimodel): let a blank thought keep the bare ErrNoContents

Review found the empty-request wrap firing for a part whose reasoning text
was only whitespace. The drop is real there, but the text would have been
skipped by newMessage anyway, so the drop is not what emptied the request —
and wrapping turned err == ErrNoContents from true to false for a shape that
behaves exactly as it does on main. The flag is now set only for reasoning
that carried text the model would otherwise have seen, which also gives the
bare sentinel back to a bare thought and a lone signature.

The rejection for a part that reaches nothing keeps the unsupported-content-part
prefix the single message used before this branch, so a caller string-matching
on that phrase still matches the shapes that now land there — an empty part,
and one carrying only VideoMetadata, MediaResolution or PartMetadata.

The comment above the payload check claimed a part is read field by field.
Text is, against a call or a response, but a call and a response on the same
part are still arms of one switch and the response is dropped, as on main. The
comment now says what the code does rather than what the phrase implies.

Two test gaps the review named: the identity table gains the blank-reasoning
case the claim turns on, plus the bare thought and lone signature, and the
genai.Part field walk now puts each unsendable field on text, on reasoning
text and on a function call rather than only on a part of its own, since the
riding shape is the one this change exists to fix.

* test(model/openaimodel): pin the drop flag across parts, and correct the doc

Review round found the flag's accumulation untested: writing it as an
assignment rather than a conditional set passed the whole suite, so a blank
thought following a real one could have talked the emptied-request report
back down. Two cases pin it, within a turn and across turns, and one more
pins the blank-skip the trim rule leans on — that a blank text item is
dropped beside a real one rather than only when it is alone.

The package doc claimed a single exception to reasoning never being sent
back. There are three: a peer agent's reply folded into context, a compacted
transcript, and a memory recall all render reasoning as ordinary text
upstream of any model package. It now says that rather than naming one.

* test(model/openaimodel): cover the user-turn blank skip and the flag across turns

Second review round. The across-turns case put the real thought in the last
turn, so a flag reset per content block would have passed it; the real turn
now comes first, which is the order that catches it. The blank skip was
pinned only on the assistant path through newOutputMessage, leaving the user
path through newMessage untested both beside real text and on its own.

The field walk asserted the error contains the field name, which a shorter
name that prefixes a longer one would satisfy; it now asserts the suffix.

Three doc comments ran past the two-sentence limit this package keeps.

---------

Co-authored-by: wojtasjan <wojtasjan@google.com>
Co-authored-by: Jan Wojtas <56300703+wojtas000@users.noreply.github.com>
li-xuankun312 added a commit to li-xuankun312/einsteinOS that referenced this pull request Sep 29, 2026
google#1395)

* fix(model/openaimodel): stop replaying prior-turn reasoning to the API

* fix(model/openaimodel): close the review gaps in the reasoning drop

Review of google#1395 found three shapes the drop got wrong and one sentence of
package doc it made untrue.

A thought carrying reasoning text on top of an image or code matched no arm
at all: the text was suppressed, the call and response arms did not apply,
and the rejection arm was gated on part.Text == "", which was false. The
media left the request with no error, against the promise the same change
had just made. The arm now keys on whether the part contributed text rather
than on whether it had any, so suppressing the text cannot suppress the
rejection with it.

replayedReasoning listed the fields that disqualified a part, which left
ToolCall, ToolResponse and AudioTranscription out and dropped them silently
where main reported an unsupported part. The test is inverted: clear the
reasoning, its signature, and the metadata that only qualifies media held in
another field, and a part is droppable when what remains is the zero value.
A field added by a later genai release is then rejected by default instead
of vanishing, and a field walk over genai.Part in the tests keeps that
honest.

An unsupported role stopped being reported when a turn's only text was a
thought: nothing buffered, so flushText returned before normalizeRole ran.
The role is now checked wherever text is dropped, so the error is raised
identically with the marker on or off.

The doc paragraph claimed reasoning is never sent back on a later turn,
unqualified. In a multi-agent run the shared flow rebuilds a peer's event as
plain user text with the thought marker unset, so a peer's reasoning still
arrives as ordinary content; the paragraph now says so and says it is not
this package's to fix.

The unbounded run loop the review also raised is closed on main by google#1290,
which bounds consecutive thought-only turns; driving runner.Run against a
stub that only ever reasons now ends after 10 calls with the request body
flat at 382 bytes, where before this change it grew until the provider
refused it.

* fix(model/openaimodel): reject unsendable payload whatever else the part carries

Review of the previous commit found the rejection still keyed on what the
part contributed, which only moved the hole. A thought carrying text on top
of an image was reported, but the same image riding on ordinary text, or on
a function call, still matched a sendable arm first and left the request
with no error at all. That predates this branch, and it is what made the
promise the branch had just written down untrue.

The check is now made once per part, before anything is emitted, and asks
the opposite question: not "which fields disqualify this part" but "is
anything set that convertContents does not account for". Text, the thought
marker and its signature, a call, a response, and the three metadata fields
that only qualify media carried elsewhere are cleared from a copy; whatever
is left over is named in the error. So an image reaches the caller as
"unsupported content part: InlineData" no matter what it travels with, and a
field added to genai.Part by a later release is reported rather than
silently dropped, because it is unaccounted for by construction.

replayedReasoning now shares that test instead of restating it, and the arm
that used to serve both jobs is left with the one it is for: a part that
contributed nothing and is not reasoning worth dropping now says it carries
nothing to send, rather than borrowing the unsupported-part wording.

On the tests: describeInput rendered an assistant input message and an
assistant output message identically, so the table could not have seen a
replayed assistant turn go out as "input_text" — the shape the Responses API
rejects. They now render as in/ and out/. The genai.Part field walk is
stated against the accounted-for list and fails loudly on an unexported
field instead of panicking, and it runs against unsupportedPayload as well
as the drop. Two tests drive the public GenerateContent surface, blocking
and streaming, asserting on the bytes that leave the process: the reasoning
is absent and the answer is not, and a request the drop empties never
reaches the model.

Each of the seven guards was reverted individually and the suite re-run;
every one is caught by a named test.

* test(model/openaimodel): prove the rejection reaches the caller, not just the predicate

Second review round. The field walk asserted what unsupportedPayload
returns, which says nothing about whether convertContents passes it on: a
mutation that swallowed the error for one field would leave the walk green.
Each generated case now also builds the request and requires the field's
name in the error, so the unit and the caller are held to the same answer.

An empty part had no case either. buildContentsDefault filters those out
upstream, so it only reaches convertContents from a caller assembling
contents itself, which is exactly why the contract is worth pinning: it is
reported, not quietly skipped.

The "carries nothing to send" error is built with errors.New rather than a
verbless fmt.Errorf, matching errors.go.

* fix(model/openaimodel): key the empty-request wrap and the role check on the drop

Review found the two of them gated on the wrong thing.

The wrap keyed on the request having contents, so a Content carrying no
parts at all reported "every part was dropped as replayed reasoning or
blank text" when nothing had been dropped, and turned the bare sentinel
into a wrapped one — err == ErrNoContents flipped from true to false for
a request that behaves exactly as it did before this branch.
convertContents now reports whether it dropped anything and the wrap
follows that, so the bare sentinel survives for a request that arrived
empty, blank text included, and the message no longer has to hedge about
which of the two emptied it.

The role check keyed on the part having text, so it ran for a thought
carrying reasoning and not for one carrying only the marker or a bare
signature — which main rejected as an unsupported part, and this branch
had made silent. It now runs wherever a part is dropped, so an
unsupported role is reported on any of the three.

Also pinned, because neither the drop nor the pairing was ever the
subject of a test here: text riding on a function response that pairs
with nothing, and on a call with no name. Both used to be discarded
because the text arm matched first, and are reported now that a part's
fields are read independently.

* fix(model/openaimodel): let a blank thought keep the bare ErrNoContents

Review found the empty-request wrap firing for a part whose reasoning text
was only whitespace. The drop is real there, but the text would have been
skipped by newMessage anyway, so the drop is not what emptied the request —
and wrapping turned err == ErrNoContents from true to false for a shape that
behaves exactly as it does on main. The flag is now set only for reasoning
that carried text the model would otherwise have seen, which also gives the
bare sentinel back to a bare thought and a lone signature.

The rejection for a part that reaches nothing keeps the unsupported-content-part
prefix the single message used before this branch, so a caller string-matching
on that phrase still matches the shapes that now land there — an empty part,
and one carrying only VideoMetadata, MediaResolution or PartMetadata.

The comment above the payload check claimed a part is read field by field.
Text is, against a call or a response, but a call and a response on the same
part are still arms of one switch and the response is dropped, as on main. The
comment now says what the code does rather than what the phrase implies.

Two test gaps the review named: the identity table gains the blank-reasoning
case the claim turns on, plus the bare thought and lone signature, and the
genai.Part field walk now puts each unsendable field on text, on reasoning
text and on a function call rather than only on a part of its own, since the
riding shape is the one this change exists to fix.

* test(model/openaimodel): pin the drop flag across parts, and correct the doc

Review round found the flag's accumulation untested: writing it as an
assignment rather than a conditional set passed the whole suite, so a blank
thought following a real one could have talked the emptied-request report
back down. Two cases pin it, within a turn and across turns, and one more
pins the blank-skip the trim rule leans on — that a blank text item is
dropped beside a real one rather than only when it is alone.

The package doc claimed a single exception to reasoning never being sent
back. There are three: a peer agent's reply folded into context, a compacted
transcript, and a memory recall all render reasoning as ordinary text
upstream of any model package. It now says that rather than naming one.

* test(model/openaimodel): cover the user-turn blank skip and the flag across turns

Second review round. The across-turns case put the real thought in the last
turn, so a flag reset per content block would have passed it; the real turn
now comes first, which is the order that catches it. The blank skip was
pinned only on the assistant path through newOutputMessage, leaving the user
path through newMessage untested both beside real text and on its own.

The field walk asserted the error contains the field name, which a shorter
name that prefixes a longer one would satisfy; it now asserts the suffix.

Three doc comments ran past the two-sentence limit this package keeps.

---------

Co-authored-by: wojtasjan <wojtasjan@google.com>
Co-authored-by: Jan Wojtas <56300703+wojtas000@users.noreply.github.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