Skip to content

Commit 2c61ff2

Browse files
authored
Merge branch 'main' into vasco/goal-agent-server
2 parents 0131ce8 + 89fd946 commit 2c61ff2

11 files changed

Lines changed: 237 additions & 97 deletions

File tree

openhands-agent-server/openhands/agent_server/conversation_service.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
ConversationInfo,
2222
ConversationPage,
2323
ConversationSortOrder,
24-
LaunchedProfile,
24+
LaunchedAgentProfile,
2525
StartConversationRequest,
2626
StoredConversation,
2727
UpdateConversationRequest,
@@ -245,7 +245,7 @@ def _resolve_agent_from_profile(
245245
profile_id: "UUID",
246246
cipher: "Cipher | None",
247247
mcp_config: "Any",
248-
) -> "tuple[AgentBase, LaunchedProfile]":
248+
) -> "tuple[AgentBase, LaunchedAgentProfile]":
249249
"""Load and resolve an agent profile by id, returning the built agent + provenance.
250250
251251
Runs synchronously (call via ``asyncio.to_thread`` from async context).
@@ -290,7 +290,10 @@ def _resolve_agent_from_profile(
290290
raise ValueError(f"Profile '{profile_name}' failed to resolve: {exc}") from exc
291291

292292
agent = settings_config.create_agent()
293-
launched = LaunchedProfile(profile_id=profile.id, revision=profile.revision)
293+
launched = LaunchedAgentProfile(
294+
agent_profile_id=profile.id,
295+
revision=profile.revision,
296+
)
294297
return agent, launched
295298

296299

@@ -318,7 +321,7 @@ def _compose_conversation_info(
318321
# The ``acp_model`` fallback is gated on the agent NOT being a live,
319322
# initialized one. Once ``init_state`` has fired, ``current_model_id`` is the
320323
# authoritative resolved value — including ``None`` when an override couldn't
321-
# be applied (unknown provider, or a resume whose ``set_session_model`` the
324+
# be applied (unknown provider, or a resume whose model-selection call the
322325
# server rejected) — so falling back to ``acp_model`` there would re-assert an
323326
# override the live session isn't actually running. The fallback is only for
324327
# *cold* reads (``init_state`` hasn't fired, PrivateAttrs still empty), where
@@ -363,7 +366,7 @@ def _compose_conversation_info(
363366
available_models=available_models,
364367
supports_runtime_model_switch=supports_runtime_model_switch,
365368
client_tools=stored.client_tools,
366-
launched_profile=stored.launched_profile,
369+
launched_agent_profile=stored.launched_agent_profile,
367370
)
368371

369372

@@ -670,7 +673,7 @@ async def _start_conversation(
670673
# Profile resolution must happen before _prepare_request_workspace (which
671674
# asserts request.agent is not None) and before model_dump so the resolved
672675
# agent is captured in request_data.
673-
launched_profile: LaunchedProfile | None = None
676+
launched_agent_profile: LaunchedAgentProfile | None = None
674677
if request.agent_profile_id is not None:
675678
# get_settings_store() is safe here: get_instance() initialises the
676679
# singleton with the server cipher before any conversation can start.
@@ -681,7 +684,7 @@ async def _start_conversation(
681684

682685
settings = get_settings_store().load() or PersistedSettings()
683686
mcp_config = settings.agent_settings.mcp_config
684-
resolved_agent, launched_profile = await asyncio.to_thread(
687+
resolved_agent, launched_agent_profile = await asyncio.to_thread(
685688
_resolve_agent_from_profile,
686689
request.agent_profile_id,
687690
self.cipher,
@@ -751,7 +754,7 @@ async def _start_conversation(
751754
# serialize to plain strings. Pass expose_secrets=True so StaticSecret values
752755
# are preserved through the round-trip; the dict is only used in-process to
753756
# construct StoredConversation, not sent over the network.
754-
# agent_profile_id is excluded: it was resolved into `launched_profile`
757+
# agent_profile_id is excluded: it was resolved into `launched_agent_profile`
755758
# above and must not re-trigger the mutual-exclusivity validator.
756759
request_data = request.model_dump(
757760
mode="json",
@@ -772,9 +775,9 @@ async def _start_conversation(
772775
{
773776
"id": conversation_id,
774777
**request_data,
775-
"launched_profile": (
776-
launched_profile.model_dump(mode="json")
777-
if launched_profile is not None
778+
"launched_agent_profile": (
779+
launched_agent_profile.model_dump(mode="json")
780+
if launched_agent_profile is not None
778781
else None
779782
),
780783
},
@@ -783,7 +786,7 @@ async def _start_conversation(
783786
else:
784787
stored = StoredConversation(
785788
id=conversation_id,
786-
launched_profile=launched_profile,
789+
launched_agent_profile=launched_agent_profile,
787790
**request_data,
788791
)
789792
event_service = await self._start_event_service(stored)

openhands-agent-server/openhands/agent_server/models.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
TextContent as TextContent,
2929
)
3030
from openhands.sdk.llm.utils.metrics import MetricsSnapshot
31-
from openhands.sdk.profiles.agent_profile import LaunchedProfile as LaunchedProfile
31+
from openhands.sdk.profiles.agent_profile import (
32+
LaunchedAgentProfile as LaunchedAgentProfile,
33+
)
3234
from openhands.sdk.secret import SecretSource
3335
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
3436
from openhands.sdk.security.confirmation_policy import (
@@ -79,7 +81,7 @@ class StoredConversation(StartConversationRequest):
7981
Extends StartConversationRequest with server-assigned fields.
8082
"""
8183

