Skip to content

Preserve reasoning items across multi-turn Responses API calls - #26

Merged
superdav42 merged 16 commits into
WordPress:trunkfrom
superdav42:fix/preserve-reasoning-round-trip
Sep 3, 2026
Merged

superdav42 merged 16 commits into
WordPress:trunkfrom
superdav42:fix/preserve-reasoning-round-trip

Conversation

@superdav42

@superdav42 superdav42 commented May 16, 2026 •

Copy link
Copy Markdown
Member

Summary

Reasoning output items returned by gpt-5 and o-series models are currently dropped during response parsing. The encrypted_content blob the Responses API expects back on the next turn is lost, so multi-turn conversations with reasoning models silently degrade in quality and the OpenAI docs guidance on round-tripping reasoning items cannot be followed.

This PR threads reasoning items through OpenAiTextGenerationModel in both directions:

  • Inbound: output[].type === 'reasoning' items are turned into thought-channel MessagePart instances and attached to the next message / function_call candidate's message parts (alongside the answer text, matching the Anthropic provider's pattern). The reasoning item's {id, encrypted_content, summary} trio is packed into a JSON blob stored on the part's thoughtSignature so all three fields are recoverable on round-trip. The summary text is also surfaced as the part's text content for human-readable consumption.
  • Outbound: thought-channel parts with a signature on any history message are re-emitted as top-level {type: 'reasoning', id, encrypted_content, summary} input items before the corresponding message item. The signature blob is JSON-decoded to restore the original shape.
  • Token accounting: usage.output_tokens_details.reasoning_tokens is now threaded into TokenUsage::$thoughtTokens when present.

Resolves #13.

Credit to @Takshil-Kunadia — the JSON-blob signature encoding (commit 2) was cherry-picked from #20.

Why reasoning is attached to the existing candidate, not emitted as a separate one

GenerativeAiResult::toText(), toFile(), toMessage() and friends all inspect only the first candidate (php-ai-client/src/Results/DTO/GenerativeAiResult.php:189). Because output[] from the Responses API always lists reasoning items before the corresponding message item, an alternative design that emits each reasoning item as its own Candidate would put the reasoning candidate first — toText() would then throw RuntimeException('No text content found in first candidate') for every gpt-5 / o-series response, breaking the canonical wp_ai_client_prompt()->generate_text() API.

Attaching reasoning as additional thought-channel parts on the answer's candidate keeps toText() working (it filters to content-channel parts) and matches how the Anthropic provider already surfaces thinking blocks.

SDK compatibility

