Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/core/openai/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"function_call_output": "function_call",
"custom_tool_call_output": "custom_tool_call",
"apply_patch_call_output": "apply_patch_call",
"tool_search_output": "tool_search_call",
}
_COMPACT_TOOL_CALL_ITEM_TYPES = frozenset(_COMPACT_TOOL_CALL_TYPE_BY_OUTPUT_TYPE.values())
_COMPACT_TOOL_CALL_OUTPUT_ITEM_TYPES = frozenset(_COMPACT_TOOL_CALL_TYPE_BY_OUTPUT_TYPE)
Expand Down
4 changes: 2 additions & 2 deletions app/modules/proxy/_service/http_bridge/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ def _trim_http_bridge_previous_response_input_items(input_items: list[JsonValue]
index
for index, item in enumerate(input_items)
if _http_bridge_input_item_type(item)
in {"function_call_output", "custom_tool_call_output", "apply_patch_call_output"}
in {"function_call_output", "custom_tool_call_output", "apply_patch_call_output", "tool_search_output"}
),
None,
)
Expand All @@ -544,7 +544,7 @@ def _trim_http_bridge_previous_response_input_items(input_items: list[JsonValue]

def _is_http_bridge_previous_response_output_item(item: JsonValue) -> bool:
item_type = _http_bridge_input_item_type(item)
if item_type in {"reasoning", "function_call", "custom_tool_call", "apply_patch_call"}:
if item_type in {"reasoning", "function_call", "custom_tool_call", "apply_patch_call", "tool_search_call"}:
return _has_http_bridge_response_output_marker(item)
if item_type != "message" or not isinstance(item, dict):
return False
Expand Down
5 changes: 3 additions & 2 deletions app/modules/proxy/_service/http_bridge/request_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3020,12 +3020,13 @@ def request_is_retryable(request_state: _WebSocketRequestState) -> bool:
# Account-scoped uploaded files cannot be replayed on a
# different owner. Keep the preferred account mandatory for
# both silent recovery and clean-close recovery.
require_preferred_reconnect = account_neutral_recovery or request_state.file_required_preferred_account
require_preferred_reconnect = request_state.file_required_preferred_account
request_text = _prepare_websocket_request_state_for_visible_output_replay(request_state)
if request_text is None:
return False
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)
Comment on lines 3027 to +3029

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 👍 / 👎.

elif not request_state.file_required_preferred_account:
if hard_owner_bound and not model_fallback_replay and not fresh_hard_request_account_switch_allowed:
request_state.preferred_account_id = session.account.id
Expand Down
5 changes: 3 additions & 2 deletions app/modules/proxy/_service/websocket/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ def _websocket_continuity_anchor_for_payload(
"function_call_output": "function_call",
"custom_tool_call_output": "custom_tool_call",
"apply_patch_call_output": "apply_patch_call",
"tool_search_output": "tool_search_call",
}
_WEBSOCKET_TOOL_CALL_ITEM_TYPES = frozenset(_WEBSOCKET_TOOL_CALL_ITEM_TYPES_BY_OUTPUT_TYPE.values())

