Files
OpenHands/src/api/agent-server-adapter.ts
T
Graham Neubigandopenhands c552545926 feat(mcp): add OAuth support to MCP install flow
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>
2026-07-07 12:03:36 +02:00

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: [] };
}