mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:58:33 +08:00
Squash merge PR #1583. This merge commit was created by an AI agent (OpenHands) on behalf of Graham Neubig. Co-authored-by: openhands <openhands@all-hands.dev>
1075 lines
35 KiB
TypeScript
1075 lines
35 KiB
TypeScript
import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client";
|
|
import { SKILLS_CATALOG } from "@openhands/extensions/skills";
|
|
import { DEFAULT_SETTINGS } from "#/services/settings";
|
|
import { ExecutionStatus } from "#/types/agent-server/core";
|
|
import { Settings, SettingsValue } from "#/types/settings";
|
|
import {
|
|
getAcpPreferredDefaultModel,
|
|
getAcpProvider,
|
|
resolveEffectiveAcpModel,
|
|
} from "#/constants/acp-providers";
|
|
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
|
import { 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,
|
|
SandboxStatus,
|
|
} from "./conversation-service/agent-server-conversation-service.types";
|
|
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";
|
|
|
|
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;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
// Module qualname for the Canvas-UI tool. The agent-server imports this via
|
|
// tool_module_qualnames; the host directory is exposed via OH_EXTRA_PYTHON_PATH
|
|
// (see scripts/dev-safe.mjs).
|
|
const CANVAS_UI_TOOL_NAME = "canvas_ui";
|
|
const CANVAS_UI_TOOL_MODULE = "canvas_ui_tool";
|
|
|
|
const DEFAULT_TOOL_NAMES = [
|
|
"terminal",
|
|
"file_editor",
|
|
"task_tracker",
|
|
CANVAS_UI_TOOL_NAME,
|
|
];
|
|
const BROWSER_TOOL_SET_NAME = "browser_tool_set";
|
|
const TASK_TOOL_SET_NAME = "task_tool_set";
|
|
|
|
function browserToolsEnabled() {
|
|
return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false";
|
|
}
|
|
|
|
/**
|
|
* Shape of the runtime services info (set by the dev launchers in
|
|
* scripts/dev-*.mjs as `VITE_RUNTIME_SERVICES_INFO`, or injected at serve time
|
|
* by `scripts/static-server.mjs` for static builds — see
|
|
* `getRawRuntimeServicesInfo`). 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.
|
|
*/
|
|
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;
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return the raw runtime-services JSON string, consulting two sources in order
|
|
* (mirrors `getBakedSessionApiKey` in agent-server-config.ts):
|
|
* 1. `VITE_RUNTIME_SERVICES_INFO` — baked into the bundle at build time by
|
|
* the dev launchers (`npm run dev`, dev:static).
|
|
* 2. `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` — injected into
|
|
* index.html at serve time by `scripts/static-server.mjs
|
|
* --runtime-services-info <json>`. This is the path used by static builds
|
|
* (the Docker image and the published binary), where the env var is empty
|
|
* in the prebuilt bundle. Without it the `<RUNTIME_SERVICES>` block is
|
|
* missing and the agent cannot reach the local automation backend.
|
|
*/
|
|
function getRawRuntimeServicesInfo(): string | null {
|
|
const envRaw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim();
|
|
if (envRaw) return envRaw;
|
|
|
|
if (typeof window !== "undefined") {
|
|
const injected = (window as unknown as Record<string, unknown>)
|
|
.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__;
|
|
if (typeof injected === "string") {
|
|
return injected.trim() || null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseRuntimeServicesInfo(): RuntimeServicesInfo | null {
|
|
const raw = getRawRuntimeServicesInfo();
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as RuntimeServicesInfo;
|
|
if (!parsed || typeof parsed !== "object") return null;
|
|
return parsed;
|
|
} catch {
|
|
// Malformed JSON: ignore and fall back to no runtime info, rather than
|
|
// tearing down conversation creation over a misconfigured env var or
|
|
// injected value.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the deployment mode from the runtime services info, e.g. "docker",
|
|
* "dev:automation", etc. Returns `null` when no runtime info is configured.
|
|
*/
|
|
export function getDeploymentMode(): string | null {
|
|
return parseRuntimeServicesInfo()?.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 configured, so callers can
|
|
* safely omit the field on production builds (where the launcher doesn't
|
|
* set `VITE_RUNTIME_SERVICES_INFO`).
|
|
*/
|
|
export function buildRuntimeServicesSystemSuffix(): string | undefined {
|
|
const info = parseRuntimeServicesInfo();
|
|
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.
|
|
const acpServer = isAcp ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? 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,
|
|
// 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,
|
|
}
|
|
: null,
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
export function toConversationPage(data: {
|
|
items: DirectConversationInfo[];
|
|
next_page_id?: string | null;
|
|
}): AppConversationPage {
|
|
return {
|
|
items: data.items.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";
|
|
|
|
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 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 === CANVAS_UI_TOOL_NAME) {
|
|
return isAgentServerToolAvailable(name);
|
|
}
|
|
|
|
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): SettingsRecord {
|
|
const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix();
|
|
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 mergedSkills = [...existingSkills, ...buildBundledSkills()];
|
|
|
|
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,
|
|
...(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,
|
|
): AgentSettingsPayload {
|
|
const agentSettings = toRecord(settings.agent_settings);
|
|
const payload: AgentSettingsPayload = {
|
|
agent_kind: "acp",
|
|
agent_context: buildAgentContext(agentSettings),
|
|
};
|
|
|
|
// 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,
|
|
): AgentSettingsPayload {
|
|
const agentSettings = toRecord(settings.agent_settings);
|
|
const llm = toRecord(agentSettings.llm);
|
|
|
|
llm.model =
|
|
typeof llm.model === "string" && llm.model.trim().length > 0
|
|
? llm.model
|
|
: DEFAULT_SETTINGS.llm_model;
|
|
|
|
// 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 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;
|
|
delete llm.base_url;
|
|
} else {
|
|
delete llm.auth_type;
|
|
delete llm.subscription_vendor;
|
|
}
|
|
|
|
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),
|
|
tools: getAgentTools(agentSettings),
|
|
};
|
|
}
|
|
|
|
function buildConfiguredAgentSettings(
|
|
settings: Settings,
|
|
): AgentSettingsPayload {
|
|
return isAcpAgent(settings)
|
|
? buildConfiguredAcpAgentSettings(settings)
|
|
: buildConfiguredOpenHandsAgentSettings(settings);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
type StartConversationPayload = Record<string, unknown> & {
|
|
agent_settings: AgentSettingsPayload;
|
|
workspace: LocalWorkspacePayload;
|
|
confirmation_policy: SettingsRecord;
|
|
security_analyzer?: SettingsRecord;
|
|
initial_message?: InitialMessagePayload;
|
|
max_iterations: number;
|
|
stuck_detection: true;
|
|
autotitle: true;
|
|
worktree: boolean;
|
|
secrets_encrypted?: true;
|
|
conversation_id?: string;
|
|
secrets?: Record<string, LookupSecret>;
|
|
tags?: Record<string, string>;
|
|
tool_module_qualnames?: Record<string, string>;
|
|
};
|
|
|
|
export interface StartConversationOptions {
|
|
settings: Settings;
|
|
query?: string;
|
|
conversationInstructions?: string;
|
|
plugins?: PluginSpec[];
|
|
conversationId?: string;
|
|
workingDir?: string;
|
|
worktree?: boolean;
|
|
encryptedAgentSettings?: Record<string, SettingsValue>;
|
|
encryptedConversationSettings?: Record<string, SettingsValue>;
|
|
secretsEncrypted?: boolean;
|
|
customSecrets?: Array<{ name: string; description?: string }>;
|
|
}
|
|
|
|
export function buildStartConversationRequest(
|
|
options: StartConversationOptions,
|
|
): StartConversationPayload {
|
|
const sourceAgentSettings = options.encryptedAgentSettings
|
|
? { ...options.settings, agent_settings: options.encryptedAgentSettings }
|
|
: options.settings;
|
|
|
|
const acpMode = isAcpAgent(sourceAgentSettings);
|
|
const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings);
|
|
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: StartConversationPayload = {
|
|
agent_settings: agentSettings,
|
|
workspace: conversationSettings.workspace,
|
|
confirmation_policy:
|
|
getConversationConfirmationPolicy(conversationSettings),
|
|
max_iterations:
|
|
typeof conversationSettings.max_iterations === "number"
|
|
? conversationSettings.max_iterations
|
|
: 500,
|
|
stuck_detection: true,
|
|
autotitle: true,
|
|
worktree: options.worktree ?? true,
|
|
};
|
|
|
|
if (acpServerTag) {
|
|
payload.tags = { [ACP_SERVER_TAG_KEY]: acpServerTag };
|
|
}
|
|
|
|
// ``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.secretsEncrypted &&
|
|
(!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config))
|
|
) {
|
|
payload.secrets_encrypted = true;
|
|
}
|
|
|
|
if (options.conversationId) {
|
|
payload.conversation_id = options.conversationId;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const toolModuleQualnames: Record<string, string> = {};
|
|
const canvasUiAvailable = isAgentServerToolAvailable(CANVAS_UI_TOOL_NAME);
|
|
if (canvasUiAvailable) {
|
|
toolModuleQualnames[CANVAS_UI_TOOL_NAME] = CANVAS_UI_TOOL_MODULE;
|
|
}
|
|
Object.assign(
|
|
toolModuleQualnames,
|
|
(conversationSettings.tool_module_qualnames as
|
|
| Record<string, string>
|
|
| undefined) ?? {},
|
|
);
|
|
if (!canvasUiAvailable) {
|
|
delete toolModuleQualnames[CANVAS_UI_TOOL_NAME];
|
|
}
|
|
if (Object.keys(toolModuleQualnames).length > 0) {
|
|
payload.tool_module_qualnames = toolModuleQualnames;
|
|
}
|
|
|
|
if (conversationSettings.agent_definitions) {
|
|
payload.agent_definitions = conversationSettings.agent_definitions;
|
|
}
|
|
|
|
// Every saved secret rides as a LookupSecret the agent-server resolves back
|
|
// 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.
|
|
if (options.customSecrets && options.customSecrets.length > 0) {
|
|
const backend = getEffectiveLocalBackend();
|
|
const headers = backend ? buildAuthHeaders(backend) : {};
|
|
|
|
const secrets: Record<string, LookupSecret> = {};
|
|
for (const secret of options.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;
|
|
}
|
|
|
|
payload.secrets = secrets;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
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;
|
|
workingDir?: string;
|
|
worktree?: boolean;
|
|
}): Promise<Record<string, unknown>> {
|
|
const { SecretsService } = await import("./secrets-service");
|
|
|
|
const [settingsResult, customSecrets] = await Promise.all([
|
|
SettingsService.getSettingsForConversation(),
|
|
SecretsService.getSecrets(),
|
|
]);
|
|
|
|
const { agentSettings, conversationSettings, secretsEncrypted } =
|
|
settingsResult;
|
|
|
|
await assertSubscriptionAuthReady(agentSettings);
|
|
|
|
return buildStartConversationRequest({
|
|
...options,
|
|
encryptedAgentSettings: agentSettings,
|
|
encryptedConversationSettings: conversationSettings,
|
|
secretsEncrypted,
|
|
customSecrets,
|
|
});
|
|
}
|
|
|
|
export function emptyHooksResponse(): GetHooksResponse {
|
|
return { hooks: [] };
|
|
}
|