openhands / src /api /cloud /conversation-service.api.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw
History Blame Contribute Delete
11.9 kB
import {
getActiveBackend,
getRegisteredBackends,
} from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { getStoredConversationMetadata } from "../conversation-metadata-store";
import type {
AppConversation,
AppConversationPage,
AppConversationStartRequest,
AppConversationStartTask,
} from "../conversation-service/agent-server-conversation-service.types";
import { AGENT_CANVAS_CLIENT_HEADERS } from "../client-source";
import { callCloudProxy } from "./proxy";
/**
* The cloud backend does not always echo `selected_repository` /
* `selected_branch` / `git_provider` back from
* `GET /api/v1/app-conversations` until its own background hydration
* completes. We persist the selection to local storage at connect time
* (see `AgentServerConversationService.updateConversationRepository`)
* and overlay it here so the chat-page git control bar reflects the
* connection immediately, instead of snapping back to the empty
* "Connect Repo" state on every refetch.
*
* Server values take precedence whenever they're populated; the
* local-storage fallback only fills in fields the server returned as
* `null`/`undefined`.
*/
function overlayStoredRepoSelection(
conversation: AppConversation | null,
): AppConversation | null {
if (!conversation?.id) return conversation;
const stored = getStoredConversationMetadata(conversation.id);
if (!stored) return conversation;
return {
...conversation,
selected_repository:
conversation.selected_repository ?? stored.selected_repository ?? null,
selected_branch:
conversation.selected_branch ?? stored.selected_branch ?? null,
git_provider: conversation.git_provider ?? stored.git_provider ?? null,
selected_workspace:
conversation.selected_workspace ?? stored.selected_workspace ?? null,
};
}
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud conversations call requires a cloud backend.");
}
return active;
}
/**
* Resolve the cloud backend a Cloud conversation should be launched against
* when the active backend may not be a cloud one. Cloud conversations cannot
* register client tools, so the typed launch action always runs from a local
* parent and has to reach a registered-but-inactive cloud backend.
*
* Prefers the active backend when it is already cloud, then the first
* registered cloud backend that carries credentials. Returns `null` when the
* user has no cloud backend connected, so callers can give the agent
* corrective guidance instead of failing opaquely.
*
* Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so
* a conversation launched against an inactive backend lands in the API key's
* own organization.
*/
export function pickCloudBackendForLaunch(): Backend | null {
const active = getActiveBackend().backend;
if (active.kind === "cloud") return active;
return (
getRegisteredBackends().find(
(backend) => backend.kind === "cloud" && !!backend.apiKey,
) ?? null
);
}
/**
* Search the cloud app-conversations list. Mirrors the local
* `AgentServerConversationService.searchConversations` interface but calls
* the cloud endpoint `/api/v1/app-conversations/search`.
*/
export async function searchCloudConversations(
limit: number = 20,
pageId?: string,
): Promise<AppConversationPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageId) params.set("page_id", pageId);
params.set("sort_order", "UPDATED_AT_DESC");
const data = await callCloudProxy<{
items: AppConversation[];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/app-conversations/search?${params.toString()}`,
});
return {
items: (data?.items ?? []).map(
(item) => overlayStoredRepoSelection(item) as AppConversation,
),
next_page_id: data?.next_page_id ?? null,
};
}
/**
* Batch-fetch cloud app-conversations by id. Mirrors the local
* `AgentServerConversationService.batchGetAppConversations` interface.
*/
export async function batchGetCloudConversations(
ids: string[],
): Promise<(AppConversation | null)[]> {
if (ids.length === 0) return [];
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
for (const id of ids) params.append("ids", id);
const data = await callCloudProxy<(AppConversation | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations?${params.toString()}`,
});
return (data ?? []).map(overlayStoredRepoSelection);
}
/**
* Create a v1 app-conversation on the cloud backend.
*
* Mirrors OpenHands' cloud flow: POST /api/v1/app-conversations with the
* `AppConversationStartRequest` payload, returning a
* `AppConversationStartTask`. The task is initially WORKING; the caller
* polls `getCloudAppConversationStartTask` (3s cadence per OpenHands)
* until status is READY (then `app_conversation_id`, `agent_server_url`,
* and `session_api_key` are populated) or ERROR.
*
* This path does NOT use encrypted-settings round-tripping. Secrets stay
* server-side on the cloud backend — the only auth carried is the cloud bearer
* token, and the conversation runtime is
* provisioned with its own ephemeral session_api_key returned in the
* task.
*/
export async function createCloudAppConversation(
request: AppConversationStartRequest,
// Defaults to the active backend. The typed launch action passes an explicit
// backend because it always runs from a *local* parent conversation.
backendOverride?: Backend,
): Promise<AppConversationStartTask> {
const backend = backendOverride ?? getActiveCloudBackend();
const data = await callCloudProxy<AppConversationStartTask>({
backend,
method: "POST",
path: "/api/v1/app-conversations",
body: request as unknown as Record<string, unknown>,
headers: AGENT_CANVAS_CLIENT_HEADERS,
});
return data;
}
/**
* Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors
* the local `AgentServerConversationService.downloadConversation` interface but
* calls
* `GET /api/v1/app-conversations/{id}/download`, which returns
* `application/zip` with `Content-Disposition` set by the cloud backend.
*/
export async function downloadCloudConversation(
conversationId: string,
): Promise<Blob> {
const backend = getActiveCloudBackend();
return callCloudProxy<Blob>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/download`,
responseType: "blob",
});
}
/**
* Delete a v1 app-conversation on the cloud backend. Mirrors the local
* `AgentServerConversationService.deleteConversation` interface but calls
* `DELETE /api/v1/app-conversations/{id}`, which returns a JSON
* `Success` envelope (discarded here — the caller only needs to know
* the request didn't error).
*/
export async function deleteCloudConversation(
conversationId: string,
): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "DELETE",
path: `/api/v1/app-conversations/${conversationId}`,
});
}
/**
* Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
* OpenHands' `AgentServerConversationService.updateConversationPublicFlag`:
* `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
* the updated conversation.
*/
export async function updateCloudConversationPublicFlag(
conversationId: string,
isPublic: boolean,
): Promise<AppConversation> {
const backend = getActiveCloudBackend();
const data = await callCloudProxy<AppConversation>({
backend,
method: "PATCH",
path: `/api/v1/app-conversations/${conversationId}`,
body: { public: isPublic },
});
return data;
}
/**
* Rename a cloud v1 app-conversation. The title belongs to the Cloud
* app-conversation resource, so updating a runtime Agent Server would not
* persist it in the Cloud conversation list.
*/
export async function updateCloudConversationTitle(
conversationId: string,
title: string,
): Promise<AppConversation> {
const backend = getActiveCloudBackend();
return callCloudProxy<AppConversation>({
backend,
method: "PATCH",
path: `/api/v1/app-conversations/${conversationId}`,
body: { title },
});
}
/**
* Pause the cloud sandbox backing a v1 app-conversation. Mirrors
* OpenHands' `SandboxService.pauseSandbox`:
* `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops
* the runtime owning the conversation.
*/
export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: `/api/v1/sandboxes/${sandboxId}/pause`,
});
}
/**
* Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox`
* by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS.
*
* This is the correct endpoint for waking a PAUSED sandbox. It is a
* lightweight unpause — NOT the same as creating a new start task via
* `POST /api/v1/app-conversations`, which provisions a fresh conversation
* and is subject to the 120-second sandbox-start timeout.
*/
export async function resumeCloudSandbox(sandboxId: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: `/api/v1/sandboxes/${sandboxId}/resume`,
});
}
/**
* Read a file from a cloud conversation's sandbox workspace. Mirrors
* OpenHands' `AgentServerConversationService.readConversationFile` — hits
* `GET /api/v1/app-conversations/{id}/file?file_path=...` on the cloud backend
* and returns the file content as a string.
*/
export async function readCloudConversationFile(
conversationId: string,
filePath: string,
): Promise<string> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.append("file_path", filePath);
const data = await callCloudProxy<string>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
});
return data ?? "";
}
/**
* List every file in a cloud conversation's sandbox workspace. Hits
* `GET /api/v1/app-conversations/{id}/files?path=...` on the cloud backend,
* which resolves the conversation's runtime and runs a bounded `find`
* server-side (see enterprise `list_conversation_files`). Unlike the
* git-changes source, this returns the full tree so the Files tab matches the
* local-backend experience. Paths come back relative to `path`.
*/
export async function listCloudConversationFiles(
conversationId: string,
path: string,
): Promise<string[]> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.append("path", path);
const data = await callCloudProxy<string[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/files?${params.toString()}`,
});
return Array.isArray(data) ? data : [];
}
/**
* Fetch a single v1 app-conversation start task. Mirrors OpenHands'
* `AgentServerConversationService.getStartTask` — uses the batch search endpoint
* with a single id and unwraps the first result.
*/
export async function getCloudAppConversationStartTask(
taskId: string,
backendOverride?: Backend,
): Promise<AppConversationStartTask | null> {
const backend = backendOverride ?? getActiveCloudBackend();
const params = new URLSearchParams();
params.set("ids", taskId);
const data = await callCloudProxy<(AppConversationStartTask | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations/start-tasks?${params.toString()}`,
});
return data?.[0] ?? null;
}