82-
# agent_profile_id is resolved into launched_profile at creation; exclude from
84+
# agent_profile_id is resolved into launched_agent_profile at creation; exclude from
8385
# the persistence payload so it does not re-appear in meta.json.
8486
agent_profile_id: UUID | None = Field(default=None, exclude=True)
8587

@@ -90,7 +92,7 @@ class StoredConversation(StartConversationRequest):
9092
metrics: MetricsSnapshot | None = None
9193
created_at: datetime = Field(default_factory=utc_now)
9294
updated_at: datetime = Field(default_factory=utc_now)
93-
launched_profile: LaunchedProfile | None = Field(
95+
launched_agent_profile: LaunchedAgentProfile | None = Field(
9496
default=None,
9597
description=(
9698
"Provenance snapshot of the agent profile that launched this "
@@ -237,25 +239,25 @@ class _ConversationInfoBase(BaseModel):
237239
supports_runtime_model_switch: bool = Field(
238240
default=False,
239241
description=(
240-
"Whether a live, mid-conversation model switch (via "
241-
"``session/set_model``) will be attempted for this conversation — "
242+
"Whether a live, mid-conversation model switch will be attempted "
243+
"for this conversation — "
242244
"tells the inline picker whether to offer a live-switch control. "
243245
"Mirrors the SDK's switch gate: ``True`` for known switch-capable "
244-
"providers; ``True`` for unknown/custom ACP servers too, since "
245-
"OpenHands attempts the switch optimistically rather than refusing "
246-
"(a rejection then surfaces as an error). ``False`` for native "
246+
"providers; ``False`` for unknown/custom ACP servers because their "
247+
"generic config writes are not guaranteed live-switch primitives. "
248+
"``False`` for native "
247249
"OpenHands agents, for a known provider that declares no support, "
248250
"and before the conversation has started a session."
249251
),
250252
)
251-
launched_profile: LaunchedProfile | None = Field(
253+
launched_agent_profile: LaunchedAgentProfile | None = Field(
252254
default=None,
253255
description=(
254256
"Provenance snapshot of the agent profile that launched this "
255257
"conversation. Set at creation when the conversation was started via "
256258
"``agent_profile_id``; ``None`` for conversations started directly "
257259
"with ``agent`` or ``agent_settings``. Clients use this to identify "
258-
"which profile is current without fragile settings-comparison."
260+
"which agent profile is current without fragile settings-comparison."
259261
),
260262
)
261263

openhands-sdk/openhands/sdk/agent/acp_agent.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,30 @@ def _write_secret_file(path: Path, value: str) -> None:
400400
# (codex-acp 0.16+, claude-agent-acp 0.44+) rather than the UNSTABLE ``models``
401401
# capability + ``session/set_model`` (gemini-cli, older codex/claude).
402402
_MODEL_CONFIG_OPTION_ID = "model"
403+
_CODEX_REASONING_EFFORTS: Final[frozenset[str]] = frozenset(
404+
{"low", "medium", "high", "xhigh"}
405+
)
406+
407+
408+
def _codex_model_config_options(model: str) -> tuple[tuple[str, str], ...]:
409+
"""Map combined Canvas Codex model IDs to codex-acp config options."""
410+
base_model, sep, effort = model.rpartition("/")
411+
if sep and base_model and effort in _CODEX_REASONING_EFFORTS:
412+
return (
413+
(_MODEL_CONFIG_OPTION_ID, base_model),
414+
("reasoning_effort", effort),
415+
)
416+
return ((_MODEL_CONFIG_OPTION_ID, model),)
417+
418+
419+
def _model_config_options(
420+
agent_name: str | None,
421+
model: str,
422+
) -> tuple[tuple[str, str], ...]:
423+
provider = detect_acp_provider_by_agent_name(agent_name or "")
424+
if provider is not None and provider.key == "codex":
425+
return _codex_model_config_options(model)
426+
return ((_MODEL_CONFIG_OPTION_ID, model),)
403427

404428

405429
def _model_config_option(response: Any) -> Any | None:
@@ -432,19 +456,23 @@ async def _apply_acp_model(
432456
session_id: str,
433457
model: str,
434458
*,
459+
agent_name: str | None = None,
435460
via_config_option: bool,
436461
) -> None:
437462
"""Apply ``model`` to a live ACP session via the mechanism the session
438463
advertised: ``set_config_option(configId="model")`` for configOptions-based
439464
servers (codex-acp 0.16+, claude-agent-acp 0.44+), else ``set_session_model``.
440465
441-
The model id is a bare preset id the server lists in its ``model`` select /
442-
``models`` capability — applied as-is on either mechanism.
466+
The model id is normally the bare preset id listed by the server. For
467+
Codex, callers may still pass a combined Canvas id such as ``gpt-5.5/high``;
468+
codex-acp exposes reasoning effort as a separate config option, so split it
469+
only on the config-options mechanism.
443470
"""
444471
if via_config_option:
445-
await conn.set_config_option(
446-
config_id=_MODEL_CONFIG_OPTION_ID, value=model, session_id=session_id
447-
)
472+
for config_id, value in _model_config_options(agent_name, model):
473+
await conn.set_config_option(
474+
config_id=config_id, value=value, session_id=session_id
475+
)
448476
else:
449477
await conn.set_session_model(model_id=model, session_id=session_id)
450478

@@ -665,7 +693,11 @@ async def _maybe_set_session_model(
665693
# session won't accept degrades to the server default rather than failing.
666694
try:
667695
await _apply_acp_model(
668-
conn, session_id, acp_model, via_config_option=via_config_option
696+
conn,
697+
session_id,
698+
acp_model,
699+
agent_name=agent_name,
700+
via_config_option=via_config_option,
669701
)
670702
return True
671703
except ACPRequestError as e:
@@ -715,7 +747,11 @@ async def _reapply_session_model_on_resume(
715747
return False
716748
try:
717749
await _apply_acp_model(
718-
conn, session_id, acp_model, via_config_option=via_config_option
750+
conn,
751+
session_id,
752+
acp_model,
753+
agent_name=agent_name,
754+
via_config_option=via_config_option,
719755
)
720756
return True
721757
except ACPRequestError as e:
@@ -3751,6 +3787,7 @@ def set_acp_model(self, model: str) -> None:
37513787
self._conn,
37523788
self._session_id,
37533789
model,
3790+
agent_name=self._agent_name,
37543791
via_config_option=self._model_via_config_option,
37553792
),
37563793
timeout=self.acp_prompt_timeout,

openhands-sdk/openhands/sdk/profiles/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
ACPAgentProfile,
66
AgentProfile,
77
AgentProfileBase,
8-
LaunchedProfile,
8+
LaunchedAgentProfile,
99
OpenHandsAgentProfile,
1010
ProfileVerificationSettings,
1111
validate_agent_profile,
@@ -38,7 +38,7 @@
3838
"AgentProfileDiagnostics",
3939
"AgentProfileStore",
4040
"DanglingMcpServerRef",
41-
"LaunchedProfile",
41+
"LaunchedAgentProfile",
4242
"OpenHandsAgentProfile",
4343
"ProfileLimitExceeded",
4444
"ProfileNotFound",

openhands-sdk/openhands/sdk/profiles/agent_profile.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,20 +209,20 @@ class ACPAgentProfile(AgentProfileBase):
209209
)
210210

211211

212-
class LaunchedProfile(BaseModel):
213-
"""Provenance snapshot recorded when a profile launches a conversation.
212+
class LaunchedAgentProfile(BaseModel):
213+
"""Provenance snapshot recorded when an agent profile launches a conversation.
214214
215215
Stored on ``StoredConversation`` and projected onto ``ConversationInfo`` so
216-
ts-client ``deriveSwitchPlan`` can identify which profile is current without
217-
fragile settings-comparison. See #3720.
216+
ts-client ``deriveSwitchPlan`` can identify which agent profile is current
217+
without fragile settings-comparison. See #3720.
218218
"""
219219

220-
profile_id: UUID = Field(
221-
description="Stable id of the profile that launched the conversation."
220+
agent_profile_id: UUID = Field(
221+
description="Stable id of the agent profile that launched the conversation.",
222222
)
223223
revision: int = Field(
224224
ge=0,
225-
description="Revision of the profile at launch time.",
225+
description="Revision of the agent profile at launch time.",
226226
)
227227

228228

openhands-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "OpenHands SDK - Core functionality for building AI agents"
55

66
requires-python = ">=3.12"
77
dependencies = [
8-
"agent-client-protocol>=0.8.1",
8+
"agent-client-protocol>=0.10.1",
99
"deprecation>=2.1.0",
1010
"fakeredis[lua]>=2.32.1", # Explicit dependency for docket/fastmcp background tasks
1111
"fastmcp>=3.0.0",

0 commit comments

Comments
 (0)