Skip to content

Commit a4947fb

Browse files
Split SDK conversation settings from agent settings
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent f7d0f0a commit a4947fb

8 files changed

Lines changed: 343 additions & 14 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,8 @@ async def start(self):
498498
tags=self.stored.tags,
499499
)
500500

501-
# Set confirmation mode if enabled
502501
conversation.set_confirmation_policy(self.stored.confirmation_policy)
502+
conversation.set_security_analyzer(self.stored.security_analyzer)
503503
self._conversation = conversation
504504

505505
# Register state change callback to automatically publish updates

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
88

99
from openhands.agent_server.utils import OpenHandsUUID, utc_now
10-
from openhands.sdk import LLM, Agent, ImageContent, Message, TextContent
10+
from openhands.sdk import (
11+
LLM,
12+
Agent,
13+
ConversationSettings,
14+
ImageContent,
15+
Message,
16+
TextContent,
17+
)
1118
from openhands.sdk.agent.acp_agent import ACPAgent
1219
from openhands.sdk.conversation.conversation_stats import ConversationStats
1320
from openhands.sdk.conversation.secret_registry import SecretRegistry
@@ -105,6 +112,10 @@ class _StartConversationRequestBase(BaseModel):
105112
description="Controls when the conversation will prompt the user before "
106113
"continuing. Defaults to never.",
107114
)
115+
security_analyzer: SecurityAnalyzerBase | None = Field(
116+
default=None,
117+
description="Optional security analyzer to evaluate action risks.",
118+
)
108119
initial_message: SendMessageRequest | None = Field(
109120
default=None, description="Initial message to pass to the LLM"
110121
)
@@ -181,12 +192,42 @@ class StartConversationRequest(_StartConversationRequestBase):
181192

182193
agent: Agent
183194

195+
@classmethod
196+
def from_settings(
197+
cls,
198+
*,
199+
agent: Agent,
200+
workspace: LocalWorkspace,
201+
conversation_settings: ConversationSettings | None = None,
202+
**kwargs: Any,
203+
) -> "StartConversationRequest":
204+
payload = dict(kwargs)
205+
if conversation_settings is not None:
206+
for key, value in conversation_settings.to_start_request_kwargs().items():
207+
payload.setdefault(key, value)
208+
return cls(agent=agent, workspace=workspace, **payload)
209+
184210

185211
class StartACPConversationRequest(_StartConversationRequestBase):
186212
"""Payload to create a conversation with ACP-capable agent support."""
187213

188214
agent: ACPEnabledAgent
189215

216+
@classmethod
217+
def from_settings(
218+
cls,
219+
*,
220+
agent: ACPEnabledAgent,
221+
workspace: LocalWorkspace,
222+
conversation_settings: ConversationSettings | None = None,
223+
**kwargs: Any,
224+
) -> "StartACPConversationRequest":
225+
payload = dict(kwargs)
226+
if conversation_settings is not None:
227+
for key, value in conversation_settings.to_start_request_kwargs().items():
228+
payload.setdefault(key, value)
229+
return cls(agent=agent, workspace=workspace, **payload)
230+
190231

