Skip to content
Draft
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ When reviewing code, provide constructive feedback:
- `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.
- `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()`.
- `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.
- 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`.
- Keep the persisted-settings contract wrapper-based and product-neutral; do not fold persistence-only version metadata into the runtime `AgentSettings` model or add consumer-specific patch/diff helpers there.

- 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.
- 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.
- 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.
Expand Down
103 changes: 102 additions & 1 deletion openhands-sdk/openhands/sdk/settings/model.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from collections.abc import Callable, Mapping
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
from typing import TYPE_CHECKING, Any, ClassVar, Literal, get_args, get_origin

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


# Canonical persisted AgentSettings payload contract:
# - v1 (legacy): raw, unversioned AgentSettings mapping
# - v2 (current): {"version": 2, "settings": <partial AgentSettings mapping>}
_LEGACY_AGENT_SETTINGS_VERSION = 1
_CURRENT_AGENT_SETTINGS_VERSION = 2
_PERSISTED_AGENT_SETTINGS_VERSION_KEY = "version"
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY = "settings"


def _migrate_agent_settings_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
return payload


_AGENT_SETTINGS_MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {
_LEGACY_AGENT_SETTINGS_VERSION: _migrate_agent_settings_v1_to_v2,
}


def _coerce_persisted_agent_settings_payload(
payload: Mapping[str, Any],
) -> tuple[int, dict[str, Any]]:
if (
_PERSISTED_AGENT_SETTINGS_VERSION_KEY in payload
or _PERSISTED_AGENT_SETTINGS_SETTINGS_KEY in payload
):
version = payload.get(_PERSISTED_AGENT_SETTINGS_VERSION_KEY)
if not isinstance(version, int) or isinstance(version, bool):
raise TypeError(
"Persisted AgentSettings version must be an integer when provided."
)
if version < _LEGACY_AGENT_SETTINGS_VERSION:
raise ValueError(f"Unsupported persisted AgentSettings version {version}.")
settings_payload = payload.get(_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY)
if not isinstance(settings_payload, Mapping):
raise TypeError(
"Persisted AgentSettings settings payload must be a mapping."
)
return version, dict(settings_payload)

return _LEGACY_AGENT_SETTINGS_VERSION, dict(payload)


def _migrate_persisted_agent_settings_payload(
payload: Mapping[str, Any],
) -> dict[str, Any]:
version, settings_payload = _coerce_persisted_agent_settings_payload(payload)

if version > _CURRENT_AGENT_SETTINGS_VERSION:
raise ValueError(
"Persisted AgentSettings version is newer than this SDK supports."
)

while version < _CURRENT_AGENT_SETTINGS_VERSION:
migrator = _AGENT_SETTINGS_MIGRATIONS.get(version)
if migrator is None:
raise ValueError(f"Missing AgentSettings migrator for version {version}.")
settings_payload = migrator(settings_payload)
version += 1

return {
_PERSISTED_AGENT_SETTINGS_VERSION_KEY: version,
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY: settings_payload,
}


def _dump_persisted_agent_settings_payload(settings: AgentSettings) -> dict[str, Any]:
return {
_PERSISTED_AGENT_SETTINGS_VERSION_KEY: settings.CURRENT_PERSISTED_VERSION,
_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY: settings.model_dump(
mode="json",
exclude_unset=True,
context={"expose_secrets": True},
),
}


class AgentSettings(BaseModel):
CURRENT_PERSISTED_VERSION: ClassVar[int] = _CURRENT_AGENT_SETTINGS_VERSION

agent: str = Field(
default="CodeActAgent",
description="Agent class to use.",
Expand Down Expand Up @@ -304,6 +383,28 @@ def export_schema(cls) -> SettingsSchema:
"""Export a structured schema describing configurable agent settings."""
return export_settings_schema(cls)

@classmethod
def migrate_persisted_payload(cls, payload: Mapping[str, Any]) -> dict[str, Any]:
"""Return the latest canonical persisted AgentSettings payload.

Legacy v1 payloads were stored as the raw, unversioned AgentSettings
mapping. The current canonical v2 payload stores that mapping under a
top-level ``settings`` key alongside an integer ``version``.
"""
return _migrate_persisted_agent_settings_payload(payload)

@classmethod
def load_persisted(cls, payload: Mapping[str, Any]) -> AgentSettings:
"""Load persisted AgentSettings after applying SDK-owned migrations."""
migrated_payload = cls.migrate_persisted_payload(payload)
settings_payload = migrated_payload[_PERSISTED_AGENT_SETTINGS_SETTINGS_KEY]
assert isinstance(settings_payload, dict)
return cls.model_validate(settings_payload)

def dump_persisted(self) -> dict[str, Any]:
"""Dump AgentSettings in the latest canonical persisted payload format."""
return _dump_persisted_agent_settings_payload(self)

def create_agent(self) -> Agent:
"""Build an :class:`Agent` purely from these settings.

Expand Down
30 changes: 30 additions & 0 deletions tests/sdk/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,33 @@ def test_roundtrip_preserves_llm_model() -> None:
data = settings.model_dump()
restored = AgentSettings.model_validate(data)
assert restored.llm.model == "test-model"


def test_load_persisted_agent_settings_migrates_legacy_payload() -> None:
legacy_payload = {
"llm": {"model": "legacy-model", "api_key": "secret-key"},
"verification": {"critic_enabled": True},
}

settings = AgentSettings.load_persisted(legacy_payload)

assert settings.llm.model == "legacy-model"
assert isinstance(settings.llm.api_key, SecretStr)
assert settings.llm.api_key.get_secret_value() == "secret-key"
assert settings.verification.critic_enabled is True
assert settings.dump_persisted() == {
"version": AgentSettings.CURRENT_PERSISTED_VERSION,
"settings": legacy_payload,
}


def test_load_persisted_agent_settings_accepts_current_payload() -> None:
current_payload = {
"version": AgentSettings.CURRENT_PERSISTED_VERSION,
"settings": {"llm": {"model": "test-model"}},
}

settings = AgentSettings.load_persisted(current_payload)

assert settings.llm.model == "test-model"
assert settings.dump_persisted() == current_payload
Loading