Skip to content
Open
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
24 changes: 24 additions & 0 deletions openhands-agent-server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ test) captures the expected behaviour.
- In async routes/services, move state-lock acquisition into `run_in_executor(...)` (or another worker-thread boundary) before awaiting network I/O.


## Per-key conversation tag endpoints

Two sub-resource endpoints allow atomic single-key mutations on the conversation
tag map without a read-modify-write round-trip:

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/conversations/{id}/tags/{key}` | Set or overwrite one tag; body: `{"value": "..."}` |
| `DELETE` | `/api/conversations/{id}/tags/{key}` | Remove one tag |

Both operations leave all other tags untouched and **do not update `updated_at`**
on the conversation (tag mutations are metadata-only and must not perturb
sort order or localStorage-to-server migration checks).

Key constraints: lowercase alphanumeric only (matches `TAG_KEY_PATTERN`); value
up to 256 characters.

Response codes:
- `200` — success
- `404` — conversation not found, or (DELETE only) key not present
- `422` — key fails the pattern validation

The existing `PATCH /api/conversations/{id}` full-replace behaviour is unchanged.

## REST API compatibility & deprecation policy

The agent-server **REST API** (the FastAPI OpenAPI surface under `/api/**`) is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
SendMessageRequest,
SetConfirmationPolicyRequest,
SetSecurityAnalyzerRequest,
SetTagRequest,
StartConversationRequest,
StartGoalRequest,
Success,
Expand All @@ -46,6 +47,7 @@
)
from openhands.sdk import LLM, Agent, TextContent
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.conversation.types import TAG_KEY_PATTERN
from openhands.sdk.marketplace.registry import (
MarketplaceNotFoundError,
PluginNotFoundError,
Expand Down Expand Up @@ -611,6 +613,62 @@ async def update_conversation(
return Success()


@conversation_router.post(
"/{conversation_id}/tags/{key}",
responses={404: {"description": "Conversation not found"}},
)
async def set_conversation_tag(
conversation_id: UUID,
key: str,
request: SetTagRequest,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> Success:
"""Set or overwrite a single tag on a conversation.

All other tags on the conversation are left unchanged.
Does not update the conversation's ``updated_at`` timestamp.
"""
if not TAG_KEY_PATTERN.match(key):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tag key must be lowercase alphanumeric only",
)
updated = await conversation_service.set_conversation_tag(
conversation_id, key, request.value
)
if not updated:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return Success()


@conversation_router.delete(
"/{conversation_id}/tags/{key}",
responses={404: {"description": "Conversation or tag key not found"}},
)
async def delete_conversation_tag(
conversation_id: UUID,
key: str,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> Success:
"""Remove a single tag from a conversation.

All other tags on the conversation are left unchanged.
Returns 404 if the conversation does not exist or the key is not present.
Does not update the conversation's ``updated_at`` timestamp.
"""
if not TAG_KEY_PATTERN.match(key):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tag key must be lowercase alphanumeric only",
)
result = await conversation_service.delete_conversation_tag(conversation_id, key)
if result is False:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found")
if result is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Tag key not found")
return Success()


@conversation_router.post(
"/{conversation_id}/ask_agent",
responses={404: {"description": "Item not found"}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1851,6 +1851,61 @@ async def update_conversation(
)
return True

async def _flush_tags_mutation(
self, event_service: "EventService", conversation_id: UUID
) -> None:
loop = asyncio.get_running_loop()
state = await event_service.get_state()
new_tags = dict(event_service.stored.tags)
await loop.run_in_executor(None, _update_state_tags_sync, state, new_tags)
record = self._conversation_records.get(conversation_id)
if record is not None:
record.stored = event_service.stored
record.cached_info = None
await event_service.save_meta()
conversation_info = await loop.run_in_executor(
None, _compose_webhook_conversation_info_sync, event_service.stored, state
)
await self._notify_conversation_webhooks(conversation_info)
Comment thread
BSmick6 marked this conversation as resolved.
Comment thread
BSmick6 marked this conversation as resolved.

async def set_conversation_tag(
self, conversation_id: UUID, key: str, value: str
) -> bool:
"""Set a single tag on a conversation without touching ``updated_at``.

Returns:
bool: True if the tag was set, False if the conversation was not found.
"""
event_service = await self._get_or_load_event_service(conversation_id)
if event_service is None:
return False

event_service.stored.tags[key] = value
await self._flush_tags_mutation(event_service, conversation_id)
logger.info("Set tag '%s' on conversation %s", key, conversation_id)
Comment thread
BSmick6 marked this conversation as resolved.
return True

async def delete_conversation_tag(
self, conversation_id: UUID, key: str
) -> bool | None:
"""Remove a single tag from a conversation without touching ``updated_at``.

Returns:
True if the tag was removed, None if the key did not exist,
False if the conversation was not found.
"""
event_service = await self._get_or_load_event_service(conversation_id)
if event_service is None:
return False

if key not in event_service.stored.tags:
return None

del event_service.stored.tags[key]
await self._flush_tags_mutation(event_service, conversation_id)
logger.info("Deleted tag '%s' from conversation %s", key, conversation_id)
return True

async def get_event_service(self, conversation_id: UUID) -> EventService | None:
return await self._get_or_load_event_service(conversation_id)

Expand Down
9 changes: 9 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,15 @@ class SetSecurityAnalyzerRequest(BaseModel):
)


class SetTagRequest(BaseModel):
"""Payload to set a single conversation tag."""

value: str = Field(
description="Tag value (arbitrary string, up to 256 characters)",
max_length=256,
)


class UpdateConversationRequest(BaseModel):
"""Payload to update conversation metadata."""

Expand Down
Loading