openhands / src /api /conversation-service /agent-server-conversation-service.api.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
41.3 kB
import {
ConversationSortOrder,
type ForkConversationRequest,
type LLMConfig,
type VSCodeStatusResponse,
} from "@openhands/typescript-client";
import {
ConversationClient,
FileClient,
ProfilesClient,
VSCodeClient,
} from "@openhands/typescript-client/clients";
import { v4 as uuidv4 } from "uuid";
import { AgentKind, Provider } from "#/types/settings";
import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import {
buildConversationWorkingDirForBackend,
getAgentServerWorkingDir,
getWorkspaceRootForBackend,
} from "../agent-server-config";
import { resolveAbsoluteAgentServerPath } from "../agent-server-home";
import {
getActiveBackend,
getEffectiveLocalBackend,
} from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import ProfilesService from "../profiles-service/profiles-service.api";
import {
batchGetCloudConversations,
createCloudAppConversation,
deleteCloudConversation,
downloadCloudConversation,
getCloudAppConversationStartTask,
readCloudConversationFile,
searchCloudConversations,
updateCloudConversationPublicFlag,
updateCloudConversationTitle,
} from "../cloud/conversation-service.api";
import {
DirectConversationInfo,
assertSubscriptionAuthReady,
buildStartConversationRequestWithEncryptedSettings,
buildStartPlanningConversationRequestWithEncryptedSettings,
emptyHooksResponse,
getDefaultConversationTitle,
toAppConversation,
toConversationPage,
} from "../agent-server-adapter";
import { GetVSCodeUrlResponse } from "../open-hands.types";
import {
getAgentServerClientOptions,
NoBackendAvailableError,
} from "../agent-server-client-options";
import SettingsService from "../settings-service/settings-service.api";
import { getTelemetryDistinctId } from "../../services/telemetry";
import {
ConversationMetadata,
getStoredConversationMetadata,
mergeStoredConversationMetadata,
removeStoredConversationMetadata,
setStoredConversationMetadata,
type WorkspaceMode,
} from "../conversation-metadata-store";
import { resolveTitleLlmProfile } from "#/utils/title-llm-profile";
import { isPlannerConversationOf } from "#/utils/plan-file";
import type {
GetHooksResponse,
PluginSpec,
AppConversation,
AppConversationPage,
AppConversationStartRequest,
AppConversationStartTask,
MetricsSnapshot,
RuntimeConversationInfo,
RuntimeConversationStats,
SendMessageRequest,
SendMessageResponse,
} from "./agent-server-conversation-service.types";
const DEFAULT_CONVERSATION_TIMESTAMP = "1970-01-01T00:00:00.000Z";
// Creating the first conversation right after a cold agent-server boot (fresh
// machine or the packaged desktop app, where uvx may still be warming caches)
// can exceed the client's 60s default timeout.
const CREATE_CONVERSATION_TIMEOUT_MS = 5 * 60 * 1000;
const INVALID_CONVERSATION_RESPONSE_MESSAGE =
"Unable to load conversations because the selected agent server returned " +
"data this UI does not understand. Check the backend URL/session key and " +
"update the agent server if needed.";
function invalidConversationResponse(): Error {
return new Error(INVALID_CONVERSATION_RESPONSE_MESSAGE);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function numberOrNull(value: unknown): number | null {
return typeof value === "number" ? value : null;
}
function numberOrZero(value: unknown): number {
return typeof value === "number" ? value : 0;
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readTimestamp(
item: Record<string, unknown>,
snakeKey: "created_at" | "updated_at",
camelKey: "createdAt" | "updatedAt",
): string {
const value = item[snakeKey] ?? item[camelKey];
return typeof value === "string" && value.trim()
? value
: DEFAULT_CONVERSATION_TIMESTAMP;
}
function normalizeTokenUsage(
value: unknown,
): NonNullable<MetricsSnapshot["accumulated_token_usage"]> | null {
if (!isRecord(value)) return null;
return {
prompt_tokens: numberOrZero(value.prompt_tokens),
completion_tokens: numberOrZero(value.completion_tokens),
cache_read_tokens: numberOrZero(value.cache_read_tokens),
cache_write_tokens: numberOrZero(value.cache_write_tokens),
context_window: numberOrZero(value.context_window),
per_turn_token: numberOrZero(value.per_turn_token),
};
}
function normalizeMetrics(value: unknown): MetricsSnapshot | null {
if (!isRecord(value)) return null;
return {
accumulated_cost: numberOrNull(value.accumulated_cost),
max_budget_per_task: numberOrNull(value.max_budget_per_task),
accumulated_token_usage: normalizeTokenUsage(value.accumulated_token_usage),
};
}
// Shallow check only (matches the trust level `getRuntimeConversation` used
// before this field was threaded through `DirectConversationInfo`): the
// per-usage-id entries are consumed via `combineUsageMetrics`, which already
// tolerates missing/malformed fields, so there's no need to validate them here.
function normalizeStats(value: unknown): RuntimeConversationStats | null {
return isRecord(value)
? (value as unknown as RuntimeConversationStats)
: null;
}
function normalizeAgent(value: unknown): DirectConversationInfo["agent"] {
if (!isRecord(value)) return null;
const llm = isRecord(value.llm)
? { model: stringOrNull(value.llm.model) }
: null;
// ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``);
// ``toAppConversation`` reads it to derive ``agent_kind``. ``acp_server`` is
// the ACP provider identity (``ACPAgent.acp_server``, SDK #3692) — required so
// the adapter can source the chip + in-conversation model list from the agent
// when the ``acpserver`` tag is absent (a profile launch doesn't stamp it,
// #1571). ``acp_model`` is the Canvas-configured model — preserved so the chip
// can fall back to it when the SDK runtime model fields aren't populated.
// Preserving these makes the wire path agree with the unit-test path that
// builds ``DirectConversationInfo`` directly
// (e.g. ``__tests__/api/agent-server-adapter.test.ts``).
return {
kind: stringOrNull(value.kind),
acp_server: stringOrNull(value.acp_server),
acp_model: stringOrNull(value.acp_model),
llm,
};
}
function normalizeWorkspace(
value: unknown,
): DirectConversationInfo["workspace"] {
if (!isRecord(value)) return null;
return { working_dir: stringOrNull(value.working_dir) };
}
/**
* Accept the agent-server's ``tags: Record[str, str]`` payload defensively:
* the wire shape is guaranteed by the server-side validator (keys
* ``^[a-z0-9]+$``, string values), but a non-conforming response (older
* server, raw API write, future schema drift) must never crash the parser
* — Canvas only consumes ``acpserver`` and falls back to a generic chip
* for anything it doesn't recognize. Drop entries whose value isn't a
* plain string; return ``null`` when the wire field is absent or not an
* object so consumers can use ``info.tags?.[KEY] ?? null`` uniformly.
*/
function normalizeTags(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null;
const tags: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") {
tags[key] = entry;
}
}
return tags;
}
/**
* ``ConversationInfo.sub_conversation_ids`` — the agent-server derives it from
* its own catalog of conversations that name this one as parent (SDK #4188),
* which is what lets Canvas find a conversation's local planner without any
* browser-local state. Absent on agent-servers older than 1.37.1; parsed
* defensively so a non-conforming payload degrades to "no children" instead of
* crashing the list.
*/
function normalizeSubConversationIds(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
return value.filter((entry): entry is string => typeof entry === "string");
}
function normalizeLaunchedAgentProfile(
value: unknown,
): DirectConversationInfo["launched_agent_profile"] {
if (!isRecord(value)) return null;
const { agent_profile_id: agentProfileId, revision } = value;
if (typeof agentProfileId !== "string" || typeof revision !== "number") {
return null;
}
return { agent_profile_id: agentProfileId, revision };
}
function normalizeAbsolutePath(path: string): string | null {
if (!path.startsWith("/")) return null;
const segments: string[] = [];
for (const segment of path.split("/")) {
if (segment && segment !== ".") {
if (segment === "..") {
if (!segments.length) return null;
segments.pop();
} else {
segments.push(segment);
}
}
}
return `/${segments.join("/")}`;
}
function requirePathInsideDirectory(path: string, directory: string): string {
const normalizedPath = normalizeAbsolutePath(path);
const normalizedDirectory = normalizeAbsolutePath(directory);
if (
!normalizedPath ||
!normalizedDirectory ||
(normalizedPath !== normalizedDirectory &&
!normalizedPath.startsWith(`${normalizedDirectory}/`))
) {
throw new Error("Conversation file path must stay inside the workspace");
}
return normalizedPath;
}
function requireDirectConversationInfo(item: unknown): DirectConversationInfo {
if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) {
throw invalidConversationResponse();
}
return {
id: item.id.trim(),
title: stringOrNull(item.title),
created_at: readTimestamp(item, "created_at", "createdAt"),
updated_at: readTimestamp(item, "updated_at", "updatedAt"),
execution_status: stringOrNull(item.execution_status),
sandbox_status: stringOrNull(item.sandbox_status),
metrics: normalizeMetrics(item.metrics),
stats: normalizeStats(item.stats),
agent: normalizeAgent(item.agent),
workspace: normalizeWorkspace(item.workspace),
tags: normalizeTags(item.tags),
launched_agent_profile: normalizeLaunchedAgentProfile(
item.launched_agent_profile,
),
sub_conversation_ids: normalizeSubConversationIds(
item.sub_conversation_ids,
),
// SDK-runtime ACP model fields (populated when the agent-server supports
// ``ConversationInfo.current_model_*``). Consumed by the conversation
// adapter to drive the per-card chip's model text. Older agent-servers
// omit these — adapter handles ``undefined`` / ``null`` gracefully.
current_model_id: stringOrNull(item.current_model_id),
current_model_name: stringOrNull(item.current_model_name),
};
}
function requireDirectConversationItems(
items: unknown,
): DirectConversationInfo[] {
if (!Array.isArray(items)) {
throw invalidConversationResponse();
}
return items.map(requireDirectConversationInfo);
}
function requireConversationSearchPage(page: unknown): {
items: DirectConversationInfo[];
next_page_id: string | null;
} {
if (Array.isArray(page)) {
return {
items: requireDirectConversationItems(page),
next_page_id: null,
};
}
if (!isRecord(page)) {
throw invalidConversationResponse();
}
return {
items: requireDirectConversationItems(page.items),
next_page_id:
typeof page.next_page_id === "string" ? page.next_page_id : null,
};
}
const RUNTIME_STATUSES = new Set<string>([
"idle",
"running",
"paused",
"waiting_for_confirmation",
"finished",
"error",
"stuck",
]);
function toRuntimeStatus(
status: DirectConversationInfo["execution_status"],
): RuntimeConversationInfo["status"] {
const nextStatus = status ?? "idle";
return (
RUNTIME_STATUSES.has(nextStatus) ? nextStatus : "idle"
) as RuntimeConversationInfo["status"];
}
function requireAppConversation(
conversation: AppConversation | null | undefined,
conversationId: string,
): AppConversation {
if (!conversation) {
throw new Error(`Conversation ${conversationId} was not found`);
}
return conversation;
}
/**
* Options for {@link AgentServerConversationService.createConversation}.
*/
export interface CreateConversationOptions {
initialUserMsg?: string;
conversationInstructions?: string;
plugins?: PluginSpec[];
metadata?: ConversationMetadata | null;
workingDirOverride?: string;
workspaceMode?: WorkspaceMode;
parentConversationId?: string;
agentType?: "default" | "plan";
sandboxId?: string;
// Launch from a saved AgentProfile (resolved server-side) instead of the
// current encrypted agent_settings (#3727). Supported on both local and the
// cloud app-server (OpenHands #15060): local threads it through the
// encrypted-settings builder; cloud sends it as a flat request field.
agentProfileId?: string;
agentProfileKind?: AgentKind;
}
class AgentServerConversationService {
static async sendMessage(
conversationId: string,
message: SendMessageRequest,
runtime?: ConversationRuntimeContext | null,
): Promise<SendMessageResponse> {
const active = getActiveBackend().backend;
let conversationUrl = runtime?.conversationUrl ?? null;
let sessionApiKey = runtime?.sessionApiKey ?? null;
if (active.kind === "cloud") {
if (!conversationUrl || !sessionApiKey) {
const [conversation] = await batchGetCloudConversations([
conversationId,
]);
conversationUrl = conversation?.conversation_url?.trim() ?? null;
sessionApiKey = conversation?.session_api_key?.trim() ?? null;
}
if (!conversationUrl || !sessionApiKey) {
throw new Error(
"Conversation sandbox is still starting. Wait for it to finish, then try again.",
);
}
await callCloudProxy({
backend: active,
method: "POST",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/conversations/${conversationId}/events`,
body: { ...message, run: true },
authMode: "session-api-key",
sessionApiKey,
});
return message;
}
await new ConversationClient(
getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
).sendEvent(conversationId, message, {
run: true,
});
return message;
}
static async createConversation(
options: CreateConversationOptions = {},
): Promise<AppConversationStartTask> {
const {
initialUserMsg,
conversationInstructions,
plugins,
metadata,
workingDirOverride,
workspaceMode,
parentConversationId,
agentType,
sandboxId,
agentProfileId,
agentProfileKind,
} = options;
if (getActiveBackend().backend.kind === "cloud") {
// Cloud path mirrors OpenHands' frontend: build a flat
// AppConversationStartRequest, POST /api/v1/app-conversations
// (returns a WORKING task), and let the conversation route's
// useTaskPolling drive it to READY. NO encrypted-settings
// round-trip — the cloud backend holds secrets server-side.
// When launching from a profile, send `agent_profile_id`; the backend
// resolves it to agent_settings server-side.
const request: AppConversationStartRequest = {
initial_message: initialUserMsg
? {
role: "user",
content: [{ type: "text", text: initialUserMsg }],
}
: null,
title: conversationInstructions ?? null,
selected_repository: metadata?.selected_repository ?? null,
selected_branch: metadata?.selected_branch ?? null,
git_provider: metadata?.git_provider ?? null,
plugins: plugins ?? null,
parent_conversation_id: parentConversationId ?? null,
agent_type: agentType,
sandbox_id: sandboxId ?? null,
agent_profile_id: agentProfileId ?? null,
trigger: "gui",
};
return createCloudAppConversation(request);
}
const [settings, profiles] = await Promise.all([
SettingsService.getSettings(),
ProfilesService.listProfiles().catch(() => undefined),
]);
const titleLlmProfile = resolveTitleLlmProfile(
settings.title_llm_profile,
profiles,
);
const conversationId = uuidv4();
// @spec WUP-001 — Send an absolute working_dir to the agent-server.
// The default is `workspace/project/<hex>` (relative); without
// resolving it here, `/api/file/upload` later prepends `/` and writes
// to `/workspace/...` (read-only on macOS and fresh containers). When
// the user picks an explicit workspace, `workingDirOverride` is
// already absolute (it comes from `search_subdirs`).
//
// Pick the base working dir per-backend:
// 1. explicit user workspace pick → use it as-is;
// 2. no pick, backend that served this frontend → the baked default
// (honors a launcher-baked absolute `VITE_WORKING_DIR`);
// 3. no pick, any other backend → the backend-relative default.
// A baked absolute dir is a path on the host that served this frontend,
// so it is only valid on that backend. Using it for a different backend
// (e.g. a remote sandbox) makes the agent-server mkdir an unwritable path
// and the conversation fails at the first prompt (e.g. `Permission
// denied: '/Users'`). The relative default is anchored per-backend by
// `resolveAbsoluteAgentServerPath()` via `/api/file/home`. The gate keys
// on the active backend's host (not its id): the seeded `default-local`
// entry is mutable, so a user can edit it to point at a remote host while
// its id stays `default-local`.
const backendHost = getActiveBackend().backend.host;
const baseWorkingDir =
workingDirOverride ??
buildConversationWorkingDirForBackend(conversationId, backendHost);
const workingDir = await resolveAbsoluteAgentServerPath(baseWorkingDir);
// The agent-server checks `<project_dir>/.openhands/hooks.json` literally,
// so hooks need the workspace root: the per-conversation subdir below it is
// created only after this request (#16907). An explicit pick is the root.
const hooksProjectDir = workingDirOverride
? workingDir
: await resolveAbsoluteAgentServerPath(
getWorkspaceRootForBackend(backendHost),
);
const resolvedWorkspaceMode =
workspaceMode ?? (workingDirOverride ? "local_repo" : "new_worktree");
// Use encrypted settings to avoid exposing secrets in the browser
const payload = await buildStartConversationRequestWithEncryptedSettings({
settings,
query: initialUserMsg,
conversationInstructions,
plugins,
conversationId,
// The agent-server rejects a parent in a different workspace, so callers
// launching a child must pass the parent's own `working_dir` as
// `workingDirOverride` (see `resolveConversationWorkingDir`). Servers
// older than 1.37.1 ignore the field and create an unlinked conversation.
parentConversationId,
workingDir,
hooksProjectDir,
worktree: resolvedWorkspaceMode === "new_worktree",
agentProfileId,
agentProfileKind,
titleLlmProfile,
});
const telemetryDistinctId = await getTelemetryDistinctId();
const data = await new ConversationClient(
getAgentServerClientOptions({ timeout: CREATE_CONVERSATION_TIMEOUT_MS }),
).createConversation<DirectConversationInfo>({
...payload,
...(telemetryDistinctId ? { user_id: telemetryDistinctId } : {}),
});
const localBackend = getEffectiveLocalBackend();
if (!localBackend) throw new NoBackendAvailableError();
if (metadata?.selected_repository || workingDirOverride) {
// The agent-server runtime has no concept of selected repo/branch/
// workspace, so persist the home-page selection client-side.
// `toAppConversation` reads the repo/branch fields back to hydrate
// the chat-page badges; `useHasAttachedSource` reads
// `selected_workspace` to default the Files tab to Diff mode when
// the user explicitly attached a local workspace.
setStoredConversationMetadata(data.id, {
selected_repository: metadata?.selected_repository ?? null,
selected_branch: metadata?.selected_branch ?? null,
git_provider: metadata?.git_provider ?? null,
selected_workspace: workingDirOverride ?? null,
workspace_mode: resolvedWorkspaceMode,
});
}
return {
id: data.id,
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: data.id,
agent_server_url: localBackend.host,
request: {
initial_message: payload.initial_message as
| AppConversationStartRequest["initial_message"]
| undefined,
plugins: plugins ?? null,
},
created_at: data.created_at,
updated_at: data.updated_at,
};
}
static async createLocalPlanningConversation(
parentConversationId: string,
initialMessage?: string,
): Promise<AppConversation> {
if (getActiveBackend().backend.kind === "cloud") {
throw new Error("Local planning conversations require a local backend.");
}
const [parent] = await this.batchGetAppConversations([
parentConversationId,
]);
const workingDir =
parent?.workspace?.working_dir ?? getAgentServerWorkingDir();
const payload =
await buildStartPlanningConversationRequestWithEncryptedSettings({
workingDir,
parentConversationId,
// Pin the planner to the parent's own current model. Only meaningful
// for "openhands"-kind parents: an ACP parent's active_profile is a
// stale launch-time snapshot (/model is a no-op for ACP), not a live
// value, so treating it as authoritative would pin the planner to
// the wrong model instead of falling through to global settings.
parentActiveProfileName:
parent?.agent_kind === "openhands"
? (parent?.active_profile ?? null)
: null,
// Fallback when active_profile can't be resolved (e.g. an ACP parent).
parentAgentProfileId:
parent?.launched_agent_profile?.agent_profile_id ?? null,
initialMessage,
});
const data = await new ConversationClient(
getAgentServerClientOptions(),
).createConversation<DirectConversationInfo>(payload);
// Client-side fallback only: agent-servers >= 1.37.1 persist the link via
// `parent_conversation_id` and hand it back on the parent's
// `sub_conversation_ids`, which is the source of truth. This hint covers
// older backends that ignore the field.
mergeStoredConversationMetadata(parentConversationId, {
local_planning_conversation_id: data.id,
});
return toAppConversation(data);
}
/**
* Ids of the conversations owned by `parentConversationId` on a local
* backend — today that means its planner helper, the only child Canvas
* creates locally.
*
* `sub_conversation_ids` is the generic server-derived child list, so each
* child is kept only if it's tagged `plannerparent` for this parent —
* otherwise deleting the parent would also delete an unrelated non-planner
* child (e.g. a delegated sub-agent). The stored metadata hint is merged in
* for agent-servers older than 1.37.1, which report no children at all.
*/
static async getLocalPlanningConversationIds(
parentConversationId: string,
): Promise<string[]> {
if (getActiveBackend().backend.kind === "cloud") return [];
const ids = new Set<string>();
try {
const [parent] = await this.batchGetAppConversations([
parentConversationId,
]);
const childIds = parent?.sub_conversation_ids ?? [];
if (childIds.length > 0) {
const children = await this.batchGetAppConversations(childIds);
for (const child of children) {
if (child && isPlannerConversationOf(child, parentConversationId)) {
ids.add(child.id);
}
}
}
} catch (error) {
// The stored hint below still covers the common case, and callers
// (delete) must not be blocked by a failed lookup.
console.warn(
`Failed to read sub-conversations of ${parentConversationId}`,
error,
);
}
const stored =
getStoredConversationMetadata(
parentConversationId,
)?.local_planning_conversation_id;
if (stored) ids.add(stored);
return [...ids];
}
static async getStartTask(
taskId: string,
): Promise<AppConversationStartTask | null> {
if (getActiveBackend().backend.kind === "cloud") {
return getCloudAppConversationStartTask(taskId);
}
// Local agent-server creates conversations synchronously — every
// local "task" is already READY when createConversation returns, so
// there's nothing to poll for.
return null;
}
static async getVSCodeUrl(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
// Local-only path. Cloud conversations read the VSCode URL straight
// from the cloud-computed `sandbox.exposed_urls` (see
// `useUnifiedVSCodeUrl` + `useCloudSandbox`); the runtime's own
// `/api/vscode/url` only knows its internal `localhost:8001`, which
// the user's browser can't reach.
const workspaceDir =
await this.resolveConversationWorkingDir(conversationId);
// Local mode: the typescript-client targets the local agent-server
// directly via the conversationUrl override.
const vscodeUrl = await new VSCodeClient(
getAgentServerClientOptions({
conversationUrl,
sessionApiKey,
}),
).getUrl({
baseUrl:
typeof window !== "undefined" ? window.location.origin : undefined,
workspaceDir,
});
return { vscode_url: vscodeUrl };
}
/**
* Read the editor's capability state from the agent-server.
*
* `/api/vscode/status` answers 200 with `enabled: false` when the
* deployment set `enable_vscode: false`, which distinguishes "this
* deployment offers no editor" from a transport, auth, or server
* failure — `/api/vscode/url` answers 503 for the former and so
* cannot be told apart from the latter.
*/
static async getVSCodeStatus(
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<VSCodeStatusResponse> {
return new VSCodeClient(
getAgentServerClientOptions({
conversationUrl,
sessionApiKey,
}),
).getStatus();
}
static async resolveConversationWorkingDir(
conversationId: string,
): Promise<string> {
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return conversation?.workspace?.working_dir ?? getAgentServerWorkingDir();
}
static async batchGetAppConversations(
ids: string[],
): Promise<(AppConversation | null)[]> {
if (ids.length === 0) return [];
if (getActiveBackend().backend.kind === "cloud") {
return batchGetCloudConversations(ids);
}
const data = await new ConversationClient(
getAgentServerClientOptions(),
).getConversations<DirectConversationInfo>(ids);
return requireDirectConversationItems(data).map((item) =>
toAppConversation(item),
);
}
static async updateConversationPublicFlag(
conversationId: string,
isPublic: boolean,
): Promise<AppConversation> {
if (getActiveBackend().backend.kind !== "cloud") {
throw new Error("Public sharing requires a cloud backend.");
}
return updateCloudConversationPublicFlag(conversationId, isPublic);
}
static async updateConversationRepository(
conversationId: string,
repository: string | null,
branch?: string | null,
gitProvider?: string | null,
): Promise<AppConversation> {
if (repository) {
const existing = getStoredConversationMetadata(conversationId);
setStoredConversationMetadata(conversationId, {
...(existing ?? {}),
selected_repository: repository,
selected_branch: branch ?? null,
git_provider: (gitProvider as Provider | null | undefined) ?? null,
});
} else {
removeStoredConversationMetadata(conversationId);
}
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return requireAppConversation(conversation, conversationId);
}
static async readConversationFile(
conversationId: string,
filePath?: string,
): Promise<string> {
if (getActiveBackend().backend.kind === "cloud") {
// Cloud exposes a per-conversation file endpoint; the sandbox
// working dir is fixed (`/workspace/project`), so PLAN.md lives at
// a known absolute path. Mirrors OpenHands' readConversationFile.
const path = requirePathInsideDirectory(
filePath ?? "/workspace/project/.agents_tmp/PLAN.md",
"/workspace/project",
);
return readCloudConversationFile(conversationId, path);
}
const workingDir = await this.resolveConversationWorkingDir(conversationId);
const path = requirePathInsideDirectory(
filePath ?? `${workingDir}/.agents_tmp/PLAN.md`,
workingDir,
);
return new FileClient(getAgentServerClientOptions()).downloadTextFile(path);
}
static async downloadConversation(conversationId: string): Promise<Blob> {
if (getActiveBackend().backend.kind === "cloud") {
return downloadCloudConversation(conversationId);
}
return new FileClient(getAgentServerClientOptions()).downloadTrajectory(
conversationId,
);
}
static async getHooks(conversationId: string): Promise<GetHooksResponse> {
if (!conversationId) {
return emptyHooksResponse();
}
return emptyHooksResponse();
}
static async getRuntimeConversation(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<RuntimeConversationInfo> {
// Fetch directly from the per-conversation runtime agent-server at conversationUrl.
const response = await new ConversationClient(
getAgentServerClientOptions({
conversationUrl,
sessionApiKey,
}),
).getConversation<DirectConversationInfo>(conversationId);
const data = requireDirectConversationInfo(response);
return {
id: data.id,
title: data.title?.trim()
? data.title
: getDefaultConversationTitle(data.id),
metrics: normalizeMetrics(data.metrics),
created_at: data.created_at,
updated_at: data.updated_at,
status: toRuntimeStatus(data.execution_status),
stats: data.stats ?? { usage_to_metrics: {} },
};
}
/**
* Force condensation ("compact") of the conversation history via
* `POST /api/conversations/{id}/condense`. Routed the same way as
* {@link sendMessage}: through the cloud proxy at the conversation's own
* runtime host for cloud backends, directly against that runtime otherwise.
*/
static async condenseConversation(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<void> {
const active = getActiveBackend().backend;
if (active.kind === "cloud" && conversationUrl) {
await callCloudProxy({
backend: active,
method: "POST",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/conversations/${conversationId}/condense`,
authMode: "session-api-key",
sessionApiKey,
});
return;
}
await new ConversationClient(
getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
).condenseConversation(conversationId);
}
static async searchConversations(
limit: number = 20,
pageId?: string,
): Promise<AppConversationPage> {
if (getActiveBackend().backend.kind === "cloud") {
return searchCloudConversations(limit, pageId);
}
const data = await new ConversationClient(
getAgentServerClientOptions(),
).searchConversations({
limit,
page_id: pageId,
sort_order: ConversationSortOrder.UPDATED_AT_DESC,
});
return toConversationPage(requireConversationSearchPage(data));
}
static async deleteConversation(conversationId: string): Promise<void> {
if (getActiveBackend().backend.kind === "cloud") {
await deleteCloudConversation(conversationId);
removeStoredConversationMetadata(conversationId);
return;
}
// The agent-server orphans children rather than cascading, and the local
// planner helper is hidden from the conversation list by its
// `plannerparent` tag — so without this it would survive its parent as an
// invisible, unreachable conversation (plus its events and state).
const planningConversationIds =
await this.getLocalPlanningConversationIds(conversationId);
const client = new ConversationClient(getAgentServerClientOptions());
await Promise.all(
planningConversationIds.map(async (planningConversationId) => {
try {
await client.deleteConversation(planningConversationId);
} catch (error) {
// Already gone (or unreachable): never block deleting the parent the
// user actually asked to remove.
console.warn(
`Failed to delete planning conversation ${planningConversationId}`,
error,
);
}
removeStoredConversationMetadata(planningConversationId);
}),
);
await client.deleteConversation(conversationId);
removeStoredConversationMetadata(conversationId);
}
static async updateConversationTitle(
conversationId: string,
title: string,
): Promise<AppConversation> {
if (getActiveBackend().backend.kind === "cloud") {
return updateCloudConversationTitle(conversationId, title);
}
await new ConversationClient(
getAgentServerClientOptions(),
).updateConversation(conversationId, {
title,
});
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return requireAppConversation(conversation, conversationId);
}
/**
* Replaces the conversation's complete server-side tag map (the PATCH is
* replace-all, so callers must merge user edits with any reserved/internal
* keys before calling). Mirrors `updateConversationTitle`; local
* agent-server conversations only — Cloud conversations don't carry tags.
*/
static async updateConversationTags(
conversationId: string,
tags: Record<string, string>,
): Promise<AppConversation> {
await new ConversationClient(
getAgentServerClientOptions(),
).updateConversation(conversationId, {
tags,
});
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return requireAppConversation(conversation, conversationId);
}
/**
* Forks a conversation, copying event history up to and including
* `fromEventId`. Local agent-server only; needs agent-server >= 1.31.0 for
* `from_event_id` (older backends copy the whole conversation).
*/
static async forkConversation(
sourceConversationId: string,
fromEventId: string,
title?: string,
): Promise<DirectConversationInfo> {
if (getActiveBackend().backend.kind === "cloud") {
throw new Error(
"Branching a conversation isn't supported on the cloud backend yet.",
);
}
// `from_event_id` is accepted by `/fork` but not yet typed in
// ForkConversationRequest (through client 1.32.0); the client forwards the
// body verbatim, so cast to carry it. A title also suppresses the backend
// auto-title, so the "(branch)" marker sticks.
const data = await new ConversationClient(
getAgentServerClientOptions(),
).forkConversation<DirectConversationInfo>(sourceConversationId, {
from_event_id: fromEventId,
...(title ? { title } : {}),
} as ForkConversationRequest & { from_event_id: string });
// Carry over the source's client-side metadata (repo/branch/workspace/
// profile/plugins) so the fork hydrates its chat-page badges the same way.
const sourceMetadata = getStoredConversationMetadata(sourceConversationId);
if (sourceMetadata) {
setStoredConversationMetadata(data.id, sourceMetadata);
}
return data;
}
/**
* Returns an event's `parent_id` (the fork point for branching *before* it),
* or undefined at the root. Uses the single-event endpoint because the events
* *search* API omits `parent_id`.
*/
static async getEventParentId(
conversationId: string,
eventId: string,
): Promise<string | undefined> {
const event = (await new ConversationClient(
getAgentServerClientOptions(),
).getEvent(conversationId, eventId)) as { parent_id?: string | null };
return event.parent_id ?? undefined;
}
/**
* Switches the LLM profile for the running conversation when one is open
* (POST /switch_profile — per-conversation swap, doesn't change the user's
* default profile). When called without a conversationId (home page),
* falls back to POST /activate so the next conversation created picks up
* the chosen profile.
*
* The per-conversation endpoint accepts only the profile name, so the UI does
* not need to fetch or forward profile secrets. That keeps switching working
* even when the agent server has no OH_SECRET_KEY for encrypted secret export.
*
* Cloud backends route to the app-server's per-conversation
* `/switch_profile`, which owns the profiles and resolves the swap
* server-side (base_url/api_key fixups, usage_id derivation, then the
* agent-server's switch_llm) — so the client only forwards the profile name,
* mirroring {@link switchAcpModel}.
*/
static async switchProfile(
conversationId: string | null,
profileName: string,
): Promise<void> {
const { backend } = getActiveBackend();
if (backend.kind === "cloud") {
// No conversation (home page): activate globally so the next
// conversation starts with it. ProfilesService routes to the cloud
// activate endpoint.
if (!conversationId) {
await ProfilesService.activateProfile(profileName);
return;
}
await callCloudProxy({
backend,
method: "POST",
path: `/api/v1/app-conversations/${conversationId}/switch_profile`,
body: { profile_name: profileName },
});
return;
}
if (!conversationId) {
await new ProfilesClient(getAgentServerClientOptions()).activateProfile(
profileName,
);
return;
}
const clientOptions = getAgentServerClientOptions();
const conversationClient = new ConversationClient(clientOptions);
const profile = await new ProfilesClient(clientOptions).getProfile(
profileName,
{ exposeSecrets: "encrypted" },
);
const model =
typeof profile.config.model === "string" ? profile.config.model : "";
if (!model) throw new Error(`Profile '${profileName}' has no model.`);
await assertSubscriptionAuthReady({ llm: profile.config });
await conversationClient.switchLLM(conversationId, {
...profile.config,
model,
// Keep streaming on after a switch (parity with conversation start);
// the profile config would otherwise default it to stream=False.
stream: true,
// Avoid stale first-write-wins entries in the backend LLM registry.
usage_id: `profile:${profileName}:${uuidv4()}`,
} as LLMConfig);
}
/**
* Switches the model of a running ACP conversation in place (POST
* /switch_acp_model — the ACP analog of {@link switchProfile}'s /switch_profile).
* The agent-server calls the ACP wrapper's ``session/set_model`` on the live
* session, preserving context. Mirrors {@link switchProfile}'s
* local-backend-only guard and per-conversation ConversationClient call.
*
* Works on a created-but-not-yet-run conversation too: the agent-server
* treats a pre-first-run switch as a persist-only deferral (the model is
* mirrored into the conversation's stored agent and applied when the session
* starts). The home/no-conversation default is persisted via the active ACP
* profile / Settings instead (see ``use-switch-acp-model``).
*/
static async switchAcpModel(
conversationId: string,
model: string,
): Promise<void> {
const { backend } = getActiveBackend();
if (backend.kind === "cloud") {
await callCloudProxy({
backend,
method: "POST",
path: `/api/v1/app-conversations/${conversationId}/switch_acp_model`,
body: { model },
});
return;
}
await new ConversationClient(getAgentServerClientOptions()).switchAcpModel(
conversationId,
model,
);
}
}
export default AgentServerConversationService;