Skip to content

fix(ai,agent): close fail-open Responses tool-call identity/lifecycle edge cases - #4276

Merged
Yeachan-Heo merged 1 commit into
devfrom
fix/issue-4274-responses-fail-closed
Aug 11, 2026
Merged

fix(ai,agent): close fail-open Responses tool-call identity/lifecycle edge cases#4276
Yeachan-Heo merged 1 commit into
devfrom
fix/issue-4274-responses-fail-closed

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Fixes #4274

processResponsesStream was fail-open for five malformed/out-of-contract Responses wire shapes, all pre-existing on 44f4e75b0e and verified to reproduce on the post-#4264 tree. This PR closes all five and adds a typed incompleteArgumentsReason across the ToolCall contract into agent-loop guidance.

Reproduction matrix (all five on exact base 678e26c4)

Shape Pre-fix behavior Post-fix behavior
S1 Duplicate call_id First item executable, second silently aliased Both fail-closed (ambiguous)
S2 id/call_id collision Delta routed to wrong item (id match wins) Both marked ambiguous, delta dropped
S3 Duplicate output_item.done Second toolcall_end emitted Ignored (no live entry)
S4 Orphan output_item.done toolcall_end at contentIndex: -1 Skipped (no phantom block)
S5 Two items sharing output_index Custom tool loses live input delta First occupant keeps idx alias; both addressable by id/call_id

Typed incomplete reason

incompleteArguments now carries incompleteArgumentsReason: "truncated" | "malformed" | "conflicting" | "ambiguous". The agent-loop gives reason-specific recovery guidance instead of always suggesting "split the work". The boolean remains the primary signal; callers reading only incompleteArguments are unaffected.

Contract evidence

  • ToolCall.incompleteArgumentsReason added to packages/ai/src/types.ts (optional, backward-safe)
  • All providers that set incompleteArguments for truncation now set reason: "truncated": anthropic, ollama, openai-completions, openai-responses-shared
  • openai-responses-shared sets "malformed" / "conflicting" / "ambiguous" where applicable
  • mock provider supports the typed reason (defaults to "truncated")
  • agent-loop.ts deserializes the reason and gives reason-specific error messages
  • #4264's decoded semantic comparison (isEquivalentJsonPayload) and execution block are unchanged

Tests

  • 5 adversarial tests in openai-responses-multi-toolcall-stream-adversarial.test.ts (one per shape)
  • Reason-specific assertions added to existing conflict/malformed/truncated tests
  • 5 new agent-loop tests for reason-specific guidance (truncated/malformed/conflicting/ambiguous/backward-compat)
  • All 42 targeted tests pass; no new regressions

Exact head

23de1bac1e on fix/issue-4274-responses-fail-closed, base 678e26c4 (origin/dev).


[repo owner's gaebal-gajae (clawdbot) 🦞]

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23de1bac1e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +499 to +501
if (hasIndex(outputIndex)) {
const idxK = idxKey(outputIndex);
if (!items.has(idxK)) items.set(idxK, entry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject duplicated output indices instead of choosing the first

When two active function calls share an output_index and their deltas omit item_id, retaining the first index alias routes every delta to that first call rather than failing closed. For streamed-only relays whose terminal items contain empty arguments, the first tool can consequently execute arguments intended for the second while the second is rejected as malformed; after the first finalizes, its alias is also deleted and later index-only deltas cannot reach the remaining call. Treat a duplicated index as ambiguous instead of selecting one occupant.

Useful? React with 👍 / 👎.

… edge cases (#4274)

processResponsesStream was fail-open for five malformed/out-of-contract
Responses wire shapes, all pre-existing on 44f4e75 and verified to
reproduce on the post-#4264 tree:

1. Duplicate call_id: two function_call items sharing a call_id collapsed
   onto one alias; the first stayed executable while the later was flagged.
   Now both are marked incompleteArguments (ambiguous identity).
2. item_id/call_id namespace collision: a delta keyed on a string that is
   one item's id and another's call_id was silently routed to the id match.
   Now both entries are marked ambiguous and the delta is dropped.
3. Duplicate output_item.done: emitted a second toolcall_end for an
   already-finalized item. Now the duplicate is ignored (no live entry).
4. Orphan output_item.done (no preceding added): emitted toolcall_end at
   contentIndex -1. Now it is skipped — no phantom block.
5. Two active items sharing one output_index: the second item stole the
   idx alias and the first (custom tool) lost its live input delta. Now
   the first occupant keeps the idx alias; both stay addressable by their
   own id/call_id.

Additionally, incompleteArguments now carries a typed reason
(truncated|malformed|conflicting|ambiguous) across the ToolCall contract,
all providers that set the flag, and the agent-loop guidance message.
The agent-loop now gives reason-specific recovery guidance instead of
always suggesting "split the work" (truncation-only) for a malformed,
conflicting, or ambiguous-identity failure. The boolean remains the
primary signal; callers that read only incompleteArguments are unaffected.

Fixes #4274

Lore-id: 4274-responses-fail-closed
Constraint: must not regress #4264 decoded semantic comparison or execution block
Constraint: valid interleaved multi-tool streams must be preserved
Constraint: backward-safe — callers reading only incompleteArguments must work
Rejected: throwing on ambiguous identity | breaks single-call relay streams that recover via terminal
Confidence: high
Scope-risk: moderate
Reversibility: trivial
Tested: all 5 fail-open shapes reproduced on base then closed; 42 targeted tests pass
Not-tested: live provider round-trip (offline adversarial harness only)
Supersedes: none
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/issue-4274-responses-fail-closed branch from 23de1ba to 465c4cf Compare August 11, 2026 19:14

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 465c4cf874

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +509 to +511
if (existing && existing !== entry) {
existing.ambiguousIdentity = true;
entry.ambiguousIdentity = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fail closed ambiguous custom-tool entries

When duplicated or colliding identities belong to custom_tool_call entries, these lines set ambiguousIdentity, but the custom-tool finalization branch never checks that flag and emits an executable tool call without incompleteArguments. Consequently, a custom tool such as apply_patch can still execute input whose identity was explicitly detected as ambiguous; propagate the ambiguous rejection to custom-tool finalization as well.

Useful? React with 👍 / 👎.

Comment on lines +504 to +505
const callK = callKey(item.call_id);
const existing = items.get(callK);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check new call IDs against existing item IDs

When an earlier item has id: "shared", a later tool has call_id: "shared", and the later tool's terminal event arrives first, this lookup checks only call:shared and misses the existing id:shared entry. The later call therefore finalizes as executable and removes its alias, after which the earlier item also finalizes cleanly, so the collision never fails closed unless an event happens to resolve the shared key first; perform the symmetric idKey(item.call_id) collision check during registration.

Useful? React with 👍 / 👎.

Comment thread packages/ai/src/types.ts
* Absent when `incompleteArguments` is not set. Existing callers that read only
* `incompleteArguments` continue to work.
*/
incompleteArgumentsReason?: "truncated" | "malformed" | "conflicting" | "ambiguous";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the public contract change in package changelogs

This adds a public ToolCall field and changes recovery behavior in both packages/ai and packages/agent, but the commit adds no entry under either package's ## [Unreleased] section. Add changelog entries for both affected packages so the release notes include the new contract and behavior.

AGENTS.md reference: AGENTS.md:L186-L186

Useful? React with 👍 / 👎.

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.

1 participant