Skip to content

fix(compact): preserve tool search pairs - #1606

Closed
Komzpa wants to merge 4 commits into
mainfrom
fix/compact-tool-search-pairs
Closed

fix(compact): preserve tool search pairs#1606
Komzpa wants to merge 4 commits into
mainfrom
fix/compact-tool-search-pairs

Conversation

@Komzpa

@Komzpa Komzpa commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Preserve Codex tool_search_call / tool_search_output pairs during compact input trimming so oversized sessions do not forward an orphan tool_search_output upstream. This fixes a live compact failure where OpenAI rejected session 019fcc77-8e08-7f02-b5d2-3388c5122004 with No tool call found for tool search output with call_id call_d2chifMNZfXqfFWin5jz0dQf.

Type of change

  • fix: — bug fix (no behavior change beyond the bug)

Linked issue: none

OpenSpec

  • Not applicable — bug fix that matches the existing spec
  • This PR touches a codex-faithful path (image pipeline, request/response shape, SSE framing, OAuth flow) and preserves upstream-equivalent behavior

Change directory: n/a

Changes

  • Adds tool_search_call and tool_search_output to the compact tool pair reconciliation sets.
  • Adds a regression test using the live-shaped tool_search_call / tool_search_output history item pair.

Test plan

.venv/bin/python -m pytest tests/unit/test_openai_requests.py -k 'tool_search_outputs or compact_trimming_keeps_selected_tool_outputs_with_matching_calls or compact_trimming_drops_selected_tool_outputs_without_matching_calls or compact_trimming_reconciles_duplicate_tool_call_ids_by_occurrence' -q\n# 4 passed, 146 deselected\n\n.venv/bin/python -m pytest tests/integration/test_proxy_compact.py -k 'compact_strips_tool_fields or side_effect_pair' -q\n# 2 passed, 25 deselected\n\ngit diff --check\n```\n\n## Screenshots / output\n\nLive replay from `/home/kom/.codex/sessions/2026/08/04/rollout-2026-08-04T15-10-15-019fcc77-8e08-7f02-b5d2-3388c5122004.jsonl` through `ResponsesCompactRequest.to_payload()` now keeps both `tool_search_call` and `tool_search_output` for `call_d2chifMNZfXqfFWin5jz0dQf`; `has_orphan_tool_search_output` is `False`.\n\n## Checklist\n\n- [x] Title is in Conventional Commits format (`<type>(<scope>)?: <subject>`).\n- [ ] Linked the related issue / discussion above.\n- [x] Added or updated tests covering the change.\n- [ ] Ran `uv run pre-commit run local-ci --hook-stage manual --all-files` or the relevant `make <target>` subset locally.\n- [ ] If touching specs: `openspec validate --specs` passes and `/opsx:verify` is clean.\n- [x] Simplicity gates reviewed: the five simplicity rules (PRINCIPLES.md P1-P5).\n- [x] CHANGELOG is **not** edited by hand (release-please handles it).\n

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Soju06

Soju06 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Verified the core fix against main and it is correct: _COMPACT_TOOL_CALL_ITEM_TYPES / _COMPACT_TOOL_CALL_OUTPUT_ITEM_TYPES (app/core/openai/requests.py:45-47) omit the tool-search pair, so _compact_reconciled_tool_call_indices treats a tool_search_output in the retained tail as ordinary context and forwards it without its call. The orphan-output direction (No tool call found for tool search output ...) is also not covered by the missing-tool-output classifier (it only matches No tool output found for ...), so the upstream 400 surfaces unmasked — the regression test models this correctly and fails without the fix.

Two items before this can merge:

  1. Scope disclosure: commit ca7d010 changes the compact trim marker role from user to developer (app/core/openai/requests.py:1472), but the PR body says "no behavior change beyond the bug" and the Changes section does not mention it. Please drop that commit or split it into its own PR with rationale. It is not a pure cosmetic change: a {"type": "message", "role": "developer"} item matches _compact_item_is_state_anchor (requests.py:1370) and the _normalize_responses_input_instructions hoist shape, so a marker that re-enters validation or trimming behaves differently from the user-role marker (instructions hoist / mandatory trim anchor). If keeping synthetic text out of user turns is the goal, that deserves its own review and test.

  2. CI: integration-bridge failed on test_v1_responses_http_bridge_reconnects_after_clean_upstream_close (409 vs 200), which is not the known flaky WS masking test. I don't see a causal path from this diff, but it needs a green rerun.

Heads-up: your open #1235 rewrites these same frozensets into _COMPACT_TOOL_CALL_TYPE_BY_OUTPUT_TYPE; whichever lands second needs a rebase that keeps tool_search_call/tool_search_output in the mapping.

