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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,9 @@ RUN set -ux; \
&& "$ACP_NODE_DIR/bin/node" --version; then \
PATH="$ACP_NODE_DIR/bin:$PATH"; \
if "$ACP_NODE_DIR/bin/npm" install -g \
@agentclientprotocol/claude-agent-acp@0.30.0 \
@zed-industries/codex-acp@0.15.0 \
@google/gemini-cli@0.38.0; then \
@agentclientprotocol/claude-agent-acp@0.44.0 \
@zed-industries/codex-acp@0.16.0 \
@google/gemini-cli@0.46.0; then \
# Create wrappers in /usr/local/bin that prepend ACP's Node 22 to PATH.
# This ensures the ACP binary's #!/usr/bin/env node shebang resolves
# to Node 22, while the repo's own node (NVM/system) stays untouched
Expand Down
382 changes: 239 additions & 143 deletions openhands-sdk/openhands/sdk/agent/acp_agent.py

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions openhands-sdk/openhands/sdk/agent/acp_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,34 @@ class ACPModelInfo(BaseModel):
model_id: str = Field(
description=(
"Server-assigned model identifier. May be concrete "
'(e.g. ``"gpt-5.5/xhigh"``) or an opaque alias '
'(e.g. ``"default"``, ``"auto"``). This is the value to pass to '
"``set_session_model`` to switch to this model."
'(e.g. ``"gpt-5.5"``) or an opaque alias '
'(e.g. ``"default"``, ``"auto"``). This is the value to pass back '
"to the server to switch to this model."
),
)
name: str | None = Field(
default=None,
description='Human-readable label, e.g. ``"GPT-5.5 (xhigh)"``.',
description='Human-readable label, e.g. ``"GPT-5.5"``.',
)
description: str | None = Field(
default=None,
description="Optional longer description supplied by the server.",
)

