Skip to content

Commit deaf33e

Browse files
fix(sdk): version persisted AgentSettings payloads
Add an SDK-owned migration flow for persisted AgentSettings payloads. Legacy unversioned settings now load as v1 and are re-serialized into the canonical v2 {version, settings} envelope via AgentSettings.load_persisted() and dump_persisted(). Co-authored-by: openhands <openhands@all-hands.dev>
1 parent e220bda commit deaf33e

File tree

3 files changed

+133
-1
lines changed

3 files changed

+133
-1
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ When reviewing code, provide constructive feedback:
106106
- `SettingsFieldSchema` intentionally does not export a `required` flag. If a consumer needs nullability semantics, inspect the underlying Python typing rather than inferring from SDK defaults.
107107
- `AgentSettings.tools` is part of the exported settings schema so the schema stays aligned with the settings payload that round-trips through `AgentSettings` and drives `create_agent()`.
108108
- `AgentSettings.mcp_config` now uses FastMCP's typed `MCPConfig` at runtime. When serializing settings back to plain data (e.g. `model_dump()` or `create_agent()`), keep the output compact with `exclude_none=True, exclude_defaults=True` so callers still see the familiar `.mcp.json`-style dict shape.
109+
- Persisted `AgentSettings` now have an SDK-owned canonical migration contract: legacy v1 payloads are the raw unversioned settings mapping, while the current canonical format is v2 `{\"version\": 2, \"settings\": ...}`. Use `AgentSettings.load_persisted()`/`dump_persisted()` so consumers share the same upgrade path and preserve sparse overlays with `exclude_unset=True`.
109110
- Anthropic malformed tool-use/tool-result history errors (for example, missing or duplicated ``tool_result`` blocks) are intentionally mapped to a dedicated `LLMMalformedConversationHistoryError` and caught separately in `Agent.step()`, so recovery can still use condensation while logs preserve that this was malformed history rather than a true context-window overflow.
110111
- AgentSkills progressive disclosure goes through `AgentContext.get_system_message_suffix()` into `<available_skills>`, and `openhands.sdk.context.skills.to_prompt()` truncates each prompt description to 1024 characters because the AgentSkills specification caps `description` at 1-1024 characters.
111112
- Workspace-wide uv resolver guardrails belong in the repository root `[tool.uv]` table. When `exclude-newer` is configured there, `uv lock` persists it into the root `uv.lock` `[options]` section as both an absolute cutoff and `exclude-newer-span`, and `uv sync --frozen` continues to use that locked workspace state.

openhands-sdk/openhands/sdk/settings/model.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3+
from collections.abc import Callable, Mapping
34
from enum import Enum
45
from pathlib import Path
5-
from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
6+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, get_args, get_origin
67

78
from fastmcp.mcp_config import MCPConfig
89
from pydantic import BaseModel, Field, SecretStr, field_serializer, field_validator
@@ -220,7 +221,85 @@ def _default_llm_settings() -> LLM:
220221
return LLM(model=model)
221222

222223

