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
3 changes: 3 additions & 0 deletions app/modules/proxy/_service/http_bridge/request_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
_prepare_websocket_request_state_for_auth_replay,
_prepare_websocket_request_state_for_visible_output_replay,
_prewarm_response_timeout_seconds,
_protected_agent_control_tool_call_ids,
_release_websocket_response_create_gate,
_response_create_client_metadata,
_security_work_advisory_event,
Expand Down Expand Up @@ -605,6 +606,7 @@ def _prepare_response_bridge_request_state(
deduped_replayed_input_count = len(replayed_input_items)
deduped_replayed_input_fingerprint = _fingerprint_input_items(replayed_input_items)
payload = payload.model_copy(update={"input": deduped_input_items})
protected_tool_output_call_ids = _protected_agent_control_tool_call_ids(payload.input)
upstream_payload = dict(payload.to_payload())
upstream_payload.pop("stream", None)
upstream_payload.pop("background", None)
Expand Down Expand Up @@ -684,6 +686,7 @@ def _prepare_response_bridge_request_state(
slimmed_payload, slim_summary = _slim_response_create_payload_for_upstream(
upstream_payload,
max_bytes=max_bytes,
protected_tool_output_call_ids=protected_tool_output_call_ids,
)
if slim_summary is not None:
upstream_payload = slimmed_payload
Expand Down
7 changes: 7 additions & 0 deletions app/modules/proxy/_service/http_bridge/service_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
from app.core.types import JsonValue
from app.core.utils.sse import CODEX_KEEPALIVE_FRAME
from app.db.models import Account, DashboardSettings
from app.modules.proxy._service.support import (
_protected_agent_control_tool_call_ids as _support_protected_agent_control_tool_call_ids,
)
from app.modules.proxy._service.support import _RequestLogFailureMetadata

T = TypeVar("T")
Expand Down Expand Up @@ -336,6 +339,10 @@ def _slim_response_create_payload_for_upstream(*args: Any, **kwargs: Any) -> Any
return _service_global("_slim_response_create_payload_for_upstream")(*args, **kwargs)


def _protected_agent_control_tool_call_ids(*args: Any, **kwargs: Any) -> Any:
return _support_protected_agent_control_tool_call_ids(*args, **kwargs)


def _enforce_response_create_size_limit(*args: Any, **kwargs: Any) -> Any:
return _service_global("_enforce_response_create_size_limit")(*args, **kwargs)

Expand Down
22 changes: 20 additions & 2 deletions app/modules/proxy/_service/response_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
_PENDING_TOOL_CALL_ITEM_TYPES,
_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE,
_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPES,
_protected_agent_control_tool_call_ids,
_WebSocketRequestState,
)

Expand All @@ -49,6 +50,7 @@
"[codex-lb omitted historical tool output ({bytes} bytes) to fit upstream websocket budget]"
)
_RESPONSE_CREATE_IMAGE_OMISSION_NOTICE = "[codex-lb omitted historical inline image to fit upstream websocket budget]"
_NO_PROTECTED_TOOL_OUTPUT_CALL_IDS: frozenset[str] = frozenset()
_OVERSIZED_RESPONSE_CREATE_DUMP_DIR: Path | None = None
_RESPONSE_CREATE_DUMP_SUFFIX = ".response-create.json.gz"
_RESPONSE_CREATE_META_SUFFIX = ".meta.json"
Expand Down Expand Up @@ -212,6 +214,7 @@ def _response_create_text_with_size_guard(
request_state: _WebSocketRequestState,
transport: str,
) -> str | None:
protected_tool_output_call_ids = _protected_agent_control_tool_call_ids(payload.input)
upstream_payload = dict(payload.to_payload())
Comment on lines +217 to 218

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 Preserve namespaces on standard response.create egress

The fresh pre-serialization capture here addresses the earlier slimming-set comment, but upstream_payload is still built immediately afterward with ResponsesRequest.to_payload(), which invokes _strip_unsupported_fields() with replayed-namespace stripping enabled. Consequently, standard HTTP-bridge and WebSocket replays containing collaboration or multi_agent_v1 calls still reach upstream without the routing namespace promised by the newly edited live-egress contract; only the model-source path was changed to avoid this serializer. Use the namespace-preserving serializer on every standard live path and assert the actual HTTP and WebSocket payloads.

