From 904e9cb95e4017e38a424f740b9673a7de9f804c Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Mon, 13 Apr 2026 01:02:13 +0000 Subject: [PATCH] feat(adapters): add capability flags to ServerAdapterModule Replace 5 hardcoded adapter type lists with declarative capability flags on ServerAdapterModule, enabling external adapter plugins to declare their capabilities without modifying Paperclip source. New optional fields on ServerAdapterModule: - supportsInstructionsBundle: managed instructions bundle support - instructionsPathKey: config key for instructions file path - requiresMaterializedRuntimeSkills: skill materialization needed Server changes: - agents.ts: capability-aware helpers with legacy fallbacks - adapters.ts: expose capabilities in GET /api/adapters response - registry.ts: explicit flags on all built-in adapters UI changes: - New useAdapterCapabilities hook for capability lookups - AgentDetail.tsx: replace hardcoded isLocal allowlist - AgentConfigForm.tsx: replace NONLOCAL_TYPES denylist - OnboardingWizard.tsx: replace NONLOCAL_TYPES denylist All flags are optional with backwards-compatible fallbacks to the legacy hardcoded lists for adapters that don't set them. Co-Authored-By: Paperclip --- packages/adapter-utils/src/types.ts | 30 +++++++++++++++++ server/src/adapters/registry.ts | 23 +++++++++++++ server/src/routes/adapters.ts | 18 ++++++++++ server/src/routes/agents.ts | 30 ++++++++++++++--- ui/src/adapters/use-adapter-capabilities.ts | 37 +++++++++++++++++++++ ui/src/api/adapters.ts | 8 +++++ ui/src/components/AgentConfigForm.tsx | 6 ++-- ui/src/components/OnboardingWizard.tsx | 6 ++-- ui/src/pages/AgentDetail.tsx | 10 ++---- 9 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 ui/src/adapters/use-adapter-capabilities.ts diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 534a5a59..e02b9efe 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -328,6 +328,36 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; + + // --------------------------------------------------------------------------- + // Adapter capability flags + // + // These allow adapter plugins to declare what "local" capabilities they + // support, replacing hardcoded type lists in the server and UI. + // All flags are optional — when undefined, the server falls back to + // legacy hardcoded lists for built-in adapters. + // --------------------------------------------------------------------------- + + /** + * Adapter supports managed instructions bundle (AGENTS.md files). + * When true, the server uses instructionsPathKey (default "instructionsFilePath") + * to resolve the instructions config key, and the UI shows the bundle editor. + * Built-in local adapters default to true; external plugins must opt in. + */ + supportsInstructionsBundle?: boolean; + + /** + * The adapterConfig key that holds the instructions file path. + * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. + */ + instructionsPathKey?: string; + + /** + * Adapter needs runtime skill entries materialized (written to disk) + * before being passed via config. Used by adapters that scan a directory + * rather than reading config.paperclipRuntimeSkills. + */ + requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f6746227..f6a1afca 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -97,6 +97,9 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, listModels: listClaudeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -112,6 +115,9 @@ const codexLocalAdapter: ServerAdapterModule = { models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -127,6 +133,9 @@ const cursorLocalAdapter: ServerAdapterModule = { models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -140,6 +149,9 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -149,6 +161,8 @@ const openclawGatewayAdapter: ServerAdapterModule = { testEnvironment: openclawGatewayTestEnvironment, models: openclawGatewayModels, supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, }; @@ -163,6 +177,9 @@ const openCodeLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -177,6 +194,9 @@ const piLocalAdapter: ServerAdapterModule = { models: [], listModels: listPiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: piAgentConfigurationDoc, }; @@ -189,6 +209,9 @@ const hermesLocalAdapter: ServerAdapterModule = { syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: hermesAgentConfigurationDoc, detectModel: () => detectModelFromHermes(), }; diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 27e32a06..5effee9d 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -59,6 +59,13 @@ interface AdapterInstallRequest { version?: string; } +interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + interface AdapterInfo { type: string; label: string; @@ -66,6 +73,7 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -103,6 +111,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } +function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + return { + supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, + supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), + supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, + requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + }; +} + function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -112,6 +129,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), + capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index bc4783c6..7e70e810 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -72,6 +72,8 @@ import { import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { + // Legacy hardcoded maps — used as fallback when adapter module does not + // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -83,6 +85,20 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); + + /** Check if an adapter supports the managed instructions bundle. */ + function adapterSupportsInstructionsBundle(adapterType: string): boolean { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; + return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); + } + + /** Resolve the adapter config key for the instructions file path. */ + function resolveInstructionsPathKey(adapterType: string): string | null { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; + return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; + } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -557,7 +573,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } @@ -638,7 +654,9 @@ export function agentRoutes(db: Db) { }; } - const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ + // Legacy hardcoded set — used as fallback when adapter module does not + // declare requiresMaterializedRuntimeSkills explicitly. + const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", @@ -646,7 +664,11 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { + return adapter.requiresMaterializedRuntimeSkills; + } + return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( @@ -1609,7 +1631,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const defaultKey = resolveInstructionsPathKey(existing.adapterType); const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts new file mode 100644 index 00000000..4653e7de --- /dev/null +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi, type AdapterCapabilities } from "@/api/adapters"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Returns a lookup function that resolves adapter capabilities by type. + * + * Capabilities are fetched from the server adapter listing API and cached + * via react-query. When the data is not yet loaded, the lookup returns + * a conservative default (all capabilities false). + */ +export function useAdapterCapabilities(): (type: string) => AdapterCapabilities { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + const capMap = useMemo(() => { + const map = new Map(); + if (adapters) { + for (const a of adapters) { + map.set(a.type, a.capabilities); + } + } + return map; + }, [adapters]); + + return (type: string): AdapterCapabilities => + capMap.get(type) ?? { + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }; +} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 86705bd4..18b154ef 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,6 +4,13 @@ import { api } from "./client"; +export interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + export interface AdapterInfo { type: string; label: string; @@ -11,6 +18,7 @@ export interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 065405f9..dcf9bf11 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -50,6 +50,7 @@ import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadat import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ @@ -269,8 +270,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocal = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 37509fee..413c98e2 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,6 +25,7 @@ import { import { getUIAdapter } from "../adapters"; import { listUIAdapters } from "../adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; @@ -198,8 +199,9 @@ export function OnboardingWizard() { queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocalAdapter = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; // Build adapter grids dynamically from the UI registry + display metadata. // External/plugin adapters automatically appear with generic defaults. diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index caa90578..b526a751 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -26,6 +26,7 @@ import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; @@ -1719,13 +1720,8 @@ function PromptsTab({ externalBundleRef.current = null; }, [agent.id]); - const isLocal = - agent.adapterType === "claude_local" || - agent.adapterType === "codex_local" || - agent.adapterType === "opencode_local" || - agent.adapterType === "pi_local" || - agent.adapterType === "hermes_local" || - agent.adapterType === "cursor"; + const getCapabilities = useAdapterCapabilities(); + const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle; const { data: bundle, isLoading: bundleLoading } = useQuery({ queryKey: queryKeys.agents.instructionsBundle(agent.id),