diff --git a/CHANGELOG.md b/CHANGELOG.md index 33719af2c3b..bb8421e3bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99. - Channels/Telegram: keep webhook-mode local listeners alive and retry Telegram `setWebhook` registration after recoverable startup network failures, so transient Bot API timeouts no longer leave reverse proxies pointing at a closed listener. Fixes #71834. Thanks @jinon86. - Agents/ACPX: bundle the Codex ACP adapter and launch it from the isolated `CODEX_HOME` wrapper before falling back to npm, so Codex ACP startup no longer depends on live `npx` resolution or the stale `@zed-industries/codex-acp@^0.11.1` range. Fixes #72037; refs #73202. Thanks @jasonftl, @sazora, and @joerod26. +- Agents/ACPX: register the embedded ACP backend at Gateway startup through a lightweight ACP backend SDK path and without importing the heavy ACPX runtime until an ACP session or explicit startup probe needs it, reducing baseline Gateway RSS. Thanks @vincentkoc. - CLI/update: keep restart health polling when the restarted Gateway is reachable but has not reported its version yet, so macOS service restarts do not fail early with `actual unavailable`. Thanks @ProspectOre. - Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang. - Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 5fa9f548b97..c025cebed59 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -212,6 +212,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/file-lock` | Re-entrant file-lock helpers | | `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers | | `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers | + | `plugin-sdk/acp-runtime-backend` | Lightweight ACP backend registration and reply-dispatch helpers for startup-loaded plugins | | `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports | | `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives | | `plugin-sdk/boolean-param` | Loose boolean param reader | diff --git a/extensions/acpx/index.test.ts b/extensions/acpx/index.test.ts index 4e41a81ea19..bccb6318322 100644 --- a/extensions/acpx/index.test.ts +++ b/extensions/acpx/index.test.ts @@ -12,7 +12,7 @@ vi.mock("./register.runtime.js", () => ({ createAcpxRuntimeService: createAcpxRuntimeServiceMock, })); -vi.mock("./runtime-api.js", () => ({ +vi.mock("openclaw/plugin-sdk/acp-runtime-backend", () => ({ tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock, })); diff --git a/extensions/acpx/index.ts b/extensions/acpx/index.ts index 660209363eb..1042613eadd 100644 --- a/extensions/acpx/index.ts +++ b/extensions/acpx/index.ts @@ -1,12 +1,11 @@ +import { tryDispatchAcpReplyHook } from "openclaw/plugin-sdk/acp-runtime-backend"; import { createAcpxRuntimeService } from "./register.runtime.js"; -import { tryDispatchAcpReplyHook, type OpenClawPluginApi } from "./runtime-api.js"; -import { createAcpxPluginConfigSchema } from "./src/config-schema.js"; +import type { OpenClawPluginApi } from "./runtime-api.js"; const plugin = { id: "acpx", name: "ACPX Runtime", description: "Embedded ACP runtime backend with plugin-owned session and transport management.", - configSchema: () => createAcpxPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerService( createAcpxRuntimeService({ diff --git a/extensions/acpx/register.runtime.ts b/extensions/acpx/register.runtime.ts index 419c0d06632..815e0760100 100644 --- a/extensions/acpx/register.runtime.ts +++ b/extensions/acpx/register.runtime.ts @@ -1 +1,154 @@ -export { createAcpxRuntimeService } from "./src/service.js"; +import { + getAcpRuntimeBackend, + registerAcpRuntimeBackend, + unregisterAcpRuntimeBackend, + type AcpRuntime, + type AcpRuntimeCapabilities, + type AcpRuntimeDoctorReport, + type AcpRuntimeStatus, +} from "openclaw/plugin-sdk/acp-runtime-backend"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core"; + +const ACPX_BACKEND_ID = "acpx"; +const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE"; + +type RealAcpxServiceModule = typeof import("./src/service.js"); +type CreateAcpxRuntimeServiceParams = NonNullable< + Parameters[0] +>; + +type AcpxRuntimeLike = AcpRuntime & { + probeAvailability(): Promise; + doctor?(): Promise; + isHealthy(): boolean; +}; + +type DeferredServiceState = { + ctx: OpenClawPluginServiceContext | null; + params: CreateAcpxRuntimeServiceParams; + realRuntime: AcpxRuntimeLike | null; + realService: OpenClawPluginService | null; + startPromise: Promise | null; +}; + +let serviceModulePromise: Promise | null = null; + +function loadServiceModule(): Promise { + serviceModulePromise ??= import("./src/service.js"); + return serviceModulePromise; +} + +function shouldRunStartupProbe(env: NodeJS.ProcessEnv = process.env): boolean { + return env[ENABLE_STARTUP_PROBE_ENV] === "1"; +} + +async function startRealService(state: DeferredServiceState): Promise { + if (state.realRuntime) { + return state.realRuntime; + } + if (!state.ctx) { + throw new Error("ACPX runtime service is not started"); + } + state.startPromise ??= (async () => { + const { createAcpxRuntimeService } = await loadServiceModule(); + const service = createAcpxRuntimeService(state.params); + state.realService = service; + await service.start(state.ctx as OpenClawPluginServiceContext); + const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID); + if (!backend?.runtime) { + throw new Error("ACPX runtime service did not register an ACP backend"); + } + state.realRuntime = backend.runtime as AcpxRuntimeLike; + return state.realRuntime; + })(); + return await state.startPromise; +} + +function createDeferredRuntime(state: DeferredServiceState): AcpxRuntimeLike { + return { + async ensureSession(input) { + return await (await startRealService(state)).ensureSession(input); + }, + async *runTurn(input) { + yield* (await startRealService(state)).runTurn(input); + }, + async getCapabilities(input): Promise { + const runtime = await startRealService(state); + return (await runtime.getCapabilities?.(input)) ?? { controls: [] }; + }, + async getStatus(input): Promise { + const runtime = await startRealService(state); + return (await runtime.getStatus?.(input)) ?? {}; + }, + async setMode(input) { + await (await startRealService(state)).setMode?.(input); + }, + async setConfigOption(input) { + await (await startRealService(state)).setConfigOption?.(input); + }, + async doctor(): Promise { + const runtime = await startRealService(state); + return (await runtime.doctor?.()) ?? { ok: true, message: "ok" }; + }, + async prepareFreshSession(input) { + await (await startRealService(state)).prepareFreshSession?.(input); + }, + async cancel(input) { + await (await startRealService(state)).cancel(input); + }, + async close(input) { + await (await startRealService(state)).close(input); + }, + async probeAvailability() { + await (await startRealService(state)).probeAvailability(); + }, + isHealthy() { + return state.realRuntime?.isHealthy() ?? false; + }, + }; +} + +export function createAcpxRuntimeService( + params: CreateAcpxRuntimeServiceParams = {}, +): OpenClawPluginService { + const state: DeferredServiceState = { + ctx: null, + params, + realRuntime: null, + realService: null, + startPromise: null, + }; + + return { + id: "acpx-runtime", + async start(ctx) { + if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") { + ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)"); + return; + } + + state.ctx = ctx; + if (shouldRunStartupProbe()) { + await startRealService(state); + return; + } + + registerAcpRuntimeBackend({ + id: ACPX_BACKEND_ID, + runtime: createDeferredRuntime(state), + }); + ctx.logger.info("embedded acpx runtime backend registered lazily"); + }, + async stop(ctx) { + if (state.realService) { + await state.realService.stop?.(ctx); + } else { + unregisterAcpRuntimeBackend(ACPX_BACKEND_ID); + } + state.ctx = null; + state.realRuntime = null; + state.realService = null; + state.startPromise = null; + }, + }; +} diff --git a/extensions/acpx/runtime-api.ts b/extensions/acpx/runtime-api.ts index 9aea1da59eb..09d4395bef8 100644 --- a/extensions/acpx/runtime-api.ts +++ b/extensions/acpx/runtime-api.ts @@ -1,11 +1,11 @@ -export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime"; +export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime-backend"; export { AcpRuntimeError, getAcpRuntimeBackend, tryDispatchAcpReplyHook, registerAcpRuntimeBackend, unregisterAcpRuntimeBackend, -} from "openclaw/plugin-sdk/acp-runtime"; +} from "openclaw/plugin-sdk/acp-runtime-backend"; export type { AcpRuntime, AcpRuntimeCapabilities, @@ -17,7 +17,7 @@ export type { AcpRuntimeTurnAttachment, AcpRuntimeTurnInput, AcpSessionUpdateTag, -} from "openclaw/plugin-sdk/acp-runtime"; +} from "openclaw/plugin-sdk/acp-runtime-backend"; export type { OpenClawPluginApi, OpenClawPluginConfigSchema, diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts index 1d9aab2400c..085b592375f 100644 --- a/extensions/acpx/src/service.test.ts +++ b/extensions/acpx/src/service.test.ts @@ -11,6 +11,32 @@ const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({ async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig, ), })); +const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } = + vi.hoisted(() => ({ + acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) { + return { + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + doctor: vi.fn(async () => ({ ok: true, message: "ok" })), + ensureSession: vi.fn(async () => ({ + backend: "acpx", + runtimeSessionName: "agent:codex:acp:test", + sessionKey: "agent:codex:acp:test", + })), + getCapabilities: vi.fn(async () => ({ controls: [] })), + getStatus: vi.fn(async () => ({ summary: "ready" })), + isHealthy: vi.fn(() => true), + prepareFreshSession: vi.fn(async () => {}), + probeAvailability: vi.fn(async () => {}), + runTurn: vi.fn(async function* () {}), + setConfigOption: vi.fn(async () => {}), + setMode: vi.fn(async () => {}), + __options: options, + }; + }), + createAgentRegistryMock: vi.fn(() => ({})), + createFileSessionStoreMock: vi.fn(() => ({})), + })); vi.mock("../runtime-api.js", () => ({ getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id), @@ -24,9 +50,9 @@ vi.mock("../runtime-api.js", () => ({ vi.mock("./runtime.js", () => ({ ACPX_BACKEND_ID: "acpx", - AcpxRuntime: function AcpxRuntime() {}, - createAgentRegistry: vi.fn(() => ({})), - createFileSessionStore: vi.fn(() => ({})), + AcpxRuntime: acpxRuntimeConstructorMock, + createAgentRegistry: createAgentRegistryMock, + createFileSessionStore: createFileSessionStoreMock, })); vi.mock("./codex-auth-bridge.js", () => ({ @@ -47,6 +73,9 @@ async function makeTempDir(): Promise { afterEach(async () => { runtimeRegistry.clear(); prepareAcpxCodexAuthConfigMock.mockClear(); + acpxRuntimeConstructorMock.mockClear(); + createAgentRegistryMock.mockClear(); + createFileSessionStoreMock.mockClear(); delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE; delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME; delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE; @@ -126,6 +155,28 @@ describe("createAcpxRuntimeService", () => { await service.stop?.(ctx); }); + it("registers the default backend without importing ACPX runtime until first use", async () => { + const workspaceDir = await makeTempDir(); + const ctx = createServiceContext(workspaceDir); + const service = createAcpxRuntimeService(); + + await service.start(ctx); + + const backend = getAcpRuntimeBackend("acpx"); + expect(backend?.runtime).toBeDefined(); + expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled(); + + await backend?.runtime.ensureSession({ + agent: "codex", + mode: "oneshot", + sessionKey: "agent:codex:acp:test", + }); + + expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce(); + + await service.stop?.(ctx); + }); + it("can run the embedded runtime probe at startup when explicitly enabled", async () => { process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1"; const workspaceDir = await makeTempDir(); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 30a47929e51..1c989bfce8a 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -14,12 +14,6 @@ import { toAcpMcpServers, type ResolvedAcpxPluginConfig, } from "./config.js"; -import { - ACPX_BACKEND_ID, - AcpxRuntime, - createAgentRegistry, - createFileSessionStore, -} from "./runtime.js"; type AcpxRuntimeLike = AcpRuntime & { probeAvailability(): Promise; @@ -32,6 +26,10 @@ type AcpxRuntimeLike = AcpRuntime & { }; const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE"; +const ACPX_BACKEND_ID = "acpx"; + +type AcpxRuntimeModule = typeof import("./runtime.js"); +let runtimeModulePromise: Promise | null = null; type AcpxRuntimeFactoryParams = { pluginConfig: ResolvedAcpxPluginConfig; @@ -40,27 +38,83 @@ type AcpxRuntimeFactoryParams = { type CreateAcpxRuntimeServiceParams = { pluginConfig?: unknown; - runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike; + runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise; }; -function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike { - return new AcpxRuntime({ - cwd: params.pluginConfig.cwd, - sessionStore: createFileSessionStore({ - stateDir: params.pluginConfig.stateDir, - }), - agentRegistry: createAgentRegistry({ - overrides: params.pluginConfig.agents, - }), - probeAgent: params.pluginConfig.probeAgent, - mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers), - permissionMode: params.pluginConfig.permissionMode, - nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions, - timeoutMs: - params.pluginConfig.timeoutSeconds != null - ? params.pluginConfig.timeoutSeconds * 1_000 - : undefined, - }); +function loadRuntimeModule(): Promise { + runtimeModulePromise ??= import("./runtime.js"); + return runtimeModulePromise; +} + +function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike { + let runtime: AcpxRuntimeLike | null = null; + let runtimePromise: Promise | null = null; + + async function resolveRuntime(): Promise { + if (runtime) { + return runtime; + } + runtimePromise ??= loadRuntimeModule().then((module) => { + runtime = new module.AcpxRuntime({ + cwd: params.pluginConfig.cwd, + sessionStore: module.createFileSessionStore({ + stateDir: params.pluginConfig.stateDir, + }), + agentRegistry: module.createAgentRegistry({ + overrides: params.pluginConfig.agents, + }), + probeAgent: params.pluginConfig.probeAgent, + mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers), + permissionMode: params.pluginConfig.permissionMode, + nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions, + timeoutMs: + params.pluginConfig.timeoutSeconds != null + ? params.pluginConfig.timeoutSeconds * 1_000 + : undefined, + }) as AcpxRuntimeLike; + return runtime; + }); + return await runtimePromise; + } + + return { + async ensureSession(input) { + return await (await resolveRuntime()).ensureSession(input); + }, + async *runTurn(input) { + yield* (await resolveRuntime()).runTurn(input); + }, + async getCapabilities(input) { + return (await (await resolveRuntime()).getCapabilities?.(input)) ?? { controls: [] }; + }, + async getStatus(input) { + return (await (await resolveRuntime()).getStatus?.(input)) ?? {}; + }, + async setMode(input) { + await (await resolveRuntime()).setMode?.(input); + }, + async setConfigOption(input) { + await (await resolveRuntime()).setConfigOption?.(input); + }, + async doctor() { + return (await (await resolveRuntime()).doctor?.()) ?? { ok: true, message: "ok" }; + }, + async prepareFreshSession(input) { + await (await resolveRuntime()).prepareFreshSession?.(input); + }, + async cancel(input) { + await (await resolveRuntime()).cancel(input); + }, + async close(input) { + await (await resolveRuntime()).close(input); + }, + async probeAvailability() { + await (await resolveRuntime()).probeAvailability(); + }, + isHealthy() { + return runtime?.isHealthy() ?? false; + }, + }; } function warnOnIgnoredLegacyCompatibilityConfig(params: { @@ -167,11 +221,15 @@ export function createAcpxRuntimeService( logger: ctx.logger, }); - const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime; - runtime = runtimeFactory({ - pluginConfig, - logger: ctx.logger, - }); + runtime = params.runtimeFactory + ? await params.runtimeFactory({ + pluginConfig, + logger: ctx.logger, + }) + : createLazyDefaultRuntime({ + pluginConfig, + logger: ctx.logger, + }); registerAcpRuntimeBackend({ id: ACPX_BACKEND_ID, diff --git a/package.json b/package.json index 969fb9ae5ec..203c690e837 100644 --- a/package.json +++ b/package.json @@ -474,6 +474,10 @@ "types": "./dist/plugin-sdk/acp-runtime.d.ts", "default": "./dist/plugin-sdk/acp-runtime.js" }, + "./plugin-sdk/acp-runtime-backend": { + "types": "./dist/plugin-sdk/acp-runtime-backend.d.ts", + "default": "./dist/plugin-sdk/acp-runtime-backend.js" + }, "./plugin-sdk/acp-binding-runtime": { "types": "./dist/plugin-sdk/acp-binding-runtime.d.ts", "default": "./dist/plugin-sdk/acp-binding-runtime.js" diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index df7b5e3e392..7a19d3308bf 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -12,6 +12,10 @@ "types": "./dist/src/plugin-sdk/acp-runtime.d.ts", "default": "./src/acp-runtime.ts" }, + "./acp-runtime-backend": { + "types": "./dist/src/plugin-sdk/acp-runtime-backend.d.ts", + "default": "./src/acp-runtime-backend.ts" + }, "./async-lock-runtime": { "types": "./dist/src/plugin-sdk/async-lock-runtime.d.ts", "default": "./src/async-lock-runtime.ts" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 196eacfe897..d0f1506d0b7 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -102,6 +102,7 @@ "process-runtime", "windows-spawn", "acp-runtime", + "acp-runtime-backend", "acp-binding-runtime", "acp-binding-resolve-runtime", "lazy-runtime", diff --git a/src/plugin-sdk/acp-runtime-backend.ts b/src/plugin-sdk/acp-runtime-backend.ts new file mode 100644 index 00000000000..6751040fe2f --- /dev/null +++ b/src/plugin-sdk/acp-runtime-backend.ts @@ -0,0 +1,112 @@ +// Lightweight ACP runtime backend helpers for startup-loaded plugins. + +import type { + PluginHookReplyDispatchContext, + PluginHookReplyDispatchEvent, + PluginHookReplyDispatchResult, +} from "../plugins/types.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; +export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; +export { + getAcpRuntimeBackend, + registerAcpRuntimeBackend, + requireAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "../acp/runtime/registry.js"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnAttachment, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "../acp/runtime/types.js"; + +let dispatchAcpRuntimePromise: Promise< + typeof import("../auto-reply/reply/dispatch-acp.runtime.js") +> | null = null; + +function loadDispatchAcpRuntime() { + dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js"); + return dispatchAcpRuntimePromise; +} + +function hasExplicitCommandCandidate(ctx: PluginHookReplyDispatchEvent["ctx"]): boolean { + const commandBody = normalizeOptionalString(ctx.CommandBody); + if (commandBody) { + return true; + } + + const normalized = normalizeOptionalString(ctx.BodyForCommands); + if (!normalized) { + return false; + } + + return normalized.startsWith("!") || normalized.startsWith("/"); +} + +export async function tryDispatchAcpReplyHook( + event: PluginHookReplyDispatchEvent, + ctx: PluginHookReplyDispatchContext, +): Promise { + // Under sendPolicy: "deny", ACP-bound sessions still need their turns to flow + // through acpManager.runTurn so session state, tool calls, and memory stay + // consistent. Delivery suppression is handled by the ACP delivery path. + if ( + event.sendPolicy === "deny" && + !event.suppressUserDelivery && + !hasExplicitCommandCandidate(event.ctx) && + !event.isTailDispatch + ) { + return; + } + const runtime = await loadDispatchAcpRuntime(); + const bypassForCommand = await runtime.shouldBypassAcpDispatchForCommand(event.ctx, ctx.cfg); + + if ( + event.sendPolicy === "deny" && + !event.suppressUserDelivery && + !bypassForCommand && + !event.isTailDispatch + ) { + return; + } + + const result = await runtime.tryDispatchAcpReply({ + ctx: event.ctx, + cfg: ctx.cfg, + dispatcher: ctx.dispatcher, + runId: event.runId, + sessionKey: event.sessionKey, + images: event.images, + abortSignal: ctx.abortSignal, + inboundAudio: event.inboundAudio, + sessionTtsAuto: event.sessionTtsAuto, + ttsChannel: event.ttsChannel, + suppressUserDelivery: event.suppressUserDelivery, + shouldRouteToOriginating: event.shouldRouteToOriginating, + originatingChannel: event.originatingChannel, + originatingTo: event.originatingTo, + shouldSendToolSummaries: event.shouldSendToolSummaries, + bypassForCommand, + onReplyStart: ctx.onReplyStart, + recordProcessed: ctx.recordProcessed, + markIdle: ctx.markIdle, + }); + + if (!result) { + return; + } + + return { + handled: true, + queuedFinal: result.queuedFinal, + counts: result.counts, + }; +} diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index c2165aad3f8..2dc00efef62 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -2,12 +2,6 @@ import { __testing as managerTesting, getAcpSessionManager } from "../acp/control-plane/manager.js"; import { __testing as registryTesting } from "../acp/runtime/registry.js"; -import type { - PluginHookReplyDispatchContext, - PluginHookReplyDispatchEvent, - PluginHookReplyDispatchResult, -} from "../plugins/types.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; export { getAcpSessionManager }; export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; @@ -32,94 +26,7 @@ export type { } from "../acp/runtime/types.js"; export { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js"; - -let dispatchAcpRuntimePromise: Promise< - typeof import("../auto-reply/reply/dispatch-acp.runtime.js") -> | null = null; - -function loadDispatchAcpRuntime() { - dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js"); - return dispatchAcpRuntimePromise; -} - -function hasExplicitCommandCandidate(ctx: PluginHookReplyDispatchEvent["ctx"]): boolean { - const commandBody = normalizeOptionalString(ctx.CommandBody); - if (commandBody) { - return true; - } - - const normalized = normalizeOptionalString(ctx.BodyForCommands); - if (!normalized) { - return false; - } - - return normalized.startsWith("!") || normalized.startsWith("/"); -} - -export async function tryDispatchAcpReplyHook( - event: PluginHookReplyDispatchEvent, - ctx: PluginHookReplyDispatchContext, -): Promise { - // Under sendPolicy: "deny", ACP-bound sessions still need their turns to flow - // through acpManager.runTurn so session state, tool calls, and memory stay - // consistent — only outbound delivery should be suppressed. The ACP delivery - // path (dispatch-acp-delivery.ts) honors event.suppressUserDelivery to drop - // user-facing sends. If suppressUserDelivery is not set under deny, we cannot - // safely route through ACP (delivery would leak), so fall back to the - // embedded reply path unless an explicit command candidate or tail dispatch - // warrants going through ACP anyway. - if ( - event.sendPolicy === "deny" && - !event.suppressUserDelivery && - !hasExplicitCommandCandidate(event.ctx) && - !event.isTailDispatch - ) { - return; - } - const runtime = await loadDispatchAcpRuntime(); - const bypassForCommand = await runtime.shouldBypassAcpDispatchForCommand(event.ctx, ctx.cfg); - - if ( - event.sendPolicy === "deny" && - !event.suppressUserDelivery && - !bypassForCommand && - !event.isTailDispatch - ) { - return; - } - - const result = await runtime.tryDispatchAcpReply({ - ctx: event.ctx, - cfg: ctx.cfg, - dispatcher: ctx.dispatcher, - runId: event.runId, - sessionKey: event.sessionKey, - images: event.images, - abortSignal: ctx.abortSignal, - inboundAudio: event.inboundAudio, - sessionTtsAuto: event.sessionTtsAuto, - ttsChannel: event.ttsChannel, - suppressUserDelivery: event.suppressUserDelivery, - shouldRouteToOriginating: event.shouldRouteToOriginating, - originatingChannel: event.originatingChannel, - originatingTo: event.originatingTo, - shouldSendToolSummaries: event.shouldSendToolSummaries, - bypassForCommand, - onReplyStart: ctx.onReplyStart, - recordProcessed: ctx.recordProcessed, - markIdle: ctx.markIdle, - }); - - if (!result) { - return; - } - - return { - handled: true, - queuedFinal: result.queuedFinal, - counts: result.counts, - }; -} +export { tryDispatchAcpReplyHook } from "./acp-runtime-backend.js"; // Keep test helpers off the hot init path. Eagerly merging them here can // create a back-edge through the bundled ACP runtime chunk before the imported