@Komzpa
Komzpa force-pushed the fix/compact-tool-search-pairs branch 3 times, most recently from af42123 to 56b7b81 Compare August 6, 2026 23:01
@Komzpa

Komzpa commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 433d75e535

ℹ️ 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 thread tests/unit/test_openai_requests.py Outdated
Comment on lines +2423 to +2424
request = ResponsesCompactRequest.model_validate(payload)
dumped = request.to_payload()

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 Exercise tool-search pairing through the compact route

Add regression coverage through /backend-api/codex/responses/compact rather than only calling ResponsesCompactRequest.to_payload() directly. The reported failure occurred at the externally visible compact endpoint, so this helper-level test does not verify that routing and final upstream preparation preserve the pair; the repository explicitly requires bug-fix coverage at the failing product path.

AGENTS.md reference: AGENTS.md:L125-L128

Useful? React with 👍 / 👎.

Comment on lines +383 to +385
async def update_last_used(self, key_id: str, *, commit: bool = True) -> None:
"""Compatibility touch for maintenance and durability checks."""
await self._session.execute(update(ApiKey).where(ApiKey.id == key_id).values(last_used_at=utcnow()))

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 Split the API-key touch into its own change

Remove this unrelated API-key persistence API from the compact-trimming change. It adds a public database mutation with commit-on-by-default behavior that is neither described nor covered by this commit's compact regression, coupling two independently reviewable and revertible concerns despite the repository's one-concern rule.

AGENTS.md reference: AGENTS.md:L121-L124

Useful? React with 👍 / 👎.

Comment thread app/core/openai/requests.py Outdated
_TOOL_TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text", "refusal"})
_COMPACT_STATE_TOOL_NAMES = frozenset({"create_goal", "get_goal", "update_goal", "update_plan"})
_TOOL_CALL_ITEM_TYPES = frozenset({"function_call", "custom_tool_call", "apply_patch_call"})
_TOOL_CALL_ITEM_TYPES = frozenset({"function_call", "custom_tool_call", "apply_patch_call", "tool_search_call"})

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 Keep tool-search types out of namespace stripping

Use a dedicated compact-pairing call-type set instead of adding tool_search_call to _TOOL_CALL_ITEM_TYPES. When a standard, compact, or configured-source request contains a tool_search_call with a namespace, the shared set now makes strip_replayed_tool_call_namespaces_from_payload() silently remove that field even though this fix only needs pairing during trimming; openspec/changes/fix-replayed-namespaced-function-call/context.md:9 explicitly limits that normalization to the established replayed call types and requires other input-item types to remain unchanged.

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.


async def update_last_used(self, key_id: str, *, commit: bool = True) -> None:
"""Compatibility touch for maintenance and durability checks."""
await self._session.execute(update(ApiKey).where(ApiKey.id == key_id).values(last_used_at=utcnow()))

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 Preserve greatest-wins semantics in the compatibility touch

Guard this update with the same last_used_at IS NULL OR last_used_at < :new predicate used by the coalescer, or route the touch through that coalescer. If another replica has already stored a later timestamp—for example because replica clocks differ or writes arrive out of observation order—this unconditional assignment moves last_used_at backwards, violating the monotonic cross-replica contract in openspec/changes/coalesce-api-key-last-used-writes/specs/api-keys/spec.md:5.

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

@Komzpa Komzpa added the 🤖 codex: needs work [@codex review] raised an issue label Aug 8, 2026
@Soju06

Soju06 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Thanks — the core fix here is real and worth landing: pairing tool_search_call / tool_search_output during compact trimming addresses a live upstream rejection (No tool call found for tool search output...), and the regression test reproduces the shape faithfully. However, the Codex review on 433d75e5 raised four findings that all check out on inspection, and they need to be addressed before merge:

  1. Drop the api-keys commit entirely (fix(api-keys): retain explicit last-used compatibility touch). The new update_last_used() in app/modules/api_keys/repository.py:383 has zero call sites in this PR or on main, no test, and commits by default — it is dead public API unrelated to compact trimming. It also writes last_used_at unconditionally, which would violate the greatest-wins monotonic contract from openspec/changes/coalesce-api-key-last-used-writes/specs/api-keys/spec.md if it were ever called. If this belongs to another PR in your series, please move it there (guarded with the coalescer predicate); here it just couples two independently revertible concerns.

  2. Use a dedicated pairing set instead of widening _TOOL_CALL_ITEM_TYPES (app/core/openai/requests.py:45). That set is shared with strip_replayed_tool_call_namespaces_from_payload() (line ~891), so this change silently makes namespace stripping mutate tool_search_call items on all request paths — beyond the scope allowed by openspec/changes/fix-replayed-namespaced-function-call/context.md. Something like _COMPACT_TOOL_CALL_PAIR_ITEM_TYPES = _TOOL_CALL_ITEM_TYPES | {"tool_search_call"} used only in the compact reconciliation (line ~1330) keeps the fix scoped.

  3. Add coverage at the failing surface. The bug manifested at /backend-api/codex/responses/compact; per AGENTS.md the regression should go through that route (e.g., in tests/integration/test_proxy_compact.py alongside the existing side-effect pair tests), not only through ResponsesCompactRequest.to_payload(). Keeping the unit test is fine, but the route-level assertion is what proves routing and final upstream preparation preserve the pair.