The thought-signature and thought-token DTO surface (MessagePart::$thoughtSignature, TokenUsage::$thoughtTokens) shipped in wordpress/php-ai-client 1.3.0 (#216). This plugin's current composer.json constraint ^0.4 || dev-trunk straddles that boundary, so all new code paths are runtime-gated:

  • MessagePart::getThoughtSignature() / 3-arg MessagePart constructor — guarded by method_exists(MessagePart::class, 'getThoughtSignature').
  • 4-arg TokenUsage constructor — guarded by ReflectionMethod::getNumberOfParameters().
  • Channel inspection — guarded by method_exists($part, 'getChannel').

On pre-1.3 SDKs the new code paths no-op cleanly; on 1.3+ they activate. No dependency bump needed.

Verification

  1. Static analysis: composer phpstan clean against the locked SDK 0.4.2 (CI's resolution).

  2. Coding standards: composer phpcs clean.

  3. toText() regression check: the canonical answer-extraction path returns "Hello world." when the response contains a leading reasoning item (would throw under an alternative design that emits reasoning as a standalone first candidate).

  4. End-to-end round-trip test (mock HTTP transporter, live WP stack, real-shape Responses API body with id + encrypted_content + summary):

    === TURN 1: send a question ===
    tokens: prompt=28 completion=12 total=40 thought=384
    [candidate 0] finish=stop
      part[0] channel=thought type=text sig=YES(557b) text=The phrasing "all but 9 die" means 9 survive.
      part[1] channel=content type=text sig=null  text=Nine sheep are left.
    
    === TURN 2: round-trip the assistant message back ===
    Turn 2 input items: 4
      [0] user      [A farmer has 17 sheep, all but 9 die...]
      [1] reasoning id=rs_live_t1 enc=enc_REASONING_BLOB_T1_AA...(422) summary_count=1
      [2] assistant [Nine sheep are left.]
      [3] user      [Now the farmer buys 5 more sheep. Final count?]
    
    === VERDICT ===
    inbound reasoning part on candidate:        YES
    inbound signature stored (JSON blob):       YES (557b)
    inbound thought tokens (turn 1):            384
    outbound reasoning item in turn 2:          YES
    outbound id preserved:                      YES (rs_live_t1)
    outbound encrypted_content preserved:       YES
    outbound summary preserved:                 YES (1 item)
    turn 2 succeeded:                           YES
    

    The verifier instantiates OpenAiTextGenerationModel through the live ProviderRegistry, swaps in a recording mock HttpTransporterInterface (the configured project API key lacks api.responses.write scope, so the real network call returns 401), and confirms the patched parse + serialize paths handle real-shape gpt-5 response bodies correctly across two consecutive turns.

  5. Backwards compatibility: re-ran the same verifier against php-ai-client 0.4.2 (the locked SDK version) — patch silently no-ops, no fatals, parser behaves identically to current trunk for callers without reasoning items.

Risk

  • Behaviour change only when the response contains output[].type === 'reasoning' (gpt-5, o-series with reasoning enabled).
  • Reasoning items previously dropped become thought-channel parts on the candidate message; callers that iterate Message::getParts() and don't filter by channel will now see those parts. GenerativeAiResult::toText() filters to content channel and is unaffected.
  • No changes to existing message / function_call / function_response handling.

Reasoning output items returned by gpt-5 and o-series models were
silently dropped during response parsing, so the encrypted_content
required to round-trip the reasoning back on the next turn was lost.

Reasoning items now become thought-channel MessagePart instances
attached to the next message or function_call candidate, and they are
re-emitted as top-level reasoning input items on subsequent requests.
Reasoning token counts from output_tokens_details are also threaded
into TokenUsage.

Thought-signature and thought-token support is runtime-gated on the
php-ai-client SDK so the plugin remains compatible with SDK versions
below 1.3.0 (the gating no-ops on older SDKs).
@github-actions

github-actions Bot commented May 16, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: superdav42 <superdav42@git.wordpress.org>
Co-authored-by: felixarntz <flixos90@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

…blob

The OpenAI Responses API expects all three fields back when echoing a
reasoning item, but the SDK's MessagePart::$thoughtSignature is a single
opaque string. Encode the trio as JSON on inbound and decode on outbound
so id and summary are preserved across multi-turn calls, not just the
encrypted_content blob.

Co-authored-by: Takshil-Kunadia <takshilkunadia@gmail.com>
The Responses API constraint that function_call / function_response parts must
be the only part in a wire Message applies to the serialized message body,
not to the in-memory Message DTO. Thought-channel parts are emitted as
separate top-level `reasoning` input items by getReasoningInputItems() and
skipped by getMessageInputItem(), so they never reach the wire Message and
must not count toward the 'only part' rule.

Before this fix, when the API returned [reasoning, function_call] (the common
case for reasoning-capable models invoking tools), parseFunctionCallOutputToCandidate
built a Candidate with a Message of [reasoning_part, function_call_part]. On
the next turn, prepareInputParam called validateMessages first, which counted
both parts, found the function_call, and threw:

    InvalidArgumentException: Function call parts must be the only part in a
    message for the OpenAI Responses API.

The fix filters thought-channel parts out before counting and type-checking,
keeping the rule identical for genuine wire content.

@felixarntz felixarntz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@superdav42 This looks great so far!

One early point of feedback: Could you change the PHP AI Client requirement of the package to 1.3.0 please? We don't need to maintain back compat with those older versions, especially because WordPress uses 1.3.0+ internally, so it would never be relevant there.

And for any other project, I think it's reasonable to expect them to upgrade too.

Per review feedback: WordPress ships php-ai-client 1.3+ internally and
other consumers can be expected to upgrade, so the defensive
method_exists() / ReflectionMethod guards around getChannel(),
getThoughtSignature(), and the 4th TokenUsage parameter are no longer
needed.

Changes:
- composer.json: require-dev wordpress/php-ai-client ^1.3 || dev-trunk
  (was ^0.4 || dev-trunk).
- OpenAiTextGenerationModel::getReasoningInputItems(): drop the
  method_exists guards; call $part->getChannel()->isThought() and
  $part->getThoughtSignature() directly.
- OpenAiTextGenerationModel::validateMessages(): drop the same guard.
- OpenAiTextGenerationModel::getMessageInputItem(): drop the same guard.
- OpenAiTextGenerationModel::parseReasoningOutputToPart(): drop the
  method_exists fallback (the method is always present at >=1.3) and
  the @phpstan-ignore-next-line on the MessagePart constructor.
- OpenAiTextGenerationModel::buildTokenUsage(): drop the
  ReflectionMethod check and always pass $thoughtTokens to TokenUsage
  (4-param signature is part of the 1.3 contract).

Also resolves the @SInCE n.e.x.t placeholders to @SInCE 1.1.0 (next
minor for new functionality) and trims verbose docblocks.
@superdav42

Copy link
Copy Markdown
Member Author

Done in ad97bfc.

  • Bumped wordpress/php-ai-client to ^1.3 || dev-trunk in require-dev (kept dev-trunk so the SDK trunk path still works).
  • Dropped the method_exists($part, 'getChannel') / method_exists(MessagePart::class, 'getThoughtSignature') guards in getReasoningInputItems, validateMessages, getMessageInputItem, and parseReasoningOutputToPart — getChannel()/getThoughtSignature() are now called directly.
  • Dropped the ReflectionMethod 4-param check in buildTokenUsage and always pass $thoughtTokens (nullable in the 1.3 TokenUsage constructor).
  • Removed the three @phpstan-ignore-next-line annotations that only existed because of those guards.

Also resolved @since n.e.x.t → @since 1.1.0 (picked the next minor since this adds new functionality — happy to bump to a different version if you'd prefer) and trimmed the verbose docblocks.

Verified locally with composer phpstan (5 files, 0 errors) and composer phpcs (6 files, clean).


aidevops.sh v3.17.14 plugin for OpenCode v1.15.6 with claude-sonnet-4-6 spent 26m and 40 tokens on this as a headless worker.

@superdav42 superdav42 self-assigned this Jun 30, 2026

Copilot AI 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.

Pull request overview

This PR updates the OpenAI Responses API adapter to preserve and round-trip “reasoning” output items across multi-turn conversations by converting them into thought-channel message parts (inbound) and re-emitting them as top-level reasoning input items (outbound). It also extends token accounting to include reasoning/thought tokens when present.

Changes:

  • Parse output[].type === 'reasoning' into thought-channel MessageParts, buffer them, and attach them to the subsequent message / function_call candidate.
  • Serialize thought-channel parts with a stored signature back into top-level {type:'reasoning', id, encrypted_content, summary} input items before the corresponding message on the next turn.
  • Introduce thought-token extraction from usage.output_tokens_details.reasoning_tokens and adjust dev dependency constraints/lock metadata.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 7 comments.

File Description
src/Models/OpenAiTextGenerationModel.php Implements buffering, parsing, and round-tripping of Responses API reasoning items plus thought token usage handling.
composer.json Changes dev constraint for wordpress/php-ai-client to ^1.3.
composer.lock Updates lock metadata (plugin-api-version).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Models/OpenAiTextGenerationModel.php
Comment thread src/Models/OpenAiTextGenerationModel.php
Comment thread src/Models/OpenAiTextGenerationModel.php
Comment thread src/Models/OpenAiTextGenerationModel.php
$thoughtTokens = $details['reasoning_tokens'];
}

return new TokenUsage($inputTokens, $outputTokens, $totalTokens, $thoughtTokens);
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread composer.json
superdav42 and others added 3 commits July 29, 2026 12:27
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@jeffpaul jeffpaul added this to the 1.1.0 milestone Aug 13, 2026
@jeffpaul
jeffpaul requested a review from dkotter August 13, 2026 19:12
# Conflicts:
#	composer.json
#	composer.lock
#	src/Provider/OpenAiProvider.php
@jeffpaul jeffpaul modified the milestones: 1.1.0, 1.2.0 Aug 17, 2026
@superdav42 superdav42 assigned dkotter and unassigned superdav42 Aug 18, 2026
@dkotter dkotter removed their assignment Aug 18, 2026
@jeffpaul

Copy link
Copy Markdown
Member

@felixarntz back to you for review/merge here

Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php
Comment thread src/Models/OpenAiTextGenerationModel.php
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
Comment thread src/Models/OpenAiTextGenerationModel.php Outdated
@superdav42
superdav42 requested a review from dkotter September 3, 2026 17:17
@superdav42
superdav42 enabled auto-merge (squash) September 3, 2026 19:33
@superdav42

Copy link
Copy Markdown
Member Author

I guess @dkotter 's approval is not good enough for me to merge it. @jeffpaul Can you approve it so I can merge?

@superdav42
superdav42 merged commit a8a2e2b into WordPress:trunk Sep 3, 2026
18 checks passed
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.

Add support for thought signatures and thought tokens count in results

5 participants