Skip to content

feat(acp): surface session effort state and support model/effort switching via config options - #4384

Open
ryanskidmore wants to merge 6 commits into
OpenHands:mainfrom
ryanskidmore:upstream/acp-effort-config-options
Open

feat(acp): surface session effort state and support model/effort switching via config options#4384
ryanskidmore wants to merge 6 commits into
OpenHands:mainfrom
ryanskidmore:upstream/acp-effort-config-options

Conversation

@ryanskidmore

@ryanskidmore ryanskidmore commented Aug 6, 2026

Copy link
Copy Markdown

HUMAN:
👋 I've been trying to use OpenHands with Claude Code but found the existing ACP implementation to be a little out of date and lacking in features (like model/effort switching) that I'd really like to see in a daily driver. I've tested this in my local fork and it works as expected.


AGENT:
End-to-end evidence beyond unit tests:

  • Real-stack e2e (Agent Canvas + this agent-server branch via editable install + mock ACP agent over stdio JSON-RPC, real chromium): live model lists reach the client, mid-session model switches apply, the effort select's current/available values surface on ConversationInfo, and composite base/effort switches split into session/set_config_option calls (model + effort) — 7/7 e2e tests green. The same client suite against the published 1.40.1 agent-server confirms behavior is additive (3/3 with effort features degrading as designed).
  • Live manual test against real Claude Code via @agentclientprotocol/claude-agent-acp 0.64.2.
  • Targeted suites on this branch: tests/sdk/agent/test_acp_agent.py (508), tests/agent_server/test_conversation_info_model.py (28), tests/sdk/conversation/test_switch_model.py (26), tests/agent_server/test_conversation_router.py, tests/sdk/settings/test_acp_providers.py, tests/sdk/test_settings.py, tests/cross/test_remote_conversation_live_server.py (new live-server case) — all passing locally.
  • make test-server-schema green (deterministic export, weak-schema allowlist unchanged — new fields strictly typed); uv run pre-commit run green on every changed file.

Why

ACP agents advertise their models and reasoning-effort levels as session config options, but the SDK only consumed the model option and had no effort concept at all — clients could not show which effort levels an agent supports or what's currently active, grouped select options silently produced empty model lists, and config_option_update notifications were dropped. Codex already had composite model/effort id handling; Claude Code (whose adapter exposes an effort config option) had none.

Summary

  • _model_config_options gains a claude-code branch mirroring the codex splitter: acp_model ids like sonnet/high or opus[1m]/max apply as two set_config_option calls (model + effort; claude accepts max, codex still rejects it). Composes through init, resume-reapply, and runtime switch with no schema/API changes; current_model_id keeps the composite, matching codex semantics.
  • Effort state is surfaced like model state: _extract_session_efforts reads the effort/reasoning_effort select, stored as ACPAgent.current_effort/available_efforts, persisted in agent_state, and lifted onto ConversationInfo as additive optional fields (current_effort: str | None, available_efforts: list[str] | None) with live→persisted precedence.
  • Robustness: grouped select options (SessionConfigSelectGroup) are flattened instead of yielding empty lists; ConfigOptionUpdate session notifications now refresh model/effort state in memory (weakref-bound bridge callback; composite-id reconciliation keeps a tracked base/effort when the server reports the same split state).
  • CLAUDE_AGENT_ACP_VERSION 0.44.0 → 0.64.2 (the version the effort/thought_level config option surface was verified against), kept in sync with the Docker image's npm pin per the comment in acp_providers.py.

Issue Number

N/A

How to Test

  1. make build, then run any ACP client against the agent-server with a claude-code agent: GET /api/conversations/{id} now includes current_effort/available_efforts alongside available_models.
  2. POST /api/conversations/{id}/switch_acp_model with {"model": "sonnet/high"} on a live claude-code session applies model + effort as separate config options; a bare id leaves effort unchanged.
  3. uv run pytest tests/sdk/agent/test_acp_agent.py tests/agent_server/test_conversation_info_model.py tests/cross/test_remote_conversation_live_server.py.
  4. make test-server-schema.