191232
class StoredConversation(StartACPConversationRequest):
192233
"""Stored details about a conversation.

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import APIRouter
44

5-
from openhands.sdk.settings import AgentSettings, SettingsSchema
5+
from openhands.sdk.settings import AgentSettings, ConversationSettings, SettingsSchema
66

77

88
settings_router = APIRouter(prefix="/settings", tags=["Settings"])
@@ -13,7 +13,18 @@ def _get_agent_settings_schema() -> SettingsSchema:
1313
return AgentSettings.export_schema()
1414

1515

16+
@lru_cache(maxsize=1)
17+
def _get_conversation_settings_schema() -> SettingsSchema:
18+
return ConversationSettings.export_schema()
19+
20+
1621
@settings_router.get("/schema", response_model=SettingsSchema)
1722
async def get_agent_settings_schema() -> SettingsSchema:
1823
"""Return the schema used to render AgentSettings-based settings forms."""
1924
return _get_agent_settings_schema()
25+
26+
27+
@settings_router.get("/conversation-schema", response_model=SettingsSchema)
28+
async def get_conversation_settings_schema() -> SettingsSchema:
29+
"""Return the schema used to render ConversationSettings-based forms."""
30+
return _get_conversation_settings_schema()

openhands-sdk/openhands/sdk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
from openhands.sdk.settings import (
5353
AgentSettings,
5454
CondenserSettings,
55+
ConversationSettings,
56+
ConversationVerificationSettings,
5557
SettingsChoice,
5658
SettingsFieldSchema,
5759
SettingsSchema,
@@ -137,6 +139,8 @@
137139
"AgentContext",
138140
"LLMSummarizingCondenser",
139141
"CondenserSettings",
142+
"ConversationSettings",
143+
"ConversationVerificationSettings",
140144
"VerificationSettings",
141145
"AgentSettings",
142146
"SettingsChoice",

openhands-sdk/openhands/sdk/settings/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from .model import (
1717
AgentSettings,
1818
CondenserSettings,
19+
ConversationSettings,
20+
ConversationVerificationSettings,
1921
SettingsChoice,
2022
SettingsFieldSchema,
2123
SettingsSchema,
@@ -27,6 +29,8 @@
2729
_MODEL_EXPORTS = {
2830
"AgentSettings",
2931
"CondenserSettings",
32+
"ConversationSettings",
33+
"ConversationVerificationSettings",
3034
"SettingsChoice",
3135
"SettingsFieldSchema",
3236
"SettingsSchema",
@@ -38,6 +42,8 @@
3842
__all__ = [
3943
"AgentSettings",
4044
"CondenserSettings",
45+
"ConversationSettings",
46+
"ConversationVerificationSettings",
4147
"SETTINGS_METADATA_KEY",
4248
"SETTINGS_SECTION_METADATA_KEY",
4349
"SettingProminence",

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

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class CondenserSettings(BaseModel):
100100

101101

102102
class VerificationSettings(BaseModel):
103-
"""Combined critic and security settings."""
103+
"""Critic and iterative-refinement settings for the agent."""
104104

105105
# -- Critic --
106106
critic_enabled: bool = Field(
@@ -192,7 +192,10 @@ class VerificationSettings(BaseModel):
192192
},
193193
)
194194

195-
# -- Security --
195+
196+
class ConversationVerificationSettings(BaseModel):
197+
"""Conversation-level confirmation and security settings."""
198+
196199
confirmation_mode: bool = Field(
197200
default=False,
198201
description="Require user confirmation before executing risky actions.",
@@ -204,7 +207,7 @@ class VerificationSettings(BaseModel):
204207
},
205208
)
206209
security_analyzer: SecurityAnalyzerType | None = Field(
207-
default=None,
210+
default="llm",
208211
description="Security analyzer that evaluates actions before execution.",
209212
json_schema_extra={
210213
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
@@ -375,6 +378,161 @@ def _diff_payload(base: Mapping[str, Any], target: Mapping[str, Any]) -> dict[st
375378
return diff
376379

377380

381+
_LEGACY_CONVERSATION_SETTINGS_VERSION = 1
382+
_CURRENT_CONVERSATION_SETTINGS_VERSION = 1
383+
384+
385+
def _coerce_persisted_conversation_settings_payload(
386+
payload: Mapping[str, Any],
387+
) -> dict[str, Any]:
388+
if (
389+
_LEGACY_WRAPPED_SETTINGS_VERSION_KEY in payload
390+
or _LEGACY_WRAPPED_SETTINGS_SETTINGS_KEY in payload
391+
):
392+
settings_payload = payload.get(_LEGACY_WRAPPED_SETTINGS_SETTINGS_KEY)
393+
if not isinstance(settings_payload, Mapping):
394+
raise TypeError(
395+
"Persisted ConversationSettings settings payload must be a mapping."
396+
)
397+
version = payload.get(_LEGACY_WRAPPED_SETTINGS_VERSION_KEY)
398+
if version is None:
399+
return dict(settings_payload)
400+
if not isinstance(version, int) or isinstance(version, bool):
401+
raise TypeError(
402+
"Persisted ConversationSettings version must be an integer"
403+
" when provided."
404+
)
405+
migrated_payload = dict(settings_payload)
406+
migrated_payload[_PERSISTED_AGENT_SETTINGS_VERSION_KEY] = version
407+
return migrated_payload
408+
409+
return dict(payload)
410+
411+
412+
def _migrate_persisted_conversation_settings_payload(
413+
payload: Mapping[str, Any],
414+
) -> dict[str, Any]:
415+
migrated_payload = _coerce_persisted_conversation_settings_payload(payload)
416+
version = migrated_payload.get(
417+
_PERSISTED_AGENT_SETTINGS_VERSION_KEY,
418+
_LEGACY_CONVERSATION_SETTINGS_VERSION,
419+
)
420+
if not isinstance(version, int) or isinstance(version, bool):
421+
raise TypeError(
422+
"Persisted ConversationSettings schema_version must be an integer."
423+
)
424+
if version < _LEGACY_CONVERSATION_SETTINGS_VERSION:
425+
raise ValueError(
426+
f"Unsupported persisted ConversationSettings version {version}."
427+
)
428+
if version > _CURRENT_CONVERSATION_SETTINGS_VERSION:
429+
raise ValueError(
430+
"Persisted ConversationSettings version is newer than this SDK supports."
431+
)
432+
433+
migrated_payload[_PERSISTED_AGENT_SETTINGS_VERSION_KEY] = (
434+
_CURRENT_CONVERSATION_SETTINGS_VERSION
435+
)
436+
return migrated_payload
437+
438+
439+
class ConversationSettings(BaseModel):
440+
CURRENT_PERSISTED_VERSION: ClassVar[int] = _CURRENT_CONVERSATION_SETTINGS_VERSION
441+
442+
schema_version: int = Field(default=_CURRENT_CONVERSATION_SETTINGS_VERSION, ge=1)
443+
max_iterations: int = Field(
444+
default=500,
445+
ge=1,
446+
description=(
447+
"Maximum number of iterations the conversation will run before stopping."
448+
),
449+
json_schema_extra={
450+
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
451+
label="Max iterations",
452+
prominence=SettingProminence.MAJOR,
453+
).model_dump()
454+
},
455+
)
456+
verification: ConversationVerificationSettings = Field(
457+
default_factory=ConversationVerificationSettings,
458+
description="Conversation confirmation and security settings.",
459+
json_schema_extra={
460+
SETTINGS_SECTION_METADATA_KEY: SettingsSectionMetadata(
461+
key="verification",
462+
label="Verification",
463+
).model_dump()
464+
},
465+
)
466+
467+
@classmethod
468+
def export_schema(cls) -> SettingsSchema:
469+
"""Export a structured schema describing configurable conversation settings."""
470+
return export_settings_schema(cls)
471+
472+
@classmethod
473+
def migrate_persisted_payload(cls, payload: Mapping[str, Any]) -> dict[str, Any]:
474+
"""Return the latest canonical persisted ConversationSettings payload."""
475+
return _migrate_persisted_conversation_settings_payload(payload)
476+
477+
@classmethod
478+
def from_persisted(cls, payload: Mapping[str, Any]) -> ConversationSettings:
479+
"""Load persisted ConversationSettings after applying SDK-owned migrations."""
480+
return cls.model_validate(cls.migrate_persisted_payload(payload))
481+
482+
def patch(self, payload: Mapping[str, Any]) -> ConversationSettings:
483+
"""Return a new settings object with a persisted patch applied."""
484+
base_payload = self.model_dump(mode="json")
485+
merged_payload = _merge_patch_payload(
486+
base_payload, _normalize_patch_payload(payload)
487+
)
488+
merged_payload[_PERSISTED_AGENT_SETTINGS_VERSION_KEY] = (
489+
self.CURRENT_PERSISTED_VERSION
490+
)
491+
return type(self).from_persisted(merged_payload)
492+
493+
def diff(self, target: ConversationSettings | Mapping[str, Any]) -> dict[str, Any]:
494+
"""Return the minimal persisted patch from these settings to ``target``."""
495+
target_settings = (
496+
target
497+
if isinstance(target, ConversationSettings)
498+
else type(self).from_persisted(target)
499+
)
500+
base_payload = self.model_dump(mode="json")
501+
target_payload = target_settings.model_dump(mode="json")
502+
return _diff_payload(base_payload, target_payload)
503+
504+
def build_confirmation_policy(self):
505+
from openhands.sdk.security.confirmation_policy import (
506+
AlwaysConfirm,
507+
ConfirmRisky,
508+
NeverConfirm,
509+
)
510+
511+
if not self.verification.confirmation_mode:
512+
return NeverConfirm()
513+
if (self.verification.security_analyzer or "").lower() == "llm":
514+
return ConfirmRisky()
515+
return AlwaysConfirm()
516+
517+
def build_security_analyzer(self):
518+
analyzer_kind = (self.verification.security_analyzer or "").lower()
519+
if not analyzer_kind or analyzer_kind == "none":
520+
return None
521+
if analyzer_kind == "llm":
522+
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
523+
524+
return LLMSecurityAnalyzer()
525+
return None
526+
527+
def to_start_request_kwargs(self) -> dict[str, Any]:
528+
"""Return StartConversationRequest-compatible kwargs for these settings."""
529+
return {
530+
"confirmation_policy": self.build_confirmation_policy(),
531+
"security_analyzer": self.build_security_analyzer(),
532+
"max_iterations": self.max_iterations,
533+
}
534+
535+
378536
class AgentSettings(BaseModel):
379537
CURRENT_PERSISTED_VERSION: ClassVar[int] = _CURRENT_AGENT_SETTINGS_VERSION
380538

tests/agent_server/test_settings_router.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ def test_get_agent_settings_schema():
1818
assert "condenser" in section_keys
1919
assert "verification" in section_keys
2020

21+
verification_section = next(
22+
section for section in body["sections"] if section["key"] == "verification"
23+
)
24+
verification_field_keys = {field["key"] for field in verification_section["fields"]}
25+
assert "verification.critic_enabled" in verification_field_keys
26+
assert "verification.confirmation_mode" not in verification_field_keys
27+
assert "verification.security_analyzer" not in verification_field_keys
28+
29+
30+
def test_get_conversation_settings_schema():
31+
client = TestClient(create_app(Config(static_files_path=None, session_api_keys=[])))
32+
33+
response = client.get("/api/settings/conversation-schema")
34+
35+
assert response.status_code == 200
36+
body = response.json()
37+
assert body["model_name"] == "ConversationSettings"
38+
39+
section_keys = [section["key"] for section in body["sections"]]
40+
assert section_keys == ["general", "verification"]
41+
2142
verification_section = next(
2243
section for section in body["sections"] if section["key"] == "verification"
2344
)

0 commit comments

Comments
 (0)