From 12ce50473a5f443a6fa9621273b31564fafe02c9 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 10 Jun 2026 03:47:06 +0000 Subject: [PATCH] Allow underscores in conversation tag keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relax the conversation tag key validator from `^[a-z0-9]+$` to `^[a-z0-9_]+$` so frontends can attach UI-specific metadata to a conversation using natural snake_case keys (e.g. `selected_workspace`, `active_profile`) instead of mashed-together strings like `selectedworkspace`. This is backward compatible: every previously-valid key (lowercase alphanumeric, no underscores) still passes. The change unblocks agent-canvas's deprecation of its client-side `conversation-metadata-store` localStorage shim — it can now round-trip conversation metadata through the existing `tags` field on the server instead of duplicating state in the browser. Co-authored-by: openhands --- .../openhands/agent_server/models.py | 6 +++--- .../sdk/conversation/impl/local_conversation.py | 3 ++- .../sdk/conversation/impl/remote_conversation.py | 3 ++- .../openhands/sdk/conversation/request.py | 3 ++- .../openhands/sdk/conversation/state.py | 7 ++++--- .../openhands/sdk/conversation/types.py | 8 +++++--- tests/sdk/conversation/test_tags.py | 16 +++++++++++++--- 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index 35da059e8f..8aeb5ce139 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -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." ), ) @@ -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( diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 75cf646956..217fab00af 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -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 diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 502d8b7fc4..a5358b31eb 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -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 diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py index 9ff6b0a544..cf5baac805 100644 --- a/openhands-sdk/openhands/sdk/conversation/request.py +++ b/openhands-sdk/openhands/sdk/conversation/request.py @@ -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( diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py index ea14d21910..989dd5f7e0 100644 --- a/openhands-sdk/openhands/sdk/conversation/state.py +++ b/openhands-sdk/openhands/sdk/conversation/state.py @@ -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) @@ -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 diff --git a/openhands-sdk/openhands/sdk/conversation/types.py b/openhands-sdk/openhands/sdk/conversation/types.py index a2b8b7fddb..91900af368 100644 --- a/openhands-sdk/openhands/sdk/conversation/types.py +++ b/openhands-sdk/openhands/sdk/conversation/types.py @@ -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 @@ -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( @@ -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. """ diff --git a/tests/sdk/conversation/test_tags.py b/tests/sdk/conversation/test_tags.py index a38b379e3f..9bb3741210 100644 --- a/tests/sdk/conversation/test_tags.py +++ b/tests/sdk/conversation/test_tags.py @@ -34,9 +34,12 @@ 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(): @@ -44,6 +47,13 @@ def test_validate_tags_invalid_key_with_spaces(): _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})