AGENTS.md reference: AGENTS.md:L129-L132

Useful? React with 👍 / 👎.

upstream_payload.pop("stream", None)
upstream_payload.pop("background", None)
Expand All @@ -238,6 +241,7 @@ def _response_create_text_with_size_guard(
slimmed_payload, slim_summary = slim_payload_for_upstream(
upstream_payload,
max_bytes=max_bytes,
protected_tool_output_call_ids=protected_tool_output_call_ids,
)
if slim_summary is not None:
upstream_payload = slimmed_payload
Expand Down Expand Up @@ -302,6 +306,7 @@ def _slim_response_create_payload_for_upstream(
payload: dict[str, JsonValue],
*,
max_bytes: int,
protected_tool_output_call_ids: set[str] | None = None,
) -> tuple[dict[str, JsonValue], dict[str, int] | None]:
input_value = payload.get("input")
if not isinstance(input_value, list) or not input_value:
Expand All @@ -315,13 +320,18 @@ def _slim_response_create_payload_for_upstream(
tool_outputs_slimmed = 0
images_slimmed = 0

if protected_tool_output_call_ids is None:
protected_tool_output_call_ids = _protected_agent_control_tool_call_ids(historical)
slimmed_historical: list[JsonValue] = []
for item in historical:
(
slimmed_item,
item_tool_outputs_slimmed,
item_images_slimmed,
) = _slim_historical_response_input_item(item)
) = _slim_historical_response_input_item(
item,
protected_tool_output_call_ids=protected_tool_output_call_ids,
)
tool_outputs_slimmed += item_tool_outputs_slimmed
images_slimmed += item_images_slimmed
slimmed_historical.append(slimmed_item)
Expand Down Expand Up @@ -449,7 +459,11 @@ def _response_create_recent_suffix_start(input_items: list[JsonValue]) -> int:
return 0


def _slim_historical_response_input_item(item: JsonValue) -> tuple[JsonValue, int, int]:
def _slim_historical_response_input_item(
item: JsonValue,
*,
protected_tool_output_call_ids: set[str] | None = None,
) -> tuple[JsonValue, int, int]:
if not is_json_mapping(item):
return item, 0, 0

Expand All @@ -459,6 +473,10 @@ def _slim_historical_response_input_item(item: JsonValue) -> tuple[JsonValue, in

item_type = item_mapping.get("type")
if isinstance(item_type, str) and item_type in _PENDING_TOOL_CALL_OUTPUT_ITEM_TYPES:
call_id = item_mapping.get("call_id")
protected_call_ids = protected_tool_output_call_ids or _NO_PROTECTED_TOOL_OUTPUT_CALL_IDS
if isinstance(call_id, str) and call_id in protected_call_ids:
return item_mapping, tool_outputs_slimmed, images_slimmed
output = item_mapping.get("output")
if isinstance(output, str):
if _should_slim_historical_tool_output(output):
Expand Down
30 changes: 30 additions & 0 deletions app/modules/proxy/_service/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
_PENDING_TOOL_CALL_ITEM_TYPES = frozenset(_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE)
_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPES = frozenset(_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE.values())
_TTFT_OUTPUT_ITEM_TYPES = _PENDING_TOOL_CALL_ITEM_TYPES - {"function_call"}
_AGENT_CONTROL_TOOL_NAMESPACES = frozenset({"collaboration", "multi_agent_v1"})
_AGENT_CONTROL_TOOL_NAMES = frozenset({"close_agent", "resume_agent", "send_input", "spawn_agent", "wait_agent"})
_WEBSOCKET_FULL_REPLAY_WAIT_MIN_ITEMS = 20
_WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS = 0.05
_HARD_HTTP_BRIDGE_AFFINITY_KINDS = frozenset(
Expand All @@ -78,6 +80,34 @@
}
)
_ACCOUNT_SELECTION_RECOVERY_MIN_SLEEP_SECONDS = 1.0


def _protected_agent_control_tool_call_ids(input_value: JsonValue) -> set[str]:
if not isinstance(input_value, list):
return set()
call_ids: set[str] = set()
for item in input_value:
if not isinstance(item, Mapping):
continue
item_type = item.get("type")
if not isinstance(item_type, str) or item_type not in _PENDING_TOOL_CALL_ITEM_TYPES:
continue
namespace = item.get("namespace")
name = item.get("name")
is_agent_control = (
isinstance(namespace, str)
and namespace in _AGENT_CONTROL_TOOL_NAMESPACES
or isinstance(name, str)
and name in _AGENT_CONTROL_TOOL_NAMES
Comment on lines +97 to +101

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 Let explicit non-agent namespaces override legacy name matching

When a non-agent namespaced tool happens to use a common name such as database.send_input or workflow.wait_agent, this or classifies it as agent control solely by name. Its oversized historical output is then exempted from slimming, so a request whose excess size comes from that output remains above the wire limit and is rejected instead of receiving the normal omission notice. Restrict the name-only fallback to calls with no usable namespace so an explicit unrelated namespace takes precedence.

Useful? React with 👍 / 👎.

)
if not is_agent_control:
continue
call_id = item.get("call_id")
if isinstance(call_id, str) and call_id:
call_ids.add(call_id)
return call_ids


_HARD_AFFINITY_RECOVERY_SLEEP_SECONDS = 2.0
_ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS = 30.0
_ACCOUNT_SELECTION_RECOVERY_MAX_SLEEP_SECONDS = 300.0
Expand Down
15 changes: 15 additions & 0 deletions app/modules/proxy/_service/websocket/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1609,6 +1609,21 @@ def _matching_websocket_request_states_for_previous_response_error(
]
if matching_requests:
return matching_requests
visible_unresolved_followups = [
request_state
for request_state in followup_requests
if request_state.response_id is None and _http_bridge_request_counts_against_queue(request_state)
]
if len(visible_unresolved_followups) == 1:
return visible_unresolved_followups
Comment on lines +1617 to +1618

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 visible-vs-draining matching through the HTTP bridge

The only new coverage for this branch calls _matching_websocket_request_states_for_previous_response_error() directly; inspection of the changed HTTP bridge integration tests finds no route-level case with one live follow-up and one unresolved draining request. Because this selection controls which request is popped, settled, and receives an anonymous previous_response_not_found, a wiring regression could still terminate the wrong continuation; add a regression through the actual Responses HTTP bridge path.

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

Useful? React with 👍 / 👎.

if len(visible_unresolved_followups) > 1:
unique_previous_response_ids = {
request_state.previous_response_id
for request_state in visible_unresolved_followups
if request_state.previous_response_id
}
if len(unique_previous_response_ids) == 1:
return visible_unresolved_followups
unresolved_followups = [request_state for request_state in followup_requests if request_state.response_id is None]
if len(unresolved_followups) == 1:
return unresolved_followups
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
## ADDED Requirements

### Requirement: Replayed tool-call namespace metadata is local-only on upstream input
### Requirement: Replayed tool-call namespace metadata survives live Responses egress

For standard and compact Responses requests, the proxy MUST omit `namespace` from every replayed `input` item whose `type` is `function_call`, `custom_tool_call`, or `apply_patch_call` before forwarding the request upstream. The proxy MUST preserve all other fields on that item, MUST retain the original namespace metadata for local call-identity and replay-deduplication processing, and MUST NOT alter client-provided top-level tool entries as part of this normalization.
For standard live Responses requests, including WebSocket `response.create` and configured Responses model-source egress, the proxy MUST preserve `namespace` on replayed `input` items whose `type` is `function_call`, `custom_tool_call`, or `apply_patch_call`. Tool namespaces are part of the live tool-routing identity. Compact Responses egress MAY omit those namespaces only as part of the compact-specific upstream compatibility serializer. The proxy MUST preserve all other fields on each tool-call item and MUST NOT alter client-provided top-level tool entries as part of this normalization.
Comment on lines +3 to +5

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 Sync the namespace contract into the main specification

This archived delta now requires standard live Responses egress to preserve replayed namespaces, but the normative main spec still contains the opposite requirement and scenarios at openspec/specs/responses-api-compat/spec.md:4357-4383, saying that standard, WebSocket, and model-source egress must omit them. Because the main capability spec is the repository's source of truth, leaving only the archived artifact updated documents the wrong compatibility contract and invites the stripping behavior to be restored; replace the obsolete main requirement and scenarios as part of this change.

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

Useful? React with 👍 / 👎.


#### Scenario: Standard Responses replay omits tool-call namespaces upstream
Historical response.create slimming MUST preserve outputs for namespaced agent-control calls, including `collaboration` and `multi_agent_v1` calls, even when those outputs are large. Such outputs carry the completed spawn/wait state needed by the next model turn and MUST NOT be replaced with a generic historical tool-output omission notice. Unrelated historical tool outputs MAY still be slimmed under the normal payload-budget policy.

#### Scenario: Standard Responses replay preserves tool-call namespaces upstream

- **WHEN** a standard Responses request replays `function_call` and `custom_tool_call` input items with `namespace`
- **THEN** the upstream payload omits only those items' `namespace`
- **THEN** the upstream payload preserves those items' `namespace`
- **AND** preserves their remaining call fields
- **AND** the local request input retains the namespace metadata

Expand All @@ -17,16 +19,16 @@ For standard and compact Responses requests, the proxy MUST omit `namespace` fro
- **THEN** its upstream payload omits the input item's `namespace`
- **AND** preserves the remaining tool-call fields

#### Scenario: WebSocket response.create omits tool-call namespaces upstream
#### Scenario: WebSocket response.create preserves tool-call namespaces upstream

- **WHEN** a Responses WebSocket request replays namespaced `function_call` and `custom_tool_call` input items
- **THEN** the upstream `response.create` frame omits only those items' `namespace`
- **THEN** the upstream `response.create` frame preserves those items' `namespace`
- **AND** preserves their remaining call fields

#### Scenario: Configured Responses model source omits tool-call namespaces upstream
#### Scenario: Configured Responses model source preserves tool-call namespaces upstream

- **WHEN** `/v1/responses` routes a replayed namespaced tool call to a configured OpenAI-compatible Responses model source
- **THEN** the source payload omits only the call item's `namespace`
- **THEN** the source payload preserves the call item's `namespace`
- **AND** preserves source-compatible request fields that the Codex upstream path does not support

#### Scenario: Account-neutral replay classification retains namespace identity
Expand All @@ -45,3 +47,10 @@ For standard and compact Responses requests, the proxy MUST omit `namespace` fro

- **WHEN** the client includes a top-level tool entry whose `type` is `namespace`
- **THEN** standard Responses serialization forwards that tool entry byte-identically

#### Scenario: Historical agent wait output remains visible after slimming

- **WHEN** a response.create payload contains a historical `multi_agent_v1.wait_agent` call with a large matching output
- **AND** also contains an unrelated large historical shell output
- **THEN** the agent wait output remains byte-preserved in the upstream input
- **AND** the unrelated shell output MAY be replaced with the historical tool-output omission notice
2 changes: 2 additions & 0 deletions openspec/specs/responses-api-compat/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3263,6 +3263,8 @@ For a namespaced side-effect function or custom-tool call, the service MUST use

Flat legacy side-effect calls MAY continue to use argument-based replay identity so reconnects that change only a call ID do not repeat shell, patch, or terminal side effects.

Historical response.create slimming MUST preserve outputs for namespaced agent-control calls, including `collaboration` and `multi_agent_v1` calls, even when those outputs are large. Such outputs carry the completed spawn/wait state needed by the next model turn and MUST NOT be replaced with a generic historical tool-output omission notice. Unrelated historical tool outputs MAY still be slimmed under the normal payload-budget policy.

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 Protect agent outputs in the direct WebSocket slimmer

This new requirement is not applied to the separate direct-upstream WebSocket path: app/core/clients/proxy.py:1958 calls its own _prepare_websocket_response_create_payload(), whose private slimmer at lines 2242-2265 still replaces every oversized historical tool output without identifying agent-control calls. Therefore, when the regular Responses route selects transport == "websocket" rather than the HTTP bridge, a large completed spawn/wait output is still replaced by the omission notice and the next model turn loses the agent state. Route that product path through the same protection logic and add a regression test through the regular Responses endpoint.

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

Useful? React with 👍 / 👎.


#### Scenario: Distinct namespaced spawns use identical arguments

- **WHEN** two `collaboration.spawn_agent` calls have identical arguments and different call IDs
Expand Down
Loading
Loading