| 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; |
| |
| 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; |
| |
| |
| |
| |
| |
| stats?: RuntimeConversationStats | null; |
| agent?: { |
| |
| |
| |
| |
| |
| kind?: string | null; |
| acp_model?: string | null; |
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| |
| |
| tags?: Record<string, string> | null; |
| launched_agent_profile?: { |
| agent_profile_id: string; |
| revision: number; |
| } | null; |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
| |
| |
| 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"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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?: { 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| export function getDeploymentMode( |
| runtimeServicesInfo?: RuntimeServicesInfo | null, |
| ): string | null { |
| return runtimeServicesInfo?.mode ?? null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| 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).", |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| |
| |
| const { host } = getAgentServerClientOptions(); |
| return `${host}/api/conversations/${conversationId}`; |
| } |
|
|
| |
| |
| |
| export function getDefaultConversationTitle(conversationId: string): string { |
| return `Conversation ${conversationId.slice(0, 5)}`; |
| } |
|
|
| export function toAppConversation( |
| info: DirectConversationInfo, |
| ): AppConversation { |
| const metadata = getStoredConversationMetadata(info.id); |
| |
| |
| |
| |
| const isAcp = info.agent?.kind === "ACPAgent"; |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| |
| |
| |
| export const AUTOMATION_TAG_KEYS: readonly string[] = [ |
| AUTOMATION_TRIGGER_TAG_KEY, |
| AUTOMATION_ID_TAG_KEY, |
| AUTOMATION_NAME_TAG_KEY, |
| AUTOMATION_RUN_ID_TAG_KEY, |
| ]; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| ]); |
|
|
| |
| |
| |
| |
| export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"]; |
|
|
| |
| |
| |
| |
| |
| |
| export function getDisplayConversationTags( |
| tags: Record<string, string> | null | undefined, |
| ): Array<[string, string]> { |
| if (!tags) { |
| return []; |
| } |
| |
| |
| |
| 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" && |
| |
| |
| (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, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function buildBundledSkills(): BundledSkill[] { |
| return SKILLS_CATALOG.map((entry) => { |
| const trigger: BundledSkill["trigger"] = |
| entry.triggers?.length > 0 |
| ? { type: "keyword", keywords: entry.triggers } |
| : null; |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| const existingSkills = Array.isArray(existingContext.skills) |
| ? (existingContext.skills as SettingsRecord[]) |
| : []; |
| const disabledSkills = enablement.disabledSkills ?? []; |
| const disabledSkillNames = new Set(disabledSkills); |
| const isSkillEnabled = buildSkillEnablementFilter(enablement); |
|
|
| |
| |
| |
| |
| |
| const mergedSkills = [ |
| ...existingSkills.filter( |
| (skill) => |
| typeof skill.name !== "string" || !disabledSkillNames.has(skill.name), |
| ), |
| ...buildBundledSkills().filter( |
| (skill) => |
| skill.name === invokedCatalogSkill || isSkillEnabled(skill.name), |
| ), |
| ]; |
|
|
| return { |
| ...existingContext, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| skills: mergedSkills, |
| load_public_skills: false, |
| load_user_skills: true, |
| load_project_skills: true, |
| |
| |
| |
| |
| 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), |
| ), |
| }; |
|
|
| |
| |
| |
| |
| |
| |
|
|
| for (const key of ACP_SETTINGS_KEYS) { |
| |
| |
| if (key === "acp_model") continue; |
| |
| if (key === "acp_env") continue; |
| const value = |
| key === "acp_command" |
| ? resolveAcpCommand(agentSettings) |
| : agentSettings[key]; |
| if (value !== undefined && value !== null) { |
| payload[key] = value; |
| } |
| } |
|
|
| |
| |
| |
| |
| const mcpConfig = toRecord(agentSettings.mcp_config); |
| if (Object.keys(mcpConfig).length > 0) { |
| payload.mcp_config = mcpConfig; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| 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]; |
| } |
| |
| |
| |
| 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; |
| } |
|
|
| |
| 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 & { |
| |
| |
| agent_settings?: AgentSettingsPayload; |
| agent_profile_id?: string; |
| agent?: never; |
| }; |
|
|
| |
| |
| 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; |
| |
| |
| |
| |
| parentConversationId?: string; |
| workingDir?: string; |
| worktree?: boolean; |
| encryptedAgentSettings?: Record<string, SettingsValue>; |
| encryptedConversationSettings?: Record<string, SettingsValue>; |
| secretsEncrypted?: boolean; |
| customSecrets?: CustomSecretInput[]; |
| |
| |
| agentProfileId?: string; |
| agentProfileKind?: AgentKind; |
| titleLlmProfile?: string; |
| runtimeServicesInfo?: RuntimeServicesInfo | null; |
| workspaceHookConfig?: HookConfig | null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 = { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ...(options.agentProfileId |
| ? { agent_profile_id: options.agentProfileId } |
| : { agent_settings: agentSettings }), |
| workspace: conversationSettings.workspace, |
| |
| |
| |
| |
| |
| 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, |
| }; |
|
|
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| llm.stream = true; |
|
|
| const planPath = buildPlanPath(options.workingDir); |
|
|
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| 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: agentContext, |
| |
| |
| |
| |
| |
| |
| |
| condenser: { |
| kind: "LLMSummarizingCondenser", |
| llm: { ...llm, usage_id: "planning_condenser" }, |
| max_size: 100, |
| keep_first: 6, |
| }, |
| }, |
| workspace: { |
| kind: "LocalWorkspace", |
| working_dir: options.workingDir, |
| }, |
| |
| |
| |
| |
| client_tools: [], |
| confirmation_policy: { kind: "NeverConfirm" }, |
| max_iterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS, |
| stuck_detection: true, |
| autotitle: false, |
| worktree: false, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function resolveLlmProfileSettings( |
| profileName: string, |
| ): Promise<SettingsRecord | null> { |
| try { |
| const { default: ProfilesService } = |
| await import("./profiles-service/profiles-service.api"); |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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."; |
| |
| |
| |
| |
| |
| |
| |
| 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: [] }; |
| } |
| |