Allow underscores in conversation tag keys#3621
Conversation
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@all-hands.dev>
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
||||||||||||||||||||||||||||||||||||||||
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ QA Report: PASS
Snake_case conversation tag keys now work through the SDK and local agent-server request path, while existing alphanumeric keys remain accepted.
Does this PR achieve its stated goal?
Yes. The PR set out to relax conversation tag keys so frontends can round-trip UI metadata like selected_workspace, active_profile, and git_provider through tags. I verified that those keys are rejected on origin/main but accepted on the PR branch via the public Conversation(...) SDK constructor, StartConversationRequest/agent-server request models, and real HTTP PATCH /api/conversations/{id} requests to a local agent server. I also verified existing mashed alphanumeric keys still work and invalid hyphen/uppercase/space keys remain rejected by request validation.
| Phase | Result |
|---|---|
| Environment Setup | ✅ make build completed successfully and installed the repo packages into .venv |
| CI Status | PR Description Check/Validate PR description), 7 pending, 1 skipped at time of QA |
| Functional Verification | ✅ SDK constructor, request-model validation, and local HTTP PATCH behavior verified before/after |
Functional Verification
Test 1: Public SDK Conversation(...) constructor accepts snake_case tags
Step 1 — Reproduce baseline without the fix:
Ran git checkout origin/main followed by this SDK script:
OPENHANDS_SUPPRESS_BANNER=1 uv run python - <<'PY'
from pydantic import ValidationError
from openhands.sdk import Agent, Conversation, LLM
agent = Agent(llm=LLM(model="gpt-4o-mini", api_key="dummy"), tools=[])
try:
conversation = Conversation(
agent=agent,
workspace="/tmp/workspace",
tags={"selected_workspace": "/tmp/workspace", "active_profile": "default"},
)
except ValidationError as exc:
error = exc.errors()[0]
print(f"Conversation constructor: REJECTED | loc={error['loc']} | msg={error['msg']}")
else:
print(f"Conversation constructor: ACCEPTED | state.tags={conversation.state.tags}")
PYOutput:
Conversation constructor: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected_workspace' is invalid: keys must be lowercase alphanumeric only
This confirms the old SDK behavior blocked natural snake_case frontend metadata keys.
Step 2 — Apply the PR's changes:
Checked out allow-underscore-tag-keys at 12ce50473a5f443a6fa9621273b31564fafe02c9.
Step 3 — Re-run with the fix in place:
Ran the same SDK script on the PR branch.
Output:
Conversation constructor: ACCEPTED | state.tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default'}
This confirms the public SDK conversation creation path now preserves snake_case tags.
Test 2: Start/update/fork request models accept snake_case and preserve compatibility
Step 1 — Reproduce baseline without the fix:
On origin/main, I created a real LocalWorkspace and built StartConversationRequest objects with agent_settings={}:
StartConversationRequest snake_case: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected_workspace' is invalid: keys must be lowercase alphanumeric only
StartConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default'}
This shows the old workaround keys worked, while the desired snake_case keys did not.
Step 2 — Apply the PR's changes:
Checked out the PR branch again.
Step 3 — Re-run with the fix in place:
Ran request-model construction for create/update/fork-style payloads with realistic frontend metadata:
StartConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default'}
StartConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default'}
UpdateConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default', 'git_provider': 'github'}
UpdateConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default', 'gitprovider': 'github'}
UpdateConversationRequest underscore-only: ACCEPTED | tags={'___': 'value'}
ForkConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default', 'git_provider': 'github'}
ForkConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default', 'gitprovider': 'github'}
UpdateConversationRequest invalid hyphen: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected-workspace' is invalid: keys must be lowercase alphanumeric (underscores allowed)
UpdateConversationRequest invalid uppercase: REJECTED | loc=('tags',) | msg=Value error, Tag key 'Active_Profile' is invalid: keys must be lowercase alphanumeric (underscores allowed)
UpdateConversationRequest invalid space: REJECTED | loc=('tags',) | msg=Value error, Tag key 'active profile' is invalid: keys must be lowercase alphanumeric (underscores allowed)
This confirms the intended additive behavior: underscores are accepted, previous alphanumeric keys still work, and the documented invalid forms are still rejected.
Test 3: Local agent-server HTTP PATCH no longer rejects snake_case tags
Step 1 — Reproduce baseline without the fix:
Started the base branch server with:
uv run python -m openhands.agent_server --host 127.0.0.1 --port 18081Then sent real PATCH requests to a dummy conversation id:
CID=00000000-0000-0000-0000-000000000001
curl -sS -X PATCH "http://127.0.0.1:18081/api/conversations/$CID" \
-H 'Content-Type: application/json' \
-d '{"tags":{"selected_workspace":"/tmp/workspace","active_profile":"default","git_provider":"github"}}' \
-w '\nHTTP %{http_code}\n'
curl -sS -X PATCH "http://127.0.0.1:18081/api/conversations/$CID" \
-H 'Content-Type: application/json' \
-d '{"tags":{"selectedworkspace":"/tmp/workspace","activeprofile":"default","gitprovider":"github"}}' \
-w '\nHTTP %{http_code}\n'Output:
BASE snake_case PATCH:
{"detail":"Internal Server Error","exception":"Object of type ValueError is not JSON serializable","error_id":"ecc35e50f78c475bbebdb9129e88dd78"}
HTTP 500
BASE mashed PATCH:
{"success":false}
HTTP 200
The dummy conversation does not exist, so {"success":false} is expected once request validation passes. The key before/after signal is that base snake_case failed during validation while mashed keys reached the route logic.
Step 2 — Apply the PR's changes:
Stopped the base server, checked out the PR branch, and started:
uv run python -m openhands.agent_server --host 127.0.0.1 --port 18080Step 3 — Re-run with the fix in place:
Ran the same snake_case PATCH against the PR server:
PR snake_case PATCH:
{"success":false}
HTTP 200
This shows snake_case tags now pass the agent-server request validation and reach the update route, matching the PR's goal.
I also checked an invalid hyphen key on both branches; it remained rejected on both. The local server currently serializes that validation failure as HTTP 500 rather than a clean 422, but that behavior existed before this PR and the invalid key is not accepted.
Issues Found
None blocking this PR's stated goal. Existing invalid-tag API errors returned HTTP 500 on both base and PR during local server QA; I did not treat that as PR-introduced.
This review was created by an AI agent (OpenHands) on behalf of the user.
Summary
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 likeselectedworkspace.Motivation
agent-canvas currently keeps a small per-conversation localStorage shim (
conversation-metadata-store.ts) for UI-only fields the agent runtime doesn't care about:selected_repository/selected_branch/git_provider— the home-page repo picker selectionselected_workspace— the local folder the user attached at conversation creation (used by the Files tab to default to diff view)active_profile— the LLM profile name the conversation was created with / last switched to (the chat-header switcher needs this because several profiles can share the same model — see agent-canvas#1082)These already round-trip cleanly through the existing
tagsfield onConversationInfo/UpdateConversationRequest, but the validator rejected the natural key names. The only mechanical workaround is to mash the names together (selectedworkspace,activeprofile), which is what we did foracpserverhistorically — readable but ugly, and it fans out a tag-key constants table that has to be kept in sync between the agent-server and every client.With underscores allowed, agent-canvas can drop the localStorage shim and round-trip all five fields through
tagsusing their normal names, completing the agent-canvas effort to eliminate per-conversation client-only state (related agent-canvas plan).Change
TAG_KEY_PATTERN:^[a-z0-9]+$→^[a-z0-9_]+$ConversationTags,ConversationState.tags,CreateConversationRequest.tags,UpdateConversationRequest.tags,ForkConversationRequest.tags, and theConversation/RemoteConversationtagskwargs updated to say "(underscores allowed)"Backward compatibility
Strictly additive: every key that was valid before is still valid. Existing tags written by older clients (including the
acpservertag agent-canvas already uses) continue to work unchanged. Hyphens, uppercase, and whitespace remain rejected.Tests
tests/sdk/conversation/test_tags.py— 13 passed (the underscore-rejection test became an acceptance test; one new test pins down the underscore-only edge case)tests/sdk/conversation/— full suite 564/564 passingtests/agent_server/— 1151/1152 passing (the one failure is an unrelated webhook timeout test that passes in isolation; not touched by this PR)This pull request was opened by an AI agent (OpenHands) on behalf of @rbren as part of a wider agent-canvas cleanup; see the companion agent-canvas PR for the consumer-side change that motivated it.
@chuckbutkus can click here to continue refining the PR
Agent Server images for this PR
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:12ce504-pythonRun
All tags pushed for this build
About Multi-Architecture Support
12ce504-python) is a multi-arch manifest supporting both amd64 and arm6412ce504-python-amd64) are also available if needed