Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 12 additions & 0 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.respondToConfirmation('c1', { accept: true });
await client.deleteConversation('c1');
await client.updateConversation('c1', { title: 'New title' });
await client.getRuntime('c1');
await client.reprovisionRuntime('c1');

expect(global.fetch).toHaveBeenNthCalledWith(
1,
Expand Down Expand Up @@ -2314,6 +2316,16 @@ describe('Auxiliary API clients', () => {
'http://example.com/api/conversations/c1',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'New title' }) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
9,
'http://example.com/api/conversations/c1/runtime',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
10,
'http://example.com/api/conversations/c1/runtime/reprovision',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
});

it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => {
Expand Down
16 changes: 16 additions & 0 deletions clients/typescript/src/client/conversation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ConversationEventPage,
ConversationEventSearchOptions,
ConversationInfo,
ConversationRuntimeInfo,
ConversationSearchRequest,
ConversationSearchResponse,
ForkConversationRequest,
Expand Down Expand Up @@ -135,6 +136,21 @@ export class ConversationClient {
return response.data;
}

async getRuntime(conversationId: string): Promise<ConversationRuntimeInfo> {
const response = await this.client.get<ConversationRuntimeInfo>(
`/api/conversations/${conversationId}/runtime`
);
return response.data;
}

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

async searchEvents(
conversationId: string,
options: ConversationEventSearchOptions = {}
Expand Down
3 changes: 3 additions & 0 deletions clients/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ export type { SwitchPlan } from './profiles/derive-switch-plan';
// Conversation models
export type {
ConversationInfo,
ConversationRuntimeStatus,
ConversationRuntimeError,
ConversationRuntimeInfo,
ACPAgentConfig,
ACPConversationInfo,
SendMessageRequest,
Expand Down
24 changes: 24 additions & 0 deletions clients/typescript/src/models/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,37 @@ export enum ConversationSortOrder {
UPDATED_AT_DESC = 'UPDATED_AT_DESC',
}

export type ConversationRuntimeStatus =
| 'available'
| 'starting'
| 'missing'
| 'ownership_lost'
| 'error';

export interface ConversationRuntimeError {
code: string;
message: string;
}

export interface ConversationRuntimeInfo {
runtime_status: ConversationRuntimeStatus;
can_resume: boolean;
runtime_error: ConversationRuntimeError | null;
}

export interface ConversationInfo {
id: ConversationID;
/**
* Current execution status of the conversation.
* Note: This field was renamed from agent_status to execution_status in the API.
*/
execution_status: ConversationExecutionStatus;
/** Runtime availability. Absent on agent-server versions before this contract. */
runtime_status?: ConversationRuntimeStatus;
/** Whether retained state permits explicit execution resumption. */
can_resume?: boolean;
/** Latest structured runtime failure, when known. */
runtime_error?: ConversationRuntimeError | null;
/**
* @deprecated Use execution_status instead. This field is kept for backward compatibility.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
AskAgentResponse,
ConversationInfo,
ConversationPage,
ConversationRuntimeInfo,
ConversationRuntimeStatus,
ConversationSortOrder,
ForkConversationRequest,
NavigateConversationRequest,
Expand Down Expand Up @@ -61,6 +63,16 @@
from openhands.tools.preset.default import get_default_tools


def _with_runtime_lifecycle(
request: Request, conversation: ConversationInfo
) -> ConversationInfo:
registry = getattr(request.app.state, "docker_registry", None)
if registry is None:
return conversation
lifecycle = registry.runtime_info(UUID(str(conversation.id)))
return conversation.model_copy(update=lifecycle.model_dump())


conversation_router = APIRouter(prefix="/conversations", tags=["Conversations"])

# Examples
Expand Down Expand Up @@ -88,6 +100,7 @@

@conversation_router.get("/search")
async def search_conversations(
request: Request,
page_id: Annotated[
str | None,
Query(title="Optional next_page_id from the previously returned page"),
Expand All @@ -113,6 +126,11 @@ async def search_conversations(
page = await conversation_service.search_conversations(
page_id, limit, status, sort_order
)
page = page.model_copy(
update={
"items": [_with_runtime_lifecycle(request, item) for item in page.items]
}
)
if not include_skills:
# ``model_copy`` rather than in-place mutation so we never
# write back into whatever the upstream service handed us
Expand Down Expand Up @@ -146,18 +164,43 @@ async def count_conversations(
)
async def get_conversation(
conversation_id: UUID,
request: Request,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Given an id, get a conversation"""
conversation = await conversation_service.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
conversation = _with_runtime_lifecycle(request, conversation)
if not include_skills:
conversation = trim_conversation_response_skills(conversation)
return conversation


@conversation_router.get("/{conversation_id}/runtime")
async def get_local_conversation_runtime(
conversation_id: UUID,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationRuntimeInfo:
"""Inspect the always-available in-process runtime."""
if await conversation_service.get_conversation(conversation_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return ConversationRuntimeInfo(
runtime_status=ConversationRuntimeStatus.AVAILABLE,
can_resume=True,
)


@conversation_router.post("/{conversation_id}/runtime/reprovision")
async def reprovision_local_conversation_runtime(
conversation_id: UUID,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationRuntimeInfo:
"""Return local runtime state; local mode has no infrastructure to provision."""
return await get_local_conversation_runtime(conversation_id, conversation_service)


@conversation_router.get(
"/{conversation_id}/agent_final_response",
responses={404: {"description": "Conversation not found"}},
Expand All @@ -181,6 +224,7 @@ async def get_conversation_agent_final_response(

@conversation_router.get("")
async def batch_get_conversations(
request: Request,
ids: Annotated[list[UUID], Query()],
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
Expand All @@ -189,6 +233,10 @@ async def batch_get_conversations(
any missing item"""
assert len(ids) < 100
conversations = await conversation_service.batch_get_conversations(ids)
conversations = [
_with_runtime_lifecycle(request, item) if item is not None else None
for item in conversations
]
if not include_skills:
return [
trim_conversation_response_skills(c) if c is not None else None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
from openhands.agent_server.config import V1_SESSION_API_KEY_ENV, Config
from openhands.agent_server.docker_runtime.broker import RuntimeCredentialBroker
from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore
from openhands.agent_server.models import (
ConversationRuntimeError,
ConversationRuntimeInfo,
ConversationRuntimeStatus,
)
from openhands.agent_server.persistence import FileSecretsStore
from openhands.agent_server.persistence.store import _get_persistence_dir
from openhands.sdk.llm.auth.credentials import CredentialStore
Expand Down Expand Up @@ -92,6 +97,7 @@ def __init__(self, config: Config) -> None:
self._provisioning: RuntimeProvisioningStore | None = None
self._brokers: dict[UUID, RuntimeCredentialBroker] = {}
self._mutations: dict[UUID, asyncio.Lock] = {}
self._runtime_errors: dict[UUID, ConversationRuntimeError] = {}

def mutation_lock(self, conversation_id: UUID) -> asyncio.Lock:
return self._mutations.setdefault(conversation_id, asyncio.Lock())
Expand Down Expand Up @@ -153,6 +159,31 @@ def cleanup_stale_containers(self) -> None:
def get(self, conversation_id: UUID) -> RunningConversationContainer | None:
return self._containers.get(conversation_id)

def runtime_info(self, conversation_id: UUID) -> ConversationRuntimeInfo:
"""Inspect runtime state without provisioning or contacting a container."""
directory = self.conversation_dir(conversation_id)
has_state = (directory / "base_state.json").is_file()
has_metadata = (directory / "meta.json").is_file()
manifest = self.provisioning.manifest_path(conversation_id)
can_resume = has_state and has_metadata and manifest.is_file()

if conversation_id in self._containers:
status = ConversationRuntimeStatus.AVAILABLE
elif conversation_id in self._starts:
status = ConversationRuntimeStatus.STARTING
elif has_state and has_metadata and not manifest.is_file():
status = ConversationRuntimeStatus.OWNERSHIP_LOST
elif conversation_id in self._runtime_errors:
status = ConversationRuntimeStatus.ERROR
else:
status = ConversationRuntimeStatus.MISSING

return ConversationRuntimeInfo(
runtime_status=status,
can_resume=can_resume,
runtime_error=self._runtime_errors.get(conversation_id),
)

def items(self) -> list[tuple[UUID, RunningConversationContainer]]:
return list(self._containers.items())

Expand Down Expand Up @@ -191,13 +222,18 @@ async def get_or_create(

try:
container = await asyncio.shield(task)
except Exception:
except Exception as exc:
async with self._lock:
if self._starts.get(conversation_id) is task:
self._starts.pop(conversation_id, None)
self._runtime_errors[conversation_id] = ConversationRuntimeError(
code="runtime_start_failed",
message=str(exc),
)
raise

async with self._lock:
self._runtime_errors.pop(conversation_id, None)
existing = self._containers.get(conversation_id)
if existing is not None:
return existing, False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
DockerConversationRegistry,
RunningConversationContainer,
)
from openhands.agent_server.models import ConversationRuntimeInfo
from openhands.agent_server.runtime_router import add_legacy_runtime_routes
from openhands.agent_server.utils import safe_rmtree
from openhands.sdk.logger import get_logger
Expand Down Expand Up @@ -356,6 +357,56 @@ async def docker_proxy_conversation_root_mutation(
)


@docker_conversation_proxy_router.get(
"/{conversation_id}/runtime",
response_model=ConversationRuntimeInfo,
)
async def get_conversation_runtime(
conversation_id: UUID, request: Request
) -> ConversationRuntimeInfo:
"""Inspect runtime availability without provisioning a container."""
registry = get_registry(request)
info = registry.runtime_info(conversation_id)
if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conversation not found: {conversation_id}",
)
return info


@docker_conversation_proxy_router.post(
"/{conversation_id}/runtime/reprovision",
response_model=ConversationRuntimeInfo,
)
async def reprovision_conversation_runtime(
conversation_id: UUID, request: Request
) -> ConversationRuntimeInfo:
"""Start missing infrastructure without resuming agent execution."""
registry = get_registry(request)
info = registry.runtime_info(conversation_id)
if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conversation not found: {conversation_id}",
)
if not info.can_resume:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Conversation runtime cannot be safely reprovisioned",
)
try:
await registry.prepare(conversation_id)
await registry.get_or_create(conversation_id)
except Exception as exc:
logger.exception("Could not reprovision conversation %s", conversation_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Could not reprovision conversation runtime",
) from exc
return registry.runtime_info(conversation_id)


@docker_conversation_proxy_router.api_route(
"/{conversation_id}/{tail:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
Expand Down
Loading
Loading