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
6 changes: 3 additions & 3 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,8 @@ class UpdateConversationRequest(BaseModel):
default=None,
description=(
"Key-value tags to set on the conversation. Keys must be lowercase "
"alphanumeric. Values are arbitrary strings up to 256 characters. "
"Replaces all existing tags when provided."
"alphanumeric (underscores allowed). Values are arbitrary strings "
"up to 256 characters. Replaces all existing tags when provided."
),
)

Expand All @@ -432,7 +432,7 @@ class ForkConversationRequest(BaseModel):
default=None,
description=(
"Optional tags for the forked conversation. Keys must be "
"lowercase alphanumeric."
"lowercase alphanumeric (underscores allowed)."
),
)
reset_metrics: bool = Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ def __init__(
decrypted when loading. If not provided, secrets are redacted
(lost) on serialization.
tags: Optional key-value tags for the conversation. Keys must be
lowercase alphanumeric, values up to 256 characters.
lowercase alphanumeric (underscores allowed),
values up to 256 characters.
client_tools: Optional list of client-defined tool specs. Each spec
is registered and injected into the agent so it can call the
tool; the executor returns an acknowledgment and the real
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,8 @@ def __init__(
- None: No visualization
secrets: Optional secrets to initialize the conversation with
tags: Optional key-value tags for the conversation. Keys must be
lowercase alphanumeric, values up to 256 characters.
lowercase alphanumeric (underscores allowed),
values up to 256 characters.
user_id: Optional user ID to associate with observability traces
client_tools: Optional list of client-defined tool specs. These tools
have no server-side executor — when the agent calls them an
Expand Down
3 changes: 2 additions & 1 deletion openhands-sdk/openhands/sdk/conversation/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ class StartConversationRequest(BaseModel):
default_factory=dict,
description=(
"Key-value tags for the conversation. Keys must be lowercase "
"alphanumeric. Values are arbitrary strings up to 256 characters."
"alphanumeric (underscores allowed). Values are arbitrary strings "
"up to 256 characters."
),
)
user_id: str | None = Field(
Expand Down
7 changes: 4 additions & 3 deletions openhands-sdk/openhands/sdk/conversation/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ class ConversationState(OpenHandsModel):
tags: ConversationTags = Field(
default_factory=dict,
description="User-defined key-value tags for the conversation. "
"Keys must be lowercase alphanumeric. Values are arbitrary strings "
"up to 256 characters.",
"Keys must be lowercase alphanumeric (underscores allowed). "
"Values are arbitrary strings up to 256 characters.",
)

# Agent-specific runtime state (simple dict for flexibility)
Expand Down Expand Up @@ -380,7 +380,8 @@ def create(
saving and decrypted when loading. If not provided, secrets
are redacted (lost) on serialization.
tags: Optional key-value tags for the conversation. Keys must be
lowercase alphanumeric, values up to 256 characters.
lowercase alphanumeric (underscores allowed),
values up to 256 characters.

Returns:
ConversationState ready for use
Expand Down
8 changes: 5 additions & 3 deletions openhands-sdk/openhands/sdk/conversation/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
ConversationID = uuid.UUID
"""Type alias for conversation IDs."""

TAG_KEY_PATTERN = re.compile(r"^[a-z0-9]+$")
TAG_KEY_PATTERN = re.compile(r"^[a-z0-9_]+$")
TAG_VALUE_MAX_LENGTH = 256


Expand All @@ -28,7 +28,8 @@ def _validate_tags(v: dict[str, str] | None) -> dict[str, str]:
for key, value in v.items():
if not TAG_KEY_PATTERN.match(key):
raise ValueError(
f"Tag key '{key}' is invalid: keys must be lowercase alphanumeric only"
f"Tag key '{key}' is invalid: keys must be lowercase alphanumeric "
"(underscores allowed)"
)
if len(value) > TAG_VALUE_MAX_LENGTH:
raise ValueError(
Expand All @@ -41,7 +42,8 @@ def _validate_tags(v: dict[str, str] | None) -> dict[str, str]:
ConversationTags = Annotated[dict[str, str], BeforeValidator(_validate_tags)]
"""Validated dict of conversation tags.

Keys must be lowercase alphanumeric. Values are arbitrary strings up to 256 chars.
Keys must be lowercase alphanumeric, with underscores allowed
(e.g. ``selected_workspace``). Values are arbitrary strings up to 256 chars.
"""


Expand Down
16 changes: 13 additions & 3 deletions tests/sdk/conversation/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,26 @@ def test_validate_tags_invalid_key_with_hyphen():
_validate_tags({"my-key": "value"})


def test_validate_tags_invalid_key_with_underscore():
with pytest.raises(ValueError, match="lowercase alphanumeric"):
_validate_tags({"my_key": "value"})
def test_validate_tags_valid_key_with_underscore():
"""Underscores are allowed so frontends can use snake_case keys like
``selected_workspace`` and ``active_profile`` to attach UI-specific
metadata to a conversation without a separate localStorage store."""
result = _validate_tags({"my_key": "value", "selected_workspace": "/foo"})
assert result == {"my_key": "value", "selected_workspace": "/foo"}


def test_validate_tags_invalid_key_with_spaces():
with pytest.raises(ValueError, match="lowercase alphanumeric"):
_validate_tags({"my key": "value"})


def test_validate_tags_invalid_key_only_underscores():
"""An underscore-only key is still considered a valid identifier under
the relaxed pattern; assert behaviour so future tightening surfaces."""
result = _validate_tags({"___": "value"})
assert result == {"___": "value"}


def test_validate_tags_value_max_length():
long_value = "x" * TAG_VALUE_MAX_LENGTH
result = _validate_tags({"key": long_value})
Expand Down
Loading