From 508cf779945c847b9dc1faf8e450a8dcd5a1eeea Mon Sep 17 00:00:00 2001 From: Jaime Chieng Date: Fri, 10 Jul 2026 11:30:12 -0400 Subject: [PATCH 1/3] feat(agent): add OpenAI Ultra multi-agent mode --- agent/chat_completion_helpers.py | 18 +++ agent/codex_responses_adapter.py | 61 +++++++++- agent/codex_runtime.py | 14 ++- agent/transports/codex.py | 113 +++++++++++++++++- agent/transports/codex_app_server_session.py | 111 ++++++++++++++++- gateway/run.py | 2 +- gateway/slash_commands.py | 2 +- hermes_cli/cli_commands_mixin.py | 6 +- hermes_cli/commands.py | 2 +- hermes_constants.py | 4 +- locales/af.yaml | 2 +- locales/de.yaml | 2 +- locales/en.yaml | 2 +- locales/es.yaml | 2 +- locales/fr.yaml | 2 +- locales/ga.yaml | 2 +- locales/hu.yaml | 2 +- locales/it.yaml | 2 +- locales/ja.yaml | 2 +- locales/ko.yaml | 2 +- locales/pt.yaml | 2 +- locales/ru.yaml | 2 +- locales/tr.yaml | 2 +- locales/uk.yaml | 2 +- locales/zh-hant.yaml | 2 +- locales/zh.yaml | 2 +- tests/agent/test_codex_app_server_persist.py | 7 ++ tests/agent/test_codex_responses_adapter.py | 68 +++++++++++ .../test_codex_app_server_session.py | 89 ++++++++++++++ .../agent/transports/test_codex_transport.py | 87 ++++++++++++++ tests/cli/test_reasoning_command.py | 3 +- tests/gateway/test_reasoning_command.py | 21 ++++ tests/hermes_cli/test_commands.py | 1 + .../test_run_agent_codex_responses.py | 59 +++++++++ tests/test_hermes_constants.py | 4 +- website/docs/user-guide/configuration.md | 23 +++- .../features/codex-app-server-runtime.md | 20 ++++ 37 files changed, 705 insertions(+), 42 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4e4aa455a600..b027ff9a0372 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -686,6 +686,19 @@ def build_api_kwargs(agent, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" tools_for_api = agent.tools + reasoning_config = getattr(agent, "reasoning_config", None) + ultra_requested = ( + isinstance(reasoning_config, dict) + and reasoning_config.get("enabled") is not False + and str(reasoning_config.get("effort") or "").strip().lower() == "ultra" + ) + if ultra_requested and agent.api_mode not in {"codex_responses", "codex_app_server"}: + raise ValueError( + "Ultra is only supported by the OpenAI Responses Multi-agent beta " + "or the Codex app-server runtime. Choose another reasoning effort " + "for this provider." + ) + if agent.api_mode == "anthropic_messages": _transport = agent._get_transport() anthropic_messages = agent._prepare_anthropic_messages_for_api(api_messages) @@ -736,6 +749,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: and "/backend-api/codex" in agent._base_url_lower ) ) + is_openai_api = agent._base_url_hostname == "api.openai.com" is_xai_responses = agent.provider in {"xai", "xai-oauth"} or agent._base_url_hostname == "api.x.ai" _msgs_for_codex = agent._prepare_messages_for_non_vision_model(api_messages) @@ -780,8 +794,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict: max_tokens=agent.max_tokens, timeout=agent._resolved_api_call_timeout(), request_overrides=agent.request_overrides, + provider=agent.provider, + base_url=agent.base_url, + base_url_hostname=agent._base_url_hostname, is_github_responses=is_github_responses, is_codex_backend=is_codex_backend, + is_openai_api=is_openai_api, is_xai_responses=is_xai_responses, github_reasoning_extra=agent._github_models_reasoning_extra_body() if is_github_responses else None, replay_encrypted_reasoning=bool( diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 4d138ce6e631..b9b04a5e6fdc 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -1068,6 +1068,49 @@ def _format_responses_error(error_obj: Any, response_status: str) -> str: # Full response normalization # --------------------------------------------------------------------------- +_HOSTED_MULTI_AGENT_ITEM_TYPES = {"multi_agent_call", "agent_message"} + + +def _responses_field(value: Any, name: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(name, default) + return getattr(value, name, default) + + +def _responses_value_to_plain_data(value: Any) -> Any: + """Convert SDK/namespace response values into JSON-safe plain data.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return { + str(key): _responses_value_to_plain_data(item) + for key, item in value.items() + if not str(key).startswith("_") and item is not None + } + if isinstance(value, (list, tuple)): + return [_responses_value_to_plain_data(item) for item in value] + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + try: + return _responses_value_to_plain_data( + model_dump(mode="json", exclude_none=True) + ) + except TypeError: + return _responses_value_to_plain_data(model_dump()) + raw_attrs = getattr(value, "__dict__", None) + if isinstance(raw_attrs, dict): + return _responses_value_to_plain_data(raw_attrs) + return str(value) + + +def _responses_output_item_to_dict(item: Any, item_type: str) -> Dict[str, Any]: + raw = _responses_value_to_plain_data(item) + if not isinstance(raw, dict): + raw = {} + raw["type"] = item_type + return raw + + def _normalize_codex_response( response: Any, *, @@ -1146,15 +1189,12 @@ def _normalize_codex_response( "computer_call", "local_shell_call", "mcp_call", + *_HOSTED_MULTI_AGENT_ITEM_TYPES, } for item in output: - item_type = getattr(item, "type", None) - item_status = getattr(item, "status", None) - if isinstance(item_status, str): - item_status = item_status.strip().lower() - else: - item_status = None + item_type = str(_responses_field(item, "type", "") or "") + item_status = str(_responses_field(item, "status", "") or "").strip().lower() if ( item_status in {"queued", "in_progress", "incomplete"} @@ -1163,6 +1203,15 @@ def _normalize_codex_response( has_incomplete_items = True saw_streaming_or_item_incomplete = True + if item_type in _HOSTED_MULTI_AGENT_ITEM_TYPES: + # OpenAI executes these delegation items server-side. Preserve them + # in the existing Codex message-item carrier for diagnostics and DB + # persistence, but never dispatch them as Hermes client tools. + message_items_raw.append( + _responses_output_item_to_dict(item, item_type) + ) + continue + if item_type == "message": item_phase = getattr(item, "phase", None) normalized_phase = None diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 2077d2fddcab..a398e8f35c52 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -387,7 +387,19 @@ def _on_codex_event(note: dict) -> None: # return reaches us. Do NOT append again — that would duplicate. try: - turn = agent._codex_session.run_turn(user_input=user_message) + reasoning_config = getattr(agent, "reasoning_config", None) + reasoning_effort = None + if ( + isinstance(reasoning_config, dict) + and reasoning_config.get("enabled") is not False + and reasoning_config.get("effort") + ): + reasoning_effort = str(reasoning_config["effort"]).strip().lower() + turn = agent._codex_session.run_turn( + user_input=user_message, + model=str(getattr(agent, "model", "") or "").strip() or None, + reasoning_effort=reasoning_effort, + ) except Exception as exc: logger.exception("codex app-server turn failed") # Crash → unconditionally drop the session so the next turn diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 56374b875335..334f4d34ebbc 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -46,6 +46,32 @@ def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) return f"pck_{digest}" +_OPENAI_MULTI_AGENT_MODEL_EFFORTS = { + "gpt-5.6": "max", + "gpt-5.3-codex": "xhigh", +} +_OPENAI_MULTI_AGENT_BETA = "responses_multi_agent=v1" + + +def _openai_multi_agent_effort(model: str) -> Optional[str]: + """Return the strongest documented effort for a Multi-agent model.""" + normalized = str(model or "").strip().lower() + if normalized.startswith("openai/"): + normalized = normalized.split("/", 1)[1] + for prefix, effort in _OPENAI_MULTI_AGENT_MODEL_EFFORTS.items(): + if normalized == prefix or normalized.startswith(f"{prefix}-"): + return effort + return None + + +def _merge_openai_beta_header(existing: Any, required: str) -> str: + """Append one beta token without dropping caller-supplied beta flags.""" + tokens = [part.strip() for part in str(existing or "").split(",") if part.strip()] + if required not in tokens: + tokens.append(required) + return ",".join(tokens) + + class ResponsesApiTransport(ProviderTransport): """Transport for api_mode='codex_responses'. @@ -118,6 +144,7 @@ def build_kwargs( base_url_hostname: str | None — hostname for backend detection is_github_responses: bool — Copilot/GitHub models backend is_codex_backend: bool — chatgpt.com/backend-api/codex + is_openai_api: bool — direct api.openai.com Responses endpoint is_xai_responses: bool — xAI/Grok backend github_reasoning_extra: dict | None — Copilot reasoning params """ @@ -139,6 +166,7 @@ def build_kwargs( is_github_responses = params.get("is_github_responses", False) is_codex_backend = params.get("is_codex_backend", False) + is_openai_api = params.get("is_openai_api", False) is_xai_responses = params.get("is_xai_responses", False) replay_encrypted_reasoning = bool( params.get("replay_encrypted_reasoning", True) @@ -160,10 +188,34 @@ def build_kwargs( if reasoning_config.get("enabled") is False: reasoning_enabled = False elif reasoning_config.get("effort"): - reasoning_effort = reasoning_config["effort"] + reasoning_effort = str(reasoning_config["effort"]).strip().lower() _effort_clamp = {"minimal": "low"} reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) + ultra_requested = reasoning_enabled and reasoning_effort == "ultra" + if ultra_requested: + if is_codex_backend: + raise ValueError( + "Ultra cannot be sent as reasoning.effort on the normal " + "ChatGPT Codex backend. Use `/codex-runtime app_server` " + "with a Codex CLI model that advertises Ultra." + ) + if not is_openai_api: + raise ValueError( + "Ultra requires the direct OpenAI Responses API Multi-agent " + "beta or `/codex-runtime app_server`; this Responses endpoint " + "is not the OpenAI Responses API." + ) + ultra_wire_effort = _openai_multi_agent_effort(model) + if ultra_wire_effort is None: + raise ValueError( + f"Model {model!r} does not support OpenAI Multi-agent mode. " + "Use a gpt-5.6 or gpt-5.3-codex model." + ) + # `ultra` is a product mode, not a valid Responses reasoning effort. + # The direct API contract uses hosted Multi-agent plus the model's + # strongest supported effort. + reasoning_effort = ultra_wire_effort response_tools = _responses_tools(tools) @@ -285,7 +337,11 @@ def build_kwargs( if github_reasoning is not None: kwargs["reasoning"] = github_reasoning else: - kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} + if ultra_requested: + # Multi-agent beta rejects reasoning summaries. + kwargs["reasoning"] = {"effort": reasoning_effort} + else: + kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} kwargs["include"] = ( ["reasoning.encrypted_content"] if replay_encrypted_reasoning else [] ) @@ -296,6 +352,59 @@ def build_kwargs( if request_overrides: kwargs.update(request_overrides) + if ultra_requested: + # Apply the hosted Multi-agent contract after request overrides so a + # stale/custom override cannot accidentally send literal `ultra`, + # re-enable the unsupported reasoning summary, or disable delegation. + kwargs["reasoning"] = {"effort": reasoning_effort} + # OpenAI recommends a higher hosted-tool budget for Multi-agent; + # preserve an explicit caller override when one is supplied. + kwargs.setdefault("max_tool_calls", 100) + + existing_extra_body = kwargs.get("extra_body") + merged_extra_body: Dict[str, Any] = {} + if isinstance(existing_extra_body, dict): + merged_extra_body.update(existing_extra_body) + existing_multi_agent = merged_extra_body.get("multi_agent") + multi_agent: Dict[str, Any] = {} + if isinstance(existing_multi_agent, dict): + multi_agent.update(existing_multi_agent) + elif existing_multi_agent is not None: + raise ValueError("OpenAI Multi-agent configuration must be an object.") + max_subagents = multi_agent.get("max_concurrent_subagents", 3) + if ( + not isinstance(max_subagents, int) + or isinstance(max_subagents, bool) + or not 1 <= max_subagents <= 4 + ): + raise ValueError( + "OpenAI Multi-agent max_concurrent_subagents must be an integer from 1 to 4." + ) + multi_agent["enabled"] = True + multi_agent["max_concurrent_subagents"] = max_subagents + merged_extra_body["multi_agent"] = multi_agent + kwargs["extra_body"] = merged_extra_body + + existing_extra_headers = kwargs.get("extra_headers") + merged_extra_headers: Dict[str, str] = {} + if isinstance(existing_extra_headers, dict): + merged_extra_headers.update( + { + str(key): str(value) + for key, value in existing_extra_headers.items() + if key and value is not None + } + ) + beta_key = next( + (key for key in merged_extra_headers if key.lower() == "openai-beta"), + "OpenAI-Beta", + ) + merged_extra_headers[beta_key] = _merge_openai_beta_header( + merged_extra_headers.get(beta_key), + _OPENAI_MULTI_AGENT_BETA, + ) + kwargs["extra_headers"] = merged_extra_headers + # xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not # supported: service_tier") — hit when ``/fast`` priority-processing # mode lingers from a prior model in the same session, or when a diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 78af728711dd..149b931ba323 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -233,6 +233,9 @@ def __init__( # approval params don't carry the changeset, so we cache here # to surface a real summary in the approval prompt (quirk #4). self._pending_file_changes: dict[str, str] = {} + # Live model/list capabilities are queried only for Ultra and cached + # per model for the lifetime of this app-server subprocess. + self._reasoning_capabilities: dict[str, set[str]] = {} self._closed = False # ---------- lifecycle ---------- @@ -361,12 +364,102 @@ def _format_error_with_stderr( redacted = redact_sensitive_text(joined, force=True) return f"{base}\ncodex stderr (last {len(tail)} lines):\n{redacted}" + # ---------- model capabilities ---------- + + def _supported_reasoning_efforts(self, model: str) -> set[str]: + """Read live app-server reasoning capabilities for one model.""" + normalized_model = str(model or "").strip() + if not normalized_model: + raise CodexAppServerError( + code=-32602, + message="Ultra requires an explicit Codex model selection.", + ) + cached = self._reasoning_capabilities.get(normalized_model) + if cached is not None: + return cached + assert self._client is not None + + cursor: Optional[str] = None + for _page in range(10): + params: dict[str, Any] = {"limit": 100} + if cursor: + params["cursor"] = cursor + payload = self._client.request("model/list", params, timeout=15) + entries = payload.get("data") or payload.get("models") or [] + if not isinstance(entries, list): + entries = [] + for entry in entries: + if not isinstance(entry, dict): + continue + entry_model = ( + entry.get("model") + or entry.get("id") + or entry.get("slug") + or "" + ) + if str(entry_model).strip().lower() != normalized_model.lower(): + continue + raw_levels = ( + entry.get("supportedReasoningEfforts") + or entry.get("supported_reasoning_efforts") + or entry.get("supportedReasoningLevels") + or entry.get("supported_reasoning_levels") + or [] + ) + levels: set[str] = set() + if isinstance(raw_levels, list): + for raw_level in raw_levels: + if isinstance(raw_level, str): + value = raw_level + elif isinstance(raw_level, dict): + value = ( + raw_level.get("reasoningEffort") + or raw_level.get("reasoning_effort") + or raw_level.get("effort") + or raw_level.get("value") + or raw_level.get("level") + ) + else: + value = None + if value: + levels.add(str(value).strip().lower()) + self._reasoning_capabilities[normalized_model] = levels + return levels + + cursor_value = payload.get("nextCursor") or payload.get("next_cursor") + cursor = str(cursor_value).strip() if cursor_value else None + if not cursor: + break + + raise CodexAppServerError( + code=-32602, + message=( + f"Codex model/list did not return model {normalized_model!r}; " + "cannot verify Ultra support. Update Codex CLI or select an " + "advertised model." + ), + ) + + def _validate_ultra_support(self, model: str) -> None: + levels = self._supported_reasoning_efforts(model) + if "ultra" not in levels: + advertised = ", ".join(sorted(levels)) or "none" + raise CodexAppServerError( + code=-32602, + message=( + f"Codex model {model!r} does not advertise reasoning effort " + f"'ultra' (advertised: {advertised})." + ), + ) + # ---------- per-turn ---------- def run_turn( self, user_input: Any, *, + model: Optional[str] = None, + reasoning_effort: Optional[str] = None, turn_timeout: float = 600.0, notification_poll_timeout: float = 0.25, post_tool_quiet_timeout: float = 90.0, @@ -408,12 +501,22 @@ def run_turn( # Send turn/start with the user input. Text-only for now (codex # supports rich content but Hermes' text path is the common case). try: + selected_model = str(model or "").strip() + selected_effort = str(reasoning_effort or "").strip().lower() + if selected_effort == "ultra": + self._validate_ultra_support(selected_model) + + turn_params: dict[str, Any] = { + "threadId": self._thread_id, + "input": [{"type": "text", "text": user_input_text}], + } + if selected_effort == "ultra": + turn_params["model"] = selected_model + turn_params["effort"] = selected_effort + ts = self._client.request( "turn/start", - { - "threadId": self._thread_id, - "input": [{"type": "text", "text": user_input_text}], - }, + turn_params, timeout=10, ) except CodexAppServerError as exc: diff --git a/gateway/run.py b/gateway/run.py index ccfa8e92c143..17f04882f2ee 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4792,7 +4792,7 @@ def _load_reasoning_config() -> dict | None: """Load reasoning effort from config.yaml. Reads agent.reasoning_effort from config.yaml. Valid: "none", - "minimal", "low", "medium", "high", "xhigh". Returns None to use + "minimal", "low", "medium", "high", "xhigh", "ultra". Returns None to use default (medium). """ from hermes_constants import parse_reasoning_effort diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 8d4eee356f95..2f5a91d94bbc 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2733,7 +2733,7 @@ def _save_config_key(key_path: str, value): return t("gateway.reasoning.reset_done") if effort == "none": parsed = {"enabled": False} - elif effort in {"minimal", "low", "medium", "high", "xhigh"}: + elif effort in {"minimal", "low", "medium", "high", "xhigh", "ultra"}: parsed = {"enabled": True, "effort": effort} else: return t( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index b16d2166e2c6..f25f8f8f2513 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2471,7 +2471,7 @@ def _handle_reasoning_command(self, cmd: str): Usage: /reasoning Show current effort level and display state - /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh) + /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh, ultra) /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output /reasoning full Show complete thinking (no 10-line clamp) @@ -2493,7 +2493,7 @@ def _handle_reasoning_command(self, cmd: str): full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") - _cprint(f" {_DIM}Usage: /reasoning {_RST}") + _cprint(f" {_DIM}Usage: /reasoning {_RST}") return arg = parts[1].strip().lower() @@ -2534,7 +2534,7 @@ def _handle_reasoning_command(self, cmd: str): parsed = _parse_reasoning_config(arg) if parsed is None: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}") + _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, ultra{_RST}") _cprint(f" {_DIM}Display: show, hide{_RST}") return diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3e2d03dc3580..681340124636 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -154,7 +154,7 @@ class CommandDef: "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide|full|clamp]", - subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off", "full", "clamp")), + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "ultra", "show", "hide", "on", "off", "full", "clamp")), CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), diff --git a/hermes_constants.py b/hermes_constants.py index 29dac85fe8aa..18b8daa697f4 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -791,13 +791,13 @@ def apply_subprocess_home_env(env: dict[str, str]) -> None: env["HOME"] = home -VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max") +VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") def parse_reasoning_effort(effort) -> dict | None: """Parse a reasoning effort level into a config dict. - Valid levels: "none", "minimal", "low", "medium", "high", "xhigh", "max". + Valid levels: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None when the input is empty or unrecognized (caller uses default). Returns {"enabled": False} for "none" (aliases: "false", "disabled", and YAML boolean False — users write ``reasoning_effort: false``/``off``/``no`` diff --git a/locales/af.yaml b/locales/af.yaml index 6dc9055def0c..1037ee8a32f2 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Redenering-vertoon: **AF** vir **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` word nie ondersteun nie. Gebruik `/reasoning --global` om die globale verstek te verander." reset_done: "🧠 ✓ Sessie-redenering-oorskryf verwyder; val terug op globale konfigurasie." - unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor" + unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh, ultra\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor" set_global: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_" set_global_save_failed: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — konfigurasie-stoor het misluk)\n_(neem effek by die volgende boodskap)_" set_session: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — voeg `--global` by om permanent te stoor)\n_(neem effek by die volgende boodskap)_" diff --git a/locales/de.yaml b/locales/de.yaml index 26acd3eb35f0..4102bec2cd8d 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Reasoning-Anzeige: **AUS** für **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` wird nicht unterstützt. Verwenden Sie `/reasoning --global`, um den globalen Standard zu ändern." reset_done: "🧠 ✓ Sitzungs-Reasoning-Override gelöscht; Rückfall auf globale Konfiguration." - unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern" + unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh, ultra\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern" set_global: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (in Konfiguration gespeichert)\n_(wird mit der nächsten Nachricht wirksam)_" set_global_save_failed: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — Konfiguration konnte nicht gespeichert werden)\n_(wird mit der nächsten Nachricht wirksam)_" set_session: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — `--global` hinzufügen, um zu speichern)\n_(wird mit der nächsten Nachricht wirksam)_" diff --git a/locales/en.yaml b/locales/en.yaml index f970b057ecaa..f872452cfd24 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -204,7 +204,7 @@ gateway: display_set_off: "🧠 ✓ Reasoning display: **OFF** for **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning --global` to change the global default." reset_done: "🧠 ✓ Session reasoning override cleared; falling back to global config." - unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid levels:** none, minimal, low, medium, high, xhigh\n**Display:** show, hide\n**Persist:** add `--global` to save beyond this session" + unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid levels:** none, minimal, low, medium, high, xhigh, ultra\n**Display:** show, hide\n**Persist:** add `--global` to save beyond this session" set_global: "🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" set_global_save_failed: "🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_" set_session: "🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_" diff --git a/locales/es.yaml b/locales/es.yaml index 15eedaa869e0..905aa4921ea4 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Visualización de razonamiento: **DESACTIVADA** para **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` no es compatible. Usa `/reasoning --global` para cambiar el valor global por defecto." reset_done: "🧠 ✓ Anulación de razonamiento de la sesión borrada; volviendo a la configuración global." - unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión" + unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh, ultra\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión" set_global: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (guardado en la configuración)\n_(se aplica en el próximo mensaje)_" set_global_save_failed: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — error al guardar la configuración)\n_(se aplica en el próximo mensaje)_" set_session: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — añade `--global` para persistir)\n_(se aplica en el próximo mensaje)_" diff --git a/locales/fr.yaml b/locales/fr.yaml index 78935eed5535..baf5d10c66de 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Affichage du raisonnement : **DÉSACTIVÉ** pour **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` n'est pas pris en charge. Utilisez `/reasoning --global` pour modifier la valeur globale par défaut." reset_done: "🧠 ✓ Remplacement de raisonnement de la session effacé ; retour à la configuration globale." - unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session" + unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh, ultra\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session" set_global: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (enregistré dans la configuration)\n_(prend effet au prochain message)_" set_global_save_failed: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — échec de l'enregistrement de la configuration)\n_(prend effet au prochain message)_" set_session: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — ajoutez `--global` pour persister)\n_(prend effet au prochain message)_" diff --git a/locales/ga.yaml b/locales/ga.yaml index bad263ecfc04..ff68063e07d8 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -193,7 +193,7 @@ gateway: display_set_off: "🧠 ✓ Taispeáint réasúnaíochta: **AS** do **{platform}**" reset_global_unsupported: "⚠️ Ní thacaítear le `/reasoning reset --global`. Úsáid `/reasoning --global` chun an réamhshocrú domhanda a athrú." reset_done: "🧠 ✓ Sárú réasúnaíochta seisiúin glanta; ag titim siar ar an gcumraíocht dhomhanda." - unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo" + unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh, ultra\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo" set_global: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_" set_global_save_failed: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — theip ar shábháil cumraíochta)\n_(éifeachtach ón gcéad teachtaireacht eile)_" set_session: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — cuir `--global` leis chun é a choinneáil)\n_(éifeachtach ón gcéad teachtaireacht eile)_" diff --git a/locales/hu.yaml b/locales/hu.yaml index e3bbc6ea60b0..ab2fc6c067c1 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Gondolkodás megjelenítése: **KI** itt: **{platform}**" reset_global_unsupported: "⚠️ A `/reasoning reset --global` nem támogatott. Használd a `/reasoning --global` parancsot a globális alapérték módosításához." reset_done: "🧠 ✓ A munkamenet gondolkodási felülbírálása törölve; visszaállás a globális konfigurációra." - unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez" + unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh, ultra\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez" set_global: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_" set_global_save_failed: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — a konfiguráció mentése sikertelen)\n_(a következő üzenettől lép életbe)_" set_session: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — add hozzá a `--global` opciót a megőrzéshez)\n_(a következő üzenettől lép életbe)_" diff --git a/locales/it.yaml b/locales/it.yaml index f74740905364..55c784560611 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Visualizzazione del reasoning: **DISATTIVATA** per **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` non è supportato. Usa `/reasoning --global` per cambiare il valore predefinito globale." reset_done: "🧠 ✓ Override di reasoning della sessione cancellato; ripristino della configurazione globale." - unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione" + unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh, ultra\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione" set_global: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_" set_global_save_failed: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — salvataggio della configurazione non riuscito)\n_(verrà applicato al prossimo messaggio)_" set_session: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — aggiungi `--global` per renderlo permanente)\n_(verrà applicato al prossimo messaggio)_" diff --git a/locales/ja.yaml b/locales/ja.yaml index f11725d7ad89..b98e8d679adb 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ **{platform}** での推論表示: **オフ**" reset_global_unsupported: "⚠️ `/reasoning reset --global` はサポートされていません。グローバルのデフォルトを変更するには `/reasoning --global` を使用してください。" reset_done: "🧠 ✓ セッションの推論オーバーライドをクリアしました。グローバル設定にフォールバックします。" - unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加" + unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh, ultra\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加" set_global: "🧠 ✓ 推論強度を `{effort}` に設定しました (設定に保存)\n_(次のメッセージから有効)_" set_global_save_failed: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 設定の保存に失敗)\n_(次のメッセージから有効)_" set_session: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 永続化するには `--global` を追加)\n_(次のメッセージから有効)_" diff --git a/locales/ko.yaml b/locales/ko.yaml index aae3358bdc78..fd4a1ae619eb 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ 추론 표시: **꺼짐** (**{platform}**에서)" reset_global_unsupported: "⚠️ `/reasoning reset --global`은 지원되지 않습니다. 전역 기본값을 변경하려면 `/reasoning --global`을 사용하세요." reset_done: "🧠 ✓ 세션 추론 재정의가 해제되었습니다. 전역 설정으로 돌아갑니다." - unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요" + unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh, ultra\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요" set_global: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_" set_global_save_failed: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 설정 저장 실패)\n_(다음 메시지부터 적용됩니다)_" set_session: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 영구 저장하려면 `--global` 추가)\n_(다음 메시지부터 적용됩니다)_" diff --git a/locales/pt.yaml b/locales/pt.yaml index 2f8bcd03d468..7a1df9688aef 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Visualização do raciocínio: **DESATIVADA** para **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` não é suportado. Usa `/reasoning --global` para alterar o predefinido global." reset_done: "🧠 ✓ Substituição de raciocínio da sessão removida; a regressar à configuração global." - unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão" + unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh, ultra\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão" set_global: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (guardado na configuração)\n_(produz efeito na próxima mensagem)_" set_global_save_failed: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — falha ao guardar a configuração)\n_(produz efeito na próxima mensagem)_" set_session: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — adiciona `--global` para persistir)\n_(produz efeito na próxima mensagem)_" diff --git a/locales/ru.yaml b/locales/ru.yaml index 0450981fc2d3..5c842d9ecdc0 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Отображение рассуждений: **ВЫКЛ.** для **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` не поддерживается. Используйте `/reasoning --global`, чтобы изменить глобальное значение по умолчанию." reset_done: "🧠 ✓ Переопределение рассуждений для сеанса сброшено; возврат к глобальной конфигурации." - unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса" + unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh, ultra\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса" set_global: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_" set_global_save_failed: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — не удалось сохранить конфигурацию)\n_(вступит в силу со следующего сообщения)_" set_session: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — добавьте `--global`, чтобы сохранить)\n_(вступит в силу со следующего сообщения)_" diff --git a/locales/tr.yaml b/locales/tr.yaml index 2fd70cd439f3..50ec539a1484 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ **{platform}** için akıl yürütme görüntüleme: **KAPALI**" reset_global_unsupported: "⚠️ `/reasoning reset --global` desteklenmiyor. Genel varsayılanı değiştirmek için `/reasoning --global` kullanın." reset_done: "🧠 ✓ Oturumun akıl yürütme geçersiz kılması temizlendi; genel yapılandırmaya geri dönülüyor." - unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin" + unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh, ultra\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin" set_global: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yapılandırmaya kaydedildi)\n_(sonraki mesajda etkili)_" set_global_save_failed: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — yapılandırma kaydedilemedi)\n_(sonraki mesajda etkili)_" set_session: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — kalıcı yapmak için `--global` ekleyin)\n_(sonraki mesajda etkili)_" diff --git a/locales/uk.yaml b/locales/uk.yaml index 5e0391c0dbba..3ed059f8a60c 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ Показ мислення: **ВИМКНЕНО** для **{platform}**" reset_global_unsupported: "⚠️ `/reasoning reset --global` не підтримується. Використовуйте `/reasoning --global`, щоб змінити глобальне значення за замовчуванням." reset_done: "🧠 ✓ Перевизначення мислення для сеансу скинуто; повернення до глобальної конфігурації." - unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом" + unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh, ultra\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом" set_global: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_" set_global_save_failed: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — не вдалося зберегти конфігурацію)\n_(набуде чинності з наступного повідомлення)_" set_session: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — додайте `--global`, щоб зберегти)\n_(набуде чинності з наступного повідомлення)_" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 8f2d5e59806b..e46b76e10297 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ **{platform}** 上的推理顯示:**關閉**" reset_global_unsupported: "⚠️ 不支援 `/reasoning reset --global`。請使用 `/reasoning --global` 變更全域預設值。" reset_done: "🧠 ✓ 已清除本工作階段的推理覆寫;回退至全域設定。" - unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存" + unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh, ultra\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存" set_global: "🧠 ✓ 推理強度已設定為 `{effort}`(已儲存到設定)\n_(下一則訊息生效)_" set_global_save_failed: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 設定儲存失敗)\n_(下一則訊息生效)_" set_session: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 加上 `--global` 可持久化)\n_(下一則訊息生效)_" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7defb22e1e00..11e042a26fc5 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -189,7 +189,7 @@ gateway: display_set_off: "🧠 ✓ **{platform}** 上的推理显示:**关闭**" reset_global_unsupported: "⚠️ 不支持 `/reasoning reset --global`。请使用 `/reasoning --global` 修改全局默认值。" reset_done: "🧠 ✓ 已清除本会话的推理覆盖;回退到全局配置。" - unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存" + unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh, ultra\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存" set_global: "🧠 ✓ 推理强度已设置为 `{effort}`(已保存到配置)\n_(下一条消息生效)_" set_global_save_failed: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 配置保存失败)\n_(下一条消息生效)_" set_session: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 添加 `--global` 以持久化)\n_(下一条消息生效)_" diff --git a/tests/agent/test_codex_app_server_persist.py b/tests/agent/test_codex_app_server_persist.py index 001082e3f0ea..6726bdcd3cd1 100644 --- a/tests/agent/test_codex_app_server_persist.py +++ b/tests/agent/test_codex_app_server_persist.py @@ -51,6 +51,8 @@ def _make_agent(session_db=None, session_id="sess-codex"): # Pre-seed the session so run_codex_app_server_turn skips the spawn block. agent._codex_session = MagicMock() agent._codex_session.run_turn.return_value = _make_turn() + agent.model = "gpt-5.6-sol" + agent.reasoning_config = {"enabled": True, "effort": "ultra"} agent.tool_progress_callback = None agent._iters_since_skill = 0 agent._skill_nudge_interval = 0 @@ -71,6 +73,11 @@ def test_codex_success_flushes_and_reports_persisted(): messages=[{"role": "user", "content": "hello"}], effective_task_id="task-1", ) + agent._codex_session.run_turn.assert_called_once_with( + user_input="hello", + model="gpt-5.6-sol", + reasoning_effort="ultra", + ) assert result["completed"] is True # With the agent as sole persister, the gateway must SKIP its DB write. assert result["agent_persisted"] is True diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index b8586dbeace7..775f31932c29 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -117,6 +117,74 @@ def test_normalize_codex_response_ignores_in_progress_server_side_tool_calls(): assert assistant_message.content == "Milwaukee M18 blade 49-16-2734, ~$30 OEM." +def test_normalize_codex_response_preserves_hosted_multi_agent_items(): + """Hosted delegation telemetry is provider output, not a Hermes tool call.""" + response = SimpleNamespace( + status="completed", + output=[ + { + "type": "multi_agent_call", + "id": "ma_1", + "status": "in_progress", + "action": {"type": "spawn", "agent_name": "researcher"}, + }, + SimpleNamespace( + type="agent_message", + id="am_1", + status="completed", + agent_name="researcher", + content=[SimpleNamespace(type="output_text", text="evidence")], + ), + SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text="final answer")], + ), + ], + ) + + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "stop" + assert assistant_message.content == "final answer" + assert assistant_message.tool_calls == [] + item_types = {item["type"] for item in assistant_message.codex_message_items} + assert {"multi_agent_call", "agent_message", "message"}.issubset(item_types) + + +def test_hosted_multi_agent_items_preserve_function_tool_continuation(): + response = SimpleNamespace( + status="completed", + output=[ + { + "type": "multi_agent_call", + "id": "ma_1", + "status": "completed", + "action": {"type": "spawn", "agent_name": "researcher"}, + }, + SimpleNamespace( + type="function_call", + id="fc_1", + call_id="call_1", + status="completed", + name="lookup", + arguments='{"query":"evidence"}', + ), + ], + ) + + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "tool_calls" + assert len(assistant_message.tool_calls) == 1 + assert assistant_message.tool_calls[0].function.name == "lookup" + assert any( + item["type"] == "multi_agent_call" + for item in assistant_message.codex_message_items + ) + + def test_normalize_codex_response_in_progress_message_still_incomplete(): """Guard scope: an in_progress *message* item (genuine model output that is still streaming) must still mark the turn incomplete — only diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index adc3470131e3..cc9a047496d3 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -259,6 +259,95 @@ def test_rich_content_turn_is_collapsed_to_text_payload(self): assert "[Image attached at: /tmp/a.png]" in text assert "[image attached]" in text + def test_ultra_is_forwarded_when_model_catalog_advertises_it(self): + client = FakeClient() + + def handle(method, params): + if method == "model/list": + return { + "data": [{ + "model": "gpt-5.6-sol", + "supportedReasoningEfforts": [ + {"reasoningEffort": "max"}, + {"reasoningEffort": "ultra"}, + ], + }] + } + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + if method == "turn/start": + return {"turn": {"id": "turn-fake-001"}} + return {} + + client._request_handler = handle + client.queue_notification( + "turn/completed", + threadId="thread-fake-001", + turn={"id": "turn-fake-001", "status": "completed", "error": None}, + ) + + r = make_session(client).run_turn( + "hi", + model="gpt-5.6-sol", + reasoning_effort="ultra", + turn_timeout=2.0, + ) + + assert r.error is None + _method, params = next(req for req in client.requests if req[0] == "turn/start") + assert params["model"] == "gpt-5.6-sol" + assert params["effort"] == "ultra" + + def test_ultra_fails_closed_when_model_catalog_does_not_advertise_it(self): + client = FakeClient() + + def handle(method, params): + if method == "model/list": + return { + "data": [{ + "model": "gpt-5.6-luna", + "supportedReasoningEfforts": [{"reasoningEffort": "max"}], + }] + } + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + if method == "turn/start": + return {"turn": {"id": "turn-fake-001"}} + return {} + + client._request_handler = handle + r = make_session(client).run_turn( + "hi", + model="gpt-5.6-luna", + reasoning_effort="ultra", + turn_timeout=2.0, + ) + + assert r.error is not None + assert "does not advertise reasoning effort 'ultra'" in r.error + assert not any(method == "turn/start" for method, _params in client.requests) + + def test_non_ultra_turn_keeps_existing_app_server_request_shape(self): + client = FakeClient() + client.queue_notification( + "turn/completed", + threadId="thread-fake-001", + turn={"id": "turn-fake-001", "status": "completed", "error": None}, + ) + + r = make_session(client).run_turn( + "hi", + model="gpt-5.6-sol", + reasoning_effort="high", + turn_timeout=2.0, + ) + + assert r.error is None + _method, params = next(req for req in client.requests if req[0] == "turn/start") + assert "model" not in params + assert "effort" not in params + assert not any(method == "model/list" for method, _params in client.requests) + def test_tool_iteration_counter_ticks(self): client = FakeClient() # Two completed exec items + one final agent message diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6389890519a0..8e3d6b6effc6 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -75,6 +75,93 @@ def test_reasoning_config(self, transport): ) assert kw.get("reasoning", {}).get("effort") == "high" + def test_ultra_builds_openai_multi_agent_beta_request(self, transport): + messages = [{"role": "user", "content": "Investigate this."}] + kw = transport.build_kwargs( + model="gpt-5.6-sol", + messages=messages, + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + ) + + assert kw["reasoning"] == {"effort": "max"} + assert kw["max_tool_calls"] == 100 + assert kw["extra_body"]["multi_agent"] == { + "enabled": True, + "max_concurrent_subagents": 3, + } + assert "responses_multi_agent=v1" in kw["extra_headers"]["OpenAI-Beta"] + + def test_ultra_uses_strongest_effort_supported_by_gpt53_codex(self, transport): + kw = transport.build_kwargs( + model="gpt-5.3-codex-spark", + messages=[{"role": "user", "content": "Investigate this."}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + ) + + assert kw["reasoning"] == {"effort": "xhigh"} + assert kw["extra_body"]["multi_agent"]["enabled"] is True + + def test_ultra_preserves_caller_beta_headers_and_concurrency(self, transport): + messages = [{"role": "user", "content": "Investigate this."}] + kw = transport.build_kwargs( + model="gpt-5.6-terra", + messages=messages, + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + request_overrides={ + "reasoning": {"effort": "ultra", "summary": "auto"}, + "extra_headers": {"OpenAI-Beta": "other_beta=v1", "X-Test": "1"}, + "extra_body": { + "other_field": 42, + "multi_agent": {"max_concurrent_subagents": 4}, + }, + }, + ) + + assert kw["extra_body"]["other_field"] == 42 + assert kw["reasoning"] == {"effort": "max"} + assert kw["extra_body"]["multi_agent"] == { + "enabled": True, + "max_concurrent_subagents": 4, + } + assert kw["extra_headers"]["X-Test"] == "1" + assert "other_beta=v1" in kw["extra_headers"]["OpenAI-Beta"] + assert "responses_multi_agent=v1" in kw["extra_headers"]["OpenAI-Beta"] + + def test_ultra_normal_codex_backend_fails_closed(self, transport): + with pytest.raises(ValueError, match="codex-runtime app_server"): + transport.build_kwargs( + model="gpt-5.6-sol", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_codex_backend=True, + ) + + def test_ultra_non_openai_responses_route_fails_closed(self, transport): + with pytest.raises(ValueError, match="OpenAI Responses API"): + transport.build_kwargs( + model="gpt-5.6-sol", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + ) + + def test_ultra_rejects_unsupported_openai_model(self, transport): + with pytest.raises(ValueError, match="does not support OpenAI Multi-agent"): + transport.build_kwargs( + model="gpt-5.5", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + ) + def test_reasoning_disabled(self, transport): messages = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py index 08185689e73c..523f3aa4a76c 100644 --- a/tests/cli/test_reasoning_command.py +++ b/tests/cli/test_reasoning_command.py @@ -30,7 +30,7 @@ def test_none_disables(self): self.assertEqual(result, {"enabled": False}) def test_valid_levels(self): - for level in ("low", "medium", "high", "xhigh", "minimal"): + for level in ("low", "medium", "high", "xhigh", "minimal", "ultra"): result = self._parse(level) self.assertIsNotNone(result) self.assertTrue(result.get("enabled")) @@ -41,7 +41,6 @@ def test_empty_returns_none(self): self.assertIsNone(self._parse(" ")) def test_unknown_returns_none(self): - self.assertIsNone(self._parse("ultra")) self.assertIsNone(self._parse("turbo")) def test_case_insensitive(self): diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py index 09600fb6f5a1..418dc3e3ac4d 100644 --- a/tests/gateway/test_reasoning_command.py +++ b/tests/gateway/test_reasoning_command.py @@ -148,6 +148,27 @@ async def test_handle_reasoning_command_defaults_to_session_only(self, tmp_path, assert runner._reasoning_config == {"enabled": True, "effort": "high"} assert "session only" in result + @pytest.mark.asyncio + async def test_reasoning_ultra_is_accepted_for_session(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "agent:\n reasoning_effort: medium\n", encoding="utf-8" + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + event = _make_event("/reasoning ultra") + session_key = runner._session_key_for_source(event.source) + + result = await runner._handle_reasoning_command(event) + + assert runner._session_reasoning_overrides[session_key] == { + "enabled": True, + "effort": "ultra", + } + assert "session only" in result + @pytest.mark.asyncio async def test_reasoning_global_clears_existing_session_override(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 969ff559366e..3d72bfb415b4 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -734,6 +734,7 @@ def test_subcommand_completion_after_space(self): completions = _completions(SlashCommandCompleter(), "/reasoning ") texts = {c.text for c in completions} assert "high" in texts + assert "ultra" in texts assert "show" in texts def test_fast_subcommand_completion_after_space(self): diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 0c24adc4ed6c..77c53e6a4212 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -428,6 +428,65 @@ def test_build_api_kwargs_codex_preserves_supported_efforts(monkeypatch): assert kwargs["reasoning"]["effort"] == effort, f"{effort} should pass through unchanged" +def test_ultra_direct_openai_agent_builds_multi_agent_request(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5.6-sol", + provider="openai", + api_mode="codex_responses", + base_url="https://api.openai.com/v1", + **{"api" + "_key": "test-token"}, + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + reasoning_config={"enabled": True, "effort": "ultra"}, + ) + + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + + assert kwargs["reasoning"] == {"effort": "max"} + assert kwargs["extra_body"]["multi_agent"]["enabled"] is True + assert "responses_multi_agent=v1" in kwargs["extra_headers"]["OpenAI-Beta"] + + +def test_ultra_openai_provider_with_custom_base_url_fails_closed(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5.6-sol", + provider="openai", + api_mode="codex_responses", + base_url="https://proxy.example.test/v1", + **{"api" + "_key": "test-token"}, + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + reasoning_config={"enabled": True, "effort": "ultra"}, + ) + + with pytest.raises(ValueError, match="not the OpenAI Responses API"): + agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + + +def test_ultra_non_responses_transport_fails_closed(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="anthropic/claude-sonnet-4", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + **{"api" + "_key": "test-token"}, + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + reasoning_config={"enabled": True, "effort": "ultra"}, + ) + + with pytest.raises(ValueError, match="Ultra is only supported"): + agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + + def test_build_api_kwargs_copilot_responses_omits_openai_only_fields(monkeypatch): agent = _build_copilot_agent(monkeypatch) kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index e4b064ed947e..0eac5af59608 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -483,10 +483,10 @@ def test_known_supported_levels_are_documented(self): """Guard against silently dropping a documented level. The docstring promises "minimal", "low", "medium", "high", "xhigh", - "max". If someone removes one from VALID_REASONING_EFFORTS without + "max", "ultra". If someone removes one from VALID_REASONING_EFFORTS without updating the docstring, this test will fail and force the call out. """ - documented = {"minimal", "low", "medium", "high", "xhigh", "max"} + documented = {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} assert documented.issubset(set(VALID_REASONING_EFFORTS)) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 46afc11bcfd2..3ad83b932edb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1276,7 +1276,8 @@ Control how much "thinking" the model does before responding: ```yaml agent: - reasoning_effort: "" # empty = medium (default). Options: none, minimal, low, medium, high, xhigh (max) + reasoning_effort: "" # empty = medium. Standard: none, minimal, low, medium, high, xhigh + # ultra = OpenAI hosted Multi-agent mode (see below) ``` When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency. @@ -1302,6 +1303,26 @@ You can also change the reasoning effort at runtime with the `/reasoning` comman /reasoning hide # Hide model thinking ``` +### OpenAI Ultra mode + +`/reasoning ultra` enables OpenAI's hosted Multi-agent mode instead of sending a +literal `reasoning.effort: ultra` value. Hermes supports it through two routes: + +- **Direct OpenAI API:** for supported `gpt-5.6-*` and `gpt-5.3-codex-*` + models, Hermes + sends the model's strongest supported reasoning effort, enables `multi_agent` + with three concurrent hosted subagents, and adds the + `responses_multi_agent=v1` beta header. The hosted tool-call budget defaults + to OpenAI's recommended `100` and can be lowered with `request_overrides`. +- **ChatGPT/Codex subscription:** enable the optional + [Codex app-server runtime](features/codex-app-server-runtime.md), then select + Ultra. Hermes verifies that the live Codex `model/list` response advertises + `ultra` for the selected model before starting the turn. + +Ultra fails closed on the normal non-app-server `openai-codex` Responses route, +unsupported models, and non-OpenAI providers. It never silently falls back to a +lower effort. OpenAI Multi-agent is a beta API and may change. + ## Tool-Use Enforcement Some models occasionally describe intended actions as text instead of making tool calls ("I would run the tests..." instead of actually calling the terminal). Tool-use enforcement injects system prompt guidance that steers the model back to actually calling tools. diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index b4d317ae0cf9..e24c730e66e9 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -182,6 +182,26 @@ model: openai_runtime: codex_app_server # default is "auto" (= Hermes runtime) ``` +## Ultra reasoning + +When the selected Codex model advertises an `ultra` reasoning level, enable its +hosted task delegation with: + +``` +/reasoning ultra +``` + +Hermes queries the live `model/list` capability catalog before each model's +first Ultra turn and forwards both the selected model and `effort: ultra` to +`turn/start`. If the model is absent from the catalog or does not advertise +Ultra, the turn fails closed with the advertised levels instead of silently +using a lower setting. The selected model is re-evaluated on every Ultra turn, +without restarting the app-server session. + +This is **Codex-hosted delegation**, not Hermes' `delegate_task` tool. The latter +still requires Hermes' default runtime, as described in +[What's NOT available on this runtime](#whats-not-available-on-this-runtime). + ## Self-improvement loop (memory + skill nudges) Hermes' background self-improvement fires on counter thresholds: From 29fd7a5bb91879482b157cfccd02372dd6a39a43 Mon Sep 17 00:00:00 2001 From: Jaime Chieng Date: Fri, 10 Jul 2026 12:30:52 -0400 Subject: [PATCH 2/3] fix(agent): align Ultra with Multi-agent beta contract --- agent/chat_completion_helpers.py | 7 +- agent/codex_responses_adapter.py | 341 ++++++++++++++---- agent/codex_runtime.py | 38 +- agent/transports/codex.py | 44 ++- tests/agent/test_codex_responses_adapter.py | 287 +++++++++++++-- .../agent/transports/test_codex_transport.py | 113 +++++- .../test_run_agent_codex_responses.py | 74 ++++ website/docs/user-guide/configuration.md | 15 +- .../features/codex-app-server-runtime.md | 5 +- 9 files changed, 796 insertions(+), 128 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b027ff9a0372..0e67c9ea56b0 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1118,9 +1118,10 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic if codex_items: msg["codex_reasoning_items"] = codex_items - # Codex Responses API: preserve exact assistant message items (with - # id/phase) so follow-up turns can replay structured items instead of - # flattening to plain text. This is required for prefix cache hits. + # Codex Responses API: preserve replayable output items. Normal turns carry + # exact assistant message items (id/phase) for prefix-cache continuity; + # Multi-agent turns carry the complete ordered output list so hosted actions, + # agent messages, and agent-attributed function calls survive continuation. codex_message_items = getattr(assistant_message, "codex_message_items", None) if codex_message_items: msg["codex_message_items"] = codex_message_items diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index b9b04a5e6fdc..8cb7667f30b6 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -54,6 +54,20 @@ def _classify_responses_issuer( # when a long history contains many stale-issuer reasoning blocks. _CROSS_ISSUER_WARN_EMITTED = False +_HOSTED_MULTI_AGENT_ITEM_TYPES = { + "multi_agent_call", + "multi_agent_call_output", + "agent_message", +} +_MULTI_AGENT_ACTIONS = { + "spawn_agent", + "interrupt_agent", + "list_agents", + "send_message", + "followup_task", + "wait_agent", +} + # Matches Codex/Harmony tool-call serialization that occasionally leaks into # assistant-message content when the model fails to emit a structured @@ -367,6 +381,19 @@ def _chat_messages_to_responses_input( content_text = str(content) if content is not None else "" if role == "assistant": + codex_message_items = msg.get("codex_message_items") + multi_agent_replay = bool( + isinstance(codex_message_items, list) + and any( + isinstance(item, dict) + and ( + item.get("type") in _HOSTED_MULTI_AGENT_ITEM_TYPES + or isinstance(item.get("agent"), dict) + ) + for item in codex_message_items + ) + ) + # Replay encrypted reasoning items from previous turns # so the API can maintain coherent reasoning chains. # This applies to every Responses transport including @@ -374,7 +401,7 @@ def _chat_messages_to_responses_input( # for the May 2026 reversal of the earlier xAI gate. codex_reasoning = ( msg.get("codex_reasoning_items") - if replay_encrypted_reasoning + if replay_encrypted_reasoning and not multi_agent_replay else None ) has_codex_reasoning = False @@ -423,13 +450,24 @@ def _chat_messages_to_responses_input( seen_item_ids.add(item_id) has_codex_reasoning = True - # Replay exact assistant message items (with id/phase) from - # previous turns so the API can maintain prefix-cache hits. - # OpenAI docs: "preserve and resend phase on all assistant - # messages — dropping it can degrade performance." - codex_message_items = msg.get("codex_message_items") + # Normal Codex turns preserve assistant messages for phase/cache + # continuity. Multi-agent turns use the same persisted carrier + # for the complete ordered output list because the beta requires + # every hosted item and agent-attributed function call on resume. replayed_message_items = 0 - if isinstance(codex_message_items, list): + if multi_agent_replay: + for raw_item in codex_message_items: + if not isinstance(raw_item, dict): + continue + replay_item = { + key: value + for key, value in raw_item.items() + if not str(key).startswith("_") + } + if replay_item: + items.append(replay_item) + replayed_message_items += 1 + elif isinstance(codex_message_items, list): for raw_item in codex_message_items: if not isinstance(raw_item, dict): continue @@ -486,7 +524,7 @@ def _chat_messages_to_responses_input( items.append({"role": "assistant", "content": ""}) tool_calls = msg.get("tool_calls") - if isinstance(tool_calls, list): + if isinstance(tool_calls, list) and not multi_agent_replay: for tc in tool_calls: if not isinstance(tc, dict): continue @@ -576,7 +614,70 @@ def _chat_messages_to_responses_input( # Input preflight / validation # --------------------------------------------------------------------------- -def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: +def _normalize_multi_agent_agent(value: Any, *, context: str) -> Optional[Dict[str, str]]: + if value is None: + return None + if not isinstance(value, dict): + raise ValueError(f"{context} agent must be an object.") + agent_name = value.get("agent_name") + if not isinstance(agent_name, str) or not agent_name.strip(): + raise ValueError(f"{context} agent.agent_name must be a non-empty string.") + return {"agent_name": agent_name.strip()} + + +def _normalize_hosted_multi_agent_item( + item: Dict[str, Any], *, index: int +) -> Dict[str, Any]: + item_type = item.get("type") + context = f"Codex Responses input[{index}] {item_type}" + + def required_string(name: str) -> str: + value = item.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{context} is missing {name}.") + return value.strip() + + normalized: Dict[str, Any] = { + "type": item_type, + "id": required_string("id"), + } + agent = _normalize_multi_agent_agent(item.get("agent"), context=context) + if agent is not None: + normalized["agent"] = agent + + if item_type in {"multi_agent_call", "multi_agent_call_output"}: + action = required_string("action") + if action not in _MULTI_AGENT_ACTIONS: + raise ValueError(f"{context} has unsupported action {action!r}.") + normalized["action"] = action + normalized["call_id"] = required_string("call_id") + if item_type == "multi_agent_call": + normalized["arguments"] = required_string("arguments") + else: + output = item.get("output") + if not isinstance(output, list) or not all( + isinstance(part, dict) for part in output + ): + raise ValueError(f"{context} output must be a list of objects.") + normalized["output"] = [dict(part) for part in output] + return normalized + + normalized["author"] = required_string("author") + normalized["recipient"] = required_string("recipient") + content = item.get("content") + if not isinstance(content, list) or not all( + isinstance(part, dict) for part in content + ): + raise ValueError(f"{context} content must be a list of objects.") + normalized["content"] = [dict(part) for part in content] + return normalized + + +def _preflight_codex_input_items( + raw_items: Any, + *, + allow_multi_agent: bool = False, +) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): raise ValueError("Codex Responses input must be a list of input items.") @@ -587,6 +688,16 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: raise ValueError(f"Codex Responses input[{idx}] must be an object.") item_type = item.get("type") + if item_type in _HOSTED_MULTI_AGENT_ITEM_TYPES: + if not allow_multi_agent: + raise ValueError( + f"Codex Responses input[{idx}] has unsupported item type {item_type!r}." + ) + normalized.append( + _normalize_hosted_multi_agent_item(item, index=idx) + ) + continue + if item_type == "function_call": call_id = item.get("call_id") name = item.get("name") @@ -602,14 +713,27 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: arguments = str(arguments) arguments = arguments.strip() or "{}" - normalized.append( - { - "type": "function_call", - "call_id": call_id.strip(), - "name": name.strip(), - "arguments": arguments, - } - ) + function_call: Dict[str, Any] = { + "type": "function_call", + "call_id": call_id.strip(), + "name": name.strip(), + "arguments": arguments, + } + if allow_multi_agent: + item_id = item.get("id") + if isinstance(item_id, str) and item_id.strip(): + function_call["id"] = item_id.strip() + agent = _normalize_multi_agent_agent( + item.get("agent"), + context=f"Codex Responses input[{idx}] function_call", + ) + if agent is not None: + function_call["agent"] = agent + for field in ("status", "caller", "namespace"): + value = item.get(field) + if value is not None: + function_call[field] = value + normalized.append(function_call) continue if item_type == "function_call_output": @@ -681,6 +805,19 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: reasoning_item["summary"] = summary else: reasoning_item["summary"] = [] + if allow_multi_agent: + if isinstance(item_id, str) and item_id.strip(): + reasoning_item["id"] = item_id.strip() + agent = _normalize_multi_agent_agent( + item.get("agent"), + context=f"Codex Responses input[{idx}] reasoning", + ) + if agent is not None: + reasoning_item["agent"] = agent + for field in ("content", "status"): + value = item.get(field) + if value is not None: + reasoning_item[field] = value normalized.append(reasoning_item) continue @@ -722,6 +859,13 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: phase = item.get("phase") if isinstance(phase, str) and phase.strip(): normalized_item["phase"] = phase.strip() + if allow_multi_agent: + agent = _normalize_multi_agent_agent( + item.get("agent"), + context=f"Codex Responses input[{idx}] message", + ) + if agent is not None: + normalized_item["agent"] = agent normalized.append(normalized_item) continue @@ -811,7 +955,20 @@ def _preflight_codex_api_kwargs( instructions = str(instructions) instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY - normalized_input = _preflight_codex_input_items(api_kwargs.get("input")) + extra_body_for_mode = api_kwargs.get("extra_body") + multi_agent_config = ( + extra_body_for_mode.get("multi_agent") + if isinstance(extra_body_for_mode, dict) + else None + ) + allow_multi_agent = bool( + isinstance(multi_agent_config, dict) + and multi_agent_config.get("enabled") is True + ) + normalized_input = _preflight_codex_input_items( + api_kwargs.get("input"), + allow_multi_agent=allow_multi_agent, + ) tools = api_kwargs.get("tools") normalized_tools = None @@ -875,7 +1032,7 @@ def _preflight_codex_api_kwargs( "model", "instructions", "input", "tools", "store", "reasoning", "include", "max_output_tokens", "temperature", "tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier", - "extra_headers", "extra_body", "timeout", + "extra_headers", "extra_query", "extra_body", "timeout", } normalized: Dict[str, Any] = { "model": model, @@ -932,6 +1089,21 @@ def _preflight_codex_api_kwargs( if normalized_headers: normalized["extra_headers"] = normalized_headers + extra_query = api_kwargs.get("extra_query") + if extra_query is not None: + if not isinstance(extra_query, dict): + raise ValueError("Codex Responses request 'extra_query' must be an object.") + normalized_query: Dict[str, Any] = {} + for key, value in extra_query.items(): + if not isinstance(key, str) or not key.strip(): + raise ValueError( + "Codex Responses request 'extra_query' keys must be non-empty strings." + ) + if value is not None: + normalized_query[key.strip()] = value + if normalized_query: + normalized["extra_query"] = normalized_query + extra_body = api_kwargs.get("extra_body") if extra_body is not None: if not isinstance(extra_body, dict): @@ -990,16 +1162,16 @@ def _preflight_codex_api_kwargs( def _extract_responses_message_text(item: Any) -> str: """Extract assistant text from a Responses message output item.""" - content = getattr(item, "content", None) + content = _responses_field(item, "content") if not isinstance(content, list): return "" chunks: List[str] = [] for part in content: - ptype = getattr(part, "type", None) + ptype = _responses_field(part, "type") if ptype not in {"output_text", "text"}: continue - text = getattr(part, "text", None) + text = _responses_field(part, "text") if isinstance(text, str) and text: chunks.append(text) return "".join(chunks).strip() @@ -1007,16 +1179,16 @@ def _extract_responses_message_text(item: Any) -> str: def _extract_responses_reasoning_text(item: Any) -> str: """Extract a compact reasoning text from a Responses reasoning item.""" - summary = getattr(item, "summary", None) + summary = _responses_field(item, "summary") if isinstance(summary, list): chunks: List[str] = [] for part in summary: - text = getattr(part, "text", None) + text = _responses_field(part, "text") if isinstance(text, str) and text: chunks.append(text) if chunks: return "\n".join(chunks).strip() - text = getattr(item, "text", None) + text = _responses_field(item, "text") if isinstance(text, str) and text: return text.strip() return "" @@ -1068,8 +1240,6 @@ def _format_responses_error(error_obj: Any, response_status: str) -> str: # Full response normalization # --------------------------------------------------------------------------- -_HOSTED_MULTI_AGENT_ITEM_TYPES = {"multi_agent_call", "agent_message"} - def _responses_field(value: Any, name: str, default: Any = None) -> Any: if isinstance(value, dict): @@ -1077,6 +1247,14 @@ def _responses_field(value: Any, name: str, default: Any = None) -> Any: return getattr(value, name, default) +def _responses_agent_name(value: Any) -> Optional[str]: + agent = _responses_field(value, "agent") + agent_name = _responses_field(agent, "agent_name") if agent is not None else None + if isinstance(agent_name, str) and agent_name.strip(): + return agent_name.strip() + return None + + def _responses_value_to_plain_data(value: Any) -> Any: """Convert SDK/namespace response values into JSON-safe plain data.""" if value is None or isinstance(value, (str, int, float, bool)): @@ -1153,10 +1331,29 @@ def _normalize_codex_response( error_msg = _format_responses_error(error_obj, response_status) raise RuntimeError(error_msg) + multi_agent_response = any( + str(_responses_field(item, "type", "") or "") + in _HOSTED_MULTI_AGENT_ITEM_TYPES + or _responses_agent_name(item) is not None + for item in output + ) + content_parts: List[str] = [] + root_final_parts: List[str] = [] + root_fallback_parts: List[str] = [] reasoning_parts: List[str] = [] reasoning_items_raw: List[Dict[str, Any]] = [] - message_items_raw: List[Dict[str, Any]] = [] + message_items_raw: List[Dict[str, Any]] = ( + [ + _responses_output_item_to_dict( + item, + str(_responses_field(item, "type", "") or ""), + ) + for item in output + ] + if multi_agent_response + else [] + ) tool_calls: List[Any] = [] has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"} saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"} @@ -1204,46 +1401,51 @@ def _normalize_codex_response( saw_streaming_or_item_incomplete = True if item_type in _HOSTED_MULTI_AGENT_ITEM_TYPES: - # OpenAI executes these delegation items server-side. Preserve them - # in the existing Codex message-item carrier for diagnostics and DB - # persistence, but never dispatch them as Hermes client tools. - message_items_raw.append( - _responses_output_item_to_dict(item, item_type) - ) + # OpenAI executes these delegation items server-side. They are + # already captured in the full ordered Multi-agent replay list and + # must never be dispatched as Hermes client tools. continue if item_type == "message": - item_phase = getattr(item, "phase", None) + item_phase = _responses_field(item, "phase") + item_agent_name = _responses_agent_name(item) + is_root_message = item_agent_name in {None, "/root"} normalized_phase = None is_commentary_phase = False if isinstance(item_phase, str): normalized_phase = item_phase.strip().lower() if normalized_phase in {"commentary", "analysis"}: - saw_commentary_phase = True is_commentary_phase = True + if not multi_agent_response or is_root_message: + saw_commentary_phase = True elif normalized_phase in {"final_answer", "final"}: - saw_final_answer_phase = True + if not multi_agent_response or is_root_message: + saw_final_answer_phase = True message_text = _extract_responses_message_text(item) - if message_text: - # Responses ``commentary``/``analysis`` phase text is mid-turn - # preamble/progress narration, never the turn's final answer - # (Codex CLI excludes it from last-message extraction; issues - # #24933 / #41293). Keep it out of assistant content so it - # can't be concatenated into — or leak as — the final response, - # but surface it through the reasoning channel so the CLI/ - # gateway display it like thinking text. The exact message - # item is still preserved below for replay/cache continuity. + if message_text and (not multi_agent_response or is_root_message): + # Multi-agent subagent messages are replay state, not user-facing + # output. Root commentary remains reasoning/progress text; root + # final_answer wins over phase-less root fallback messages. if is_commentary_phase: reasoning_parts.append(message_text) + elif multi_agent_response and normalized_phase in { + "final_answer", + "final", + }: + root_final_parts.append(message_text) + elif multi_agent_response: + root_fallback_parts.append(message_text) else: content_parts.append(message_text) + + if message_text and not multi_agent_response: raw_message_item: Dict[str, Any] = { "type": "message", "role": "assistant", "status": _normalize_responses_message_status(item_status), "content": [{"type": "output_text", "text": message_text}], } - item_id = getattr(item, "id", None) + item_id = _responses_field(item, "id") if isinstance(item_id, str) and item_id: raw_message_item["id"] = item_id if normalized_phase: @@ -1252,12 +1454,15 @@ def _normalize_codex_response( elif item_type == "reasoning": saw_reasoning_item = True reasoning_text = _extract_responses_reasoning_text(item) - if reasoning_text: + item_agent_name = _responses_agent_name(item) + if reasoning_text and ( + not multi_agent_response or item_agent_name in {None, "/root"} + ): reasoning_parts.append(reasoning_text) # Capture the full reasoning item for multi-turn continuity. # encrypted_content is an opaque blob the API needs back on # subsequent turns to maintain coherent reasoning chains. - encrypted = getattr(item, "encrypted_content", None) + encrypted = _responses_field(item, "encrypted_content") if isinstance(encrypted, str) and encrypted: raw_item = {"type": "reasoning", "encrypted_content": encrypted} # Stamp the issuer so future turns can detect when a @@ -1266,7 +1471,7 @@ def _normalize_codex_response( # cross-issuer guard. if issuer_kind: raw_item["_issuer_kind"] = issuer_kind - item_id = getattr(item, "id", None) + item_id = _responses_field(item, "id") if isinstance(item_id, str) and item_id.startswith("rs_tmp_"): logger.debug( "Skipping transient Codex reasoning item during normalization: %s", @@ -1276,24 +1481,25 @@ def _normalize_codex_response( if isinstance(item_id, str) and item_id: raw_item["id"] = item_id # Capture summary — required by the API when replaying reasoning items - summary = getattr(item, "summary", None) + summary = _responses_field(item, "summary") if isinstance(summary, list): raw_summary = [] for part in summary: - text = getattr(part, "text", None) + text = _responses_field(part, "text") if isinstance(text, str): raw_summary.append({"type": "summary_text", "text": text}) raw_item["summary"] = raw_summary - reasoning_items_raw.append(raw_item) + if not multi_agent_response: + reasoning_items_raw.append(raw_item) elif item_type == "function_call": if item_status in {"queued", "in_progress", "incomplete"}: continue - fn_name = getattr(item, "name", "") or "" - arguments = getattr(item, "arguments", "{}") + fn_name = _responses_field(item, "name", "") or "" + arguments = _responses_field(item, "arguments", "{}") if not isinstance(arguments, str): arguments = json.dumps(arguments, ensure_ascii=False) - raw_call_id = getattr(item, "call_id", None) - raw_item_id = getattr(item, "id", None) + raw_call_id = _responses_field(item, "call_id") + raw_item_id = _responses_field(item, "id") embedded_call_id, _ = _split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): @@ -1305,16 +1511,19 @@ def _normalize_codex_response( id=call_id, call_id=call_id, response_item_id=response_item_id, + agent=_responses_value_to_plain_data( + _responses_field(item, "agent") + ), type="function", function=SimpleNamespace(name=fn_name, arguments=arguments), )) elif item_type == "custom_tool_call": - fn_name = getattr(item, "name", "") or "" - arguments = getattr(item, "input", "{}") + fn_name = _responses_field(item, "name", "") or "" + arguments = _responses_field(item, "input", "{}") if not isinstance(arguments, str): arguments = json.dumps(arguments, ensure_ascii=False) - raw_call_id = getattr(item, "call_id", None) - raw_item_id = getattr(item, "id", None) + raw_call_id = _responses_field(item, "call_id") + raw_item_id = _responses_field(item, "id") embedded_call_id, _ = _split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): @@ -1326,13 +1535,21 @@ def _normalize_codex_response( id=call_id, call_id=call_id, response_item_id=response_item_id, + agent=_responses_value_to_plain_data( + _responses_field(item, "agent") + ), type="function", function=SimpleNamespace(name=fn_name, arguments=arguments), )) - final_text = "\n".join([p for p in content_parts if p]).strip() + if multi_agent_response: + visible_parts = root_final_parts or root_fallback_parts + final_text = "\n".join([part for part in visible_parts if part]).strip() + else: + final_text = "\n".join([part for part in content_parts if part]).strip() if ( not final_text + and not multi_agent_response and hasattr(response, "output_text") and not (saw_commentary_phase and not saw_final_answer_phase) ): diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index a398e8f35c52..a71f659dff3d 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -591,6 +591,20 @@ def _item_field(item: Any, name: str, default: Any = None) -> Any: return value if value is not None else default +def _event_agent_name(event: Any, item: Any = None) -> str | None: + """Return agent attribution from an SSE event or its nested item.""" + for source in (event, item): + if source is None: + continue + agent = _event_field(source, "agent") + if agent is None: + continue + agent_name = _item_field(agent, "agent_name") + if isinstance(agent_name, str) and agent_name.strip(): + return agent_name.strip() + return None + + def _raise_stream_error(event: Any) -> None: """Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame. @@ -654,6 +668,8 @@ def _consume_codex_event_stream( has_tool_calls = False first_delta_fired = False active_message_phase: str | None = None + active_message_agent: str | None = None + message_meta_by_output_index: dict[Any, tuple[str | None, str | None]] = {} terminal_status: str = "completed" terminal_usage: Any = None terminal_response_id: str = None @@ -698,15 +714,35 @@ def _consume_codex_event_stream( if item_type == "message": phase = _item_field(item, "phase", None) active_message_phase = phase.strip().lower() if isinstance(phase, str) else None + active_message_agent = _event_agent_name(event, item) + output_index = _event_field(event, "output_index") + if output_index is not None: + message_meta_by_output_index[output_index] = ( + active_message_phase, + active_message_agent, + ) else: active_message_phase = None + active_message_agent = None if "function_call" in str(item_type): has_tool_calls = True continue if "output_text.delta" in event_type or event_type == "response.output_text.delta": delta_text = _event_field(event, "delta", "") - is_commentary_delta = active_message_phase in {"commentary", "analysis"} + output_index = _event_field(event, "output_index") + message_phase, message_agent = message_meta_by_output_index.get( + output_index, + (active_message_phase, active_message_agent), + ) + event_agent = _event_agent_name(event) + if event_agent is not None: + message_agent = event_agent + if message_agent not in {None, "/root"}: + # Subagent text is replay/diagnostic state. Only /root renders + # user-facing output. + continue + is_commentary_delta = message_phase in {"commentary", "analysis"} if delta_text and is_commentary_delta: # Commentary streams through the reasoning channel, not the # visible answer stream (and stays out of output_text). diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 334f4d34ebbc..4930827da334 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -48,7 +48,9 @@ def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) _OPENAI_MULTI_AGENT_MODEL_EFFORTS = { "gpt-5.6": "max", - "gpt-5.3-codex": "xhigh", + "gpt-5.6-sol": "max", + "gpt-5.6-terra": "max", + "gpt-5.6-luna": "max", } _OPENAI_MULTI_AGENT_BETA = "responses_multi_agent=v1" @@ -58,10 +60,7 @@ def _openai_multi_agent_effort(model: str) -> Optional[str]: normalized = str(model or "").strip().lower() if normalized.startswith("openai/"): normalized = normalized.split("/", 1)[1] - for prefix, effort in _OPENAI_MULTI_AGENT_MODEL_EFFORTS.items(): - if normalized == prefix or normalized.startswith(f"{prefix}-"): - return effort - return None + return _OPENAI_MULTI_AGENT_MODEL_EFFORTS.get(normalized) def _merge_openai_beta_header(existing: Any, required: str) -> str: @@ -210,7 +209,7 @@ def build_kwargs( if ultra_wire_effort is None: raise ValueError( f"Model {model!r} does not support OpenAI Multi-agent mode. " - "Use a gpt-5.6 or gpt-5.3-codex model." + "Use gpt-5.6, gpt-5.6-sol, gpt-5.6-terra, or gpt-5.6-luna." ) # `ultra` is a product mode, not a valid Responses reasoning effort. # The direct API contract uses hosted Multi-agent plus the model's @@ -357,28 +356,37 @@ def build_kwargs( # stale/custom override cannot accidentally send literal `ultra`, # re-enable the unsupported reasoning summary, or disable delegation. kwargs["reasoning"] = {"effort": reasoning_effort} - # OpenAI recommends a higher hosted-tool budget for Multi-agent; - # preserve an explicit caller override when one is supplied. - kwargs.setdefault("max_tool_calls", 100) + # Multi-agent rejects max_tool_calls, including caller overrides. + kwargs.pop("max_tool_calls", None) existing_extra_body = kwargs.get("extra_body") merged_extra_body: Dict[str, Any] = {} if isinstance(existing_extra_body, dict): merged_extra_body.update(existing_extra_body) + elif existing_extra_body is not None: + raise ValueError("OpenAI Multi-agent extra_body must be an object.") existing_multi_agent = merged_extra_body.get("multi_agent") multi_agent: Dict[str, Any] = {} if isinstance(existing_multi_agent, dict): multi_agent.update(existing_multi_agent) elif existing_multi_agent is not None: raise ValueError("OpenAI Multi-agent configuration must be an object.") + unknown_multi_agent_fields = sorted( + set(multi_agent) - {"enabled", "max_concurrent_subagents"} + ) + if unknown_multi_agent_fields: + raise ValueError( + "OpenAI Multi-agent configuration has unsupported field(s): " + + ", ".join(unknown_multi_agent_fields) + ) max_subagents = multi_agent.get("max_concurrent_subagents", 3) if ( not isinstance(max_subagents, int) or isinstance(max_subagents, bool) - or not 1 <= max_subagents <= 4 + or max_subagents <= 0 ): raise ValueError( - "OpenAI Multi-agent max_concurrent_subagents must be an integer from 1 to 4." + "OpenAI Multi-agent max_concurrent_subagents must be a positive integer." ) multi_agent["enabled"] = True multi_agent["max_concurrent_subagents"] = max_subagents @@ -395,6 +403,8 @@ def build_kwargs( if key and value is not None } ) + elif existing_extra_headers is not None: + raise ValueError("OpenAI Multi-agent extra_headers must be an object.") beta_key = next( (key for key in merged_extra_headers if key.lower() == "openai-beta"), "OpenAI-Beta", @@ -405,6 +415,18 @@ def build_kwargs( ) kwargs["extra_headers"] = merged_extra_headers + # openai>=2.45's beta Responses SDK posts to /responses?beta=true. + # Hermes pins openai==2.24, so mirror that route through the stable + # client's extra_query escape hatch while preserving caller params. + existing_extra_query = kwargs.get("extra_query") + merged_extra_query: Dict[str, Any] = {} + if isinstance(existing_extra_query, dict): + merged_extra_query.update(existing_extra_query) + elif existing_extra_query is not None: + raise ValueError("OpenAI Multi-agent extra_query must be an object.") + merged_extra_query["beta"] = "true" + kwargs["extra_query"] = merged_extra_query + # xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not # supported: service_tier") — hit when ``/fast`` priority-processing # mode lingers from a prior model in the same session, or when a diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index 775f31932c29..8a4f935e3f9d 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -1,8 +1,10 @@ +import json from types import SimpleNamespace import pytest from agent.codex_responses_adapter import ( + _chat_messages_to_responses_input, _format_responses_error, _normalize_codex_response, _preflight_codex_api_kwargs, @@ -119,29 +121,55 @@ def test_normalize_codex_response_ignores_in_progress_server_side_tool_calls(): def test_normalize_codex_response_preserves_hosted_multi_agent_items(): """Hosted delegation telemetry is provider output, not a Hermes tool call.""" + output_items = [ + { + "type": "multi_agent_call", + "id": "ma_1", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_1", + "agent": {"agent_name": "/root"}, + }, + { + "type": "multi_agent_call_output", + "id": "mao_1", + "action": "spawn_agent", + "call_id": "ma_call_1", + "output": [{"type": "output_text", "text": "spawned"}], + "agent": {"agent_name": "/root"}, + }, + { + "type": "agent_message", + "id": "am_1", + "author": "/root/researcher", + "recipient": "/root", + "content": [ + {"type": "encrypted_content", "encrypted_content": "opaque"} + ], + "agent": {"agent_name": "/root/researcher"}, + }, + { + "type": "message", + "id": "msg_subagent", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root/researcher"}, + "content": [{"type": "output_text", "text": "subagent evidence"}], + }, + { + "type": "message", + "id": "msg_root", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root"}, + "content": [{"type": "output_text", "text": "final answer"}], + }, + ] response = SimpleNamespace( status="completed", - output=[ - { - "type": "multi_agent_call", - "id": "ma_1", - "status": "in_progress", - "action": {"type": "spawn", "agent_name": "researcher"}, - }, - SimpleNamespace( - type="agent_message", - id="am_1", - status="completed", - agent_name="researcher", - content=[SimpleNamespace(type="output_text", text="evidence")], - ), - SimpleNamespace( - type="message", - role="assistant", - status="completed", - content=[SimpleNamespace(type="output_text", text="final answer")], - ), - ], + output=output_items, ) assistant_message, finish_reason = _normalize_codex_response(response) @@ -149,8 +177,14 @@ def test_normalize_codex_response_preserves_hosted_multi_agent_items(): assert finish_reason == "stop" assert assistant_message.content == "final answer" assert assistant_message.tool_calls == [] + assert assistant_message.codex_message_items == output_items item_types = {item["type"] for item in assistant_message.codex_message_items} - assert {"multi_agent_call", "agent_message", "message"}.issubset(item_types) + assert { + "multi_agent_call", + "multi_agent_call_output", + "agent_message", + "message", + }.issubset(item_types) def test_hosted_multi_agent_items_preserve_function_tool_continuation(): @@ -160,17 +194,20 @@ def test_hosted_multi_agent_items_preserve_function_tool_continuation(): { "type": "multi_agent_call", "id": "ma_1", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_1", + "agent": {"agent_name": "/root"}, + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", "status": "completed", - "action": {"type": "spawn", "agent_name": "researcher"}, + "name": "lookup", + "arguments": '{"query":"evidence"}', + "agent": {"agent_name": "/root/researcher"}, }, - SimpleNamespace( - type="function_call", - id="fc_1", - call_id="call_1", - status="completed", - name="lookup", - arguments='{"query":"evidence"}', - ), ], ) @@ -179,10 +216,192 @@ def test_hosted_multi_agent_items_preserve_function_tool_continuation(): assert finish_reason == "tool_calls" assert len(assistant_message.tool_calls) == 1 assert assistant_message.tool_calls[0].function.name == "lookup" - assert any( - item["type"] == "multi_agent_call" - for item in assistant_message.codex_message_items + assert assistant_message.tool_calls[0].agent == { + "agent_name": "/root/researcher" + } + assert [ + item["type"] for item in assistant_message.codex_message_items + ] == ["multi_agent_call", "function_call"] + + +def test_multi_agent_items_replay_in_order_before_function_outputs(): + output_items = [ + { + "type": "multi_agent_call", + "id": "ma_1", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_1", + "agent": {"agent_name": "/root"}, + }, + { + "type": "multi_agent_call_output", + "id": "mao_1", + "action": "spawn_agent", + "call_id": "ma_call_1", + "output": [{"type": "output_text", "text": "spawned"}], + "agent": {"agent_name": "/root"}, + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "status": "completed", + "name": "lookup", + "arguments": '{"query":"evidence"}', + "agent": {"agent_name": "/root/researcher"}, + }, + ] + messages = [ + { + "role": "assistant", + "content": "", + "codex_message_items": output_items, + "tool_calls": [ + { + "id": "call_1", + "call_id": "call_1", + "response_item_id": "fc_1", + "type": "function", + "function": { + "name": "lookup", + "arguments": '{"query":"evidence"}', + }, + "agent": {"agent_name": "/root/researcher"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ] + + items = _chat_messages_to_responses_input(messages) + + assert items[:3] == output_items + assert sum(item.get("type") == "function_call" for item in items) == 1 + assert items[3] == { + "type": "function_call_output", + "call_id": "call_1", + "output": "result", + } + + +def test_preflight_preserves_beta_query_and_hosted_replay_items(): + hosted = { + "type": "multi_agent_call", + "id": "ma_1", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_1", + "agent": {"agent_name": "/root"}, + } + kwargs = { + "model": "gpt-5.6-sol", + "instructions": "You are helpful.", + "input": [hosted], + "store": False, + "reasoning": {"effort": "max"}, + "extra_body": { + "multi_agent": { + "enabled": True, + "max_concurrent_subagents": 3, + } + }, + "extra_headers": {"OpenAI-Beta": "responses_multi_agent=v1"}, + "extra_query": {"beta": "true"}, + } + + normalized = _preflight_codex_api_kwargs(kwargs, allow_stream=True) + + assert normalized["input"] == [hosted] + assert normalized["extra_query"] == {"beta": "true"} + + +def test_pinned_openai_sdk_serializes_hosted_multi_agent_replay_items(): + import httpx + from openai import BadRequestError, OpenAI + + hosted_items = [ + { + "type": "multi_agent_call", + "id": "ma_1", + "action": "spawn_agent", + "arguments": "{}", + "call_id": "ma_call_1", + "agent": {"agent_name": "/root"}, + }, + { + "type": "multi_agent_call_output", + "id": "mao_1", + "action": "spawn_agent", + "call_id": "ma_call_1", + "output": [{"type": "output_text", "text": "spawned"}], + "agent": {"agent_name": "/root"}, + }, + { + "type": "agent_message", + "id": "am_1", + "author": "/root/researcher", + "recipient": "/root", + "content": [ + {"type": "encrypted_content", "encrypted_content": "opaque"} + ], + "agent": {"agent_name": "/root/researcher"}, + }, + ] + captured = {} + + def handler(request): + captured["request"] = request + return httpx.Response( + 400, + json={ + "error": { + "message": "stop after capture", + "type": "invalid_request_error", + "param": None, + "code": "capture", + } + }, + ) + + client = OpenAI( + **{"api" + "_key": "test-token"}, + base_url="https://api.openai.com/v1", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), ) + kwargs = _preflight_codex_api_kwargs( + { + "model": "gpt-5.6-sol", + "instructions": "You are helpful.", + "input": hosted_items, + "store": False, + "reasoning": {"effort": "max"}, + "extra_body": { + "multi_agent": { + "enabled": True, + "max_concurrent_subagents": 3, + } + }, + "extra_headers": {"OpenAI-Beta": "responses_multi_agent=v1"}, + "extra_query": {"beta": "true"}, + } + ) + + try: + with pytest.raises(BadRequestError, match="stop after capture"): + client.responses.create(**kwargs) + finally: + client.close() + + request = captured["request"] + payload = json.loads(request.content) + assert str(request.url).endswith("/responses?beta=true") + assert payload["input"] == hosted_items + assert [item["type"] for item in payload["input"]] == [ + "multi_agent_call", + "multi_agent_call_output", + "agent_message", + ] def test_normalize_codex_response_in_progress_message_still_incomplete(): diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 8e3d6b6effc6..a0ec318fb26c 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -86,24 +86,80 @@ def test_ultra_builds_openai_multi_agent_beta_request(self, transport): ) assert kw["reasoning"] == {"effort": "max"} - assert kw["max_tool_calls"] == 100 + assert "max_tool_calls" not in kw + assert kw["extra_query"] == {"beta": "true"} assert kw["extra_body"]["multi_agent"] == { "enabled": True, "max_concurrent_subagents": 3, } assert "responses_multi_agent=v1" in kw["extra_headers"]["OpenAI-Beta"] - def test_ultra_uses_strongest_effort_supported_by_gpt53_codex(self, transport): - kw = transport.build_kwargs( - model="gpt-5.3-codex-spark", + def test_ultra_serializes_beta_contract_with_pinned_openai_sdk( + self, transport + ): + import httpx + from openai import BadRequestError, OpenAI + + from agent.codex_responses_adapter import _preflight_codex_api_kwargs + + captured = {} + + def handler(request): + captured["request"] = request + return httpx.Response( + 400, + json={ + "error": { + "message": "stop after capture", + "type": "invalid_request_error", + "param": None, + "code": "capture", + } + }, + ) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = OpenAI( + **{"api" + "_key": "test-token"}, + base_url="https://api.openai.com/v1", + http_client=http_client, + ) + kwargs = transport.build_kwargs( + model="gpt-5.6-sol", messages=[{"role": "user", "content": "Investigate this."}], tools=[], reasoning_config={"enabled": True, "effort": "ultra"}, is_openai_api=True, ) + kwargs = _preflight_codex_api_kwargs(kwargs) + + try: + with pytest.raises(BadRequestError, match="stop after capture"): + client.responses.create(**kwargs) + finally: + client.close() + + request = captured["request"] + payload = json.loads(request.content) + assert request.url.path == "/v1/responses" + assert request.url.params["beta"] == "true" + assert request.headers["OpenAI-Beta"] == "responses_multi_agent=v1" + assert payload["reasoning"] == {"effort": "max"} + assert payload["multi_agent"] == { + "enabled": True, + "max_concurrent_subagents": 3, + } + assert "max_tool_calls" not in payload - assert kw["reasoning"] == {"effort": "xhigh"} - assert kw["extra_body"]["multi_agent"]["enabled"] is True + def test_ultra_rejects_gpt53_codex_on_direct_openai_api(self, transport): + with pytest.raises(ValueError, match="does not support OpenAI Multi-agent"): + transport.build_kwargs( + model="gpt-5.3-codex-spark", + messages=[{"role": "user", "content": "Investigate this."}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + ) def test_ultra_preserves_caller_beta_headers_and_concurrency(self, transport): messages = [{"role": "user", "content": "Investigate this."}] @@ -115,24 +171,65 @@ def test_ultra_preserves_caller_beta_headers_and_concurrency(self, transport): is_openai_api=True, request_overrides={ "reasoning": {"effort": "ultra", "summary": "auto"}, + "max_tool_calls": 100, "extra_headers": {"OpenAI-Beta": "other_beta=v1", "X-Test": "1"}, + "extra_query": {"other": "1", "beta": "false"}, "extra_body": { "other_field": 42, - "multi_agent": {"max_concurrent_subagents": 4}, + "multi_agent": {"max_concurrent_subagents": 12}, }, }, ) assert kw["extra_body"]["other_field"] == 42 assert kw["reasoning"] == {"effort": "max"} + assert "max_tool_calls" not in kw + assert kw["extra_query"] == {"other": "1", "beta": "true"} assert kw["extra_body"]["multi_agent"] == { "enabled": True, - "max_concurrent_subagents": 4, + "max_concurrent_subagents": 12, } assert kw["extra_headers"]["X-Test"] == "1" assert "other_beta=v1" in kw["extra_headers"]["OpenAI-Beta"] assert "responses_multi_agent=v1" in kw["extra_headers"]["OpenAI-Beta"] + @pytest.mark.parametrize("invalid_value", [0, -1, True, 1.5, "3"]) + def test_ultra_rejects_non_positive_integer_concurrency( + self, transport, invalid_value + ): + with pytest.raises(ValueError, match="positive integer"): + transport.build_kwargs( + model="gpt-5.6-luna", + messages=[{"role": "user", "content": "Investigate this."}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + request_overrides={ + "extra_body": { + "multi_agent": { + "max_concurrent_subagents": invalid_value, + } + } + }, + ) + + def test_ultra_rejects_unknown_multi_agent_override_fields(self, transport): + with pytest.raises(ValueError, match="unsupported field"): + transport.build_kwargs( + model="gpt-5.6-sol", + messages=[{"role": "user", "content": "Investigate this."}], + tools=[], + reasoning_config={"enabled": True, "effort": "ultra"}, + is_openai_api=True, + request_overrides={ + "extra_body": { + "multi_agent": { + "mystery_limit": 99, + } + } + }, + ) + def test_ultra_normal_codex_backend_fails_closed(self, transport): with pytest.raises(ValueError, match="codex-runtime app_server"): transport.build_kwargs( diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 77c53e6a4212..9cf900882fa7 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -446,6 +446,8 @@ def test_ultra_direct_openai_agent_builds_multi_agent_request(monkeypatch): kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) assert kwargs["reasoning"] == {"effort": "max"} + assert "max_tool_calls" not in kwargs + assert kwargs["extra_query"] == {"beta": "true"} assert kwargs["extra_body"]["multi_agent"]["enabled"] is True assert "responses_multi_agent=v1" in kwargs["extra_headers"]["OpenAI-Beta"] @@ -756,6 +758,78 @@ def test_consume_codex_stream_keeps_final_answer_phase_deltas(monkeypatch): assert response.output_text == "visible answer" +def test_consume_codex_stream_filters_interleaved_subagent_text_by_output_index(): + from agent.codex_runtime import _consume_codex_event_stream + + subagent_item = { + "type": "message", + "id": "msg_subagent", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root/researcher"}, + "content": [{"type": "output_text", "text": "private evidence"}], + } + root_item = { + "type": "message", + "id": "msg_root", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root"}, + "content": [{"type": "output_text", "text": "public answer"}], + } + streamed = [] + + response = _consume_codex_event_stream( + _FakeCreateStream( + [ + {"type": "response.created"}, + { + "type": "response.output_item.added", + "output_index": 0, + "item": subagent_item, + }, + { + "type": "response.output_item.added", + "output_index": 1, + "item": root_item, + }, + { + "type": "response.output_text.delta", + "output_index": 0, + "delta": "private evidence", + }, + { + "type": "response.output_text.delta", + "output_index": 1, + "delta": "public answer", + }, + { + "type": "response.output_item.done", + "output_index": 0, + "item": subagent_item, + }, + { + "type": "response.output_item.done", + "output_index": 1, + "item": root_item, + }, + { + "type": "response.completed", + "response": {"status": "completed"}, + }, + ] + ), + model="gpt-5.6-sol", + on_text_delta=streamed.append, + ) + + assert streamed == ["public answer"] + assert response.output_text == "public answer" + assert response.output == [subagent_item, root_item] + + def test_run_codex_stream_surfaces_failed_status_in_final_response(monkeypatch): """A ``response.failed`` terminal event is reflected on the returned object.""" agent = _build_agent(monkeypatch) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 3ad83b932edb..982f8767407b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1276,7 +1276,7 @@ Control how much "thinking" the model does before responding: ```yaml agent: - reasoning_effort: "" # empty = medium. Standard: none, minimal, low, medium, high, xhigh + reasoning_effort: "" # empty = medium. Standard: none, minimal, low, medium, high, xhigh, max # ultra = OpenAI hosted Multi-agent mode (see below) ``` @@ -1308,12 +1308,13 @@ You can also change the reasoning effort at runtime with the `/reasoning` comman `/reasoning ultra` enables OpenAI's hosted Multi-agent mode instead of sending a literal `reasoning.effort: ultra` value. Hermes supports it through two routes: -- **Direct OpenAI API:** for supported `gpt-5.6-*` and `gpt-5.3-codex-*` - models, Hermes - sends the model's strongest supported reasoning effort, enables `multi_agent` - with three concurrent hosted subagents, and adds the - `responses_multi_agent=v1` beta header. The hosted tool-call budget defaults - to OpenAI's recommended `100` and can be lowered with `request_overrides`. +- **Direct OpenAI API:** for `gpt-5.6`, `gpt-5.6-sol`, + `gpt-5.6-terra`, and `gpt-5.6-luna`, Hermes sends `reasoning.effort: max`, + enables `multi_agent` with the recommended default of three concurrent hosted + subagents, and adds the `responses_multi_agent=v1` beta header plus the beta + query parameter. A positive `max_concurrent_subagents` override is preserved; + OpenAI documents no fixed upper bound. Hermes removes `reasoning.summary` and + `max_tool_calls` because Multi-agent does not support either field. - **ChatGPT/Codex subscription:** enable the optional [Codex app-server runtime](features/codex-app-server-runtime.md), then select Ultra. Hermes verifies that the live Codex `model/list` response advertises diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index e24c730e66e9..dd4cf22664f4 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -195,8 +195,9 @@ Hermes queries the live `model/list` capability catalog before each model's first Ultra turn and forwards both the selected model and `effort: ultra` to `turn/start`. If the model is absent from the catalog or does not advertise Ultra, the turn fails closed with the advertised levels instead of silently -using a lower setting. The selected model is re-evaluated on every Ultra turn, -without restarting the app-server session. +using a lower setting. Capabilities are cached per model for the lifetime of the +app-server subprocess; switching models consults that model's cached or freshly +queried entry without restarting the session. This is **Codex-hosted delegation**, not Hermes' `delegate_task` tool. The latter still requires Hermes' default runtime, as described in From fc8bd16df77884c9f72d432d0efa479f1c7aa151 Mon Sep 17 00:00:00 2001 From: Jaime Chieng Date: Fri, 10 Jul 2026 13:11:40 -0400 Subject: [PATCH 3/3] fix(agent): harden Ultra session state handling --- agent/anthropic_adapter.py | 46 +------ agent/codex_responses_adapter.py | 37 +++--- agent/message_content.py | 62 ++++++++- agent/transports/codex_app_server_session.py | 123 ++++++++++-------- tests/agent/test_codex_app_server_persist.py | 3 + tests/agent/test_codex_responses_adapter.py | 70 ++++++++++ .../test_codex_app_server_session.py | 108 ++++++++++++++- 7 files changed, 325 insertions(+), 124 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 215868306348..328f504eb904 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -21,6 +21,7 @@ from pathlib import Path from urllib.parse import urlparse +from agent.message_content import to_plain_data as _message_value_to_plain_data from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple from utils import base_url_host_matches, normalize_proxy_env_vars @@ -1753,49 +1754,8 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any: - """Recursively convert SDK objects to plain Python data structures. - - Guards against circular references (``_path`` tracks ``id()`` of objects - on the *current* recursion path) and runaway depth (capped at 20 levels). - Uses path-based tracking so shared (but non-cyclic) objects referenced by - multiple siblings are converted correctly rather than being stringified. - """ - _MAX_DEPTH = 20 - if _depth > _MAX_DEPTH: - return str(value) - - if _path is None: - _path = set() - - obj_id = id(value) - if obj_id in _path: - return str(value) - - if hasattr(value, "model_dump"): - _path.add(obj_id) - result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path) - _path.discard(obj_id) - return result - if isinstance(value, dict): - _path.add(obj_id) - result = {k: _to_plain_data(v, _depth=_depth + 1, _path=_path) for k, v in value.items()} - _path.discard(obj_id) - return result - if isinstance(value, (list, tuple)): - _path.add(obj_id) - result = [_to_plain_data(v, _depth=_depth + 1, _path=_path) for v in value] - _path.discard(obj_id) - return result - if hasattr(value, "__dict__"): - _path.add(obj_id) - result = { - k: _to_plain_data(v, _depth=_depth + 1, _path=_path) - for k, v in vars(value).items() - if not k.startswith("_") - } - _path.discard(obj_id) - return result - return value + """Compatibility wrapper around the provider-neutral SDK converter.""" + return _message_value_to_plain_data(value, _depth=_depth, _path=_path) def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 8cb7667f30b6..fb3b9025a27e 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -18,6 +18,7 @@ from types import SimpleNamespace from typing import Any, Dict, List, Optional +from agent.message_content import get_message_field, to_plain_data from agent.prompt_builder import DEFAULT_AGENT_IDENTITY logger = logging.getLogger(__name__) @@ -1242,9 +1243,7 @@ def _format_responses_error(error_obj: Any, response_status: str) -> str: def _responses_field(value: Any, name: str, default: Any = None) -> Any: - if isinstance(value, dict): - return value.get(name, default) - return getattr(value, name, default) + return get_message_field(value, name, default) def _responses_agent_name(value: Any) -> Optional[str]: @@ -1257,28 +1256,24 @@ def _responses_agent_name(value: Any) -> Optional[str]: def _responses_value_to_plain_data(value: Any) -> Any: """Convert SDK/namespace response values into JSON-safe plain data.""" - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, dict): - return { - str(key): _responses_value_to_plain_data(item) - for key, item in value.items() - if not str(key).startswith("_") and item is not None - } - if isinstance(value, (list, tuple)): - return [_responses_value_to_plain_data(item) for item in value] model_dump = getattr(value, "model_dump", None) if callable(model_dump): try: - return _responses_value_to_plain_data( - model_dump(mode="json", exclude_none=True) - ) + value = model_dump(mode="json", exclude_none=True) except TypeError: - return _responses_value_to_plain_data(model_dump()) - raw_attrs = getattr(value, "__dict__", None) - if isinstance(raw_attrs, dict): - return _responses_value_to_plain_data(raw_attrs) - return str(value) + value = model_dump() + plain = to_plain_data(value) + if isinstance(plain, dict): + return { + str(key): _responses_value_to_plain_data(item) + for key, item in plain.items() + if not str(key).startswith("_") and item is not None + } + if isinstance(plain, list): + return [_responses_value_to_plain_data(item) for item in plain] + if plain is None or isinstance(plain, (str, int, float, bool)): + return plain + return str(plain) def _responses_output_item_to_dict(item: Any, item_type: str) -> Dict[str, Any]: diff --git a/agent/message_content.py b/agent/message_content.py index c42bf408550e..bedc83e3ce50 100644 --- a/agent/message_content.py +++ b/agent/message_content.py @@ -8,10 +8,68 @@ _TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text") +def get_message_field(value: Any, key: str, default: Any = None) -> Any: + """Read a field from mapping-backed or attribute-backed SDK values.""" + if isinstance(value, Mapping): + return value.get(key, default) + return getattr(value, key, default) + + def _field(value: Any, key: str) -> Any: + return get_message_field(value, key) + + +def to_plain_data( + value: Any, + *, + _depth: int = 0, + _path: set[int] | None = None, +) -> Any: + """Recursively convert provider SDK objects to cycle-safe plain data.""" + if _depth > 20: + return str(value) + if _path is None: + _path = set() + + obj_id = id(value) + if obj_id in _path: + return str(value) + + if hasattr(value, "model_dump"): + _path.add(obj_id) + result = to_plain_data( + value.model_dump(), + _depth=_depth + 1, + _path=_path, + ) + _path.discard(obj_id) + return result if isinstance(value, Mapping): - return value.get(key) - return getattr(value, key, None) + _path.add(obj_id) + result = { + str(key): to_plain_data(item, _depth=_depth + 1, _path=_path) + for key, item in value.items() + } + _path.discard(obj_id) + return result + if isinstance(value, (list, tuple)): + _path.add(obj_id) + result = [ + to_plain_data(item, _depth=_depth + 1, _path=_path) + for item in value + ] + _path.discard(obj_id) + return result + if hasattr(value, "__dict__"): + _path.add(obj_id) + result = { + key: to_plain_data(item, _depth=_depth + 1, _path=_path) + for key, item in vars(value).items() + if not key.startswith("_") + } + _path.discard(obj_id) + return result + return value def _text_from_part(part: Any) -> str: diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 149b931ba323..43818dd78174 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -233,9 +233,10 @@ def __init__( # approval params don't carry the changeset, so we cache here # to surface a real summary in the approval prompt (quirk #4). self._pending_file_changes: dict[str, str] = {} - # Live model/list capabilities are queried only for Ultra and cached - # per model for the lifetime of this app-server subprocess. + # Live model/list capabilities are queried only for Ultra. One bounded + # catalog snapshot is cached for the lifetime of this subprocess. self._reasoning_capabilities: dict[str, set[str]] = {} + self._reasoning_catalog_scanned = False self._closed = False # ---------- lifecycle ---------- @@ -374,63 +375,74 @@ def _supported_reasoning_efforts(self, model: str) -> set[str]: code=-32602, message="Ultra requires an explicit Codex model selection.", ) - cached = self._reasoning_capabilities.get(normalized_model) + cache_key = normalized_model.lower() + cached = self._reasoning_capabilities.get(cache_key) if cached is not None: return cached assert self._client is not None - cursor: Optional[str] = None - for _page in range(10): - params: dict[str, Any] = {"limit": 100} - if cursor: - params["cursor"] = cursor - payload = self._client.request("model/list", params, timeout=15) - entries = payload.get("data") or payload.get("models") or [] - if not isinstance(entries, list): - entries = [] - for entry in entries: - if not isinstance(entry, dict): - continue - entry_model = ( - entry.get("model") - or entry.get("id") - or entry.get("slug") - or "" - ) - if str(entry_model).strip().lower() != normalized_model.lower(): - continue - raw_levels = ( - entry.get("supportedReasoningEfforts") - or entry.get("supported_reasoning_efforts") - or entry.get("supportedReasoningLevels") - or entry.get("supported_reasoning_levels") - or [] - ) - levels: set[str] = set() - if isinstance(raw_levels, list): - for raw_level in raw_levels: - if isinstance(raw_level, str): - value = raw_level - elif isinstance(raw_level, dict): - value = ( - raw_level.get("reasoningEffort") - or raw_level.get("reasoning_effort") - or raw_level.get("effort") - or raw_level.get("value") - or raw_level.get("level") - ) - else: - value = None - if value: - levels.add(str(value).strip().lower()) - self._reasoning_capabilities[normalized_model] = levels - return levels - - cursor_value = payload.get("nextCursor") or payload.get("next_cursor") - cursor = str(cursor_value).strip() if cursor_value else None - if not cursor: - break + if not self._reasoning_catalog_scanned: + cursor: Optional[str] = None + seen_cursors: set[str] = set() + for _page in range(10): + params: dict[str, Any] = {"limit": 100} + if cursor: + params["cursor"] = cursor + payload = self._client.request("model/list", params, timeout=15) + entries = payload.get("data") or payload.get("models") or [] + if not isinstance(entries, list): + entries = [] + for entry in entries: + if not isinstance(entry, dict): + continue + entry_model = ( + entry.get("model") + or entry.get("id") + or entry.get("slug") + or "" + ) + entry_key = str(entry_model).strip().lower() + if not entry_key: + continue + raw_levels = ( + entry.get("supportedReasoningEfforts") + or entry.get("supported_reasoning_efforts") + or entry.get("supportedReasoningLevels") + or entry.get("supported_reasoning_levels") + or [] + ) + levels: set[str] = set() + if isinstance(raw_levels, list): + for raw_level in raw_levels: + if isinstance(raw_level, str): + value = raw_level + elif isinstance(raw_level, dict): + value = ( + raw_level.get("reasoningEffort") + or raw_level.get("reasoning_effort") + or raw_level.get("effort") + or raw_level.get("value") + or raw_level.get("level") + ) + else: + value = None + if value: + levels.add(str(value).strip().lower()) + self._reasoning_capabilities.setdefault(entry_key, set()).update( + levels + ) + + cursor_value = payload.get("nextCursor") or payload.get("next_cursor") + next_cursor = str(cursor_value).strip() if cursor_value else None + if not next_cursor or next_cursor in seen_cursors: + break + seen_cursors.add(next_cursor) + cursor = next_cursor + self._reasoning_catalog_scanned = True + cached = self._reasoning_capabilities.get(cache_key) + if cached is not None: + return cached raise CodexAppServerError( code=-32602, message=( @@ -510,8 +522,9 @@ def run_turn( "threadId": self._thread_id, "input": [{"type": "text", "text": user_input_text}], } - if selected_effort == "ultra": + if selected_model: turn_params["model"] = selected_model + if selected_effort: turn_params["effort"] = selected_effort ts = self._client.request( diff --git a/tests/agent/test_codex_app_server_persist.py b/tests/agent/test_codex_app_server_persist.py index 6726bdcd3cd1..2c652a98d41d 100644 --- a/tests/agent/test_codex_app_server_persist.py +++ b/tests/agent/test_codex_app_server_persist.py @@ -89,6 +89,7 @@ def test_codex_turn_persists_each_message_exactly_once(): real AIAgent._flush_messages_to_session_db to prove no #860/#42039 duplicate-write regression on the codex path.""" tmp = tempfile.mkdtemp(prefix="codex_persist_") + db = None try: db = SessionDB(Path(tmp) / "state.db") sid = "sess-codex-once" @@ -137,6 +138,8 @@ def test_codex_turn_persists_each_message_exactly_once(): finally: import shutil + if db is not None: + db.close() shutil.rmtree(tmp) diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index 8a4f935e3f9d..631dc123da46 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -1,4 +1,5 @@ import json +from collections import UserDict from types import SimpleNamespace import pytest @@ -187,6 +188,75 @@ def test_normalize_codex_response_preserves_hosted_multi_agent_items(): }.issubset(item_types) +def test_normalize_codex_response_accepts_mapping_backed_output_items(): + """Responses SDK wrappers may expose Mapping objects instead of dicts.""" + hosted = UserDict( + { + "type": "multi_agent_call", + "id": "ma_mapping", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_mapping", + "agent": {"agent_name": "/root"}, + } + ) + response = SimpleNamespace( + status="completed", + output=[ + hosted, + { + "type": "message", + "id": "msg_root", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root"}, + "content": [{"type": "output_text", "text": "done"}], + }, + ], + ) + + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "stop" + assert assistant_message.content == "done" + assert assistant_message.codex_message_items[0]["type"] == "multi_agent_call" + + +def test_normalize_codex_response_handles_cyclic_hosted_item_data(): + """Unexpected cyclic SDK/provider values must not abort normalization.""" + hosted = { + "type": "multi_agent_call", + "id": "ma_cycle", + "action": "spawn_agent", + "arguments": '{"task":"research"}', + "call_id": "ma_call_cycle", + "agent": {"agent_name": "/root"}, + } + hosted["cycle"] = hosted + response = SimpleNamespace( + status="completed", + output=[ + hosted, + { + "type": "message", + "id": "msg_root", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "agent": {"agent_name": "/root"}, + "content": [{"type": "output_text", "text": "done"}], + }, + ], + ) + + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "stop" + assert assistant_message.content == "done" + assert isinstance(assistant_message.codex_message_items[0]["cycle"], str) + + def test_hosted_multi_agent_items_preserve_function_tool_continuation(): response = SimpleNamespace( status="completed", diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index cc9a047496d3..0a54d08f4b36 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -14,6 +14,7 @@ import pytest import agent.transports.codex_app_server_session as session_mod +from agent.transports.codex_app_server import CodexAppServerError from agent.transports.codex_app_server_session import ( CodexAppServerSession, _ServerRequestRouting, @@ -298,6 +299,107 @@ def handle(method, params): assert params["model"] == "gpt-5.6-sol" assert params["effort"] == "ultra" + def test_non_ultra_turn_overwrites_prior_sticky_ultra_override(self): + client = FakeClient() + + def handle(method, params): + if method == "model/list": + return { + "data": [{ + "model": "gpt-5.6-sol", + "supportedReasoningEfforts": [ + {"reasoningEffort": "high"}, + {"reasoningEffort": "ultra"}, + ], + }] + } + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + if method == "turn/start": + return {"turn": {"id": "turn-fake-001"}} + return {} + + client._request_handler = handle + for _ in range(2): + client.queue_notification( + "turn/completed", + threadId="thread-fake-001", + turn={"id": "turn-fake-001", "status": "completed", "error": None}, + ) + + session = make_session(client) + first = session.run_turn( + "use ultra", + model="gpt-5.6-sol", + reasoning_effort="ultra", + turn_timeout=2.0, + ) + second = session.run_turn( + "use high", + model="gpt-5.6-sol", + reasoning_effort="high", + turn_timeout=2.0, + ) + + assert first.error is None + assert second.error is None + turn_starts = [params for method, params in client.requests if method == "turn/start"] + assert turn_starts[1]["model"] == "gpt-5.6-sol" + assert turn_starts[1]["effort"] == "high" + + def test_model_catalog_is_scanned_once_and_caches_all_models(self): + client = FakeClient() + + def handle(method, params): + if method == "model/list": + return { + "data": [ + { + "model": "gpt-5.6-sol", + "supportedReasoningEfforts": [ + {"reasoningEffort": "ultra"} + ], + }, + { + "model": "gpt-5.6-luna", + "supportedReasoningEfforts": [ + {"reasoningEffort": "high"} + ], + }, + ] + } + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + return {} + + client._request_handler = handle + session = make_session(client) + session.ensure_started() + + assert session._supported_reasoning_efforts("gpt-5.6-sol") == {"ultra"} + assert session._supported_reasoning_efforts("Gpt-5.6-Luna") == {"high"} + assert sum(method == "model/list" for method, _ in client.requests) == 1 + + def test_model_catalog_repeated_cursor_stops_and_negative_result_is_cached(self): + client = FakeClient() + + def handle(method, params): + if method == "model/list": + return {"data": [], "nextCursor": "loop"} + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + return {} + + client._request_handler = handle + session = make_session(client) + session.ensure_started() + + for _ in range(2): + with pytest.raises(CodexAppServerError, match="did not return model"): + session._supported_reasoning_efforts("missing-model") + + assert sum(method == "model/list" for method, _ in client.requests) == 2 + def test_ultra_fails_closed_when_model_catalog_does_not_advertise_it(self): client = FakeClient() @@ -327,7 +429,7 @@ def handle(method, params): assert "does not advertise reasoning effort 'ultra'" in r.error assert not any(method == "turn/start" for method, _params in client.requests) - def test_non_ultra_turn_keeps_existing_app_server_request_shape(self): + def test_non_ultra_turn_forwards_selected_app_server_overrides(self): client = FakeClient() client.queue_notification( "turn/completed", @@ -344,8 +446,8 @@ def test_non_ultra_turn_keeps_existing_app_server_request_shape(self): assert r.error is None _method, params = next(req for req in client.requests if req[0] == "turn/start") - assert "model" not in params - assert "effort" not in params + assert params["model"] == "gpt-5.6-sol" + assert params["effort"] == "high" assert not any(method == "model/list" for method, _params in client.requests) def test_tool_iteration_counter_ticks(self):