Video/Screenshots

N/A (server-side; UI screenshots live on the paired Agent Canvas PR, linked above).

Type

  • Feature

Notes

  • Everything is additive: no removals, no version bumps, weak-schema allowlist untouched.
  • Documented scope cuts (in code comments): the notification-path refresh does not write agent_state (no ConversationState reference there; persisted on next init_state), and composite reconciliation gates on _model_override_applied, so a post-runtime-switch server push may demote a tracked composite to its base id — harmless since effort is tracked separately.
  • The agent-client-protocol dependency is not touched. Note for maintainers: acp 0.12 removed ClientSideConnection.set_session_model, which the legacy non-config-option fallback branch still calls — worth a guard before any future bump.
  • Paired client PR: OpenHands/OpenHands (Agent Canvas) — linked in the first comment.

ryanskidmore and others added 5 commits August 5, 2026 21:41
Mirror the codex-acp reasoning_effort handling for claude-agent-acp:
acp_model ids like sonnet/high or opus[1m]/max now apply as two
set_config_option calls (model + effort). Claude accepts max on top of
the codex levels; codex behavior is unchanged and still rejects max.
Composes through init, resume-reapply, and runtime switch, which all go
via _model_config_options. current_model_id keeps the full composite,
matching existing codex semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Extract the effort/reasoning_effort select config option from ACP
session responses alongside the model option, store it on the agent
(current_effort/available_efforts), persist it in agent_state, and lift
it into ConversationInfo with the same live-then-persisted precedence
as model state. Runtime switches with composite ids refresh
current_effort; bare-id switches leave it unchanged. Additive optional
REST fields only; strictly typed so no weak-schema allowlist entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Grouped select options (SessionConfigSelectGroup) are flattened before
model/effort extraction instead of being silently dropped. The bridge
now dispatches ConfigOptionUpdate session notifications to the agent,
which re-extracts and refreshes current/available model and effort
state in memory (persisted on next init_state). Composite-id
reconciliation keeps a tracked model/effort composite when the server
reports the same split state, and adopts server state otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
The 0.44.0 pin predates the adapter's effort (thought_level) config
option, which the effort features rely on; 0.64.2 is the version the
config-option surface was verified against (model + effort selects in
session/new, set_config_option handling).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Round-trips acp_current_effort/acp_available_efforts from persisted
agent_state through GET /api/conversations/{id} on a real FastAPI
server, and asserts the native-agent default is None/absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
@ryanskidmore

Copy link
Copy Markdown
Author

Paired Agent Canvas (client) PR: OpenHands/OpenHands#16350 — the UI consuming current_effort/available_efforts and the composite-id switching; includes e2e coverage that adapts to agent-servers with and without this change.

@VascoSch92

Copy link
Copy Markdown
Member

Hey @ryanskidmore

thanks for the PR.

One point:

_reconcile_current_model_id (acp_agent.py) only preserves a tracked composite id (e.g. "sonnet/high") across a config_option_update resend when self._model_override_applied is True. That flag is set exactly once, in _init() at session start/resume, based on whether the initial self.acp_model was pushed, but set_acp_model() (the live runtime-switch method) never sets it. So for any session that starts without a forced acp_model (the common case), a runtime switch to a composite id gets silently demoted back to the bare model id as soon as the server sends its spec-mandated resend after set_config_option.