224+
# Canonical persisted AgentSettings payload contract:
225+
# - v1 (legacy): raw, unversioned AgentSettings mapping
226+
# - v2 (current): {"version": 2, "settings": <partial AgentSettings mapping>}
227+
_LEGACY_AGENT_SETTINGS_VERSION = 1
228+
_CURRENT_AGENT_SETTINGS_VERSION = 2
229+
_PERSISTED_AGENT_SETTINGS_VERSION_KEY = "version"
230+
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY = "settings"
231+
232+
233+
def _migrate_agent_settings_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
234+
return payload
235+
236+
237+
_AGENT_SETTINGS_MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {
238+
_LEGACY_AGENT_SETTINGS_VERSION: _migrate_agent_settings_v1_to_v2,
239+
}
240+
241+
242+
def _coerce_persisted_agent_settings_payload(
243+
payload: Mapping[str, Any],
244+
) -> tuple[int, dict[str, Any]]:
245+
if (
246+
_PERSISTED_AGENT_SETTINGS_VERSION_KEY in payload
247+
or _PERSISTED_AGENT_SETTINGS_SETTINGS_KEY in payload
248+
):
249+
version = payload.get(_PERSISTED_AGENT_SETTINGS_VERSION_KEY)
250+
if not isinstance(version, int) or isinstance(version, bool):
251+
raise TypeError(
252+
"Persisted AgentSettings version must be an integer when provided."
253+
)
254+
if version < _LEGACY_AGENT_SETTINGS_VERSION:
255+
raise ValueError(f"Unsupported persisted AgentSettings version {version}.")
256+
settings_payload = payload.get(_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY)
257+
if not isinstance(settings_payload, Mapping):
258+
raise TypeError(
259+
"Persisted AgentSettings settings payload must be a mapping."
260+
)
261+
return version, dict(settings_payload)
262+
263+
return _LEGACY_AGENT_SETTINGS_VERSION, dict(payload)
264+
265+
266+
def _migrate_persisted_agent_settings_payload(
267+
payload: Mapping[str, Any],
268+
) -> dict[str, Any]:
269+
version, settings_payload = _coerce_persisted_agent_settings_payload(payload)
270+
271+
if version > _CURRENT_AGENT_SETTINGS_VERSION:
272+
raise ValueError(
273+
"Persisted AgentSettings version is newer than this SDK supports."
274+
)
275+
276+
while version < _CURRENT_AGENT_SETTINGS_VERSION:
277+
migrator = _AGENT_SETTINGS_MIGRATIONS.get(version)
278+
if migrator is None:
279+
raise ValueError(f"Missing AgentSettings migrator for version {version}.")
280+
settings_payload = migrator(settings_payload)
281+
version += 1
282+
283+
return {
284+
_PERSISTED_AGENT_SETTINGS_VERSION_KEY: version,
285+
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY: settings_payload,
286+
}
287+
288+
289+
def _dump_persisted_agent_settings_payload(settings: AgentSettings) -> dict[str, Any]:
290+
return {
291+
_PERSISTED_AGENT_SETTINGS_VERSION_KEY: settings.CURRENT_PERSISTED_VERSION,
292+
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY: settings.model_dump(
293+
mode="json",
294+
exclude_unset=True,
295+
context={"expose_secrets": True},
296+
),
297+
}
298+
299+
223300
class AgentSettings(BaseModel):
301+
CURRENT_PERSISTED_VERSION: ClassVar[int] = _CURRENT_AGENT_SETTINGS_VERSION
302+
224303
agent: str = Field(
225304
default="CodeActAgent",
226305
description="Agent class to use.",
@@ -304,6 +383,28 @@ def export_schema(cls) -> SettingsSchema:
304383
"""Export a structured schema describing configurable agent settings."""
305384
return export_settings_schema(cls)
306385

386+
@classmethod
387+
def migrate_persisted_payload(cls, payload: Mapping[str, Any]) -> dict[str, Any]:
388+
"""Return the latest canonical persisted AgentSettings payload.
389+
390+
Legacy v1 payloads were stored as the raw, unversioned AgentSettings
391+
mapping. The current canonical v2 payload stores that mapping under a
392+
top-level ``settings`` key alongside an integer ``version``.
393+
"""
394+
return _migrate_persisted_agent_settings_payload(payload)
395+
396+
@classmethod
397+
def load_persisted(cls, payload: Mapping[str, Any]) -> AgentSettings:
398+
"""Load persisted AgentSettings after applying SDK-owned migrations."""
399+
migrated_payload = cls.migrate_persisted_payload(payload)
400+
settings_payload = migrated_payload[_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY]
401+
assert isinstance(settings_payload, dict)
402+
return cls.model_validate(settings_payload)
403+
404+
def dump_persisted(self) -> dict[str, Any]:
405+
"""Dump AgentSettings in the latest canonical persisted payload format."""
406+
return _dump_persisted_agent_settings_payload(self)
407+
307408
def create_agent(self) -> Agent:
308409
"""Build an :class:`Agent` purely from these settings.
309410

tests/sdk/test_settings.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,33 @@ def test_roundtrip_preserves_llm_model() -> None:
241241
data = settings.model_dump()
242242
restored = AgentSettings.model_validate(data)
243243
assert restored.llm.model == "test-model"
244+
245+
246+
def test_load_persisted_agent_settings_migrates_legacy_payload() -> None:
247+
legacy_payload = {
248+
"llm": {"model": "legacy-model", "api_key": "secret-key"},
249+
"verification": {"critic_enabled": True},
250+
}
251+
252+
settings = AgentSettings.load_persisted(legacy_payload)
253+
254+
assert settings.llm.model == "legacy-model"
255+
assert isinstance(settings.llm.api_key, SecretStr)
256+
assert settings.llm.api_key.get_secret_value() == "secret-key"
257+
assert settings.verification.critic_enabled is True
258+
assert settings.dump_persisted() == {
259+
"version": AgentSettings.CURRENT_PERSISTED_VERSION,
260+
"settings": legacy_payload,
261+
}
262+
263+
264+
def test_load_persisted_agent_settings_accepts_current_payload() -> None:
265+
current_payload = {
266+
"version": AgentSettings.CURRENT_PERSISTED_VERSION,
267+
"settings": {"llm": {"model": "test-model"}},
268+
}
269+
270+
settings = AgentSettings.load_persisted(current_payload)
271+
272+
assert settings.llm.model == "test-model"
273+
assert settings.dump_persisted() == current_payload

0 commit comments

Comments
 (0)