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 <noreply@paperclip.ing>
This commit is contained in:
2026-04-13 01:02:13 +00:00
parent b649bd454f
commit 904e9cb95e
9 changed files with 153 additions and 15 deletions
+30
View File
@@ -328,6 +328,36 @@ export interface ServerAdapterModule {
* resolved inside this method — the caller receives a fully hydrated schema.
*/
getConfigSchema?: () => Promise<AdapterConfigSchema> | 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;
}
// ---------------------------------------------------------------------------
+23
View File
@@ -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(),
};
+18
View File
@@ -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<string>): 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.
+26 -4
View File
@@ -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<string, string> = {
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<T> {
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({
@@ -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<string, AdapterCapabilities>();
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,
};
}
+8
View File
@@ -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) */
+4 -2
View File
@@ -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 });
+4 -2
View File
@@ -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.
+3 -7
View File
@@ -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),