@classmethod
def from_protocol(cls, raw: Any) -> ACPModelInfo:
def from_protocol(cls, raw: Any, *, id_attr: str = "model_id") -> ACPModelInfo:
"""Build from a raw ACP ``ModelInfo`` (or any duck-typed object).

Tolerant of partial/malformed entries: non-string fields degrade to
``""`` (``model_id``) or ``None`` (``name``/``description``) rather
than raising, since the source is an UNSTABLE protocol capability that
older or half-implemented agents may emit incompletely.

``id_attr`` names the attribute carrying the model id — ``"model_id"``
for a ``models``-capability ``ModelInfo``, ``"value"`` for a
``configOptions`` select option.
"""
model_id = getattr(raw, "model_id", None)
model_id = getattr(raw, id_attr, None)
name = getattr(raw, "name", None)
description = getattr(raw, "description", None)
return cls(
Expand Down
144 changes: 77 additions & 67 deletions openhands-sdk/openhands/sdk/settings/acp_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
- ``agent_name_patterns`` lowercase substrings in the runtime agent name;
used by ``ACPAgent`` to auto-detect mode / protocol
- ``supports_set_session_model`` whether the provider applies its *initial*
model via the ``set_session_model`` protocol
call at session creation
- ``supports_runtime_model_switch`` whether the server supports the
``session/set_model`` protocol call for
runtime, mid-conversation model switching
model via a protocol call at session creation
(``set_config_option``/``set_session_model``)
- ``supports_runtime_model_switch`` whether the server supports a protocol-level
model switch for runtime, mid-conversation use
- ``session_meta_key`` top-level ``_meta`` key for model selection (or ``None``)
- ``available_models`` curated list of selectable models for the provider's
model picker (``acp_model`` candidates)
Expand Down Expand Up @@ -155,13 +154,12 @@ class ACPProviderInfo:
"""

default_session_mode: str
"""ACP session-mode ID that suppresses all permission prompts.
"""ACP session-mode ID set right after ``session/new``.

Different servers use different IDs for the same concept:

- ``bypassPermissions`` — claude-agent-acp
- ``full-access`` — codex-acp
- ``yolo`` — gemini-cli
For servers with a permission-suppressing mode that is the value:
``bypassPermissions`` (claude-agent-acp), ``full-access`` (codex-acp).
gemini-cli uses ``default`` (its ``yolo`` mode errors at init); the ACP
bridge auto-approves permission requests, so the mode doesn't gate prompts.
"""

agent_name_patterns: tuple[str, ...]
Expand Down Expand Up @@ -195,12 +193,13 @@ class ACPProviderInfo:
When non-``None``, the model is additionally advertised via ACP session
``_meta`` using the structure
``{session_meta_key: {"options": {"model": <model>}}}`` passed to
``new_session()``. This is best-effort only: claude-agent-acp 0.30.0
ignores it (#3654), so the authoritative initial selection is the
``set_session_model`` call gated on :attr:`supports_set_session_model`.
``new_session()``. This is best-effort only: claude-agent-acp ignores it
(#3654), so the authoritative initial selection is the protocol call gated
on :attr:`supports_set_session_model`.

Runtime switches always use ``set_session_model`` (gated on
:attr:`supports_runtime_model_switch`).
Runtime switches use the mechanism the session advertised
(``set_config_option`` or ``set_session_model``), gated on
:attr:`supports_runtime_model_switch`.

- ``"claudeCode"`` — claude-agent-acp
- ``None`` — codex-acp, gemini-cli
Expand Down Expand Up @@ -235,7 +234,7 @@ class ACPProviderInfo:
Unlike :attr:`supports_set_session_model`, this is about switching the
model of an *already-running* session, not the initial selection. A
provider may select its initial model via ``_meta`` (claude-agent-acp)
yet still support ``set_session_model`` for later switches.
yet still support a protocol-level switch for later changes.

Defaults to ``False`` so forward-compat providers — and any external
caller constructing this dataclass positionally — keep working without a
Expand Down Expand Up @@ -293,50 +292,50 @@ class ACPProviderInfo:
# ``acp_model`` outside these lists is always allowed.
# ---------------------------------------------------------------------------

# Canonical model IDs the Claude Code CLI accepts, curated to the newest
# generation per capability tier. ``opus[1m]`` is the SDK-documented
# version-agnostic 1M-context alias (auto-tracks the newest 1M-capable model —
# keep its label version-less to match). ``opusplan`` routes planning to Opus
# and execution to Sonnet.
# Model IDs the Claude Code CLI accepts, mirroring the ``model`` configOptions
# select claude-agent-acp 0.44.0 reports at ``session/new`` (the short aliases
# the CLI's own ``/model`` menu offers, switched via ``set_config_option``).
# ``opus[1m]`` is the SDK-documented version-agnostic 1M-context alias and the
# CLI's own default (``currentValue``); ``default`` is the CLI's recommended
# tier (Opus 4.8 · 1M). The ``/model`` menu is dynamic/account-dependent and the
# CLI validates ``set_config_option(model)`` against the live select — it rejects
# an absent id (e.g. ``sonnet`` on accounts without it), so these are pre-session
# suggestions, not ground truth; a rejected id degrades to the server default.
_CLAUDE_MODELS: tuple[ACPModelOption, ...] = (
ACPModelOption(id="claude-fable-5", label="Claude Fable 5"),
ACPModelOption(id="claude-opus-4-8", label="Claude Opus 4.8"),
ACPModelOption(id="opus[1m]", label="Claude Opus (1M)"),
ACPModelOption(id="claude-sonnet-4-6", label="Claude Sonnet 4.6"),
ACPModelOption(id="claude-haiku-4-5", label="Claude Haiku 4.5"),
ACPModelOption(id="opusplan", label="Opus (plan) + Sonnet (execute)"),
ACPModelOption(id="default", label="Default (recommended)"),
ACPModelOption(id="opus[1m]", label="Claude Opus 4.8 (1M)"),
ACPModelOption(id="sonnet", label="Claude Sonnet 4.6"),
ACPModelOption(id="haiku", label="Claude Haiku 4.5"),
)

# Model IDs accepted by ``@zed-industries/codex-acp`` (``session/new``
# ``availableModels`` on the pinned CLI), curated to the frontier family plus
# the cost-efficient mini tier. Format is ``<base-model>/<effort>`` where the
# trailing tier (``low``/``medium``/``high``/``xhigh``) hints the reasoning
# effort for the turn.
# Bare preset ids the ``@zed-industries/codex-acp`` 0.16 ``model`` configOptions
# select reports at ``session/new`` (``set_config_option(configId="model")``
# targets). The reasoning-effort tier is a *separate* ``reasoning_effort``
# configOption on 0.16, not part of the model id, so it is not encoded here.
_CODEX_MODELS: tuple[ACPModelOption, ...] = (
ACPModelOption(id="gpt-5.5/low", label="GPT-5.5 (low)"),
ACPModelOption(id="gpt-5.5/medium", label="GPT-5.5 (medium)"),
ACPModelOption(id="gpt-5.5/high", label="GPT-5.5 (high)"),
ACPModelOption(id="gpt-5.5/xhigh", label="GPT-5.5 (xhigh)"),
ACPModelOption(id="gpt-5.4-mini/low", label="GPT-5.4 Mini (low)"),
ACPModelOption(id="gpt-5.4-mini/medium", label="GPT-5.4 Mini (medium)"),
ACPModelOption(id="gpt-5.4-mini/high", label="GPT-5.4 Mini (high)"),
ACPModelOption(id="gpt-5.4-mini/xhigh", label="GPT-5.4 Mini (xhigh)"),
ACPModelOption(id="gpt-5.5", label="GPT-5.5"),
ACPModelOption(id="gpt-5.4", label="GPT-5.4"),
ACPModelOption(id="gpt-5.4-mini", label="GPT-5.4 Mini"),
)

# Model IDs accepted by ``@google/gemini-cli --acp``. The ``auto-gemini-*``
# entries delegate version selection to the CLI's router; the explicit
# ``gemini-3.1-*`` / ``gemini-2.5-*`` entries pin to a specific snapshot.
# Model IDs accepted by ``@google/gemini-cli --acp``. Mirrors the
# ``availableModels`` the CLI reports at ``session/new`` on the pinned version
# (gemini-cli 0.46.0). ``auto`` delegates version selection to the CLI's
# router; the explicit ``gemini-*`` entries pin to a specific snapshot. The CLI
# also accepts ids outside this list (it remaps them at generation), so these
# are curated suggestions, not an access check.
_GEMINI_MODELS: tuple[ACPModelOption, ...] = (
ACPModelOption(id="auto-gemini-3", label="Auto (Gemini 3)"),
ACPModelOption(id="auto-gemini-2.5", label="Auto (Gemini 2.5)"),
ACPModelOption(id="auto", label="Auto"),
# gemini-cli 0.46 surfaces the pro-preview as ``gemini-3.1-pro-preview`` once
# the Gemini 3.1 launch flag is on (``PREVIEW_GEMINI_3_1_MODEL``), falling
# back to ``gemini-3-pro-preview`` (``PREVIEW_GEMINI_MODEL``) otherwise — keep
# both so the picker matches either rollout state.
ACPModelOption(id="gemini-3.1-pro-preview", label="Gemini 3.1 Pro (preview)"),
ACPModelOption(id="gemini-3-pro-preview", label="Gemini 3 Pro (preview)"),
ACPModelOption(id="gemini-3-flash-preview", label="Gemini 3 Flash (preview)"),
ACPModelOption(
id="gemini-3.1-flash-lite-preview", label="Gemini 3.1 Flash Lite (preview)"
),
ACPModelOption(id="gemini-3.1-flash-lite", label="Gemini 3.1 Flash Lite"),
ACPModelOption(id="gemini-2.5-pro", label="Gemini 2.5 Pro"),
ACPModelOption(id="gemini-2.5-flash", label="Gemini 2.5 Flash"),
ACPModelOption(id="gemini-2.5-flash-lite", label="Gemini 2.5 Flash Lite"),
)


Expand Down Expand Up @@ -378,9 +377,13 @@ class ACPProviderInfo:
# permission-disabling session mode. In the image the binary rewrite in
# `ACPAgentSettings.resolve_acp_command` runs the pinned `binary_name` instead,
# so the `@version` suffix is a no-op there.
CLAUDE_AGENT_ACP_VERSION = "0.30.0"
CODEX_ACP_VERSION = "0.15.0"
GEMINI_CLI_VERSION = "0.38.0"
#
# claude-agent-acp 0.44+ / codex-acp 0.16+ select the model via a ``model``
# ``configOptions`` entry rather than ``session/set_model``; the SDK detects
# which per session and applies it through the matching call.
CLAUDE_AGENT_ACP_VERSION = "0.44.0"
CODEX_ACP_VERSION = "0.16.0"
GEMINI_CLI_VERSION = "0.46.0"


ACP_PROVIDERS: Mapping[str, ACPProviderInfo] = MappingProxyType(
Expand All @@ -397,17 +400,20 @@ class ACPProviderInfo:
base_url_env_var="ANTHROPIC_BASE_URL",
default_session_mode="bypassPermissions",
agent_name_patterns=("claude-agent",),
# claude-agent-acp 0.30.0 silently ignores the session-_meta model
# selection (the requested model only becomes a picker option; the
# session keeps running "default"), so the init path must push the
# model via session/set_model like codex/gemini (#3654). The _meta
# claude-agent-acp ignores the session-_meta model selection (the
# requested model only becomes a picker option; the session keeps
# running its default), so the init path must push the model via a
# protocol call (#3654). On 0.44.0+ that call is
# ``set_config_option(configId="model")`` rather than
# ``set_session_model`` (auto-detected from session/new); the _meta
# payload (session_meta_key below) is still sent — harmless, and
# picks up the same model if a future CLI honours it.
supports_set_session_model=True,
supports_runtime_model_switch=True,
session_meta_key="claudeCode",
available_models=_CLAUDE_MODELS,
default_model="claude-opus-4-8",
# The CLI's own default (model configOptions ``currentValue``).
default_model="opus[1m]",
binary_name="claude-agent-acp",
data_dir_env_var="CLAUDE_CONFIG_DIR",
),
Expand All @@ -427,7 +433,7 @@ class ACPProviderInfo:
supports_runtime_model_switch=True,
session_meta_key=None,
available_models=_CODEX_MODELS,
default_model="gpt-5.5/medium",
default_model="gpt-5.5",
file_secrets=_CODEX_FILE_SECRETS,
binary_name="codex-acp",
data_dir_env_var="CODEX_HOME",
Expand All @@ -443,18 +449,22 @@ class ACPProviderInfo:
),
api_key_env_var="GEMINI_API_KEY",
base_url_env_var="GEMINI_BASE_URL",
default_session_mode="yolo",
# gemini-cli 0.46.0 rejects ``set_session_mode("yolo")`` at session
# init (-32603), which crashes headless startup; ``default`` is
# accepted. The ACP bridge auto-approves every request_permission, so
# prompts never block regardless of mode. See #3772.
default_session_mode="default",
agent_name_patterns=("gemini-cli",),
supports_set_session_model=True,
supports_runtime_model_switch=True,
session_meta_key=None,
available_models=_GEMINI_MODELS,
# Match the Gemini CLI's own no-model-configured default
# (``DEFAULT_GEMINI_MODEL_AUTO``), i.e. the auto-router — not a
# manually-pinned snapshot. Pinning ``gemini-2.5-pro`` here would
# make downstream clients persist a value that bypasses the CLI's
# auto-routing.
default_model="auto-gemini-2.5",
# Match the Gemini CLI's own auto-router rather than a manually
# pinned snapshot. Pinning e.g. ``gemini-2.5-pro`` here would make
# downstream clients persist a value that bypasses the CLI's
# auto-routing. ``auto`` is the router id the CLI reports in its
# 0.46.0 ``availableModels``.
default_model="auto",
file_secrets=_GEMINI_FILE_SECRETS,
binary_name="gemini",
# Gemini CLI has no dedicated config-dir var; it hard-codes
Expand Down Expand Up @@ -516,7 +526,7 @@ def detect_acp_provider_by_command(
*caller-controlled*: each token is reduced to its basename (last path segment,
minus a trailing ``@version`` pin) and a provider matches only when that
basename *starts with* one of its patterns. This accepts the real forms —
``@zed-industries/codex-acp``, ``@google/gemini-cli@0.43.0``,
``@zed-industries/codex-acp``, ``@google/gemini-cli@0.46.0``,
``/opt/node_modules/.bin/codex-acp`` — while rejecting incidental substrings
like ``my-codex-acp-wrapper`` or ``/opt/shims/not-codex-acp`` that a plain
substring test would misattribute.
Expand Down
8 changes: 4 additions & 4 deletions openhands-sdk/openhands/sdk/settings/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1322,10 +1322,10 @@ def _serialize_acp_env(self, value: dict[str, str], info):
acp_model: str | None = Field(
default=None,
description=(
"Model identifier for the ACP server to use (e.g. "
"``'claude-opus-4-6'``). claude-agent-acp receives it via session "
"_meta; codex-acp and gemini-cli via ``set_session_model``. "
"Leave blank to let the server pick its default."
"Model identifier for the ACP server to use (e.g. ``'sonnet'`` or "
"``'gpt-5.5'``). Applied via the protocol — set_config_option(model) "
"for configOptions-based servers (codex, claude), else "
"set_session_model. Leave blank to let the server pick its default."
),
json_schema_extra={
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
Expand Down
4 changes: 2 additions & 2 deletions tests/agent_server/test_conversation_info_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,13 @@ def test_available_models_lifted_from_acp_agent():
def test_available_models_empty_when_server_omits_them():
"""Servers that don't surface the UNSTABLE ``models`` capability yield []."""
agent = ACPAgent(acp_command=["echo", "test"])
agent._current_model_id = "gpt-5.5/xhigh"
agent._current_model_id = "gpt-5.5"
state = _make_state(agent)
stored = _make_stored(state)

info = _compose_conversation_info(stored, state)

assert info.current_model_id == "gpt-5.5/xhigh"
assert info.current_model_id == "gpt-5.5"
assert info.available_models == []


Expand Down
Loading
Loading