Repro (drop into a scratch file, run with uv run python repro.py from repo root. It uses the PR's own test helpers):

import asyncio, sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

sys.path.insert(0, "tests")
from sdk.agent.test_acp_agent import _make_agent

def wire(agent, agent_name):
    conn = MagicMock()
    conn.set_config_option = AsyncMock()
    agent._conn = conn
    agent._session_id = "sess-1"
    agent._agent_name = agent_name
    agent._model_via_config_option = True
    executor = MagicMock()
    executor.run_async = MagicMock(
        side_effect=lambda coro, timeout=None: asyncio.new_event_loop().run_until_complete(coro)
    )
    agent._executor = executor
    return agent

def opt(v): return SimpleNamespace(value=v, name=None)
def model_select(cur, opts): return SimpleNamespace(id="model", type="select", current_value=cur, options=opts)
def effort_select(cur, opts): return SimpleNamespace(id="effort", type="select", current_value=cur, options=opts)

agent = wire(_make_agent(), "claude-agent-acp")  # acp_model=None: no initial override
agent.set_acp_model("sonnet/high")               # live picker switch
print("after switch:", agent._current_model_id, "/", agent._current_effort)

# Server MUST resend the full configOptions state after set_config_option (ACP spec).
agent._on_config_options_update([
    model_select("sonnet", [opt("sonnet"), opt("opus")]),
    effort_select("high", [opt("low"), opt("high")]),
])
print("after mandatory resend:", agent._current_model_id, "/", agent._current_effort)

The output should be

Output:
after switch:            sonnet/high / high
after mandatory resend:  sonnet / high        <- current_model_id regressed

current_effort stays correct, but current_model_id (what ConversationInfo surfaces, and what a test comment in this PR says Canvas reads the composite back from) loses the effort suffix immediately after the very feature this PR adds. Not caught by the existing tests because test_composite_preserved_when_report_matches_tracked_split manually sets agent._model_override_applied = True rather than going through set_acp_model().

A fix is to set the flag where the switch actually lands on the wire:

# acp_agent.py, in set_acp_model(), right after:
self._current_model_id = model
self._model_override_applied = True  # <- add this

@simonrosenberg

Copy link
Copy Markdown
Member

Automated review (high effort)

Correctness

  1. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1753switch_acp_model() unconditionally persists new_agent.current_effort as acp_current_effort. When the switch happens with no live ACP session, set_acp_model() (the only thing that updates _current_effort) never runs, so the old model's effort gets carried over and persisted against the new model until the next full session resume.
  2. openhands-sdk/openhands/sdk/agent/acp_agent.py:2841 — The config_option_update callback captures a weakref to the ACPAgent at session start. switch_acp_model() later replaces self.agent with a model_copy(); the old instance the weakref points to gets GC'd, so the callback silently becomes a no-op — any subsequent server-initiated config update (e.g. a rate-limit fallback) stops being applied to current_model_id/current_effort.

Cleanup — several docstrings/comments are much longer than the change they document and read as PR-description prose pasted into source (multi-paragraph bug narration, Returns-blocks on private helpers, decision tables weighing alternatives, "deliberate scope cut" justifications). Worth trimming to one-line WHYs:

  • acp_agent.py:506 (_flatten_select_options)
  • acp_agent.py:622 (_extract_session_efforts)
  • acp_agent.py:689 (_reconcile_current_model_id)
  • acp_agent.py:1960 (18-line comment over 2 PrivateAttr lines)
  • acp_agent.py:2850 (_on_config_options_update)
  • openhands-agent-server/openhands/agent_server/conversation_service.py:452
  • local_conversation.py:1742 (8-line comment over a 1-line dict entry)

@simonrosenberg

Copy link
Copy Markdown
Member

#4391 (standalone bump of the pinned claude-agent-acp/codex-acp npm versions) just merged to main — CLAUDE_AGENT_ACP_VERSION is now 0.63.0 and CODEX_ACP_VERSION is now 1.1.7 (both picked specifically to clear the review bot's 7-day supply-chain freshness gate).

Could you rebase this branch onto latest main? Heads up: this branch carries its own version-bump commit (5a0b8b4bc, "chore: bump pinned claude-agent-acp to 0.64.2") that touches the same lines in acp_providers.py/the Dockerfile, so the rebase will conflict. Worth checking whether 0.63.0 is already sufficient for the effort/thought_level config-option support this PR relies on (that option has existed since claude-agent-acp 0.31.0, well before both the old and new pin) — if so, drop 5a0b8b4bc entirely rather than re-resolving it to 0.64.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants