Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
16 changes: 14 additions & 2 deletions clients/typescript/src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2285,6 +2285,8 @@ describe('Auxiliary API clients', () => {
await client.updateConversation('c1', { title: 'New title' });
await client.getRuntime('c1');
await client.reprovisionRuntime('c1');
await client.archiveConversation('c1');
await client.unarchiveConversation('c1');

expect(global.fetch).toHaveBeenNthCalledWith(
1,
Expand Down Expand Up @@ -2324,6 +2326,16 @@ describe('Auxiliary API clients', () => {
'http://example.com/api/conversations/c1/runtime/reprovision',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
11,
'http://example.com/api/conversations/c1/archive',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
12,
'http://example.com/api/conversations/c1/unarchive',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
});

it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => {
Expand Down Expand Up @@ -2357,7 +2369,7 @@ describe('Auxiliary API clients', () => {

const client = new ConversationClient({ host: 'http://example.com' });
await expect(
client.countConversations({ status: ConversationExecutionStatus.IDLE })
client.countConversations({ status: ConversationExecutionStatus.IDLE, archived: true })
).resolves.toBe(2);
await expect(client.searchEvents('c1', { kind: 'MessageEvent', limit: 5 })).resolves.toEqual({
items: [event],
Expand All @@ -2376,7 +2388,7 @@ describe('Auxiliary API clients', () => {

expect(global.fetch).toHaveBeenNthCalledWith(
1,
'http://example.com/api/conversations/count?status=idle',
'http://example.com/api/conversations/count?status=idle&archived=true',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
Expand Down
18 changes: 17 additions & 1 deletion clients/typescript/src/client/conversation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class ConversationClient {
}

async countConversations(
options: { status?: ConversationExecutionStatus } = {}
options: { status?: ConversationExecutionStatus; archived?: boolean } = {}
): Promise<number> {
const response = await this.client.get<number>('/api/conversations/count', {
params: options as Record<string, unknown>,
Expand Down Expand Up @@ -124,6 +124,22 @@ export class ConversationClient {
return response.data;
}

async archiveConversation(conversationId: string): Promise<ConversationInfo> {
const response = await this.client.post<ConversationInfo>(
`/api/conversations/${conversationId}/archive`,
{}
);
return response.data;
}

async unarchiveConversation(conversationId: string): Promise<ConversationInfo> {
const response = await this.client.post<ConversationInfo>(
`/api/conversations/${conversationId}/unarchive`,
{}
);
return response.data;
}

async searchEvents(
conversationId: string,
options: ConversationEventSearchOptions = {}
Expand Down
8 changes: 5 additions & 3 deletions clients/typescript/src/conversation/conversation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export class ACPConversationNamespace {
return this.manager.searchACPConversations(options);
}

countConversations(options: { status?: ConversationExecutionStatus } = {}): Promise<number> {
countConversations(
options: { status?: ConversationExecutionStatus; archived?: boolean } = {}
): Promise<number> {
return this.manager.countACPConversations(options);
}

Expand Down Expand Up @@ -148,7 +150,7 @@ export class ConversationManager {
* Count conversations matching the provided filters.
*/
async countConversations(
options: { status?: ConversationExecutionStatus } = {}
options: { status?: ConversationExecutionStatus; archived?: boolean } = {}
): Promise<number> {
const response = await this.client.get<number>('/api/conversations/count', {
params: options as Record<string, unknown>,
Expand Down Expand Up @@ -275,7 +277,7 @@ export class ConversationManager {
* Count ACP-capable conversations.
*/
async countACPConversations(
options: { status?: ConversationExecutionStatus } = {}
options: { status?: ConversationExecutionStatus; archived?: boolean } = {}
): Promise<number> {
const response = await this.client.get<number>('/api/conversations/count', {
params: options,
Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/models/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface ConversationInfo {
title?: string;
created_at?: string;
updated_at?: string;
archived_at?: string | null;
tags?: Record<string, string>;
/**
* HEAD of the conversation tree: the parent of the next appended event.
Expand Down Expand Up @@ -171,6 +172,7 @@ export interface ConversationSearchRequest {
limit?: number;
status?: ConversationExecutionStatus;
sort_order?: ConversationSortOrder;
archived?: boolean;
tag?: string[];
}

Expand Down
11 changes: 11 additions & 0 deletions openhands-agent-server/openhands/agent_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ The OpenHands Agent Server is a minimal REST API and WebSocket server that provi
- **Webhooks**: Configurable webhook notifications for events
- **Auto-reload**: Development mode with automatic code reloading


## Conversation lifecycle

Conversation metadata separates retained history from runtime availability:

- `runtime_status` reports whether execution infrastructure is available; it does not imply archive state.
- `POST /api/conversations/{id}/archive` sets `archived_at`, hides the conversation from the default search, and stops only runtime infrastructure owned by this server. It preserves history and workspace files.
- `POST /api/conversations/{id}/unarchive` clears `archived_at` without provisioning infrastructure or starting agent execution.
- `GET /api/conversations/search?archived=true` returns archived conversations; the default `archived=false` returns active conversations. `GET /api/conversations/count` accepts the same `archived` filter and default.
- `DELETE /api/conversations/{id}` permanently removes the conversation rather than retaining it as archived.

## Quick Start

### Prerequisites
Expand Down
11 changes: 11 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
conversation_router,
)
from openhands.agent_server.conversation_service import (
ConversationArchivedError,
CredentialBindingActivationRequired,
get_default_conversation_service,
)
Expand Down Expand Up @@ -559,6 +560,16 @@ async def _credential_binding_activation_required_handler(
},
)

@api.exception_handler(ConversationArchivedError)
async def _conversation_archived_handler(
_request: Request,
exc: ConversationArchivedError,
) -> JSONResponse:
# 409 matches the Docker runtime's rejection of the same operation, so a
# client sees one status whether the conversation is host-local or
# container-backed.
return JSONResponse(status_code=409, content={"detail": str(exc)})

@api.exception_handler(RequestValidationError)
async def _validation_exception_handler(
request: Request, exc: RequestValidationError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from openhands.agent_server.conversation_registry import ConversationRegistry
from openhands.agent_server.conversation_service import (
ConversationArchivedError,
ConversationService,
InvalidParentConversation,
)
Expand Down Expand Up @@ -121,12 +122,15 @@ async def search_conversations(
ConversationSortOrder,
Query(title="Sort order for conversations"),
] = ConversationSortOrder.CREATED_AT_DESC,
archived: Annotated[
bool, Query(title="Return archived rather than active conversations")
] = False,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationPage:
"""Search / List conversations"""
page = await conversation_service.search_conversations(
page_id, limit, status, sort_order
page_id, limit, status, sort_order, archived
)
if not include_skills:
# ``model_copy`` rather than in-place mutation so we never
Expand All @@ -153,10 +157,13 @@ async def count_conversations(
ConversationExecutionStatus | None,
Query(title="Optional filter by conversation execution status"),
] = None,
archived: Annotated[
bool, Query(title="Count archived rather than active conversations")
] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> int:
"""Count conversations matching the given filters"""
count = await conversation_service.count_conversations(status)
count = await conversation_service.count_conversations(status, archived)
return count


Expand Down Expand Up @@ -207,6 +214,36 @@ async def reprovision_local_conversation_runtime(
)


@conversation_router.post("/{conversation_id}/archive")
async def archive_conversation(
conversation_id: UUID,
request: Request,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Archive retained history and stop only its currently owned runtime."""
conversation = await conversation_service.set_conversation_archived(
conversation_id, archived=True
)
if conversation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return _with_runtime_info(request, conversation)


@conversation_router.post("/{conversation_id}/unarchive")
async def unarchive_conversation(
conversation_id: UUID,
request: Request,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Restore catalog visibility without provisioning or starting execution."""
conversation = await conversation_service.set_conversation_archived(
conversation_id, archived=False
)
if conversation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return _with_runtime_info(request, conversation)


@conversation_router.get(
"/{conversation_id}/agent_final_response",
responses={404: {"description": "Conversation not found"}},
Expand Down Expand Up @@ -266,6 +303,10 @@ async def start_conversation(
"""Start a conversation in the local environment."""
try:
info, is_new = await conversation_service.start_conversation(request)
except ConversationArchivedError as e:
# Matches the Docker runtime, which rejects start/resume of an archived
# conversation with 409 rather than silently returning a stale record.
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
except ProfileNotFound as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except DanglingMcpServerRef as e:
Expand Down
Loading
Loading