From eba4487b3ac2ac0f7de28f93ace23f0c4b04080a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 6 Aug 2026 00:28:56 +0800 Subject: [PATCH] improve(anthropic): reduce session catalog startup memory (#119619) * perf(anthropic): lazy-load session catalog runtime Punchcard-Session: coral-workshop-workshop-3f * fix(anthropic): keep session catalog helpers private Punchcard-Session: coral-workshop-workshop-3f * docs(plugin-sdk): document catalog create target Punchcard-Session: coral-workshop-workshop-3f * fix(plugins): continue catalog target selection Punchcard-Session: coral-workshop-workshop-3f --- docs/plugins/sdk-runtime.md | 11 ++ extensions/anthropic/index.test.ts | 3 + extensions/anthropic/lazy-import.test.ts | 71 ++++++++++ extensions/anthropic/register.runtime.ts | 6 +- .../session-catalog-node-commands.ts | 125 +++++------------- .../anthropic/session-catalog-registration.ts | 119 ++++++++++++++++- .../anthropic/session-catalog-runtime.ts | 33 ----- extensions/anthropic/session-catalog.test.ts | 69 ++++++---- extensions/anthropic/session-catalog.ts | 18 ++- .../test-helpers/plugin-runtime-mock.ts | 4 + .../runtime/runtime-agent-session-catalog.ts | 52 ++++++++ src/plugins/runtime/runtime-agent.test.ts | 53 ++++++++ src/plugins/runtime/runtime-agent.ts | 2 + src/plugins/runtime/types-core.ts | 2 + 14 files changed, 408 insertions(+), 160 deletions(-) create mode 100644 extensions/anthropic/lazy-import.test.ts create mode 100644 src/plugins/runtime/runtime-agent-session-catalog.ts diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 4f700224a195..bc9ac7558fbf 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -150,6 +150,15 @@ two-party event loops that do not go through the shared inbound reply runner. // pass level to an embedded run } + // Resolve a synchronous create target for a session catalog + const target = api.runtime.agent.resolveSessionCatalogCreateTarget({ + config: api.runtime.config.current(), + requestedAgentId: agentId, + provider: "example", + modelIds: ["example-model"], + agentRuntime: "example-cli", + }); + // Get agent timeout const timeoutMs = api.runtime.agent.resolveAgentTimeoutMs(cfg); @@ -176,6 +185,8 @@ two-party event loops that do not go through the shared inbound reply runner. `normalizeThinkingLevel(...)` converts user text such as `on`, `x-high`, or `extra high` to the canonical stored level before checking it against the resolved policy. + `resolveSessionCatalogCreateTarget(...)` is the supported synchronous policy seam for trusted native plugins that implement `SessionCatalogProvider.resolveCreateSession`. It selects the first candidate model routed to the requested runtime and allowed for the requested or default agent. It returns `undefined` when no candidate satisfies both policies. Use this helper instead of importing or duplicating core model-selection policy in a plugin. + **Session store helpers** are under `api.runtime.agent.session`: ```typescript diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 8bf5f373ad68..7b0ed236591f 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -99,6 +99,7 @@ describe("anthropic provider replay hooks", () => { it("lets native session discovery be disabled without disabling Anthropic", () => { const registerCliBackend = vi.fn(); const registerNodeHostCommand = vi.fn(); + const registerNodeInvokePolicy = vi.fn(); const registerProvider = vi.fn(); const registerSessionCatalog = vi.fn(); anthropicPlugin.register( @@ -110,12 +111,14 @@ describe("anthropic provider replay hooks", () => { pluginConfig: { sessionCatalog: { enabled: false } }, registerCliBackend, registerNodeHostCommand, + registerNodeInvokePolicy, registerProvider, registerSessionCatalog, }), ); expect(registerCliBackend).toHaveBeenCalledOnce(); + expect(registerNodeInvokePolicy).toHaveBeenCalledOnce(); expect(registerProvider).toHaveBeenCalledOnce(); expect(registerNodeHostCommand).not.toHaveBeenCalled(); expect(registerSessionCatalog).not.toHaveBeenCalled(); diff --git a/extensions/anthropic/lazy-import.test.ts b/extensions/anthropic/lazy-import.test.ts new file mode 100644 index 000000000000..58abb0657294 --- /dev/null +++ b/extensions/anthropic/lazy-import.test.ts @@ -0,0 +1,71 @@ +import type { + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, +} from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; +import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("anthropic session catalog lazy imports", () => { + afterEach(() => { + vi.doUnmock("./session-catalog.js"); + vi.doUnmock("./session-catalog-node-commands.js"); + vi.resetModules(); + }); + + it("loads catalog and node handlers only on first use", async () => { + let catalogImports = 0; + let nodeCommandImports = 0; + vi.doMock("./session-catalog.js", () => { + catalogImports += 1; + return { + createClaudeSessionCatalogRuntime: () => ({ + list: async () => [], + read: async () => ({ hostId: "gateway:local", label: "Local", threadId: "", items: [] }), + continueSession: async () => ({ sessionKey: "agent:main:test" }), + openTerminal: async () => ({ kind: "local", argv: ["claude"] }), + checkUpstreamActivity: async () => [], + }), + }; + }); + vi.doMock("./session-catalog-node-commands.js", () => { + nodeCommandImports += 1; + return { + listClaudeSessions: async () => "[]", + readClaudeSession: async () => "{}", + resumeClaudeSession: async () => "{}", + }; + }); + + const { default: anthropicPlugin } = await import("./index.js"); + const catalogs: SessionCatalogProvider[] = []; + const nodeCommands: OpenClawPluginNodeHostCommand[] = []; + const nodePolicies: OpenClawPluginNodeInvokePolicy[] = []; + anthropicPlugin.register( + createTestPluginApi({ + id: "anthropic", + name: "Anthropic", + source: "test", + config: {}, + runtime: createPluginRuntimeMock(), + registerSessionCatalog: (provider) => catalogs.push(provider), + registerNodeHostCommand: (command) => nodeCommands.push(command), + registerNodeInvokePolicy: (policy) => nodePolicies.push(policy), + }), + ); + + expect(catalogImports).toBe(0); + expect(nodeCommandImports).toBe(0); + expect(catalogs).toHaveLength(1); + expect(nodeCommands).toHaveLength(3); + expect(nodePolicies).toHaveLength(1); + + await expect(catalogs[0]?.list({})).resolves.toEqual([]); + await expect(catalogs[0]?.list({})).resolves.toEqual([]); + await expect(nodeCommands[0]?.handle()).resolves.toBe("[]"); + await expect(nodeCommands[0]?.handle()).resolves.toBe("[]"); + expect(catalogImports).toBe(1); + expect(nodeCommandImports).toBe(1); + }); +}); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index cb231c5741f5..f1a2e5d614c4 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -66,8 +66,10 @@ import { acceptsAnthropicLiveModelContract } from "./live-model-contract-gate.js import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js"; -import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js"; -import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js"; +import { + createClaudeSessionNodeInvokePolicies, + registerClaudeSessionDiscovery, +} from "./session-catalog-registration.js"; import { isAnthropicOAuthApiKey, wrapAnthropicProviderStream } from "./stream-wrappers.js"; import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js"; diff --git a/extensions/anthropic/session-catalog-node-commands.ts b/extensions/anthropic/session-catalog-node-commands.ts index c281a4fe7f40..d241d3e12b40 100644 --- a/extensions/anthropic/session-catalog-node-commands.ts +++ b/extensions/anthropic/session-catalog-node-commands.ts @@ -1,41 +1,17 @@ -import { statSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { decodeNodePtyResumeParams, + type OpenClawPluginNodeHostCommandIo, runNodePtyCommand, validateClaudeSessionId, } from "openclaw/plugin-sdk/node-host"; -import type { - OpenClawPluginNodeHostCommand, - OpenClawPluginNodeInvokePolicy, -} from "openclaw/plugin-sdk/plugin-entry"; import { isExactClaudeSessionCursor } from "./session-catalog-cursor.js"; import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js"; -import { - CLAUDE_CLI_NODE_RUN_COMMAND, - CLAUDE_SESSION_READ_COMMAND, - CLAUDE_SESSIONS_LIST_COMMAND, - CLAUDE_TERMINAL_RESUME_COMMAND, - isResumableClaudeSource, -} from "./session-catalog-shared.js"; +import { isResumableClaudeSource } from "./session-catalog-shared.js"; import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js"; import { listLocalClaudeSessionPage, readLocalClaudeTranscriptPage } from "./session-catalog.js"; -const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions"; const CLAUDE_NODE_LOOKUP_PAGE_LIMIT = 100; -// Nodes advertise the catalog commands only when this machine has a Claude -// Code session store; without it the gateway skips the node entirely. -function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean { - const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); - try { - return statSync(path.join(homeDir, ".claude", "projects")).isDirectory(); - } catch { - return false; - } -} - function parseNodeParams(paramsJSON?: string | null): unknown { if (!paramsJSON) { return undefined; @@ -77,71 +53,38 @@ async function requireLocalResumableClaudeSession( throw new Error("Claude session cannot be resumed in a terminal"); } -export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { - return [ - { - command: CLAUDE_SESSIONS_LIST_COMMAND, - cap: CLAUDE_SESSIONS_CAPABILITY, - dangerous: false, - isAvailable: ({ env }) => claudeProjectsAvailable(env), - handle: async (paramsJSON) => - JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON))), - }, - { - command: CLAUDE_SESSION_READ_COMMAND, - cap: CLAUDE_SESSIONS_CAPABILITY, - dangerous: false, - isAvailable: ({ env }) => claudeProjectsAvailable(env), - handle: async (paramsJSON) => - JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))), - }, - { - command: CLAUDE_TERMINAL_RESUME_COMMAND, - cap: CLAUDE_SESSIONS_CAPABILITY, - dangerous: false, - duplex: true, - isAvailable: ({ env }) => - claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)), - handle: async (paramsJSON, io) => { - if (!io) { - throw new Error("Claude terminal command requires duplex transport"); - } - const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId); - const record = await requireLocalResumableClaudeSession(params.threadId); - const resolution = resolveClaudeTerminalExecutable(); - if (!resolution) { - throw new Error("Claude CLI is unavailable"); - } - return JSON.stringify( - await runNodePtyCommand( - { - file: resolution.executable, - args: ["--resume", params.threadId], - cwd: record.cwd, - ...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}), - cols: params.cols, - rows: params.rows, - }, - io, - ), - ); - }, - }, - ]; +export async function listClaudeSessions(paramsJSON?: string | null): Promise { + return JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON))); } -export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { - return [ - { - commands: [ - CLAUDE_SESSIONS_LIST_COMMAND, - CLAUDE_SESSION_READ_COMMAND, - CLAUDE_CLI_NODE_RUN_COMMAND, - CLAUDE_TERMINAL_RESUME_COMMAND, - ], - defaultPlatforms: ["macos", "linux", "windows"], - handle: (context) => - context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), - }, - ]; +export async function readClaudeSession(paramsJSON?: string | null): Promise { + return JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))); +} + +export async function resumeClaudeSession( + paramsJSON: string | null | undefined, + io: OpenClawPluginNodeHostCommandIo | undefined, +): Promise { + if (!io) { + throw new Error("Claude terminal command requires duplex transport"); + } + const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId); + const record = await requireLocalResumableClaudeSession(params.threadId); + const resolution = resolveClaudeTerminalExecutable(); + if (!resolution) { + throw new Error("Claude CLI is unavailable"); + } + return JSON.stringify( + await runNodePtyCommand( + { + file: resolution.executable, + args: ["--resume", params.threadId], + cwd: record.cwd, + ...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}), + cols: params.cols, + rows: params.rows, + }, + io, + ), + ); } diff --git a/extensions/anthropic/session-catalog-registration.ts b/extensions/anthropic/session-catalog-registration.ts index bb7dd330b831..9618492dc28d 100644 --- a/extensions/anthropic/session-catalog-registration.ts +++ b/extensions/anthropic/session-catalog-registration.ts @@ -1,6 +1,31 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js"; -import { registerClaudeSessionCatalog } from "./session-catalog.js"; +import { statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + createLazyRuntimeModule, + createLazyRuntimeSurface, +} from "openclaw/plugin-sdk/lazy-runtime"; +import type { + OpenClawPluginApi, + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, +} from "openclaw/plugin-sdk/plugin-entry"; +import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; +import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS } from "./cli-constants.js"; +import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js"; +import { + CLAUDE_CLI_NODE_RUN_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, +} from "./session-catalog-shared.js"; + +const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions"; + +const loadClaudeSessionNodeCommands = createLazyRuntimeModule( + () => import("./session-catalog-node-commands.js"), +); function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean { if (!pluginConfig || typeof pluginConfig !== "object") { @@ -14,6 +39,94 @@ function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean { ); } +// Node declarations expose catalog commands only when this machine owns a +// Claude session store; otherwise the gateway must skip the node capability. +function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean { + const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); + try { + return statSync(path.join(homeDir, ".claude", "projects")).isDirectory(); + } catch { + return false; + } +} + +function currentConfig(api: OpenClawPluginApi): OpenClawConfig { + return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig; +} + +function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { + const loadCatalogRuntime = createLazyRuntimeSurface( + () => import("./session-catalog.js"), + (module) => module.createClaudeSessionCatalogRuntime(api), + ); + const provider: SessionCatalogProvider = { + id: "claude", + label: "Claude Code", + resolveCreateSession: ({ agentId }) => + api.runtime.agent.resolveSessionCatalogCreateTarget({ + config: currentConfig(api), + requestedAgentId: agentId, + provider: "anthropic", + modelIds: CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS, + agentRuntime: CLAUDE_CLI_BACKEND_ID, + }), + list: async (query) => await (await loadCatalogRuntime()).list(query), + read: async (request) => await (await loadCatalogRuntime()).read(request), + continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request), + openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request), + checkUpstreamActivity: async (probes) => + await (await loadCatalogRuntime()).checkUpstreamActivity(probes), + }; + api.registerSessionCatalog(provider); +} + +function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { + return [ + { + command: CLAUDE_SESSIONS_LIST_COMMAND, + cap: CLAUDE_SESSIONS_CAPABILITY, + dangerous: false, + isAvailable: ({ env }) => claudeProjectsAvailable(env), + handle: async (paramsJSON) => + await (await loadClaudeSessionNodeCommands()).listClaudeSessions(paramsJSON), + }, + { + command: CLAUDE_SESSION_READ_COMMAND, + cap: CLAUDE_SESSIONS_CAPABILITY, + dangerous: false, + isAvailable: ({ env }) => claudeProjectsAvailable(env), + handle: async (paramsJSON) => + await (await loadClaudeSessionNodeCommands()).readClaudeSession(paramsJSON), + }, + { + command: CLAUDE_TERMINAL_RESUME_COMMAND, + cap: CLAUDE_SESSIONS_CAPABILITY, + dangerous: false, + duplex: true, + isAvailable: ({ env }) => + claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)), + handle: async (paramsJSON, io) => + await (await loadClaudeSessionNodeCommands()).resumeClaudeSession(paramsJSON, io), + }, + ]; +} + +export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { + return [ + { + commands: [ + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_CLI_NODE_RUN_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, + ], + defaultPlatforms: ["macos", "linux", "windows"], + handle: (context) => + context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), + }, + ]; +} + export function registerClaudeSessionDiscovery(api: OpenClawPluginApi): void { if (!isClaudeSessionCatalogEnabled(api.pluginConfig)) { return; diff --git a/extensions/anthropic/session-catalog-runtime.ts b/extensions/anthropic/session-catalog-runtime.ts index 88b36c279c71..f24ec84ad7e9 100644 --- a/extensions/anthropic/session-catalog-runtime.ts +++ b/extensions/anthropic/session-catalog-runtime.ts @@ -1,8 +1,3 @@ -import { - resolveAllowedModelRef, - resolveDefaultAgentId, - resolveDefaultModelForAgent, -} from "openclaw/plugin-sdk/agent-runtime"; import { resolveEffectiveAgentRuntime } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; @@ -89,31 +84,3 @@ export function resolveClaudeCliRoutedModelId( }) === CLAUDE_CLI_BACKEND_ID, ); } - -export function resolveClaudeCatalogCreateSession( - api: OpenClawPluginApi, - requestedAgentId?: string, -): { model: string; agentRuntime: string } | undefined { - const config = currentClaudeSessionCatalogConfig(api); - const agentId = requestedAgentId ?? resolveDefaultAgentId(config); - const routedModelId = resolveClaudeCliRoutedModelId(config, agentId); - if (!routedModelId) { - return undefined; - } - const routedModelRef = `anthropic/${routedModelId}`; - const defaultModel = resolveDefaultModelForAgent({ cfg: config, agentId }); - const allowed = resolveAllowedModelRef({ - cfg: config, - catalog: [], - raw: routedModelRef, - defaultProvider: defaultModel.provider, - defaultModel: defaultModel.model, - agentId, - }); - return "error" in allowed - ? undefined - : { - model: routedModelRef, - agentRuntime: CLAUDE_CLI_BACKEND_ID, - }; -} diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 9c2652b5d787..fdb2e33110dd 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -2,15 +2,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { + OpenClawPluginApi, + OpenClawPluginNodeHostCommand, +} from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; import { afterEach, describe, expect, it, vi } from "vitest"; import { adoptedSourceKey } from "./session-catalog-adoption.js"; import { - createClaudeSessionNodeHostCommands, createClaudeSessionNodeInvokePolicies, -} from "./session-catalog-node-commands.js"; + registerClaudeSessionDiscovery, +} from "./session-catalog-registration.js"; import { listBoundClaudeSessions } from "./session-catalog-runtime.js"; import { CLAUDE_CLI_NODE_RUN_COMMAND, @@ -19,9 +23,29 @@ import { CLAUDE_TERMINAL_RESUME_COMMAND, listLocalClaudeSessionPage, readLocalClaudeTranscriptPage, - registerClaudeSessionCatalog, } from "./session-catalog.js"; +function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { + registerClaudeSessionDiscovery({ + ...api, + registerNodeHostCommand: api.registerNodeHostCommand ?? (() => {}), + }); +} + +function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { + const commands: OpenClawPluginNodeHostCommand[] = []; + registerClaudeSessionDiscovery({ + id: "anthropic", + config: {}, + runtime: createPluginRuntimeMock(), + registerSessionCatalog: () => {}, + registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => { + commands.push(command); + }, + } as unknown as OpenClawPluginApi); + return commands; +} + function captureCatalogProvider(runtime: PluginRuntime): SessionCatalogProvider { let provider: SessionCatalogProvider | undefined; const runtimeWithSession = { @@ -509,29 +533,28 @@ describe("Claude session catalog", () => { sessionId: "openclaw-adopted", entry: { sessionId: "openclaw-adopted", updatedAt: Date.now() }, })); + const config = { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + } satisfies OpenClawConfig; let provider: SessionCatalogProvider | undefined; const api = { id: "anthropic", config: {}, - runtime: { - config: { - current: () => ({ - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }), - }, + runtime: createPluginRuntimeMock({ + config: { current: () => config }, agent: { session: { listSessionEntries: () => [], createSessionEntry, }, }, - }, + }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, @@ -581,9 +604,7 @@ describe("Claude session catalog", () => { const api = { id: "anthropic", config: {}, - runtime: { - config: { current: () => config }, - }, + runtime: createPluginRuntimeMock({ config: { current: () => config } }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, @@ -623,7 +644,7 @@ describe("Claude session catalog", () => { const api = { id: "anthropic", config, - runtime: { config: { current: () => config } }, + runtime: createPluginRuntimeMock({ config: { current: () => config } }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, @@ -661,7 +682,7 @@ describe("Claude session catalog", () => { const api = { id: "anthropic", config, - runtime: { config: { current: () => config } }, + runtime: createPluginRuntimeMock({ config: { current: () => config } }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, @@ -698,7 +719,7 @@ describe("Claude session catalog", () => { const api = { id: "anthropic", config, - runtime: { config: { current: () => config } }, + runtime: createPluginRuntimeMock({ config: { current: () => config } }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, @@ -736,7 +757,7 @@ describe("Claude session catalog", () => { const api = { id: "anthropic", config, - runtime: { config: { current: () => config } }, + runtime: createPluginRuntimeMock({ config: { current: () => config } }), registerSessionCatalog: (candidate: SessionCatalogProvider) => { provider = candidate; }, diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index a2af923bdfd6..f5c3b2f9e935 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -26,7 +26,6 @@ import { createNodeListFailedError, resolveNodeLabel } from "./session-catalog-n import { currentClaudeSessionCatalogConfig, listBoundClaudeSessions, - resolveClaudeCatalogCreateSession, resolveClaudeCliRoutedModelId, } from "./session-catalog-runtime.js"; import { @@ -1904,11 +1903,17 @@ function toGenericClaudeHost( }; } -export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { - const provider: SessionCatalogProvider = { - id: "claude", - label: "Claude Code", - resolveCreateSession: ({ agentId }) => resolveClaudeCatalogCreateSession(api, agentId), +type ClaudeSessionCatalogRuntime = Required< + Pick< + SessionCatalogProvider, + "list" | "read" | "continueSession" | "openTerminal" | "checkUpstreamActivity" + > +>; + +export function createClaudeSessionCatalogRuntime( + api: OpenClawPluginApi, +): ClaudeSessionCatalogRuntime { + return { list: async (query) => { const adopted = listBoundClaudeSessions(api, query.sessionEntries); const localCliAvailable = catalogTerminal.isClaudeCliAvailable(); @@ -1954,6 +1959,5 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { ).items; }), }; - api.registerSessionCatalog(provider); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 8ec15270c094..8ff0f56bc9d1 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -11,6 +11,7 @@ import { import { createChannelReplyPipeline } from "../../channels/message/reply-pipeline.js"; import { resolveSessionEntryResetFreshness } from "../../config/sessions/entry-freshness.js"; import { createChannelRuntimeContextRegistry } from "../../plugins/runtime/channel-runtime-contexts.js"; +import { resolveSessionCatalogCreateTarget } from "../../plugins/runtime/runtime-agent-session-catalog.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { implicitMentionKindWhen, @@ -527,6 +528,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = resolveAgentIdentity: vi.fn(() => ({ name: "test-agent", })) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"], + resolveSessionCatalogCreateTarget: vi.fn( + resolveSessionCatalogCreateTarget, + ) as unknown as PluginRuntime["agent"]["resolveSessionCatalogCreateTarget"], resolveThinkingDefault: vi.fn( () => "off", ) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"], diff --git a/src/plugins/runtime/runtime-agent-session-catalog.ts b/src/plugins/runtime/runtime-agent-session-catalog.ts new file mode 100644 index 000000000000..ee8c24d95555 --- /dev/null +++ b/src/plugins/runtime/runtime-agent-session-catalog.ts @@ -0,0 +1,52 @@ +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + resolveAllowedModelRef, + resolveDefaultModelForAgent, +} from "../../agents/model-selection.js"; +import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SessionCatalogCreateTarget } from "../session-catalog.js"; + +type RuntimeSessionCatalogCreateTargetParams = { + config: OpenClawConfig; + requestedAgentId?: string; + provider: string; + modelIds: readonly string[]; + agentRuntime: string; +}; + +/** + * Resolve a synchronous catalog create target through the same model/runtime + * policy used by agent turns, without making plugins import that policy graph. + */ +export function resolveSessionCatalogCreateTarget( + params: RuntimeSessionCatalogCreateTargetParams, +): SessionCatalogCreateTarget | undefined { + const agentId = params.requestedAgentId ?? resolveDefaultAgentId(params.config); + const defaultModel = resolveDefaultModelForAgent({ cfg: params.config, agentId }); + for (const modelId of params.modelIds) { + if ( + resolveEffectiveAgentRuntime({ + cfg: params.config, + provider: params.provider, + modelId, + agentId, + }) !== params.agentRuntime + ) { + continue; + } + const model = `${params.provider}/${modelId}`; + const allowed = resolveAllowedModelRef({ + cfg: params.config, + catalog: [], + raw: model, + defaultProvider: defaultModel.provider, + defaultModel: defaultModel.model, + agentId, + }); + if (!("error" in allowed)) { + return { model, agentRuntime: params.agentRuntime }; + } + } + return undefined; +} diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts index fb17a59fcdb2..f6ebe73bf184 100644 --- a/src/plugins/runtime/runtime-agent.test.ts +++ b/src/plugins/runtime/runtime-agent.test.ts @@ -21,6 +21,59 @@ function createDeferred(): { promise: Promise; resolve: () => void } { } describe("plugin runtime session creation", () => { + it("resolves synchronous session catalog targets through agent model policy", () => { + const runtime = createRuntimeAgent(); + const config = { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }; + + expect( + runtime.resolveSessionCatalogCreateTarget({ + config, + provider: "anthropic", + modelIds: ["claude-opus-5", "claude-opus-4-8"], + agentRuntime: "claude-cli", + }), + ).toEqual({ + model: "anthropic/claude-opus-4-8", + agentRuntime: "claude-cli", + }); + }); + + it("skips routed catalog targets denied by agent model policy", () => { + const runtime = createRuntimeAgent(); + const config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-8" }, + models: { + "anthropic/claude-opus-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } }, + }, + modelPolicy: { allow: ["anthropic/claude-opus-4-8"] }, + }, + }, + }; + + expect( + runtime.resolveSessionCatalogCreateTarget({ + config, + provider: "anthropic", + modelIds: ["claude-opus-5", "claude-opus-4-8"], + agentRuntime: "claude-cli", + }), + ).toEqual({ + model: "anthropic/claude-opus-4-8", + agentRuntime: "claude-cli", + }); + }); + it("requires recovery initialization to return the final trusted patch", () => { type CreateSessionParams = Parameters< ReturnType["session"]["createSessionEntry"] diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index fa95e2b84b06..1f4a063a0cb1 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -39,6 +39,7 @@ import { runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import { resolveSessionCatalogCreateTarget } from "./runtime-agent-session-catalog.js"; import { resolveRuntimeThinkingCatalog } from "./runtime-agent-thinking.js"; import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; @@ -580,6 +581,7 @@ export function createRuntimeAgent(): PluginRuntime["agent"] { resolveAgentDir, resolveAgentWorkspaceDir, resolveAgentIdentity, + resolveSessionCatalogCreateTarget, resolveThinkingDefault, normalizeThinkingLevel: normalizeThinkLevel, resolveThinkingPolicy: (params) => { diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index e3d970c84c78..ba4af21916a2 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -327,6 +327,8 @@ export type PluginRuntimeCore = { resolveAgentDir: typeof import("../../agents/agent-scope.js").resolveAgentDir; resolveAgentWorkspaceDir: typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir; resolveAgentIdentity: typeof import("../../agents/identity.js").resolveAgentIdentity; + /** Resolve an allowed catalog create target through canonical agent model/runtime policy. */ + resolveSessionCatalogCreateTarget: typeof import("./runtime-agent-session-catalog.js").resolveSessionCatalogCreateTarget; resolveThinkingDefault: (params: { cfg: import("../../config/types.openclaw.js").OpenClawConfig; provider: string;