Once those are in (and the Codex threads resolved), this looks mergeable — CI is already green.

@Komzpa Komzpa added the needs rebase Needs rebase or conflict repair against current main label Aug 11, 2026
@Komzpa
Komzpa force-pushed the fix/compact-tool-search-pairs branch 2 times, most recently from 9c23412 to 7f83b33 Compare August 14, 2026 08:46
@Komzpa

Komzpa commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 7f83b330dc

ℹ️ 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 3027 to +3029
if account_neutral_recovery:
request_state.preferred_account_id = session.account.id
request_state.preferred_account_id = None
request_state.excluded_account_ids.add(session.account.id)

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 Add an OpenSpec change for cross-account recovery

This branch changes an account-neutral pre-created retry from reconnecting to the same preferred account to excluding that account and selecting another one, which is a proxy-routing and failover contract change. The only OpenSpec delta touched by this commit documents tool-search replay-prefix trimming, not cross-account recovery, so this behavior needs its own active OpenSpec change and regression scenarios before landing.

AGENTS.md reference: AGENTS.md:L92-L98

Useful? React with 👍 / 👎.

Comment on lines +104 to +113
"tool_search_output": frozenset(
{
"call_id",
"caller",
"execution",
"id",
_INTERNAL_CHAT_MESSAGE_METADATA_FIELD,
"output",
"status",
"tools",

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 Validate the real tool-search output shape

Handle tool_search_output.tools in the self-contained output validator instead of only allowing the field here. The owning proposal explicitly records that this item carries a tools list rather than the synthetic output shape (proposal.md:47-54), but pairing it through _TOOL_CALL_TYPE_BY_OUTPUT_TYPE sends it to _tool_output_is_self_contained(), which accepts only an output string/content-part list. Consequently a real compact history with {type: "tool_search_output", tools: [...]} is still classified as non-account-neutral, preventing the new fresh-replay recovery path from engaging; the new test masks this by using output: "Found codex-lb".

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

Comment on lines +1052 to +1053
if item_type == "compaction" and _compaction_item_is_self_contained(current):
continue

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 Keep encrypted compaction state pinned to its owner

Do not skip the account-scoped reference check for compaction ciphertext without a verified portability guarantee. For an unanchored full resend containing a completed compaction item, this continue bypasses _mapping_has_account_scoped_reference(), whose existing invariant classifies every non-empty encrypted_content value as account-scoped; the request can therefore be marked account-neutral and the new retry branch can resend account A's encrypted context on account B. That risks an upstream rejection or unusable recovered context, so compaction state should remain owner-pinned unless the owning specification and product-path coverage prove that its encryption is cross-account portable.

AGENTS.md reference: AGENTS.md:L105-L110

Useful? React with 👍 / 👎.

Comment on lines +22 to +23
### Requirement: Previous-response replay trimming handles tool-search output pairs
When a Responses HTTP bridge or WebSocket continuation carries `previous_response_id` and replays already-stored response output items before a fresh `tool_search_output`, the service MUST trim the replayed `tool_search_call` prefix and preserve the `tool_search_output` plus the fresh turn. The service MUST NOT forward both the replayed `tool_search_call` and its `tool_search_output` on top of the `previous_response_id` anchor.

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 Sync the trimming requirement out of the archive

Do not add the new tool-search trimming contract only to an already archived delta. openspec/specs/responses-api-compat/spec.md contains no corresponding requirement, so the repository's normative SSOT does not actually describe the behavior implemented here and future spec validation or changes will operate from stale requirements. Create or reopen an active change and sync this requirement into the main capability spec before archiving it.

AGENTS.md reference: AGENTS.md:L24-L28

Useful? React with 👍 / 👎.

@Komzpa Komzpa removed the needs rebase Needs rebase or conflict repair against current main label Aug 14, 2026
@Komzpa
Komzpa force-pushed the fix/compact-tool-search-pairs branch from 7f83b33 to 5831738 Compare August 14, 2026 18:21
@Komzpa

Komzpa commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #1720: tool-search pair preservation joins the compact payload sanitization carrier; commits replayed onto a clean base to shed stale branch ancestry.

@Komzpa Komzpa closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 codex: needs work [@codex review] raised an issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants