Download src/api/agent-server-adapter.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 58.6 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/api/agent-server-adapter.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/api/agent-server-adapter.ts
-
curl -L -o agent-server-adapter.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/api/agent-server-adapter.ts
58.6 kB
| import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client"; | |
| import type { HookConfig } from "@openhands/typescript-client"; | |
| import { ServerClient } from "@openhands/typescript-client/clients"; | |
| import { SKILLS_CATALOG } from "@openhands/extensions/skills"; | |
| import { DEFAULT_SETTINGS } from "#/services/settings"; | |
| import { ExecutionStatus } from "#/types/agent-server/core"; | |
| import { AgentKind, Settings, SettingsValue } from "#/types/settings"; | |
| import { | |
| getAcpPreferredDefaultModel, | |
| getAcpProvider, | |
| resolveEffectiveAcpModel, | |
| } from "#/constants/acp-providers"; | |
| import { getAgentServerClientOptions } from "./agent-server-client-options"; | |
| import { | |
| getCachedAgentServerInfo, | |
| isAgentServerToolAvailable, | |
| } from "./agent-server-compatibility"; | |
| import { getAgentServerWorkingDir } from "./agent-server-config"; | |
| import { getEffectiveLocalBackend } from "./backend-registry/active-store"; | |
| import { buildAuthHeaders } from "./backend-registry/auth"; | |
| import { | |
| GetHooksResponse, | |
| PluginSpec, | |
| AppConversation, | |
| AppConversationPage, | |
| RuntimeConversationStats, | |
| SandboxStatus, | |
| } from "./conversation-service/agent-server-conversation-service.types"; | |
| import { combineUsageMetrics } from "#/utils/conversation-metrics"; | |
| import { | |
| buildSkillEnablementFilter, | |
| findInvokedCatalogSkill, | |
| toSkillEnablement, | |
| type SkillEnablement, | |
| } from "#/utils/skill-enablement"; | |
| import SettingsService from "./settings-service/settings-service.api"; | |
| import { getStoredConversationMetadata } from "./conversation-metadata-store"; | |
| import LLMSubscriptionService from "./llm-subscription-service"; | |
| import { | |
| LLM_AUTH_TYPE_SUBSCRIPTION, | |
| OPENAI_SUBSCRIPTION_VENDOR, | |
| isSubscriptionLlmConfig, | |
| } from "#/constants/llm-subscription"; | |
| import { | |
| CANVAS_UI_CLIENT_TOOL, | |
| CANVAS_UI_CLIENT_TOOL_NAME, | |
| LEGACY_CANVAS_UI_TOOL_NAME, | |
| type ClientToolSpec, | |
| } from "./canvas-ui-client-tool"; | |
| import { | |
| LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL, | |
| LAUNCH_CHILD_CONVERSATION_TOOL_NAME, | |
| } from "./launch-child-conversation-client-tool"; | |
| import { | |
| buildPlanPath, | |
| LOCAL_PLANNER_PARENT_TAG_KEY, | |
| PLAN_STRUCTURE_TEXT, | |
| PLANNING_AGENT_INSTRUCTION, | |
| PLANNING_FILE_EDITOR_TOOL_NAME, | |
| PLANNING_SYSTEM_PROMPT_FILENAME, | |
| } from "#/utils/plan-file"; | |
| export interface DirectConversationInfo { | |
| id: string; | |
| title?: string | null; | |
| created_at: string; | |
| updated_at: string; | |
| execution_status?: string | null; | |
| /** Cloud-only sandbox lifecycle state. Omitted / null for local agent-server conversations. */ | |
| sandbox_status?: string | null; | |
| metrics?: { | |
| accumulated_cost?: number | null; | |
| max_budget_per_task?: number | null; | |
| accumulated_token_usage?: { | |
| prompt_tokens?: number; | |
| completion_tokens?: number; | |
| cache_read_tokens?: number; | |
| cache_write_tokens?: number; | |
| context_window?: number; | |
| per_turn_token?: number; | |
| } | null; | |
| } | null; | |
| /** | |
| * Raw per-usage-id LLM stats from the agent-server. Search/list responses | |
| * often carry real usage here even when `metrics` above comes back unset; | |
| * {@link toAppConversation} combines this as a fallback in that case. | |
| */ | |
| stats?: RuntimeConversationStats | null; | |
| agent?: { | |
| /** | |
| * Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI | |
| * subprocesses (model lives on the subprocess via ``acp_model``), | |
| * ``"Agent"`` for direct litellm. Read by {@link toAppConversation}. | |
| */ | |
| kind?: string | null; | |
| acp_model?: string | null; | |
| /** | |
| * ACP CLI identity (``claude-code`` / ``codex`` / ``gemini-cli``) from the | |
| * SDK's ``ACPAgent.acp_server`` (#3692). Preferred fallback when the | |
| * ``acpserver`` tag is absent — e.g. a profile launch doesn't stamp the tag | |
| * client-side and the server may not repopulate it. Read by {@link toAppConversation}. | |
| */ | |
| acp_server?: string | null; | |
| llm?: { | |
| model?: string | null; | |
| } | null; | |
| } | null; | |
| current_model_id?: string | null; | |
| current_model_name?: string | null; | |
| workspace?: { | |
| working_dir?: string | null; | |
| } | null; | |
| /** | |
| * Arbitrary string-keyed conversation tags surfaced by the agent-server | |
| * (see ``ConversationInfo.tags``). Canvas only consumes one key today — | |
| * ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a | |
| * generic record so future readers don't need another wire-shape change. | |
| * Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator; | |
| * values are opaque strings. | |
| */ | |
| tags?: Record<string, string> | null; | |
| launched_agent_profile?: { | |
| agent_profile_id: string; | |
| revision: number; | |
| } | null; | |
| /** | |
| * Server-owned, derived from the catalog's ``parent_conversation_id`` link | |
| * (agent-server >= 1.37.1, SDK #4188) — a conversation started with a | |
| * parent is discoverable here on any browser, which is what makes the | |
| * local planner's relationship server state rather than a browser-local | |
| * hint. Absent on older agent-servers and on the cloud wire shape. | |
| */ | |
| sub_conversation_ids?: string[] | null; | |
| } | |
| const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"]; | |
| const BROWSER_TOOL_SET_NAME = "browser_tool_set"; | |
| const TASK_TOOL_SET_NAME = "task_tool_set"; | |
| // Falls back to the same default the code agent uses when the user has not | |
| // configured `conversation_settings.max_iterations` (see buildConfiguredConversationSettings). | |
| const DEFAULT_MAX_ITERATIONS = 500; | |
| function resolveMaxIterations(value: unknown): number { | |
| return typeof value === "number" ? value : DEFAULT_MAX_ITERATIONS; | |
| } | |
| function browserToolsEnabled() { | |
| return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; | |
| } | |
| /** | |
| * Shape of the runtime services info served by Agent Canvas backends in | |
| * `/server_info.runtime_services`. All URLs are written from the agent's point of | |
| * view, not the browser's. The block is rendered into the agent's system prompt | |
| * via `AgentContext.system_message_suffix` so the agent knows what's reachable | |
| * from inside its sandbox without having to probe. | |
| */ | |
| export interface RuntimeServicesInfo { | |
| mode?: string; | |
| agent_host_alias?: string; | |
| services?: { | |
| agent_server?: { description?: string; url_from_agent?: string }; | |
| ingress?: { description?: string; url_from_agent?: string }; | |
| frontend?: { | |
| kind?: "vite" | "static"; | |
| description?: string; | |
| url_from_agent?: string; | |
| }; | |
| // `vite` is the legacy key name for the frontend entry, accepted for | |
| // one release while older dev-stack launchers may still emit it. | |
| vite?: { description?: string; url_from_agent?: string }; | |
| automation?: { | |
| description?: string; | |
| url_from_agent?: string; | |
| api_prefix?: string; | |
| docs_url?: string; | |
| openapi_url?: string; | |
| auth_env_var?: string; | |
| }; | |
| }; | |
| } | |
| export function parseRuntimeServicesInfo( | |
| value: unknown, | |
| ): RuntimeServicesInfo | null { | |
| if (typeof value === "string") { | |
| const raw = value.trim(); | |
| if (!raw) return null; | |
| try { | |
| return parseRuntimeServicesInfo(JSON.parse(raw)); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| return null; | |
| } | |
| const parsed = value as RuntimeServicesInfo; | |
| if (!parsed.services || typeof parsed.services !== "object") return null; | |
| return parsed; | |
| } | |
| export async function fetchBackendRuntimeServicesInfo(): Promise<RuntimeServicesInfo | null> { | |
| let clientOptions: ReturnType<typeof getAgentServerClientOptions>; | |
| try { | |
| clientOptions = getAgentServerClientOptions({ timeout: 3000 }); | |
| } catch { | |
| return null; | |
| } | |
| const cached = parseRuntimeServicesInfo( | |
| getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services, | |
| ); | |
| if (cached) return cached; | |
| try { | |
| const serverInfo = await new ServerClient(clientOptions).getServerInfo(); | |
| return parseRuntimeServicesInfo( | |
| (serverInfo as { runtime_services?: unknown }).runtime_services, | |
| ); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Return the deployment mode from the runtime services info, e.g. "docker", | |
| * "dev:automation", etc. Returns `null` when no runtime info is supplied. | |
| */ | |
| export function getDeploymentMode( | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| ): string | null { | |
| return runtimeServicesInfo?.mode ?? null; | |
| } | |
| /** | |
| * Render the runtime services info into a markdown block suitable for | |
| * appending to the system prompt via `AgentContext.system_message_suffix`. | |
| * | |
| * Returns `undefined` when no runtime info is available, so callers can safely | |
| * omit the field when the selected backend does not advertise runtime services. | |
| */ | |
| export function buildRuntimeServicesSystemSuffix( | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| ): string | undefined { | |
| const info = parseRuntimeServicesInfo(runtimeServicesInfo); | |
| if (!info?.services) return undefined; | |
| const lines: string[] = []; | |
| lines.push("<RUNTIME_SERVICES>"); | |
| if (info.mode) { | |
| lines.push( | |
| `You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`, | |
| ); | |
| } else { | |
| lines.push("You are running inside an agent-canvas dev stack."); | |
| } | |
| lines.push( | |
| "The following services are reachable from your sandbox. URLs are written", | |
| "from your point of view (i.e., as you should curl/fetch them).", | |
| "", | |
| ); | |
| const { agent_server, ingress, automation } = info.services; | |
| const { frontend } = info.services; | |
| if (agent_server?.url_from_agent) { | |
| lines.push( | |
| `* Agent Server (you): ${agent_server.url_from_agent}`, | |
| ` ${agent_server.description ?? "The agent-server hosting your tool calls."}`, | |
| ); | |
| } | |
| if (ingress?.url_from_agent) { | |
| lines.push( | |
| `* Ingress: ${ingress.url_from_agent}`, | |
| ` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`, | |
| ); | |
| } | |
| if (frontend?.url_from_agent) { | |
| lines.push( | |
| `* Frontend: ${frontend.url_from_agent}`, | |
| ` ${frontend.description ?? "Frontend dev server."}`, | |
| ); | |
| } | |
| if (automation?.url_from_agent) { | |
| lines.push( | |
| `* Automation backend: ${automation.url_from_agent}`, | |
| ` ${automation.description ?? "OpenHands Automations service."}`, | |
| ); | |
| if (automation.docs_url) { | |
| lines.push(` Docs: ${automation.docs_url}`); | |
| } | |
| if (automation.openapi_url) { | |
| lines.push(` OpenAPI: ${automation.openapi_url}`); | |
| } | |
| if (automation.auth_env_var) { | |
| // X-Session-API-Key is the local convention shared by the agent-server | |
| // and automation backend (see openhands-automation auth.py). | |
| lines.push( | |
| ` Auth: header 'X-Session-API-Key: $${automation.auth_env_var}'`, | |
| ); | |
| } | |
| } else { | |
| lines.push( | |
| "* Automation backend: not running in this dev mode (skip /api/automation calls).", | |
| ); | |
| } | |
| // Anchor the "don't guess" warning to the actual agent-server URL for | |
| // this stack instead of a hardcoded port. The agent-server listens on | |
| // different ports across dev modes, and baking the wrong port into the | |
| // system prompt is exactly the kind of confusion this block is meant to | |
| // prevent. | |
| const agentServerUrl = agent_server?.url_from_agent; | |
| lines.push( | |
| "", | |
| "Trust this block over guessing: do not assume any other URLs are running.", | |
| ); | |
| if (agentServerUrl) { | |
| lines.push( | |
| `In particular, ${agentServerUrl} inside your sandbox is the Agent Server`, | |
| "you are running inside of — NOT the automation backend.", | |
| ); | |
| } | |
| lines.push("</RUNTIME_SERVICES>"); | |
| return lines.join("\n"); | |
| } | |
| export function toConversationUrl(conversationId: string): string { | |
| // Local-format conversation URL — points at whichever local agent-server | |
| // is actually serving the conversation (the bundled one when the active | |
| // selection is cloud). | |
| const { host } = getAgentServerClientOptions(); | |
| return `${host}/api/conversations/${conversationId}`; | |
| } | |
| // TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE | |
| // with `{{shortId}}` interpolation. Kept as a literal for now to keep the | |
| // fallback inside this pure adapter rather than fanning out to display sites. | |
| export function getDefaultConversationTitle(conversationId: string): string { | |
| return `Conversation ${conversationId.slice(0, 5)}`; | |
| } | |
| export function toAppConversation( | |
| info: DirectConversationInfo, | |
| ): AppConversation { | |
| const metadata = getStoredConversationMetadata(info.id); | |
| // ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the | |
| // runtime model fields when available, then the configured ``acp_model`` that | |
| // Canvas saves for built-in providers. ``agent_kind`` still gates model | |
| // switching, so surfacing this string is display-only. | |
| const isAcp = info.agent?.kind === "ACPAgent"; | |
| // Only surface ``acp_server`` for ACP conversations even if the wire | |
| // payload accidentally carries an ``acpserver`` tag on an OpenHands | |
| // conversation — the chip is identity info for the ACP CLI subprocess, | |
| // and showing it on a non-ACP conversation would be a lie. Fall back to the | |
| // agent's own ``acp_server`` (#3692) when the tag is missing — a profile | |
| // launch doesn't stamp the tag, so tag-only reads would drop the ACP model | |
| // picker and degrade the chip to a generic "ACP" (#1571). | |
| const acpServer = isAcp | |
| ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? info.agent?.acp_server ?? null) | |
| : null; | |
| return { | |
| id: info.id, | |
| created_by_user_id: null, | |
| selected_repository: metadata?.selected_repository ?? null, | |
| selected_branch: metadata?.selected_branch ?? null, | |
| git_provider: metadata?.git_provider ?? null, | |
| selected_workspace: metadata?.selected_workspace ?? null, | |
| active_profile: metadata?.active_profile ?? null, | |
| title: info.title?.trim() | |
| ? info.title | |
| : getDefaultConversationTitle(info.id), | |
| trigger: null, | |
| pr_number: [], | |
| agent_kind: isAcp ? "acp" : "openhands", | |
| acp_server: acpServer, | |
| tags: info.tags ?? null, | |
| launched_agent_profile: info.launched_agent_profile ?? null, | |
| // Chip path: omit ``providerDefault`` so that when no concrete model | |
| // resolves, the chip falls back to the provider display name in | |
| // ConversationCardFooter rather than a registry default the session may | |
| // not actually be running. | |
| llm_model: isAcp | |
| ? resolveEffectiveAcpModel({ | |
| runtimeName: info.current_model_name, | |
| runtimeId: info.current_model_id, | |
| configured: info.agent?.acp_model, | |
| sdkLlm: info.agent?.llm?.model, | |
| }) | |
| : (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model), | |
| metrics: info.metrics | |
| ? { | |
| accumulated_cost: info.metrics.accumulated_cost ?? null, | |
| max_budget_per_task: info.metrics.max_budget_per_task ?? null, | |
| accumulated_token_usage: info.metrics.accumulated_token_usage | |
| ? { | |
| prompt_tokens: | |
| info.metrics.accumulated_token_usage.prompt_tokens ?? 0, | |
| completion_tokens: | |
| info.metrics.accumulated_token_usage.completion_tokens ?? 0, | |
| cache_read_tokens: | |
| info.metrics.accumulated_token_usage.cache_read_tokens ?? 0, | |
| cache_write_tokens: | |
| info.metrics.accumulated_token_usage.cache_write_tokens ?? 0, | |
| context_window: | |
| info.metrics.accumulated_token_usage.context_window ?? 0, | |
| per_turn_token: | |
| info.metrics.accumulated_token_usage.per_turn_token ?? 0, | |
| } | |
| : null, | |
| } | |
| : combineUsageMetrics(info.stats), | |
| created_at: info.created_at, | |
| updated_at: info.updated_at, | |
| execution_status: | |
| (info.execution_status as AppConversation["execution_status"]) ?? | |
| ExecutionStatus.IDLE, | |
| sandbox_status: (info.sandbox_status as SandboxStatus | null) ?? null, | |
| conversation_url: toConversationUrl(info.id), | |
| session_api_key: getAgentServerClientOptions().apiKey ?? null, | |
| sandbox_id: null, | |
| workspace: { | |
| working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(), | |
| }, | |
| public: false, | |
| sub_conversation_ids: info.sub_conversation_ids ?? [], | |
| }; | |
| } | |
| export function toConversationPage(data: { | |
| items: DirectConversationInfo[]; | |
| next_page_id?: string | null; | |
| }): AppConversationPage { | |
| return { | |
| items: data.items | |
| .filter((item) => !item.tags?.[LOCAL_PLANNER_PARENT_TAG_KEY]) | |
| .map(toAppConversation), | |
| next_page_id: data.next_page_id ?? null, | |
| }; | |
| } | |
| type SettingsRecord = Record<string, unknown>; | |
| interface AgentToolSpec { | |
| name: string; | |
| params: SettingsRecord; | |
| } | |
| type AgentSettingsPayload = SettingsRecord & { | |
| llm?: SettingsRecord; | |
| agent_context: SettingsRecord; | |
| tools?: AgentToolSpec[]; | |
| }; | |
| interface LocalWorkspacePayload { | |
| kind: "LocalWorkspace"; | |
| working_dir: string; | |
| } | |
| interface InitialMessagePayload { | |
| role: "user"; | |
| content: Array<{ type: "text"; text: string }>; | |
| run: true; | |
| } | |
| type ConversationSettingsPayload = SettingsRecord & { | |
| workspace: LocalWorkspacePayload; | |
| initial_message?: InitialMessagePayload; | |
| }; | |
| export const ACP_SERVER_TAG_KEY = "acpserver"; | |
| export const CLIENT_SOURCE_TAG_KEY = "clientsource"; | |
| export const AGENT_CANVAS_SOURCE = "agentcanvas"; | |
| export const AUTOMATION_TRIGGER_TAG_KEY = "automationtrigger"; | |
| export const AUTOMATION_ID_TAG_KEY = "automationid"; | |
| export const AUTOMATION_NAME_TAG_KEY = "automationname"; | |
| export const AUTOMATION_RUN_ID_TAG_KEY = "automationrunid"; | |
| /** | |
| * Tag keys stamped on conversations created by automation runs (see the SDK's | |
| * `RemoteWorkspace.default_conversation_tags`). The presence of any of these | |
| * marks a conversation as automation-born. | |
| */ | |
| export const AUTOMATION_TAG_KEYS: readonly string[] = [ | |
| AUTOMATION_TRIGGER_TAG_KEY, | |
| AUTOMATION_ID_TAG_KEY, | |
| AUTOMATION_NAME_TAG_KEY, | |
| AUTOMATION_RUN_ID_TAG_KEY, | |
| ]; | |
| /** | |
| * Conversation tag keys that must not appear as generic chips / hovercard | |
| * rows. Each is either already surfaced by a first-class UI source or is | |
| * internal routing data: | |
| * - ``acpserver`` → ACP provider chip | |
| * - ``clientsource`` → telemetry attribution | |
| * - ``title`` → conversation card heading | |
| * - git / repo / branch / workspace stamps → repo-branch metadata + directory | |
| * footer / hovercard rows (``selected_repository``, ``selected_branch``, | |
| * ``git_provider``, ``workspace.working_dir``) | |
| * - the automation family (``automationtrigger`` / ``automationid`` / | |
| * ``automationname`` / ``automationrunid``) → provenance the SDK stamps at | |
| * creation; the conversation panel's automation filter is its first-class | |
| * UI source. The tag surface is user organization data, so machine stamps | |
| * stay out of it — and users can't edit or spoof automation classification. | |
| * - ``localplannerparent`` → internal routing for the local planner; already | |
| * surfaced by the hidden-from-list planner filter | |
| */ | |
| export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet<string> = new Set([ | |
| ACP_SERVER_TAG_KEY, | |
| CLIENT_SOURCE_TAG_KEY, | |
| AUTOMATION_TRIGGER_TAG_KEY, | |
| AUTOMATION_ID_TAG_KEY, | |
| AUTOMATION_NAME_TAG_KEY, | |
| AUTOMATION_RUN_ID_TAG_KEY, | |
| "title", | |
| "git_provider", | |
| "repo_name", | |
| "repo", | |
| "repository", | |
| "selected_branch", | |
| "branch", | |
| "archiveworkspacepath", | |
| "workspace", | |
| "working_dir", | |
| LOCAL_PLANNER_PARENT_TAG_KEY, | |
| ]); | |
| /** | |
| * High-signal tag keys shown first in the chip row (before A–Z). Automations | |
| * often stamp ``origin``; remaining free-form tags sort alphabetically. | |
| */ | |
| export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"]; | |
| /** | |
| * User-facing subset of a conversation's server-side tags: everything except | |
| * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries. | |
| * Priority keys come first (in {@link PRIORITY_CONVERSATION_TAG_KEYS} order); | |
| * the rest sort A–Z so chip order doesn't shuffle between refetches. | |
| */ | |
| export function getDisplayConversationTags( | |
| tags: Record<string, string> | null | undefined, | |
| ): Array<[string, string]> { | |
| if (!tags) { | |
| return []; | |
| } | |
| // Both the reserved-key check and the priority lookup must see the same | |
| // normalized key: a cloud backend can stamp ``Origin`` / `` origin``, and | |
| // ranking those off the raw key would silently drop them out of first place. | |
| const priorityRank = (key: string): number => { | |
| const index = PRIORITY_CONVERSATION_TAG_KEYS.indexOf( | |
| key.trim().toLowerCase(), | |
| ); | |
| return index === -1 ? Number.POSITIVE_INFINITY : index; | |
| }; | |
| return Object.entries(tags) | |
| .filter( | |
| ([key, value]) => | |
| !RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) && | |
| typeof value === "string" && | |
| // Bare tags (empty value) are displayable — chips/tooltips render | |
| // the key. Whitespace-only values stay dropped (raw-write junk). | |
| (value === "" || value.trim().length > 0), | |
| ) | |
| .sort(([a], [b]) => { | |
| const aRank = priorityRank(a); | |
| const bRank = priorityRank(b); | |
| if (aRank !== bRank) { | |
| return aRank - bRank; | |
| } | |
| return a.localeCompare(b); | |
| }); | |
| } | |
| const FERNET_TOKEN_PREFIX = "gAAAAA"; | |
| const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ | |
| "schema_version", | |
| "agent_settings", | |
| "workspace", | |
| "conversation_id", | |
| "initial_message", | |
| "plugins", | |
| ]); | |
| function toRecord(value: unknown): SettingsRecord { | |
| if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| return {}; | |
| } | |
| return structuredClone(value as SettingsRecord); | |
| } | |
| function normalizeSecretString(value: unknown): string | undefined { | |
| if (typeof value !== "string") { | |
| return undefined; | |
| } | |
| const trimmed = value.trim(); | |
| return trimmed.length > 0 ? trimmed : undefined; | |
| } | |
| function buildNormalizedLlmSettings(value: unknown): SettingsRecord { | |
| const llm = toRecord(value); | |
| llm.model = | |
| typeof llm.model === "string" && llm.model.trim().length > 0 | |
| ? llm.model | |
| : DEFAULT_SETTINGS.llm_model; | |
| const apiKey = normalizeSecretString(llm.api_key); | |
| if (apiKey) { | |
| llm.api_key = apiKey; | |
| } else { | |
| delete llm.api_key; | |
| } | |
| const baseUrl = normalizeSecretString(llm.base_url); | |
| if (baseUrl) { | |
| llm.base_url = baseUrl; | |
| } else { | |
| delete llm.base_url; | |
| } | |
| if (isSubscriptionLlmConfig(llm)) { | |
| llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; | |
| llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; | |
| delete llm.api_key; | |
| } else { | |
| delete llm.auth_type; | |
| delete llm.subscription_vendor; | |
| } | |
| return llm; | |
| } | |
| function isPlainRecord(value: unknown): value is Record<string, unknown> { | |
| return !!value && typeof value === "object" && !Array.isArray(value); | |
| } | |
| function hasEncryptedString(value: unknown): boolean { | |
| if (typeof value === "string") { | |
| return value.startsWith(FERNET_TOKEN_PREFIX); | |
| } | |
| if (Array.isArray(value)) { | |
| return value.some(hasEncryptedString); | |
| } | |
| if (isPlainRecord(value)) { | |
| return Object.values(value).some(hasEncryptedString); | |
| } | |
| return false; | |
| } | |
| function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean { | |
| if (!isPlainRecord(mcpConfig)) { | |
| return false; | |
| } | |
| return Object.values(mcpConfig).some(hasEncryptedString); | |
| } | |
| function getConversationConfirmationPolicy( | |
| conversationSettings: SettingsRecord, | |
| ) { | |
| if (conversationSettings.confirmation_mode !== true) { | |
| return { kind: "NeverConfirm" }; | |
| } | |
| if (conversationSettings.security_analyzer === "llm") { | |
| return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true }; | |
| } | |
| return { kind: "AlwaysConfirm" }; | |
| } | |
| function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { | |
| switch (conversationSettings.security_analyzer) { | |
| case "llm": | |
| return { kind: "LLMSecurityAnalyzer" }; | |
| case "pattern": | |
| return { kind: "PatternSecurityAnalyzer" }; | |
| case "policy_rail": | |
| return { kind: "PolicyRailSecurityAnalyzer" }; | |
| default: | |
| return undefined; | |
| } | |
| } | |
| function isToolRecord( | |
| value: unknown, | |
| ): value is { name: string; params?: unknown } { | |
| return ( | |
| !!value && | |
| typeof value === "object" && | |
| !Array.isArray(value) && | |
| typeof (value as { name?: unknown }).name === "string" | |
| ); | |
| } | |
| function shouldIncludeTool(name: string, agentSettings: SettingsRecord) { | |
| if (name === BROWSER_TOOL_SET_NAME) { | |
| return browserToolsEnabled() && isAgentServerToolAvailable(name); | |
| } | |
| if (name === TASK_TOOL_SET_NAME) { | |
| return ( | |
| agentSettings.enable_sub_agents === true && | |
| isAgentServerToolAvailable(name) | |
| ); | |
| } | |
| return true; | |
| } | |
| function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] { | |
| const tools = new Map<string, AgentToolSpec>(); | |
| for (const name of DEFAULT_TOOL_NAMES) { | |
| if (shouldIncludeTool(name, agentSettings)) { | |
| tools.set(name, { name, params: {} }); | |
| } | |
| } | |
| for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) { | |
| if (shouldIncludeTool(name, agentSettings)) { | |
| tools.set(name, { name, params: {} }); | |
| } | |
| } | |
| const configuredTools = agentSettings.tools; | |
| if ( | |
| Array.isArray(configuredTools) && | |
| configuredTools.every((tool) => isToolRecord(tool)) | |
| ) { | |
| for (const tool of configuredTools) { | |
| if (shouldIncludeTool(tool.name, agentSettings)) { | |
| tools.set(tool.name, { | |
| name: tool.name, | |
| params: toRecord(tool.params), | |
| }); | |
| } | |
| } | |
| } | |
| return Array.from(tools.values()); | |
| } | |
| function buildInitialMessage( | |
| query?: string, | |
| conversationInstructions?: string, | |
| ): InitialMessagePayload | null { | |
| const parts = [query?.trim(), conversationInstructions?.trim()].filter( | |
| Boolean, | |
| ); | |
| if (parts.length === 0) { | |
| return null; | |
| } | |
| return { | |
| role: "user", | |
| content: [{ type: "text", text: parts.join("\n\n") }], | |
| run: true, | |
| }; | |
| } | |
| /** | |
| * Shape of a bundled skill entry passed to the agent-server SDK via | |
| * `agent_context.skills`. Mirrors the SDK's `Skill` model fields that | |
| * the server uses for trigger matching, activation, and system-prompt | |
| * injection. | |
| */ | |
| interface BundledSkill { | |
| name: string; | |
| content: string; | |
| trigger: { type: "keyword"; keywords: string[] } | null; | |
| source: string; | |
| description: string | null; | |
| is_agentskills_format: true; | |
| license?: string; | |
| compatibility?: string; | |
| } | |
| /** | |
| * Convert the bundled `SKILLS_CATALOG` entries into the SDK `Skill` JSON | |
| * shape so the agent-server can perform trigger matching, skill activation, | |
| * and system-prompt injection without cloning the extensions repo. | |
| * | |
| * The SDK discriminates triggers via `{ type: "keyword", keywords: [...] }`. | |
| * Skills with no triggers get `trigger: null` (always-active / on-demand). | |
| */ | |
| function buildBundledSkills(): BundledSkill[] { | |
| return SKILLS_CATALOG.map((entry) => { | |
| const trigger: BundledSkill["trigger"] = | |
| entry.triggers?.length > 0 | |
| ? { type: "keyword", keywords: entry.triggers } | |
| : null; | |
| // Use the absolute path to the skill's SKILL.md so the Python | |
| // agent-server can resolve bundled resources (scripts/, references/). | |
| // Falls back to "public" in library builds where the path isn't known. | |
| const source = __EXTENSIONS_SKILLS_DIR__ | |
| ? `${__EXTENSIONS_SKILLS_DIR__}/${entry.name}/SKILL.md` | |
| : "public"; | |
| return { | |
| name: entry.name, | |
| content: entry.content, | |
| trigger, | |
| source, | |
| description: entry.description ?? null, | |
| is_agentskills_format: true as const, | |
| ...(entry.license ? { license: entry.license } : {}), | |
| ...(entry.compatibility ? { compatibility: entry.compatibility } : {}), | |
| }; | |
| }); | |
| } | |
| function buildAgentContext( | |
| agentSettings: SettingsRecord, | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| enablement: SkillEnablement = {}, | |
| invokedCatalogSkill?: string, | |
| ): SettingsRecord { | |
| const runtimeServicesSuffix = | |
| buildRuntimeServicesSystemSuffix(runtimeServicesInfo); | |
| const existingContext = toRecord(agentSettings.agent_context); | |
| // Merge bundled public skills with any skills already present in the | |
| // agent context (e.g. user-defined skills set via the settings API). | |
| const existingSkills = Array.isArray(existingContext.skills) | |
| ? (existingContext.skills as SettingsRecord[]) | |
| : []; | |
| const disabledSkills = enablement.disabledSkills ?? []; | |
| const disabledSkillNames = new Set(disabledSkills); | |
| const isSkillEnabled = buildSkillEnablementFilter(enablement); | |
| // The bundled catalog is allow-listed, not deny-listed: it is a build-time | |
| // snapshot of ~60 skills, so a deny-list puts every future addition into | |
| // every system prompt (OpenHands#16302). Skills the agent context already | |
| // carries are user-authored and stay opt-out. A skill the opening message | |
| // invokes by name overrides both, for this conversation only. | |
| const mergedSkills = [ | |
| ...existingSkills.filter( | |
| (skill) => | |
| typeof skill.name !== "string" || !disabledSkillNames.has(skill.name), | |
| ), | |
| ...buildBundledSkills().filter( | |
| (skill) => | |
| skill.name === invokedCatalogSkill || isSkillEnabled(skill.name), | |
| ), | |
| ]; | |
| return { | |
| ...existingContext, | |
| // Public skills are bundled at build time from the @openhands/extensions | |
| // npm package and passed directly in agent_context.skills. Setting | |
| // load_public_skills to false tells the agent-server SDK to skip its own | |
| // extensions-repo clone — the frontend is the sole source of public | |
| // skills now. | |
| // | |
| // Migration: the former VITE_LOAD_PUBLIC_SKILLS env var was removed | |
| // because bundled skills have no clone latency. Users who previously set | |
| // VITE_LOAD_PUBLIC_SKILLS=false to avoid clone delays no longer need it. | |
| skills: mergedSkills, | |
| load_public_skills: false, | |
| load_user_skills: true, | |
| load_project_skills: true, | |
| // The backend also auto-loads user/project skills; the deny-list must | |
| // travel with the context so those skills are excluded from the system | |
| // prompt too. The allow-list has no counterpart to send: the backend | |
| // loads no catalog skills of its own (`load_public_skills` is false). | |
| disabled_skills: disabledSkills, | |
| ...(runtimeServicesSuffix | |
| ? { system_message_suffix: runtimeServicesSuffix } | |
| : {}), | |
| }; | |
| } | |
| function isAcpAgent(settings: Settings): boolean { | |
| const agentSettings = toRecord(settings.agent_settings); | |
| return agentSettings.agent_kind === "acp"; | |
| } | |
| function getAcpServerTag(settings: Settings): string | undefined { | |
| const agentSettings = toRecord(settings.agent_settings); | |
| const value = agentSettings.acp_server; | |
| return typeof value === "string" && value.length > 0 ? value : undefined; | |
| } | |
| function resolveAcpCommand(agentSettings: SettingsRecord): unknown { | |
| const cmd = agentSettings.acp_command; | |
| const isEmpty = Array.isArray(cmd) && cmd.length === 0; | |
| const noCommand = cmd === undefined; | |
| if (!isEmpty && !noCommand) { | |
| return cmd; | |
| } | |
| const serverKey = | |
| typeof agentSettings.acp_server === "string" | |
| ? agentSettings.acp_server | |
| : undefined; | |
| const provider = getAcpProvider(serverKey); | |
| return provider ? [...provider.default_command] : cmd; | |
| } | |
| function buildConfiguredAcpAgentSettings( | |
| settings: Settings, | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| query?: string, | |
| ): AgentSettingsPayload { | |
| const agentSettings = toRecord(settings.agent_settings); | |
| const payload: AgentSettingsPayload = { | |
| agent_kind: "acp", | |
| agent_context: buildAgentContext( | |
| agentSettings, | |
| runtimeServicesInfo, | |
| toSkillEnablement(settings), | |
| findInvokedCatalogSkill(query), | |
| ), | |
| }; | |
| // TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized | |
| // backend so concurrent same-provider conversations don't race on a shared | |
| // HOME. The SDK supports it (software-agent-sdk#3492), but the released | |
| // ``@openhands/typescript-client`` (1.24.3) doesn't surface it on | |
| // ``ACPAgentSettings`` yet, so sending it risks a validation error on older | |
| // servers. Cloud grouping isolation is separate (agent-canvas#1016). | |
| for (const key of ACP_SETTINGS_KEYS) { | |
| // ``acp_model`` is resolved separately below so a saved ``null`` still | |
| // falls back to the provider's default rather than being dropped. | |
| if (key === "acp_model") continue; | |
| // ``acp_env`` is deprecated — provider creds now route via ``request.secrets``. | |
| if (key === "acp_env") continue; | |
| const value = | |
| key === "acp_command" | |
| ? resolveAcpCommand(agentSettings) | |
| : agentSettings[key]; | |
| if (value !== undefined && value !== null) { | |
| payload[key] = value; | |
| } | |
| } | |
| // ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it | |
| // so the ACP subprocess connects to the configured MCP servers at session | |
| // creation. Only include it when it actually carries servers — an empty or | |
| // malformed value is dropped rather than sending ``mcp_config: {}``. | |
| const mcpConfig = toRecord(agentSettings.mcp_config); | |
| if (Object.keys(mcpConfig).length > 0) { | |
| payload.mcp_config = mcpConfig; | |
| } | |
| // Saved settings may carry ``acp_model: null`` (existing users predating | |
| // the default-model registry, or saved fields the agent-server stripped). | |
| // Fall back to the *preferred* default (Vertex-safe for Gemini) so the | |
| // conversation starts with whatever the Settings → Agent UI shows — without | |
| // that, the form's displayed default would silently not take effect at | |
| // runtime until the user re-saved the page. | |
| const serverKey = | |
| typeof agentSettings.acp_server === "string" | |
| ? agentSettings.acp_server | |
| : undefined; | |
| const effectiveModel = resolveEffectiveAcpModel({ | |
| configured: agentSettings.acp_model as string | null | undefined, | |
| providerDefault: getAcpPreferredDefaultModel(serverKey), | |
| }); | |
| if (effectiveModel) { | |
| payload.acp_model = effectiveModel; | |
| } | |
| return payload; | |
| } | |
| function buildConfiguredOpenHandsAgentSettings( | |
| settings: Settings, | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| query?: string, | |
| ): AgentSettingsPayload { | |
| const agentSettings = toRecord(settings.agent_settings); | |
| const llm = buildNormalizedLlmSettings(agentSettings.llm); | |
| // Stream assistant tokens (parity with ACP agents). The agent-server only | |
| // emits StreamingDeltaEvents for SDK LLM agents when an LLM has stream=True. | |
| llm.stream = true; | |
| const mcpConfig = toRecord(agentSettings.mcp_config); | |
| if (Object.keys(mcpConfig).length === 0) { | |
| delete agentSettings.mcp_config; | |
| } | |
| delete agentSettings.acp_server; | |
| for (const key of ACP_SETTINGS_KEYS) { | |
| delete agentSettings[key]; | |
| } | |
| // ``acp_env`` is no longer a forwarded ACP setting (provider creds ride the | |
| // Secrets panel), but a legacy value may linger on persisted settings — | |
| // scrub it so it never leaks onto the OpenHands payload. | |
| delete agentSettings.acp_env; | |
| return { | |
| ...agentSettings, | |
| llm, | |
| agent_context: buildAgentContext( | |
| agentSettings, | |
| runtimeServicesInfo, | |
| toSkillEnablement(settings), | |
| findInvokedCatalogSkill(query), | |
| ), | |
| tools: getAgentTools(agentSettings), | |
| }; | |
| } | |
| function buildConfiguredAgentSettings( | |
| settings: Settings, | |
| runtimeServicesInfo?: RuntimeServicesInfo | null, | |
| query?: string, | |
| ): AgentSettingsPayload { | |
| return isAcpAgent(settings) | |
| ? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo, query) | |
| : buildConfiguredOpenHandsAgentSettings( | |
| settings, | |
| runtimeServicesInfo, | |
| query, | |
| ); | |
| } | |
| function buildConfiguredConversationSettings(options: { | |
| settings: Settings; | |
| query?: string; | |
| conversationInstructions?: string; | |
| plugins?: PluginSpec[]; | |
| workingDir?: string; | |
| }): ConversationSettingsPayload { | |
| const { settings, query, conversationInstructions, plugins, workingDir } = | |
| options; | |
| const conversationSettings = toRecord(settings.conversation_settings); | |
| const initialMessage = buildInitialMessage(query, conversationInstructions); | |
| CONVERSATION_SETTINGS_METADATA_KEYS.forEach( | |
| (key) => delete conversationSettings[key], | |
| ); | |
| const payload: ConversationSettingsPayload = { | |
| ...conversationSettings, | |
| workspace: { | |
| kind: "LocalWorkspace", | |
| working_dir: workingDir ?? getAgentServerWorkingDir(), | |
| }, | |
| ...(initialMessage ? { initial_message: initialMessage } : {}), | |
| ...(plugins?.length | |
| ? { | |
| plugins: plugins.map((plugin) => ({ | |
| source: plugin.source, | |
| ...(plugin.ref ? { ref: plugin.ref } : {}), | |
| ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), | |
| })), | |
| } | |
| : {}), | |
| }; | |
| return payload; | |
| } | |
| interface LookupSecret { | |
| kind: "LookupSecret"; | |
| url: string; | |
| headers?: Record<string, string>; | |
| description?: string; | |
| } | |
| /** A custom secret's public identity — name + optional description, no value. */ | |
| type CustomSecretInput = { name: string; description?: string }; | |
| type StartConversationPayloadBase = Record<string, unknown> & { | |
| workspace: LocalWorkspacePayload; | |
| confirmation_policy: SettingsRecord; | |
| security_analyzer?: SettingsRecord; | |
| initial_message?: InitialMessagePayload; | |
| max_iterations: number; | |
| stuck_detection: true; | |
| autotitle: boolean; | |
| title_llm_profile?: string; | |
| worktree: boolean; | |
| secrets_encrypted?: true; | |
| conversation_id?: string; | |
| parent_conversation_id?: string; | |
| secrets?: Record<string, LookupSecret>; | |
| tags?: Record<string, string>; | |
| client_tools: ClientToolSpec[]; | |
| tool_module_qualnames?: Record<string, string>; | |
| }; | |
| type AgentSettingsStartConversationPayload = StartConversationPayloadBase & { | |
| // Omitted when launching via ``agent_profile_id`` — the two are mutually | |
| // exclusive agent sources; the server resolves the profile server-side. | |
| agent_settings?: AgentSettingsPayload; | |
| agent_profile_id?: string; | |
| agent?: never; | |
| }; | |
| // The local planner bypasses agent assembly entirely and sends a raw agent | |
| // spec, so neither of the two managed agent sources applies. | |
| type RawAgentStartConversationPayload = StartConversationPayloadBase & { | |
| agent: SettingsRecord; | |
| agent_settings?: never; | |
| agent_profile_id?: never; | |
| parent_conversation_id?: string; | |
| }; | |
| export interface StartConversationOptions { | |
| settings: Settings; | |
| query?: string; | |
| conversationInstructions?: string; | |
| plugins?: PluginSpec[]; | |
| conversationId?: string; | |
| // Links the new conversation to an existing one as its child. The | |
| // agent-server requires the parent to exist and to share this | |
| // conversation's requested `workspace.working_dir` (software-agent-sdk | |
| // #4188, agent-server >= 1.37.1); older servers ignore the field. | |
| parentConversationId?: string; | |
| workingDir?: string; | |
| worktree?: boolean; | |
| encryptedAgentSettings?: Record<string, SettingsValue>; | |
| encryptedConversationSettings?: Record<string, SettingsValue>; | |
| secretsEncrypted?: boolean; | |
| customSecrets?: CustomSecretInput[]; | |
| // When set, the conversation launches from this AgentProfile (resolved | |
| // server-side) instead of an inline ``agent_settings`` dump (#3727). | |
| agentProfileId?: string; | |
| agentProfileKind?: AgentKind; | |
| titleLlmProfile?: string; | |
| runtimeServicesInfo?: RuntimeServicesInfo | null; | |
| workspaceHookConfig?: HookConfig | null; | |
| } | |
| /** | |
| * Build the `request.secrets` map shared by the standard and planning | |
| * conversation builders. Every saved secret rides as a LookupSecret the | |
| * agent-server resolves from its own store at spawn time — `request.secrets` is | |
| * the sole channel, uniform for ACP and non-ACP (agent-canvas#1039). For ACP the | |
| * resolution runs off the event loop (software-agent-sdk#3510, >=1.25.0), so the | |
| * loopback fetch can't deadlock. Returns `undefined` when there are no custom | |
| * secrets so callers can omit the field. | |
| */ | |
| function buildCustomSecrets( | |
| customSecrets: CustomSecretInput[] | undefined, | |
| ): Record<string, LookupSecret> | undefined { | |
| if (!customSecrets?.length) return undefined; | |
| const backend = getEffectiveLocalBackend(); | |
| const headers = backend ? buildAuthHeaders(backend) : {}; | |
| const secrets: Record<string, LookupSecret> = {}; | |
| for (const secret of customSecrets) { | |
| const lookupSecret: LookupSecret = { | |
| kind: "LookupSecret", | |
| url: `/api/settings/secrets/${encodeURIComponent(secret.name)}`, | |
| description: secret.description, | |
| }; | |
| if (Object.keys(headers).length > 0) { | |
| lookupSecret.headers = headers; | |
| } | |
| secrets[secret.name] = lookupSecret; | |
| } | |
| return secrets; | |
| } | |
| export function buildStartConversationRequest( | |
| options: StartConversationOptions, | |
| ): AgentSettingsStartConversationPayload { | |
| const sourceAgentSettings = options.encryptedAgentSettings | |
| ? { ...options.settings, agent_settings: options.encryptedAgentSettings } | |
| : options.settings; | |
| const acpMode = isAcpAgent(sourceAgentSettings); | |
| const launchAgentKind = options.agentProfileId | |
| ? options.agentProfileKind | |
| : acpMode | |
| ? "acp" | |
| : "openhands"; | |
| const agentSettings = buildConfiguredAgentSettings( | |
| sourceAgentSettings, | |
| options.runtimeServicesInfo, | |
| options.query, | |
| ); | |
| const acpServerTag = acpMode | |
| ? getAcpServerTag(sourceAgentSettings) | |
| : undefined; | |
| const sourceConversationOptions = options.encryptedConversationSettings | |
| ? { | |
| ...options, | |
| settings: { | |
| ...options.settings, | |
| conversation_settings: options.encryptedConversationSettings, | |
| }, | |
| } | |
| : options; | |
| const conversationSettings = buildConfiguredConversationSettings( | |
| sourceConversationOptions, | |
| ); | |
| const payload: AgentSettingsStartConversationPayload = { | |
| // ``agent_profile_id`` and ``agent_settings`` are mutually exclusive agent | |
| // sources; the profile path lets the server resolve the profile (#3727). | |
| // | |
| // Enrichment boundary: on the profile path the server rebuilds the agent | |
| // purely from the stored profile fields, so the client-owned enrichments | |
| // this adapter folds into ``agent_settings`` do NOT apply. The exec toolset | |
| // (terminal/file_editor/task_tracker) and public-skill loading are the | |
| // server/SDK's responsibility to restore on the profile path — tracked in | |
| // software-agent-sdk#3967 (profile resolution must attach the default | |
| // toolset + public skills, else a profile-launched OpenHands agent has only | |
| // Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains | |
| // agent-settings-only; the Canvas UI tool is a top-level client tool and | |
| // therefore works on both inline-agent and profile launch paths. | |
| // | |
| // Persistent memory is NOT on that boundary: ``load_memory`` is a global | |
| // user preference, so the agent-server stamps the stored | |
| // ``agent_settings.agent_context.load_memory`` onto the profile-resolved | |
| // agent the same way it already applies the global ``mcp_config``. The | |
| // toggle therefore applies to both launch paths, and the client must not | |
| // re-send it here (``agent_profile_id`` and ``agent_settings`` are | |
| // mutually exclusive). | |
| ...(options.agentProfileId | |
| ? { agent_profile_id: options.agentProfileId } | |
| : { agent_settings: agentSettings }), | |
| workspace: conversationSettings.workspace, | |
| // The agent-server caches each client tool's schema per tool *name* for the | |
| // life of the process and rejects a re-registration with a different schema | |
| // (`ClientToolSchemaConflictError`). Editing either schema below therefore | |
| // requires restarting a long-running dev agent-server before new | |
| // conversations can start. | |
| client_tools: | |
| launchAgentKind === "openhands" | |
| ? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL] | |
| : [], | |
| confirmation_policy: | |
| getConversationConfirmationPolicy(conversationSettings), | |
| max_iterations: resolveMaxIterations(conversationSettings.max_iterations), | |
| stuck_detection: true, | |
| autotitle: true, | |
| ...(options.titleLlmProfile | |
| ? { title_llm_profile: options.titleLlmProfile } | |
| : {}), | |
| worktree: options.worktree ?? true, | |
| }; | |
| // Stamp the client source tag so the agent-server can attribute the | |
| // conversation to Canvas in telemetry (conversation_source = "canvas"). | |
| // A profile launch resolves the ACP server server-side, so don't stamp the | |
| // tag from current settings (it may not match the launched profile). | |
| if (!options.agentProfileId && acpServerTag) { | |
| payload.tags = { | |
| [ACP_SERVER_TAG_KEY]: acpServerTag, | |
| [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE, | |
| }; | |
| } else { | |
| payload.tags = { [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE }; | |
| } | |
| // ``secrets_encrypted`` makes the agent-server decrypt request secrets at | |
| // conversation start. Non-ACP conversations need it for encrypted LLM keys. | |
| // ACP normally carries provider credentials as LookupSecrets, so avoid | |
| // forcing a cipher on fresh ACP-only backends. The exception is MCP: | |
| // encrypted settings round-trip mcp_config secrets as Fernet tokens, | |
| // and ACP forwards mcp_config directly to the subprocess. | |
| if ( | |
| !options.agentProfileId && | |
| options.secretsEncrypted && | |
| (!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config)) | |
| ) { | |
| payload.secrets_encrypted = true; | |
| } | |
| if (options.conversationId) { | |
| payload.conversation_id = options.conversationId; | |
| } | |
| if (options.parentConversationId) { | |
| payload.parent_conversation_id = options.parentConversationId; | |
| } | |
| const securityAnalyzer = | |
| getConversationSecurityAnalyzer(conversationSettings); | |
| if (securityAnalyzer) { | |
| payload.security_analyzer = securityAnalyzer; | |
| } | |
| if (conversationSettings.initial_message) { | |
| payload.initial_message = conversationSettings.initial_message; | |
| } | |
| if (conversationSettings.plugins) { | |
| payload.plugins = conversationSettings.plugins; | |
| } | |
| if (conversationSettings.hook_config) { | |
| payload.hook_config = conversationSettings.hook_config; | |
| } else if (options.workspaceHookConfig) { | |
| payload.hook_config = options.workspaceHookConfig; | |
| } | |
| const toolModuleQualnames = { | |
| ...((conversationSettings.tool_module_qualnames as | |
| | Record<string, string> | |
| | undefined) ?? {}), | |
| }; | |
| delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME]; | |
| delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME]; | |
| delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME]; | |
| if (Object.keys(toolModuleQualnames).length > 0) { | |
| payload.tool_module_qualnames = toolModuleQualnames; | |
| } | |
| if (conversationSettings.agent_definitions) { | |
| payload.agent_definitions = conversationSettings.agent_definitions; | |
| } | |
| const secrets = buildCustomSecrets(options.customSecrets); | |
| if (secrets) { | |
| payload.secrets = secrets; | |
| } | |
| return payload; | |
| } | |
| export function buildStartPlanningConversationRequest(options: { | |
| encryptedAgentSettings: Record<string, SettingsValue>; | |
| workingDir: string; | |
| parentConversationId: string; | |
| initialMessage?: string; | |
| secretsEncrypted?: boolean; | |
| customSecrets?: CustomSecretInput[]; | |
| /** Mirrors the parent's configured `conversation_settings.max_iterations` — see DEFAULT_MAX_ITERATIONS. */ | |
| maxIterations?: number; | |
| /** Mirrors the code agent's skill filtering — see buildConfiguredOpenHandsAgentSettings. */ | |
| skillEnablement?: SkillEnablement; | |
| }): RawAgentStartConversationPayload { | |
| const agentSettings = toRecord(options.encryptedAgentSettings); | |
| const llm = buildNormalizedLlmSettings(agentSettings.llm); | |
| // Stream assistant tokens, matching the code agent (see | |
| // buildConfiguredOpenHandsAgentSettings) — otherwise the agent-server never | |
| // emits StreamingDeltaEvents for the planner and its replies would appear | |
| // all at once instead of token-by-token. | |
| llm.stream = true; | |
| const planPath = buildPlanPath(options.workingDir); | |
| // Put the planner's directive + boundaries in the system prompt (matching the | |
| // OpenHands app-server's PLANNING_AGENT_INSTRUCTION), preserving any suffix | |
| // buildAgentContext already set (e.g. the runtime-services block). | |
| const agentContext = buildAgentContext( | |
| agentSettings, | |
| undefined, | |
| options.skillEnablement, | |
| ); | |
| const existingSuffix = agentContext.system_message_suffix; | |
| agentContext.system_message_suffix = | |
| typeof existingSuffix === "string" | |
| ? `${PLANNING_AGENT_INSTRUCTION}\n\n${existingSuffix}` | |
| : PLANNING_AGENT_INSTRUCTION; | |
| // Idle planner: "Create a Plan" only switches to plan mode and provisions | |
| // this conversation; the user sends the first message themselves, so nothing | |
| // is injected into the chat. An explicit initialMessage is still honored. | |
| const initialMessage = buildInitialMessage(options.initialMessage); | |
| const payload: RawAgentStartConversationPayload = { | |
| agent: { | |
| kind: "Agent", | |
| llm, | |
| tools: [ | |
| { name: "glob", params: {} }, | |
| { name: "grep", params: {} }, | |
| { | |
| name: PLANNING_FILE_EDITOR_TOOL_NAME, | |
| params: { plan_path: planPath }, | |
| }, | |
| ], | |
| system_prompt_filename: PLANNING_SYSTEM_PROMPT_FILENAME, | |
| system_prompt_kwargs: { plan_structure: PLAN_STRUCTURE_TEXT }, | |
| // agent_context (skills + project context) is intentionally included so | |
| // the planner shares the code agent's project context. This goes beyond | |
| // the bare SDK `get_planning_agent()` preset (which sets no context); it | |
| // mirrors how the frontend builds every agent, so the local planner is | |
| // not context-starved relative to the code agent. | |
| agent_context: agentContext, | |
| // Mirror the SDK planning preset's condenser (openhands-tools preset | |
| // `get_planning_condenser`): a larger rolling window and more pinned | |
| // initial context than the default, with its own usage_id so its | |
| // summarization LLM calls are attributed separately. The planning | |
| // conversation is sent as a raw agent spec (it bypasses the agent-server's | |
| // default agent assembly), so without this it would never condense — | |
| // diverging from the canonical planning agent on long sessions. | |
| condenser: { | |
| kind: "LLMSummarizingCondenser", | |
| llm: { ...llm, usage_id: "planning_condenser" }, | |
| max_size: 100, | |
| keep_first: 6, | |
| }, | |
| }, | |
| workspace: { | |
| kind: "LocalWorkspace", | |
| working_dir: options.workingDir, | |
| }, | |
| // No Canvas UI client tool: the planner's only output is PLAN.md, which the | |
| // Planner tab surfaces on its own via PlanningFileEditorObservation. Handing | |
| // it the panel-control tool would only let it navigate the right-side panel | |
| // away from the plan the user is reading. | |
| client_tools: [], | |
| confirmation_policy: { kind: "NeverConfirm" }, | |
| max_iterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS, | |
| stuck_detection: true, | |
| autotitle: false, | |
| worktree: false, | |
| // Two independent links to the parent, for two different jobs: | |
| // | |
| // - ``parent_conversation_id`` is the server-owned relationship (SDK | |
| // #4188, agent-server >= 1.37.1). The parent's ``sub_conversation_ids`` | |
| // is derived from it, so the planner is recoverable from the server on | |
| // any browser and after storage loss. Older agent-servers ignore the | |
| // field (``StartConversationRequest`` does not forbid extras), which is | |
| // why the client-side metadata hint is still written as a fallback. | |
| // - The tag is what hides the helper from the conversation list; it is | |
| // also the only marker on agent-servers too old for the parent link. | |
| parent_conversation_id: options.parentConversationId, | |
| tags: { [LOCAL_PLANNER_PARENT_TAG_KEY]: options.parentConversationId }, | |
| ...(initialMessage ? { initial_message: initialMessage } : {}), | |
| }; | |
| if (options.secretsEncrypted) { | |
| payload.secrets_encrypted = true; | |
| } | |
| const secrets = buildCustomSecrets(options.customSecrets); | |
| if (secrets) { | |
| payload.secrets = secrets; | |
| } | |
| return payload; | |
| } | |
| /** | |
| * Resolve the encrypted LLM config for a named LLM profile, so a planner can | |
| * run the parent conversation's *current* model (`active_profile`, tracking | |
| * `/model` and `SwitchLLMTool`) rather than a stale launch-time value. | |
| * Returns `null` — caller falls back further — if unknown or lookup fails. | |
| */ | |
| async function resolveLlmProfileSettings( | |
| profileName: string, | |
| ): Promise<SettingsRecord | null> { | |
| try { | |
| const { default: ProfilesService } = | |
| await import("./profiles-service/profiles-service.api"); | |
| // ``encrypted`` matches ``secrets_encrypted`` on the payload: the | |
| // agent-server decrypts with the same cipher it encrypted with. | |
| const detail = await ProfilesService.getProfile(profileName, "encrypted"); | |
| return isPlainRecord(detail.config) ? detail.config : null; | |
| } catch (error) { | |
| console.warn( | |
| `Falling back: could not resolve LLM profile ${profileName}`, | |
| error, | |
| ); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Resolve the encrypted LLM config the given AgentProfile launches with, as a | |
| * fallback for `resolveLlmProfileSettings`. AgentProfiles hold a reference | |
| * (`llm_profile_ref`), not credentials, so this is a two-hop lookup: profile | |
| * id -> llm profile name -> encrypted LLM config. Returns `null` — caller | |
| * falls back to global settings — for an unknown/ACP/dangling-ref profile or | |
| * a failed lookup, none of which should block plan creation outright. | |
| */ | |
| async function resolveAgentProfileLlmSettings( | |
| agentProfileId: string, | |
| ): Promise<SettingsRecord | null> { | |
| try { | |
| const { default: AgentProfilesService } = | |
| await import("./agent-profiles-service/agent-profiles-service.api"); | |
| const { profiles } = await AgentProfilesService.listProfiles(); | |
| const summary = profiles.find( | |
| (profile) => profile.id === agentProfileId && profile.llm_profile_ref, | |
| ); | |
| if (!summary?.llm_profile_ref) return null; | |
| return await resolveLlmProfileSettings(summary.llm_profile_ref); | |
| } catch (error) { | |
| console.warn( | |
| `Falling back to global agent settings: could not resolve the LLM for agent profile ${agentProfileId}`, | |
| error, | |
| ); | |
| return null; | |
| } | |
| } | |
| export async function buildStartPlanningConversationRequestWithEncryptedSettings(options: { | |
| workingDir: string; | |
| parentConversationId: string; | |
| /** | |
| * The parent conversation's current LLM profile (`AppConversation.active_profile`, | |
| * tracking `/model` and `SwitchLLMTool`). Takes priority over | |
| * `parentAgentProfileId` so a model switch on the parent carries over to a | |
| * planner created afterward, without a *different* conversation's global | |
| * profile activation repointing it. | |
| */ | |
| parentActiveProfileName?: string | null; | |
| /** | |
| * `launched_agent_profile.agent_profile_id` of the parent, when started | |
| * from an AgentProfile. Fallback for when `parentActiveProfileName` can't | |
| * be resolved (e.g. an ACP parent, whose `active_profile` is a stale | |
| * launch-time snapshot rather than anything the ACP agent itself runs). | |
| */ | |
| parentAgentProfileId?: string | null; | |
| initialMessage?: string; | |
| }): Promise<RawAgentStartConversationPayload> { | |
| const { SecretsService } = await import("./secrets-service"); | |
| const [settingsResult, customSecrets] = await Promise.all([ | |
| SettingsService.getSettingsForConversation(), | |
| SecretsService.getSecrets(), | |
| ]); | |
| const profileLlm = | |
| (options.parentActiveProfileName | |
| ? await resolveLlmProfileSettings(options.parentActiveProfileName) | |
| : null) ?? | |
| (options.parentAgentProfileId | |
| ? await resolveAgentProfileLlmSettings(options.parentAgentProfileId) | |
| : null); | |
| const encryptedAgentSettings: Record<string, SettingsValue> = profileLlm | |
| ? { ...settingsResult.agentSettings, llm: profileLlm as SettingsValue } | |
| : settingsResult.agentSettings; | |
| await assertSubscriptionAuthReady(encryptedAgentSettings); | |
| // Mirror the code agent's configured cap (see DEFAULT_MAX_ITERATIONS) rather | |
| // than hardcoding a different value the planner could silently stop against. | |
| const maxIterations = resolveMaxIterations( | |
| settingsResult.conversationSettings.max_iterations, | |
| ); | |
| return buildStartPlanningConversationRequest({ | |
| ...options, | |
| encryptedAgentSettings, | |
| secretsEncrypted: settingsResult.secretsEncrypted, | |
| customSecrets, | |
| maxIterations, | |
| skillEnablement: settingsResult.skillEnablement, | |
| }); | |
| } | |
| export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR = | |
| "Connect your ChatGPT subscription before starting a conversation with this LLM profile."; | |
| /** | |
| * Throws if a ChatGPT subscription LLM profile is not connected. | |
| * Called before conversation creation and LLM profile switch only — not on | |
| * subsequent message sends or conversation resume. The agent-server must handle | |
| * mid-conversation token expiry gracefully. | |
| */ | |
| export async function assertSubscriptionAuthReady( | |
| agentSettings: Record<string, unknown>, | |
| ): Promise<void> { | |
| const llm = toRecord(agentSettings.llm); | |
| if (!isSubscriptionLlmConfig(llm)) return; | |
| const status = await LLMSubscriptionService.getOpenAIStatus(); | |
| if (!status.connected) { | |
| throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR); | |
| } | |
| } | |
| export async function buildStartConversationRequestWithEncryptedSettings(options: { | |
| settings: Settings; | |
| query?: string; | |
| conversationInstructions?: string; | |
| plugins?: PluginSpec[]; | |
| conversationId?: string; | |
| parentConversationId?: string; | |
| workingDir?: string; | |
| /** Workspace root for the hooks lookup, not the per-conversation `workingDir` (#16907). */ | |
| hooksProjectDir?: string; | |
| worktree?: boolean; | |
| agentProfileId?: string; | |
| agentProfileKind?: AgentKind; | |
| titleLlmProfile?: string; | |
| }): Promise<Record<string, unknown>> { | |
| const [{ SecretsService }, { default: HooksService }] = await Promise.all([ | |
| import("./secrets-service"), | |
| import("./hooks-service"), | |
| ]); | |
| const [ | |
| settingsResult, | |
| customSecrets, | |
| runtimeServicesInfo, | |
| workspaceHookConfig, | |
| ] = await Promise.all([ | |
| SettingsService.getSettingsForConversation(), | |
| SecretsService.getSecrets(), | |
| fetchBackendRuntimeServicesInfo(), | |
| HooksService.loadWorkspaceHooks(options.hooksProjectDir), | |
| ]); | |
| const { agentSettings, conversationSettings, secretsEncrypted } = | |
| settingsResult; | |
| // A profile launch resolves the LLM server-side, so the current-settings | |
| // subscription check doesn't apply (and can't see the profile's LLM). | |
| if (!options.agentProfileId) { | |
| await assertSubscriptionAuthReady(agentSettings); | |
| } | |
| return buildStartConversationRequest({ | |
| ...options, | |
| encryptedAgentSettings: agentSettings, | |
| encryptedConversationSettings: conversationSettings, | |
| secretsEncrypted, | |
| customSecrets, | |
| runtimeServicesInfo, | |
| workspaceHookConfig, | |
| }); | |
| } | |
| export function emptyHooksResponse(): GetHooksResponse { | |
| return { hooks: [] }; | |
| } | |