Expand Down Expand Up @@ -1881,7 +1882,7 @@ def _trim_websocket_previous_response_input_items(input_items: list[JsonValue])
index
for index, item in enumerate(input_items)
if _websocket_input_item_type(item)
in {"function_call_output", "custom_tool_call_output", "apply_patch_call_output"}
in {"function_call_output", "custom_tool_call_output", "apply_patch_call_output", "tool_search_output"}
),
None,
)
Expand All @@ -1897,7 +1898,7 @@ def _is_websocket_previous_response_output_item(item: JsonValue) -> bool:
if isinstance(item, dict) and _websocket_input_item_type(item) is None and item.get("role") == "assistant":
return True
item_type = _websocket_input_item_type(item)
if item_type in {"reasoning", "function_call", "custom_tool_call", "apply_patch_call"}:
if item_type in {"reasoning", "function_call", "custom_tool_call", "apply_patch_call", "tool_search_call"}:
return True
if item_type != "message" or not isinstance(item, dict):
return False
Expand Down
34 changes: 34 additions & 0 deletions app/modules/proxy/replay_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"function_call_output": "function_call",
"custom_tool_call_output": "custom_tool_call",
"apply_patch_call_output": "apply_patch_call",
"tool_search_output": "tool_search_call",
}
_TOOL_CALL_TYPES = frozenset(_TOOL_CALL_TYPE_BY_OUTPUT_TYPE.values())
_ACCOUNT_NEUTRAL_REPLAY_OMITTED_ITEM_TYPES = frozenset(
Expand All @@ -39,6 +40,7 @@
"additional_tools",
"apply_patch_call",
"apply_patch_call_output",
"compaction",
"custom_tool_call",
"custom_tool_call_output",
"function_call",
Expand All @@ -47,6 +49,8 @@
"input_image",
"input_text",
"message",
"tool_search_call",
"tool_search_output",
}
)
_ACCOUNT_NEUTRAL_MESSAGE_CONTENT_TYPES = frozenset(
Expand All @@ -65,6 +69,7 @@
}
_ACCOUNT_NEUTRAL_INPUT_ITEM_FIELDS = {
"additional_tools": frozenset({"role", "tools", "type"}),
"compaction": frozenset({"encrypted_content", "id", "status", "type"}),
"apply_patch_call": frozenset(
{
"call_id",
Expand Down Expand Up @@ -93,6 +98,22 @@
"function_call_output": frozenset(
{"call_id", "caller", "id", _INTERNAL_CHAT_MESSAGE_METADATA_FIELD, "output", "status", "type"}
),
"tool_search_call": frozenset(
{"arguments", "call_id", "caller", "execution", "id", _INTERNAL_CHAT_MESSAGE_METADATA_FIELD, "status", "type"}
),
"tool_search_output": frozenset(
{
"call_id",
"caller",
"execution",
"id",
_INTERNAL_CHAT_MESSAGE_METADATA_FIELD,
"output",
"status",
"tools",
Comment on lines +104 to +113

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 👍 / 👎.

"type",
}
),
}
_ACCOUNT_NEUTRAL_ITEM_STATUSES = frozenset({"completed", "failed"})
_ACCOUNT_NEUTRAL_APPLY_PATCH_OPERATION_FIELDS = {
Expand Down Expand Up @@ -269,6 +290,10 @@ def responses_input_items_are_self_contained_fresh_replay(input_items: list[Json
item_type = item_type_value if isinstance(item_type_value, str) else None
if not _input_item_has_only_known_fields(item, item_type):
return False
if item_type == "compaction":
if not _compaction_item_is_self_contained(item):
return False
continue
call_id_value = item.get("call_id")
call_id = call_id_value if isinstance(call_id_value, str) and call_id_value else None
if item_type in _TOOL_CALL_TYPES:
Expand Down Expand Up @@ -628,6 +653,9 @@ def _tool_call_is_self_contained(item_type: str, item: Mapping[str, JsonValue])
return _is_nonblank_string(item.get("name")) and isinstance(item.get("arguments"), str)
if item_type == "custom_tool_call":
return _is_nonblank_string(item.get("name")) and isinstance(item.get("input"), str)
if item_type == "tool_search_call":
arguments = item.get("arguments")
return isinstance(arguments, dict) and item.get("execution") in (None, "client")
operation = item.get("operation")
patch = item.get("patch")
input_value = item.get("input")
Expand All @@ -640,6 +668,10 @@ def _tool_call_is_self_contained(item_type: str, item: Mapping[str, JsonValue])
return _is_nonblank_string(input_value)


def _compaction_item_is_self_contained(item: Mapping[str, JsonValue]) -> bool:
return item.get("status") in (None, "completed") and _is_nonblank_string(item.get("encrypted_content"))


def _caller_is_self_contained(item: Mapping[str, JsonValue]) -> bool:
caller = item.get("caller")
return caller is None or caller == {"type": "direct"}
Expand Down Expand Up @@ -1017,6 +1049,8 @@ def _contains_account_scoped_input_state(value: JsonValue) -> bool:
return True
if item_type == "additional_tools" and not _tools_are_account_neutral(current.get("tools")):
return True
if item_type == "compaction" and _compaction_item_is_self_contained(current):
continue
Comment on lines +1052 to +1053

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 👍 / 👎.

if (
isinstance(item_type, str)
and (item_type.endswith("_call") or item_type.endswith("_call_output"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ item type is real, only the classifier list is stale.
there is nothing for the continuity recovery paths to do with it.
- Add regression coverage for the classifier and for the HTTP-bridge masking
surface.
- Treat `tool_search_call` / `tool_search_output` like the other client-side
tool pairs when trimming already-stored previous-response replay prefixes, so
compaction-preserved tool-search pairs do not get resent in full on top of a
`previous_response_id` anchor.

## Non-goals

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,18 @@ The service MUST classify an upstream `invalid_request_error` with `param=input`
#### Scenario: hosted web search wording stays unclassified
- **WHEN** upstream emits `invalid_request_error` with `param=input` and a message starting `No tool output found for web search call`
- **THEN** the service does not treat it as a missing-tool-output continuity error

### 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.
Comment on lines +22 to +23

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 👍 / 👎.


#### Scenario: HTTP bridge trims replayed tool-search call prefix
- **GIVEN** an HTTP bridge session has a completed previous response
- **WHEN** the next request carries `previous_response_id` and input `[tool_search_call, tool_search_output, user_message]`
- **THEN** the upstream request keeps the same `previous_response_id`
- **AND** its input is `[tool_search_output, user_message]`

#### Scenario: WebSocket bridge trims replayed tool-search call prefix
- **GIVEN** a WebSocket Responses session has a completed previous response
- **WHEN** the next request carries `previous_response_id` and input `[tool_search_call, tool_search_output, user_message]`
- **THEN** the upstream request keeps the same `previous_response_id`
- **AND** its input is `[tool_search_output, user_message]`
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
- [x] Extend the missing-tool-output message classifier with the tool-search wording.
- [x] Keep the hosted `web search call` wording unclassified.
- [x] Add classifier and HTTP-bridge masking regression coverage.
- [x] Trim replayed tool-search call prefixes on previous-response HTTP bridge and WebSocket continuations.
- [x] Run focused unit tests, lint/format, type check, architecture check, diff check, and strict OpenSpec validation.
Loading
Loading