Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9b6737
feat(agent-server): add explicit conversation archiving
openhands-agent Sep 12, 2026
6edc0fe
fix(agent-server): preserve archived runtime state
openhands-agent Sep 21, 2026
5e9f84e
Merge branch 'main' into feat/conversation-archive-4994
neubig Sep 28, 2026
511a277
fix(agent-server): keep archived conversations deletable
openhands-agent Sep 28, 2026
c88e9c9
fix(agent-server): reject archived resume and keep archived history r…
openhands-agent Sep 29, 2026
768b6e7
fix(agent-server): keep runtime metadata when archiving; filter count…
openhands-agent Sep 30, 2026
c27d536
Merge branch 'main' into feat/conversation-archive-4994
openhands-agent Oct 3, 2026
034226f
Merge branch 'main' into feat/conversation-archive-4994
neubig Oct 3, 2026
753dfa9
fix(agent-server): resolve symlinked mounts before the archive check
openhands-agent Oct 3, 2026
9689361
chore: Remove PR-only artifacts [automated]
Oct 3, 2026
5327b02
Merge main into feat/conversation-archive-4994
openhands-agent Oct 3, 2026
d52774e
feat(ts-client): normalize conversation lifecycle (#5003)
neubig Oct 3, 2026
57894d7
Merge main into feat/conversation-archive-4994
openhands-agent Oct 22, 2026
e89936c
Merge main into feat/conversation-archive-4994
openhands-agent Oct 23, 2026
18b936d
fix(agent-server): distinguish runtime expiration from conversation a…
openhands-agent Oct 23, 2026
dc9eeab
test(agent-server): make websocket backpressure assertion deterministic
openhands-agent Oct 23, 2026
9222293
Merge remote-tracking branch 'origin/main' into feat/conversation-arc…
openhands-agent Nov 2, 2026
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 @@ -2383,6 +2383,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 @@ -2422,6 +2424,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 @@ -2455,7 +2467,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 @@ -2474,7 +2486,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 @@ -122,7 +122,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 @@ -161,6 +161,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 @@ -41,6 +41,7 @@
conversation_router,
)
from openhands.agent_server.conversation_service import (
ConversationArchivedError,
CredentialBindingActivationRequired,
get_default_conversation_service,
)
Expand Down Expand Up @@ -577,6 +578,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 @@ -24,6 +24,7 @@
)
from openhands.agent_server.conversation_registry import ConversationRegistry
from openhands.agent_server.conversation_service import (
ConversationArchivedError,
ConversationService,
InvalidParentConversation,
)
Expand Down Expand Up @@ -173,12 +174,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 @@ -205,10 +209,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 @@ -259,6 +266,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 @@ -339,6 +376,10 @@ async def start_conversation(
)
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, AgentLaunchError, LaunchStoreError) as e:
raise launch_http_exception(e) from e
except ClientToolRegistrationError as e:
Expand Down
Loading
Loading