openhands / src /api /agent-server-adapter.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
63522a5 verified
Raw
History Blame Contribute Delete
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: [] };
}