mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(acpx): lazy-load startup backend
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<RealAcpxServiceModule["createAcpxRuntimeService"]>[0]
|
||||
>;
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor?(): Promise<AcpRuntimeDoctorReport>;
|
||||
isHealthy(): boolean;
|
||||
};
|
||||
|
||||
type DeferredServiceState = {
|
||||
ctx: OpenClawPluginServiceContext | null;
|
||||
params: CreateAcpxRuntimeServiceParams;
|
||||
realRuntime: AcpxRuntimeLike | null;
|
||||
realService: OpenClawPluginService | null;
|
||||
startPromise: Promise<AcpxRuntimeLike> | null;
|
||||
};
|
||||
|
||||
let serviceModulePromise: Promise<RealAcpxServiceModule> | null = null;
|
||||
|
||||
function loadServiceModule(): Promise<RealAcpxServiceModule> {
|
||||
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<AcpxRuntimeLike> {
|
||||
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<AcpRuntimeCapabilities> {
|
||||
const runtime = await startRealService(state);
|
||||
return (await runtime.getCapabilities?.(input)) ?? { controls: [] };
|
||||
},
|
||||
async getStatus(input): Promise<AcpRuntimeStatus> {
|
||||
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<AcpRuntimeDoctorReport> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> {
|
||||
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();
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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<AcpxRuntimeModule> | 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<AcpxRuntimeLike>;
|
||||
};
|
||||
|
||||
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<AcpxRuntimeModule> {
|
||||
runtimeModulePromise ??= import("./runtime.js");
|
||||
return runtimeModulePromise;
|
||||
}
|
||||
|
||||
function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
|
||||
|
||||
async function resolveRuntime(): Promise<AcpxRuntimeLike> {
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"process-runtime",
|
||||
"windows-spawn",
|
||||
"acp-runtime",
|
||||
"acp-runtime-backend",
|
||||
"acp-binding-runtime",
|
||||
"acp-binding-resolve-runtime",
|
||||
"lazy-runtime",
|
||||
|
||||
112
src/plugin-sdk/acp-runtime-backend.ts
Normal file
112
src/plugin-sdk/acp-runtime-backend.ts
Normal file
@@ -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<PluginHookReplyDispatchResult | void> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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<PluginHookReplyDispatchResult | void> {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user