diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index b36bc1c3f84e..09dec3b401be 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -254,12 +254,39 @@ "@openclaw/acp-core/normalize-text": [ "../dist/plugin-sdk/packages/acp-core/src/normalize-text.d.ts" ], + "@openclaw/acp-core/meta": [ + "../dist/plugin-sdk/packages/acp-core/src/meta.d.ts" + ], + "@openclaw/acp-core/numeric-options": [ + "../dist/plugin-sdk/packages/acp-core/src/numeric-options.d.ts" + ], "@openclaw/acp-core/record-shared": [ "../dist/plugin-sdk/packages/acp-core/src/record-shared.d.ts" ], + "@openclaw/acp-core/session": [ + "../dist/plugin-sdk/packages/acp-core/src/session.d.ts" + ], + "@openclaw/acp-core/session-interaction-mode": [ + "../dist/plugin-sdk/packages/acp-core/src/session-interaction-mode.d.ts" + ], + "@openclaw/acp-core/session-lineage-meta": [ + "../dist/plugin-sdk/packages/acp-core/src/session-lineage-meta.d.ts" + ], + "@openclaw/acp-core/types": [ + "../dist/plugin-sdk/packages/acp-core/src/types.d.ts" + ], + "@openclaw/acp-core/runtime/error-text": [ + "../dist/plugin-sdk/packages/acp-core/src/runtime/error-text.d.ts" + ], "@openclaw/acp-core/runtime/errors": [ "../dist/plugin-sdk/packages/acp-core/src/runtime/errors.d.ts" ], + "@openclaw/acp-core/runtime/session-identifiers": [ + "../dist/plugin-sdk/packages/acp-core/src/runtime/session-identifiers.d.ts" + ], + "@openclaw/acp-core/runtime/session-identity": [ + "../dist/plugin-sdk/packages/acp-core/src/runtime/session-identity.d.ts" + ], "@openclaw/acp-core/runtime/types": [ "../dist/plugin-sdk/packages/acp-core/src/runtime/types.d.ts" ], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 5504e4f19f5d..6f87213bfba2 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -240,12 +240,39 @@ "@openclaw/acp-core/normalize-text": [ "../../dist/plugin-sdk/packages/acp-core/src/normalize-text.d.ts" ], + "@openclaw/acp-core/meta": [ + "../../dist/plugin-sdk/packages/acp-core/src/meta.d.ts" + ], + "@openclaw/acp-core/numeric-options": [ + "../../dist/plugin-sdk/packages/acp-core/src/numeric-options.d.ts" + ], "@openclaw/acp-core/record-shared": [ "../../dist/plugin-sdk/packages/acp-core/src/record-shared.d.ts" ], + "@openclaw/acp-core/session": [ + "../../dist/plugin-sdk/packages/acp-core/src/session.d.ts" + ], + "@openclaw/acp-core/session-interaction-mode": [ + "../../dist/plugin-sdk/packages/acp-core/src/session-interaction-mode.d.ts" + ], + "@openclaw/acp-core/session-lineage-meta": [ + "../../dist/plugin-sdk/packages/acp-core/src/session-lineage-meta.d.ts" + ], + "@openclaw/acp-core/types": [ + "../../dist/plugin-sdk/packages/acp-core/src/types.d.ts" + ], + "@openclaw/acp-core/runtime/error-text": [ + "../../dist/plugin-sdk/packages/acp-core/src/runtime/error-text.d.ts" + ], "@openclaw/acp-core/runtime/errors": [ "../../dist/plugin-sdk/packages/acp-core/src/runtime/errors.d.ts" ], + "@openclaw/acp-core/runtime/session-identifiers": [ + "../../dist/plugin-sdk/packages/acp-core/src/runtime/session-identifiers.d.ts" + ], + "@openclaw/acp-core/runtime/session-identity": [ + "../../dist/plugin-sdk/packages/acp-core/src/runtime/session-identity.d.ts" + ], "@openclaw/acp-core/runtime/types": [ "../../dist/plugin-sdk/packages/acp-core/src/runtime/types.d.ts" ], diff --git a/scripts/lib/ci-node-test-plan.mjs b/scripts/lib/ci-node-test-plan.mjs index 5bc7512fd4fc..9d2c8005e61f 100644 --- a/scripts/lib/ci-node-test-plan.mjs +++ b/scripts/lib/ci-node-test-plan.mjs @@ -234,6 +234,12 @@ function resolveGatewayServerShardName(file) { ) { return "agentic-control-plane-startup-runtime"; } + if (name.includes("cron")) { + return "agentic-control-plane-runtime-cron"; + } + if (name.includes("network")) { + return "agentic-control-plane-runtime-network"; + } if ( name.includes("plugin") || name.includes("hooks") || @@ -242,6 +248,12 @@ function resolveGatewayServerShardName(file) { ) { return "agentic-control-plane-http-plugin-ws"; } + if (name.startsWith("server-")) { + return "agentic-control-plane-runtime-server"; + } + if (name.startsWith("server.") || name.startsWith("server/")) { + return "agentic-control-plane-runtime-state"; + } return "agentic-control-plane-runtime"; } @@ -257,6 +269,10 @@ function createGatewayServerSplitShards() { "agentic-control-plane-http-models", "agentic-control-plane-http-plugin-ws", "agentic-control-plane-runtime", + "agentic-control-plane-runtime-cron", + "agentic-control-plane-runtime-network", + "agentic-control-plane-runtime-server", + "agentic-control-plane-runtime-state", "agentic-control-plane-startup-runtime", ] .map((shardName) => ({ diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index a6bcb28432b8..8558d4ccdf54 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -20,6 +20,38 @@ const privateLocalOnlyPluginSdkPackageDtsPaths = Object.fromEntries( ]), ) as Record; +function buildPackageBoundaryDtsPaths(params: { + packageName: string; + packageDir: string; +}): Record { + const packageJson = JSON.parse( + readFileSync(join("packages", params.packageDir, "package.json"), "utf8"), + ) as { exports?: Record }; + return Object.fromEntries( + Object.entries(packageJson.exports ?? {}).flatMap(([exportKey, value]) => { + const subpath = + exportKey === "." ? "" : exportKey.startsWith("./") ? exportKey.slice(2) : null; + const importPath = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record).import + : value; + if (subpath === null || subpath.includes("..") || typeof importPath !== "string") { + return []; + } + if (!importPath.startsWith("./dist/") || !importPath.endsWith(".mjs")) { + return []; + } + const specifier = subpath ? `${params.packageName}/${subpath}` : params.packageName; + return [ + [ + specifier, + [`../dist/plugin-sdk/packages/${params.packageDir}/src/${subpath || "index"}.d.ts`], + ], + ]; + }), + ); +} + export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = { "openclaw/extension-api": ["../src/extensionAPI.ts"], "openclaw/plugin-sdk": ["../dist/plugin-sdk/index.d.ts"], @@ -147,19 +179,10 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = { "../dist/plugin-sdk/packages/normalization-core/src/string-coerce.d.ts", ], "@openclaw/normalization-core/*": ["../dist/plugin-sdk/packages/normalization-core/src/*.d.ts"], - "@openclaw/acp-core": ["../dist/plugin-sdk/packages/acp-core/src/index.d.ts"], - "@openclaw/acp-core/normalize-text": [ - "../dist/plugin-sdk/packages/acp-core/src/normalize-text.d.ts", - ], - "@openclaw/acp-core/record-shared": [ - "../dist/plugin-sdk/packages/acp-core/src/record-shared.d.ts", - ], - "@openclaw/acp-core/runtime/errors": [ - "../dist/plugin-sdk/packages/acp-core/src/runtime/errors.d.ts", - ], - "@openclaw/acp-core/runtime/types": [ - "../dist/plugin-sdk/packages/acp-core/src/runtime/types.d.ts", - ], + ...buildPackageBoundaryDtsPaths({ + packageName: "@openclaw/acp-core", + packageDir: "acp-core", + }), "@openclaw/acp-core/*": ["../dist/plugin-sdk/packages/acp-core/src/*.d.ts"], "@openclaw/terminal-core": ["../dist/plugin-sdk/packages/terminal-core/src/index.d.ts"], "@openclaw/terminal-core/ansi": ["../dist/plugin-sdk/packages/terminal-core/src/ansi.d.ts"], diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 9f70b5ec4235..6e2ca5a03f89 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -13,6 +13,27 @@ const ROOT_SHIMS_MAX_OLD_SPACE_SIZE = const ROOT_SHIMS_NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=${ROOT_SHIMS_MAX_OLD_SPACE_SIZE}`.trim(); +function listPackageDtsOutputsFromExports({ packageDir, outputPrefix }) { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, "packages", packageDir, "package.json"), "utf8"), + ); + return Object.entries(packageJson.exports ?? {}) + .flatMap(([exportKey, value]) => { + const entry = + exportKey === "." ? "index" : exportKey.startsWith("./") ? exportKey.slice(2) : ""; + const importPath = + value && typeof value === "object" && !Array.isArray(value) ? value.import : value; + if (!entry || entry.includes("..") || typeof importPath !== "string") { + return []; + } + if (!importPath.startsWith("./dist/") || !importPath.endsWith(".mjs")) { + return []; + } + return [`${outputPrefix}/${entry}.d.ts`]; + }) + .toSorted((a, b) => a.localeCompare(b)); +} + const PLUGIN_SDK_TYPE_INPUTS = [ "tsconfig.json", "src/plugin-sdk", @@ -33,6 +54,10 @@ const PLUGIN_SDK_TYPE_INPUTS = [ ]; const ROOT_DTS_INPUTS = ["tsconfig.plugin-sdk.dts.json", ...PLUGIN_SDK_TYPE_INPUTS]; const ROOT_DTS_STAMP = "dist/plugin-sdk/.boundary-dts.stamp"; +const ACP_CORE_REQUIRED_DTS_OUTPUTS = listPackageDtsOutputsFromExports({ + packageDir: "acp-core", + outputPrefix: "dist/plugin-sdk/packages/acp-core/src", +}); const ROOT_DTS_REQUIRED_OUTPUTS = [ "dist/plugin-sdk/packages/memory-host-sdk/src/engine-embeddings.d.ts", "dist/plugin-sdk/packages/memory-host-sdk/src/secret.d.ts", @@ -66,11 +91,7 @@ const ROOT_DTS_REQUIRED_OUTPUTS = [ "dist/plugin-sdk/packages/media-core/src/mime.d.ts", "dist/plugin-sdk/packages/media-core/src/read-byte-stream-with-limit.d.ts", "dist/plugin-sdk/packages/media-core/src/read-response-with-limit.d.ts", - "dist/plugin-sdk/packages/acp-core/src/index.d.ts", - "dist/plugin-sdk/packages/acp-core/src/normalize-text.d.ts", - "dist/plugin-sdk/packages/acp-core/src/record-shared.d.ts", - "dist/plugin-sdk/packages/acp-core/src/runtime/errors.d.ts", - "dist/plugin-sdk/packages/acp-core/src/runtime/types.d.ts", + ...ACP_CORE_REQUIRED_DTS_OUTPUTS, "dist/plugin-sdk/packages/terminal-core/src/ansi.d.ts", "dist/plugin-sdk/packages/terminal-core/src/decorative-emoji.d.ts", "dist/plugin-sdk/packages/terminal-core/src/health-style.d.ts", @@ -103,6 +124,10 @@ const ROOT_DTS_REQUIRED_OUTPUTS = [ ]; const PACKAGE_DTS_INPUTS = ["packages/plugin-sdk/tsconfig.json", ...PLUGIN_SDK_TYPE_INPUTS]; const PACKAGE_DTS_STAMP = "packages/plugin-sdk/dist/.boundary-dts.stamp"; +const ACP_CORE_REQUIRED_PACKAGE_DTS_OUTPUTS = listPackageDtsOutputsFromExports({ + packageDir: "acp-core", + outputPrefix: "packages/plugin-sdk/dist/packages/acp-core/src", +}); const PACKAGE_DTS_REQUIRED_OUTPUTS = [ "packages/plugin-sdk/dist/packages/markdown-core/src/code-spans.d.ts", "packages/plugin-sdk/dist/packages/markdown-core/src/fences.d.ts", @@ -128,11 +153,7 @@ const PACKAGE_DTS_REQUIRED_OUTPUTS = [ "packages/plugin-sdk/dist/packages/media-core/src/mime.d.ts", "packages/plugin-sdk/dist/packages/media-core/src/read-byte-stream-with-limit.d.ts", "packages/plugin-sdk/dist/packages/media-core/src/read-response-with-limit.d.ts", - "packages/plugin-sdk/dist/packages/acp-core/src/index.d.ts", - "packages/plugin-sdk/dist/packages/acp-core/src/normalize-text.d.ts", - "packages/plugin-sdk/dist/packages/acp-core/src/record-shared.d.ts", - "packages/plugin-sdk/dist/packages/acp-core/src/runtime/errors.d.ts", - "packages/plugin-sdk/dist/packages/acp-core/src/runtime/types.d.ts", + ...ACP_CORE_REQUIRED_PACKAGE_DTS_OUTPUTS, "packages/plugin-sdk/dist/packages/model-catalog-core/src/configured-model-refs.d.ts", "packages/plugin-sdk/dist/packages/model-catalog-core/src/model-catalog-normalize.d.ts", "packages/plugin-sdk/dist/packages/model-catalog-core/src/model-catalog-refs.d.ts", diff --git a/src/acp/control-plane/manager.background-task.ts b/src/acp/control-plane/manager.background-task.ts new file mode 100644 index 000000000000..cf1d20d53255 --- /dev/null +++ b/src/acp/control-plane/manager.background-task.ts @@ -0,0 +1,206 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { logVerbose } from "../../globals.js"; +import { + createRunningTaskRun, + completeTaskRunByRunId, + failTaskRunByRunId, + startTaskRunByRunId, +} from "../../tasks/detached-task-runtime.js"; +import { resolveRequiredCompletionTerminalResult } from "../../tasks/task-completion-contract.js"; +import type { DeliveryContext } from "../../utils/delivery-context.js"; +import { AcpRuntimeError } from "../runtime/errors.js"; +import type { AcpSessionManagerDeps } from "./manager.types.js"; +import { normalizeText } from "./runtime-options.js"; + +const ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH = 160; +const ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH = 240; + +export type BackgroundTaskContext = { + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; + childSessionKey: string; + runId: string; + label?: string; + task: string; +}; + +export function summarizeBackgroundTaskText(text: string): string { + const normalized = normalizeText(text) ?? "ACP background task"; + if (normalized.length <= ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH) { + return normalized; + } + return `${normalized.slice(0, ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH - 1)}…`; +} + +export function appendBackgroundTaskProgressSummary(current: string, chunk: string): string { + const normalizedChunk = chunk.replace(/\s+/g, " "); + if (!normalizedChunk) { + return current; + } + const chunkToAppend = current ? normalizedChunk : normalizedChunk.trimStart(); + if (!chunkToAppend) { + return current; + } + const combined = `${current}${chunkToAppend}`.replace(/\s+/g, " "); + if (combined.length <= ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH) { + return combined; + } + return `${combined.slice(0, ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH - 1)}…`; +} + +export function resolveBackgroundTaskFailureStatus(error: AcpRuntimeError): "failed" | "timed_out" { + return /\btimed out\b/i.test(error.message) ? "timed_out" : "failed"; +} + +export function resolveBackgroundTaskTerminalResult(progressSummary: string): { + terminalOutcome?: "blocked"; + terminalSummary?: string; +} { + const requiredCompletionResult = resolveRequiredCompletionTerminalResult(progressSummary); + if (requiredCompletionResult.terminalOutcome) { + return requiredCompletionResult; + } + const normalized = normalizeText(progressSummary)?.replace(/\s+/g, " ").trim(); + if (!normalized) { + return {}; + } + const permissionDeniedMatch = normalized.match( + /\b(?:write failed:\s*)?permission denied(?: for (?\S+))?\.?/i, + ); + if (permissionDeniedMatch) { + const path = normalizeText(permissionDeniedMatch.groups?.path)?.replace(/[.,;:!?]+$/, ""); + return { + terminalOutcome: "blocked", + terminalSummary: path ? `Permission denied for ${path}.` : "Permission denied.", + }; + } + if ( + /\bneed a writable session\b/i.test(normalized) || + /\bfilesystem authorization\b/i.test(normalized) || + /`?apply_patch`?/i.test(normalized) + ) { + return { + terminalOutcome: "blocked", + terminalSummary: "Writable session or apply_patch authorization required.", + }; + } + return {}; +} + +export function resolveBackgroundTaskContext(params: { + deps: AcpSessionManagerDeps; + cfg: OpenClawConfig; + sessionKey: string; + requestId: string; + text: string; +}): BackgroundTaskContext | null { + const childEntry = params.deps.readSessionEntry({ + cfg: params.cfg, + sessionKey: params.sessionKey, + })?.entry; + const requesterSessionKey = + normalizeText(childEntry?.spawnedBy) ?? normalizeText(childEntry?.parentSessionKey); + if (!requesterSessionKey) { + return null; + } + const parentEntry = params.deps.readSessionEntry({ + cfg: params.cfg, + sessionKey: requesterSessionKey, + })?.entry; + return { + requesterSessionKey, + requesterOrigin: parentEntry?.deliveryContext ?? childEntry?.deliveryContext, + childSessionKey: params.sessionKey, + runId: params.requestId, + label: normalizeText(childEntry?.label), + task: summarizeBackgroundTaskText(params.text), + }; +} + +export function createBackgroundTaskRecord( + context: BackgroundTaskContext, + startedAt: number, +): void { + try { + createRunningTaskRun({ + runtime: "acp", + sourceId: context.runId, + ownerKey: context.requesterSessionKey, + scopeKind: "session", + requesterOrigin: context.requesterOrigin, + childSessionKey: context.childSessionKey, + runId: context.runId, + label: context.label, + task: context.task, + startedAt, + }); + } catch (error) { + logVerbose( + `acp-manager: failed creating background task for ${context.runId}: ${String(error)}`, + ); + } +} + +export function markBackgroundTaskRunning( + runId: string, + params: { + sessionKey?: string; + lastEventAt?: number; + progressSummary?: string | null; + }, +): void { + try { + startTaskRunByRunId({ + runId, + runtime: "acp", + sessionKey: params.sessionKey, + lastEventAt: params.lastEventAt, + progressSummary: params.progressSummary, + }); + } catch (error) { + logVerbose(`acp-manager: failed updating background task for ${runId}: ${String(error)}`); + } +} + +export function markBackgroundTaskTerminal( + runId: string, + params: { + sessionKey?: string; + status: "succeeded" | "failed" | "timed_out"; + endedAt: number; + lastEventAt?: number; + error?: string; + progressSummary?: string | null; + terminalSummary?: string | null; + terminalOutcome?: "succeeded" | "blocked" | null; + }, +): void { + try { + if (params.status === "succeeded") { + completeTaskRunByRunId({ + runId, + runtime: "acp", + sessionKey: params.sessionKey, + endedAt: params.endedAt, + lastEventAt: params.lastEventAt, + progressSummary: params.progressSummary, + terminalSummary: params.terminalSummary, + terminalOutcome: params.terminalOutcome, + }); + return; + } + failTaskRunByRunId({ + runId, + runtime: "acp", + sessionKey: params.sessionKey, + status: params.status, + endedAt: params.endedAt, + lastEventAt: params.lastEventAt, + error: params.error, + progressSummary: params.progressSummary, + terminalSummary: params.terminalSummary, + }); + } catch (error) { + logVerbose(`acp-manager: failed updating background task for ${runId}: ${String(error)}`); + } +} diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 40c73f14d6b2..547e76959849 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -12,26 +12,15 @@ import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle, - AcpRuntimeSessionMode, AcpRuntimeStatus, } from "@openclaw/acp-core/runtime/types"; -import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { isAcpSessionKey } from "../../sessions/session-key-utils.js"; -import { - createRunningTaskRun, - completeTaskRunByRunId, - failTaskRunByRunId, - startTaskRunByRunId, -} from "../../tasks/detached-task-runtime.js"; -import { resolveRequiredCompletionTerminalResult } from "../../tasks/task-completion-contract.js"; -import type { DeliveryContext } from "../../utils/delivery-context.js"; import { AcpRuntimeError, formatAcpErrorChain, @@ -40,12 +29,26 @@ import { } from "../runtime/errors.js"; import type { AcpRuntimeErrorCode } from "../runtime/errors.js"; import { clearAcpTurnActive, markAcpTurnActive } from "./active-turns.js"; +import { + appendBackgroundTaskProgressSummary, + createBackgroundTaskRecord, + markBackgroundTaskRunning, + markBackgroundTaskTerminal, + resolveBackgroundTaskContext, + resolveBackgroundTaskFailureStatus, + resolveBackgroundTaskTerminalResult, +} from "./manager.background-task.js"; import { reconcileManagerRuntimeSessionIdentifiers } from "./manager.identity-reconcile.js"; import { applyManagerRuntimeControls, resolveManagerRuntimeCapabilities, } from "./manager.runtime-controls.js"; import { consumeAcpTurnStream } from "./manager.turn-stream.js"; +import { + awaitTurnWithTimeout, + cleanupTimedOutTurn, + resolveTurnTimeoutMs, +} from "./manager.turn-timeout.js"; import { type AcpCloseSessionInput, type AcpCloseSessionResult, @@ -92,82 +95,6 @@ import { import { SessionActorQueue } from "./session-actor-queue.js"; const ACP_TURN_TIMEOUT_GRACE_MS = 1_000; -const ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS = 2_000; -const ACP_TURN_TIMEOUT_REASON = "turn-timeout"; -const ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH = 160; -const ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH = 240; - -function summarizeBackgroundTaskText(text: string): string { - const normalized = normalizeText(text) ?? "ACP background task"; - if (normalized.length <= ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH) { - return normalized; - } - return `${normalized.slice(0, ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH - 1)}…`; -} - -function appendBackgroundTaskProgressSummary(current: string, chunk: string): string { - const normalizedChunk = chunk.replace(/\s+/g, " "); - if (!normalizedChunk) { - return current; - } - const chunkToAppend = current ? normalizedChunk : normalizedChunk.trimStart(); - if (!chunkToAppend) { - return current; - } - const combined = `${current}${chunkToAppend}`.replace(/\s+/g, " "); - if (combined.length <= ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH) { - return combined; - } - return `${combined.slice(0, ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH - 1)}…`; -} - -function resolveBackgroundTaskFailureStatus(error: AcpRuntimeError): "failed" | "timed_out" { - return /\btimed out\b/i.test(error.message) ? "timed_out" : "failed"; -} - -function resolveBackgroundTaskTerminalResult(progressSummary: string): { - terminalOutcome?: "blocked"; - terminalSummary?: string; -} { - const requiredCompletionResult = resolveRequiredCompletionTerminalResult(progressSummary); - if (requiredCompletionResult.terminalOutcome) { - return requiredCompletionResult; - } - const normalized = normalizeText(progressSummary)?.replace(/\s+/g, " ").trim(); - if (!normalized) { - return {}; - } - const permissionDeniedMatch = normalized.match( - /\b(?:write failed:\s*)?permission denied(?: for (?\S+))?\.?/i, - ); - if (permissionDeniedMatch) { - const path = normalizeText(permissionDeniedMatch.groups?.path)?.replace(/[.,;:!?]+$/, ""); - return { - terminalOutcome: "blocked", - terminalSummary: path ? `Permission denied for ${path}.` : "Permission denied.", - }; - } - if ( - /\bneed a writable session\b/i.test(normalized) || - /\bfilesystem authorization\b/i.test(normalized) || - /`?apply_patch`?/i.test(normalized) - ) { - return { - terminalOutcome: "blocked", - terminalSummary: "Writable session or apply_patch authorization required.", - }; - } - return {}; -} - -type BackgroundTaskContext = { - requesterSessionKey: string; - requesterOrigin?: DeliveryContext; - childSessionKey: string; - runId: string; - label?: string; - task: string; -}; export class AcpSessionManager { private readonly actorQueue = new SessionActorQueue(); @@ -745,7 +672,8 @@ export class AcpSessionManager { const actorKey = normalizeActorKey(sessionKey); const taskContext = input.mode === "prompt" - ? this.resolveBackgroundTaskContext({ + ? resolveBackgroundTaskContext({ + deps: this.deps, cfg: input.cfg, sessionKey, requestId: input.requestId, @@ -753,7 +681,7 @@ export class AcpSessionManager { }) : null; if (taskContext) { - this.createBackgroundTaskRecord(taskContext, turnStartedAt); + createBackgroundTaskRecord(taskContext, turnStartedAt); } let taskProgressSummary = ""; const initialResolution = this.resolveSession({ @@ -805,7 +733,7 @@ export class AcpSessionManager { errorCode: errorToRecord.code, }); if (taskContext) { - this.markBackgroundTaskTerminal(taskContext.runId, { + markBackgroundTaskTerminal(taskContext.runId, { sessionKey, status: resolveBackgroundTaskFailureStatus(errorToRecord), endedAt: Date.now(), @@ -942,7 +870,7 @@ export class AcpSessionManager { ); } if (taskContext) { - this.markBackgroundTaskRunning(taskContext.runId, { + markBackgroundTaskRunning(taskContext.runId, { sessionKey, lastEventAt: Date.now(), progressSummary: taskProgressSummary || null, @@ -951,12 +879,12 @@ export class AcpSessionManager { }, onEvent: input.onEvent, }); - const turnTimeoutMs = this.resolveTurnTimeoutMs({ + const turnTimeoutMs = resolveTurnTimeoutMs({ cfg: input.cfg, meta, }); const sessionMode = meta.mode; - const turnOutcome = await this.awaitTurnWithTimeout({ + const turnOutcome = await awaitTurnWithTimeout({ sessionKey, turnPromise, timeoutMs: turnTimeoutMs + ACP_TURN_TIMEOUT_GRACE_MS, @@ -967,10 +895,16 @@ export class AcpSessionManager { if (!activeTurn) { return; } - await this.cleanupTimedOutTurn({ + await cleanupTimedOutTurn({ sessionKey, activeTurn, mode: sessionMode, + clearCachedRuntimeStateIfHandleMatches: (turn) => { + this.clearCachedRuntimeStateIfHandleMatches({ + sessionKey, + handle: turn.handle, + }); + }, }); }, }); @@ -985,7 +919,7 @@ export class AcpSessionManager { }); if (taskContext) { const terminalResult = resolveBackgroundTaskTerminalResult(taskProgressSummary); - this.markBackgroundTaskTerminal(taskContext.runId, { + markBackgroundTaskTerminal(taskContext.runId, { sessionKey, status: "succeeded", endedAt: Date.now(), @@ -1092,200 +1026,6 @@ export class AcpSessionManager { ); } - private resolveTurnTimeoutMs(params: { cfg: OpenClawConfig; meta: SessionAcpMeta }): number { - const runtimeTimeoutSeconds = resolveRuntimeOptionsFromMeta(params.meta).timeoutSeconds; - if ( - typeof runtimeTimeoutSeconds === "number" && - Number.isFinite(runtimeTimeoutSeconds) && - runtimeTimeoutSeconds > 0 - ) { - return clampTimerTimeoutMs(Math.round(runtimeTimeoutSeconds * 1_000), 1_000) ?? 1_000; - } - return resolveAgentTimeoutMs({ - cfg: params.cfg, - minMs: 1_000, - }); - } - - private async awaitTurnWithTimeout(params: { - sessionKey: string; - turnPromise: Promise; - timeoutMs: number; - timeoutLabelMs: number; - onTimeout: () => Promise; - }): Promise { - const observedTurnPromise: Promise< - | { - kind: "value"; - value: T; - } - | { - kind: "error"; - error: unknown; - } - > = params.turnPromise.then( - (value) => ({ - kind: "value" as const, - value, - }), - (error) => ({ - kind: "error" as const, - error, - }), - ); - - if (params.timeoutMs <= 0) { - const outcome = await observedTurnPromise; - if (outcome.kind === "error") { - throw outcome.error; - } - return outcome.value; - } - - const timeoutMs = clampTimerTimeoutMs(params.timeoutMs, 1); - if (timeoutMs === undefined) { - const outcome = await observedTurnPromise; - if (outcome.kind === "error") { - throw outcome.error; - } - return outcome.value; - } - - const timeoutToken = Symbol("acp-turn-timeout"); - let timer: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((resolve) => { - timer = setTimeout(() => resolve(timeoutToken), timeoutMs); - timer.unref?.(); - }); - - try { - const outcome = await Promise.race([observedTurnPromise, timeoutPromise]); - if (outcome === timeoutToken) { - void observedTurnPromise.then((lateOutcome) => { - if (lateOutcome.kind === "error") { - logVerbose( - `acp-manager: detached late turn error after timeout for ${params.sessionKey}: ${String(lateOutcome.error)}`, - ); - } - }); - await params.onTimeout(); - throw new AcpRuntimeError( - "ACP_TURN_FAILED", - `ACP turn timed out after ${Math.max(1, Math.round(params.timeoutLabelMs / 1_000))}s.`, - ); - } - if (outcome.kind === "error") { - throw outcome.error; - } - return outcome.value; - } finally { - if (timer) { - clearTimeout(timer); - } - } - } - - private async cleanupTimedOutTurn(params: { - sessionKey: string; - activeTurn: ActiveTurnState; - mode: AcpRuntimeSessionMode; - }): Promise { - params.activeTurn.abortController.abort(); - if (!params.activeTurn.cancelPromise) { - params.activeTurn.cancelPromise = params.activeTurn.runtime.cancel({ - handle: params.activeTurn.handle, - reason: ACP_TURN_TIMEOUT_REASON, - }); - } - const cancelFinished = await this.awaitCleanupWithGrace({ - sessionKey: params.sessionKey, - label: "cancel", - promise: params.activeTurn.cancelPromise, - }); - if (params.mode !== "oneshot") { - return; - } - const closePromise = params.activeTurn.runtime.close({ - handle: params.activeTurn.handle, - reason: ACP_TURN_TIMEOUT_REASON, - }); - const closeFinished = await this.awaitCleanupWithGrace({ - sessionKey: params.sessionKey, - label: "close", - promise: closePromise, - }); - if (cancelFinished && closeFinished) { - this.clearCachedRuntimeStateIfHandleMatches({ - sessionKey: params.sessionKey, - handle: params.activeTurn.handle, - }); - return; - } - void Promise.allSettled([params.activeTurn.cancelPromise, closePromise]).then(() => { - this.clearCachedRuntimeStateIfHandleMatches({ - sessionKey: params.sessionKey, - handle: params.activeTurn.handle, - }); - }); - } - - private async awaitCleanupWithGrace(params: { - sessionKey: string; - label: "cancel" | "close"; - promise: Promise; - }): Promise { - const observedCleanupPromise: Promise< - | { - kind: "done"; - } - | { - kind: "error"; - error: unknown; - } - > = params.promise.then( - () => ({ - kind: "done" as const, - }), - (error) => ({ - kind: "error" as const, - error, - }), - ); - const timeoutToken = Symbol(`acp-timeout-${params.label}`); - let timer: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((resolve) => { - timer = setTimeout(() => resolve(timeoutToken), ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS); - timer.unref?.(); - }); - - try { - const outcome = await Promise.race([observedCleanupPromise, timeoutPromise]); - if (outcome === timeoutToken) { - void observedCleanupPromise.then((lateOutcome) => { - if (lateOutcome.kind === "error") { - logVerbose( - `acp-manager: detached timed-out turn ${params.label} cleanup failed for ${params.sessionKey}: ${String(lateOutcome.error)}`, - ); - } - }); - logVerbose( - `acp-manager: timed-out turn ${params.label} cleanup exceeded ${ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS}ms for ${params.sessionKey}`, - ); - return false; - } - if (outcome.kind === "error") { - logVerbose( - `acp-manager: timed-out turn ${params.label} cleanup failed for ${params.sessionKey}: ${String(outcome.error)}`, - ); - } - return true; - } finally { - if (timer) { - clearTimeout(timer); - } - } - } - async cancelSession(params: { cfg: OpenClawConfig; sessionKey: string; @@ -2277,118 +2017,4 @@ export class AcpSessionManager { normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId) ?? ""; return actualAcpxRecordId === expectedAcpxRecordId; } - - private resolveBackgroundTaskContext(params: { - cfg: OpenClawConfig; - sessionKey: string; - requestId: string; - text: string; - }): BackgroundTaskContext | null { - const childEntry = this.deps.readSessionEntry({ - cfg: params.cfg, - sessionKey: params.sessionKey, - })?.entry; - const requesterSessionKey = - normalizeText(childEntry?.spawnedBy) ?? normalizeText(childEntry?.parentSessionKey); - if (!requesterSessionKey) { - return null; - } - const parentEntry = this.deps.readSessionEntry({ - cfg: params.cfg, - sessionKey: requesterSessionKey, - })?.entry; - return { - requesterSessionKey, - requesterOrigin: parentEntry?.deliveryContext ?? childEntry?.deliveryContext, - childSessionKey: params.sessionKey, - runId: params.requestId, - label: normalizeText(childEntry?.label), - task: summarizeBackgroundTaskText(params.text), - }; - } - - private createBackgroundTaskRecord(context: BackgroundTaskContext, startedAt: number): void { - try { - createRunningTaskRun({ - runtime: "acp", - sourceId: context.runId, - ownerKey: context.requesterSessionKey, - scopeKind: "session", - requesterOrigin: context.requesterOrigin, - childSessionKey: context.childSessionKey, - runId: context.runId, - label: context.label, - task: context.task, - startedAt, - }); - } catch (error) { - logVerbose( - `acp-manager: failed creating background task for ${context.runId}: ${String(error)}`, - ); - } - } - - private markBackgroundTaskRunning( - runId: string, - params: { - sessionKey?: string; - lastEventAt?: number; - progressSummary?: string | null; - }, - ): void { - try { - startTaskRunByRunId({ - runId, - runtime: "acp", - sessionKey: params.sessionKey, - lastEventAt: params.lastEventAt, - progressSummary: params.progressSummary, - }); - } catch (error) { - logVerbose(`acp-manager: failed updating background task for ${runId}: ${String(error)}`); - } - } - - private markBackgroundTaskTerminal( - runId: string, - params: { - sessionKey?: string; - status: "succeeded" | "failed" | "timed_out"; - endedAt: number; - lastEventAt?: number; - error?: string; - progressSummary?: string | null; - terminalSummary?: string | null; - terminalOutcome?: "succeeded" | "blocked" | null; - }, - ): void { - try { - if (params.status === "succeeded") { - completeTaskRunByRunId({ - runId, - runtime: "acp", - sessionKey: params.sessionKey, - endedAt: params.endedAt, - lastEventAt: params.lastEventAt, - progressSummary: params.progressSummary, - terminalSummary: params.terminalSummary, - terminalOutcome: params.terminalOutcome, - }); - return; - } - failTaskRunByRunId({ - runId, - runtime: "acp", - sessionKey: params.sessionKey, - status: params.status, - endedAt: params.endedAt, - lastEventAt: params.lastEventAt, - error: params.error, - progressSummary: params.progressSummary, - terminalSummary: params.terminalSummary, - }); - } catch (error) { - logVerbose(`acp-manager: failed updating background task for ${runId}: ${String(error)}`); - } - } } diff --git a/src/acp/control-plane/manager.turn-timeout.ts b/src/acp/control-plane/manager.turn-timeout.ts new file mode 100644 index 000000000000..30f452ca6662 --- /dev/null +++ b/src/acp/control-plane/manager.turn-timeout.ts @@ -0,0 +1,203 @@ +import type { AcpRuntimeSessionMode } from "@openclaw/acp-core/runtime/types"; +import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { logVerbose } from "../../globals.js"; +import { AcpRuntimeError } from "../runtime/errors.js"; +import type { ActiveTurnState, SessionAcpMeta } from "./manager.types.js"; +import { resolveRuntimeOptionsFromMeta } from "./runtime-options.js"; + +const ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS = 2_000; +const ACP_TURN_TIMEOUT_REASON = "turn-timeout"; + +export function resolveTurnTimeoutMs(params: { + cfg: OpenClawConfig; + meta: SessionAcpMeta; +}): number { + const runtimeTimeoutSeconds = resolveRuntimeOptionsFromMeta(params.meta).timeoutSeconds; + if ( + typeof runtimeTimeoutSeconds === "number" && + Number.isFinite(runtimeTimeoutSeconds) && + runtimeTimeoutSeconds > 0 + ) { + return clampTimerTimeoutMs(Math.round(runtimeTimeoutSeconds * 1_000), 1_000) ?? 1_000; + } + return resolveAgentTimeoutMs({ + cfg: params.cfg, + minMs: 1_000, + }); +} + +export async function awaitTurnWithTimeout(params: { + sessionKey: string; + turnPromise: Promise; + timeoutMs: number; + timeoutLabelMs: number; + onTimeout: () => Promise; +}): Promise { + const observedTurnPromise: Promise< + | { + kind: "value"; + value: T; + } + | { + kind: "error"; + error: unknown; + } + > = params.turnPromise.then( + (value) => ({ + kind: "value" as const, + value, + }), + (error) => ({ + kind: "error" as const, + error, + }), + ); + + if (params.timeoutMs <= 0) { + const outcome = await observedTurnPromise; + if (outcome.kind === "error") { + throw outcome.error; + } + return outcome.value; + } + + const timeoutMs = clampTimerTimeoutMs(params.timeoutMs, 1); + if (timeoutMs === undefined) { + const outcome = await observedTurnPromise; + if (outcome.kind === "error") { + throw outcome.error; + } + return outcome.value; + } + + const timeoutToken = Symbol("acp-turn-timeout"); + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutToken), timeoutMs); + timer.unref?.(); + }); + + try { + const outcome = await Promise.race([observedTurnPromise, timeoutPromise]); + if (outcome === timeoutToken) { + void observedTurnPromise.then((lateOutcome) => { + if (lateOutcome.kind === "error") { + logVerbose( + `acp-manager: detached late turn error after timeout for ${params.sessionKey}: ${String(lateOutcome.error)}`, + ); + } + }); + await params.onTimeout(); + throw new AcpRuntimeError( + "ACP_TURN_FAILED", + `ACP turn timed out after ${Math.max(1, Math.round(params.timeoutLabelMs / 1_000))}s.`, + ); + } + if (outcome.kind === "error") { + throw outcome.error; + } + return outcome.value; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +export async function cleanupTimedOutTurn(params: { + sessionKey: string; + activeTurn: ActiveTurnState; + mode: AcpRuntimeSessionMode; + clearCachedRuntimeStateIfHandleMatches: (activeTurn: ActiveTurnState) => void; +}): Promise { + params.activeTurn.abortController.abort(); + if (!params.activeTurn.cancelPromise) { + params.activeTurn.cancelPromise = params.activeTurn.runtime.cancel({ + handle: params.activeTurn.handle, + reason: ACP_TURN_TIMEOUT_REASON, + }); + } + const cancelFinished = await awaitCleanupWithGrace({ + sessionKey: params.sessionKey, + label: "cancel", + promise: params.activeTurn.cancelPromise, + }); + if (params.mode !== "oneshot") { + return; + } + const closePromise = params.activeTurn.runtime.close({ + handle: params.activeTurn.handle, + reason: ACP_TURN_TIMEOUT_REASON, + }); + const closeFinished = await awaitCleanupWithGrace({ + sessionKey: params.sessionKey, + label: "close", + promise: closePromise, + }); + if (cancelFinished && closeFinished) { + params.clearCachedRuntimeStateIfHandleMatches(params.activeTurn); + return; + } + void Promise.allSettled([params.activeTurn.cancelPromise, closePromise]).then(() => { + params.clearCachedRuntimeStateIfHandleMatches(params.activeTurn); + }); +} + +async function awaitCleanupWithGrace(params: { + sessionKey: string; + label: "cancel" | "close"; + promise: Promise; +}): Promise { + const observedCleanupPromise: Promise< + | { + kind: "done"; + } + | { + kind: "error"; + error: unknown; + } + > = params.promise.then( + () => ({ + kind: "done" as const, + }), + (error) => ({ + kind: "error" as const, + error, + }), + ); + const timeoutToken = Symbol(`acp-timeout-${params.label}`); + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutToken), ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS); + timer.unref?.(); + }); + + try { + const outcome = await Promise.race([observedCleanupPromise, timeoutPromise]); + if (outcome === timeoutToken) { + void observedCleanupPromise.then((lateOutcome) => { + if (lateOutcome.kind === "error") { + logVerbose( + `acp-manager: detached timed-out turn ${params.label} cleanup failed for ${params.sessionKey}: ${String(lateOutcome.error)}`, + ); + } + }); + logVerbose( + `acp-manager: timed-out turn ${params.label} cleanup exceeded ${ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS}ms for ${params.sessionKey}`, + ); + return false; + } + if (outcome.kind === "error") { + logVerbose( + `acp-manager: timed-out turn ${params.label} cleanup failed for ${params.sessionKey}: ${String(outcome.error)}`, + ); + } + return true; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/src/acp/control-plane/manager.utils.ts b/src/acp/control-plane/manager.utils.ts index b51d064b344e..bc6268e0ddf9 100644 --- a/src/acp/control-plane/manager.utils.ts +++ b/src/acp/control-plane/manager.utils.ts @@ -1,3 +1,4 @@ +import { ACP_ERROR_CODES, AcpRuntimeError } from "@openclaw/acp-core/runtime/errors"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { canonicalizeMainSessionAlias, @@ -10,7 +11,6 @@ import { normalizeMainKey, parseAgentSessionKey, } from "../../routing/session-key.js"; -import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js"; import type { AcpSessionResolution } from "./manager.types.js"; export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string { diff --git a/src/acp/policy.ts b/src/acp/policy.ts index 21d8b1dc1cf5..af1a24583f53 100644 --- a/src/acp/policy.ts +++ b/src/acp/policy.ts @@ -1,6 +1,6 @@ +import { AcpRuntimeError } from "@openclaw/acp-core/runtime/errors"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import { AcpRuntimeError } from "./runtime/errors.js"; const ACP_DISABLED_MESSAGE = "ACP is disabled by policy (`acp.enabled=false`)."; const ACP_DISPATCH_DISABLED_MESSAGE = diff --git a/src/acp/translator.presentation.ts b/src/acp/translator.presentation.ts new file mode 100644 index 000000000000..14631ff82367 --- /dev/null +++ b/src/acp/translator.presentation.ts @@ -0,0 +1,266 @@ +import type { + InitializeRequest, + SessionConfigOption, + SessionModeState, +} from "@agentclientprotocol/sdk"; +import { + toAcpSessionLineageMeta, + type AcpSessionLineageMeta, +} from "@openclaw/acp-core/session-lineage-meta"; +import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; +import type { GatewaySessionRow } from "../gateway/session-utils.js"; + +export const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; +export const ACP_FAST_MODE_CONFIG_ID = "fast_mode"; +export const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; +export const ACP_TRACE_LEVEL_CONFIG_ID = "trace_level"; +export const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; +export const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; +export const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; +export const ACP_TIMEOUT_CONFIG_ID = "timeout"; +export const ACP_TIMEOUT_SECONDS_CONFIG_ID = "timeout_seconds"; + +export type ClientCapabilityState = { + readTextFile: boolean; + writeTextFile: boolean; + terminal: boolean; +}; + +export type GatewaySessionPresentationRow = Pick< + GatewaySessionRow, + | "key" + | "kind" + | "channel" + | "parentSessionKey" + | "spawnedBy" + | "spawnDepth" + | "subagentRole" + | "subagentControlScope" + | "spawnedWorkspaceDir" + | "spawnedCwd" + | "displayName" + | "label" + | "derivedTitle" + | "updatedAt" + | "thinkingLevel" + | "fastMode" + | "modelProvider" + | "model" + | "thinkingLevels" + | "verboseLevel" + | "traceLevel" + | "reasoningLevel" + | "responseUsage" + | "elevatedLevel" + | "totalTokens" + | "totalTokensFresh" + | "contextTokens" +>; + +export type SessionPresentation = { + configOptions: SessionConfigOption[]; + modes: SessionModeState; +}; + +export type SessionMetadata = { + title?: string | null; + updatedAt?: string | null; + _meta?: AcpSessionLineageMeta; +}; + +export type SessionUsageSnapshot = { + size: number; + used: number; +}; + +export type SessionSnapshot = SessionPresentation & { + metadata?: SessionMetadata; + usage?: SessionUsageSnapshot; +}; + +export function normalizeClientCapabilities( + capabilities: InitializeRequest["clientCapabilities"] | undefined, +): ClientCapabilityState { + return { + readTextFile: capabilities?.fs?.readTextFile === true, + writeTextFile: capabilities?.fs?.writeTextFile === true, + terminal: capabilities?.terminal === true, + }; +} + +function formatThinkingLevelName(level: string): string { + switch (level) { + case "xhigh": + return "Extra High"; + case "adaptive": + return "Adaptive"; + default: + return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown"; + } +} + +function buildThinkingModeDescription(level: string): string | undefined { + if (level === "adaptive") { + return "Use the Gateway session default thought level."; + } + return undefined; +} + +function formatConfigValueName(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + default: + return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown"; + } +} + +function buildSelectConfigOption(params: { + id: string; + name: string; + description: string; + currentValue: string; + values: readonly string[]; + category?: string; +}): SessionConfigOption { + return { + type: "select", + id: params.id, + name: params.name, + category: params.category, + description: params.description, + currentValue: params.currentValue, + options: params.values.map((value) => ({ + value, + name: formatConfigValueName(value), + })), + }; +} + +export function buildSessionPresentation(params: { + row?: GatewaySessionPresentationRow; + overrides?: Partial; +}): SessionPresentation { + const row = { + ...params.row, + ...params.overrides, + }; + const availableLevelIds: string[] = row.thinkingLevels?.map((level) => level.id) ?? [ + ...BASE_THINKING_LEVELS, + ]; + const currentModeId = normalizeOptionalString(row.thinkingLevel) || "adaptive"; + if (!availableLevelIds.includes(currentModeId)) { + availableLevelIds.push(currentModeId); + } + + const modes: SessionModeState = { + currentModeId, + availableModes: availableLevelIds.map((level) => ({ + id: level, + name: formatThinkingLevelName(level), + description: buildThinkingModeDescription(level), + })), + }; + + const configOptions: SessionConfigOption[] = [ + buildSelectConfigOption({ + id: ACP_THOUGHT_LEVEL_CONFIG_ID, + name: "Thought level", + category: "thought_level", + description: + "Controls how much deliberate reasoning OpenClaw requests from the Gateway model.", + currentValue: currentModeId, + values: availableLevelIds, + }), + buildSelectConfigOption({ + id: ACP_FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: "Controls whether OpenAI sessions use the Gateway fast-mode profile.", + currentValue: row.fastMode ? "on" : "off", + values: ["off", "on"], + }), + buildSelectConfigOption({ + id: ACP_VERBOSE_LEVEL_CONFIG_ID, + name: "Tool verbosity", + description: + "Controls how much tool progress and output detail OpenClaw keeps enabled for the session.", + currentValue: normalizeOptionalString(row.verboseLevel) || "off", + values: ["off", "on", "full"], + }), + buildSelectConfigOption({ + id: ACP_TRACE_LEVEL_CONFIG_ID, + name: "Plugin trace", + description: "Controls whether plugin-owned trace lines are shown for the session.", + currentValue: normalizeOptionalString(row.traceLevel) || "off", + values: ["off", "on"], + }), + buildSelectConfigOption({ + id: ACP_REASONING_LEVEL_CONFIG_ID, + name: "Reasoning stream", + description: "Controls whether reasoning-capable models emit reasoning text for the session.", + currentValue: normalizeOptionalString(row.reasoningLevel) || "off", + values: ["off", "on", "stream"], + }), + buildSelectConfigOption({ + id: ACP_RESPONSE_USAGE_CONFIG_ID, + name: "Usage detail", + description: + "Controls how much usage information OpenClaw attaches to responses for the session.", + currentValue: normalizeOptionalString(row.responseUsage) || "off", + values: ["off", "tokens", "full"], + }), + buildSelectConfigOption({ + id: ACP_ELEVATED_LEVEL_CONFIG_ID, + name: "Elevated actions", + description: "Controls how aggressively the session allows elevated execution behavior.", + currentValue: normalizeOptionalString(row.elevatedLevel) || "off", + values: ["off", "on", "ask", "full"], + }), + ]; + + return { configOptions, modes }; +} + +export function buildSessionMetadata(params: { + row?: GatewaySessionPresentationRow; + sessionKey: string; +}): SessionMetadata { + const title = + normalizeOptionalString(params.row?.derivedTitle) || + normalizeOptionalString(params.row?.displayName) || + normalizeOptionalString(params.row?.label) || + params.sessionKey; + const updatedAt = timestampMsToIsoString(params.row?.updatedAt) ?? null; + return { + title, + updatedAt, + _meta: toAcpSessionLineageMeta( + params.row ?? { + key: params.sessionKey, + kind: "unknown", + }, + ), + }; +} + +export function buildSessionUsageSnapshot( + row?: GatewaySessionPresentationRow, +): SessionUsageSnapshot | undefined { + const totalTokens = row?.totalTokens; + const contextTokens = row?.contextTokens; + if ( + row?.totalTokensFresh !== true || + typeof totalTokens !== "number" || + !Number.isFinite(totalTokens) || + typeof contextTokens !== "number" || + !Number.isFinite(contextTokens) || + contextTokens <= 0 + ) { + return undefined; + } + const size = Math.max(0, Math.floor(contextTokens)); + const used = Math.max(0, Math.min(Math.floor(totalTokens), size)); + return { size, used }; +} diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 3e9249961f03..9fd14aeeb42a 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -21,9 +21,7 @@ import type { PromptResponse, ResumeSessionRequest, ResumeSessionResponse, - SessionConfigOption, SessionInfo, - SessionModeState, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, @@ -35,14 +33,10 @@ import type { } from "@agentclientprotocol/sdk"; import { readBool, readNonNegativeInteger, readNumber, readString } from "@openclaw/acp-core/meta"; import { defaultAcpSessionStore, type AcpSessionStore } from "@openclaw/acp-core/session"; -import { - toAcpSessionLineageMeta, - type AcpSessionLineageMeta, -} from "@openclaw/acp-core/session-lineage-meta"; +import { toAcpSessionLineageMeta } from "@openclaw/acp-core/session-lineage-meta"; import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { EventFrame } from "../../packages/gateway-protocol/src/index.js"; -import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; import type { GatewayClient } from "../gateway/client.js"; import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js"; import { @@ -74,19 +68,28 @@ import { type GatewayExecApprovalEvent, } from "./permission-relay.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; +import { + ACP_ELEVATED_LEVEL_CONFIG_ID, + ACP_FAST_MODE_CONFIG_ID, + ACP_REASONING_LEVEL_CONFIG_ID, + ACP_RESPONSE_USAGE_CONFIG_ID, + ACP_THOUGHT_LEVEL_CONFIG_ID, + ACP_TIMEOUT_CONFIG_ID, + ACP_TIMEOUT_SECONDS_CONFIG_ID, + ACP_TRACE_LEVEL_CONFIG_ID, + ACP_VERBOSE_LEVEL_CONFIG_ID, + buildSessionMetadata, + buildSessionPresentation, + buildSessionUsageSnapshot, + normalizeClientCapabilities, + type ClientCapabilityState, + type GatewaySessionPresentationRow, + type SessionSnapshot, +} from "./translator.presentation.js"; import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; // Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw) const MAX_PROMPT_BYTES = 2 * 1024 * 1024; -const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; -const ACP_FAST_MODE_CONFIG_ID = "fast_mode"; -const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; -const ACP_TRACE_LEVEL_CONFIG_ID = "trace_level"; -const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; -const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; -const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; -const ACP_TIMEOUT_CONFIG_ID = "timeout"; -const ACP_TIMEOUT_SECONDS_CONFIG_ID = "timeout_seconds"; const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000; const ACP_GATEWAY_DISCONNECT_GRACE_MS = 5_000; const ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE = 100; @@ -131,12 +134,6 @@ type PendingPrompt = { toolCalls?: Map; }; -type ClientCapabilityState = { - readTextFile: boolean; - writeTextFile: boolean; - terminal: boolean; -}; - type PendingApprovalRelay = { approvalId: string; runId: string; @@ -157,63 +154,6 @@ type AcpGatewayAgentOptions = AcpServerOptions & { sessionStore?: AcpSessionStore; }; -type GatewaySessionPresentationRow = Pick< - GatewaySessionRow, - | "key" - | "kind" - | "channel" - | "parentSessionKey" - | "spawnedBy" - | "spawnDepth" - | "subagentRole" - | "subagentControlScope" - | "spawnedWorkspaceDir" - | "spawnedCwd" - | "displayName" - | "label" - | "derivedTitle" - | "updatedAt" - | "thinkingLevel" - | "fastMode" - | "modelProvider" - | "model" - | "thinkingLevels" - | "verboseLevel" - | "traceLevel" - | "reasoningLevel" - | "responseUsage" - | "elevatedLevel" - | "totalTokens" - | "totalTokensFresh" - | "contextTokens" ->; - -type SessionPresentation = { - configOptions: SessionConfigOption[]; - modes: SessionModeState; -}; - -type SessionMetadata = { - title?: string | null; - updatedAt?: string | null; - _meta?: AcpSessionLineageMeta; -}; - -type SessionUsageSnapshot = { - size: number; - used: number; -}; - -function normalizeClientCapabilities( - capabilities: InitializeRequest["clientCapabilities"] | undefined, -): ClientCapabilityState { - return { - readTextFile: capabilities?.fs?.readTextFile === true, - writeTextFile: capabilities?.fs?.writeTextFile === true, - terminal: capabilities?.terminal === true, - }; -} - function isAdminScopeProvenanceRejection(err: unknown): boolean { if (!(err instanceof Error)) { return false; @@ -233,11 +173,6 @@ function isGatewayCloseError(err: unknown): boolean { return err instanceof Error && err.message.startsWith("gateway closed ("); } -type SessionSnapshot = SessionPresentation & { - metadata?: SessionMetadata; - usage?: SessionUsageSnapshot; -}; - type AgentWaitResult = { status?: "ok" | "error" | "timeout"; error?: string; @@ -317,139 +252,6 @@ function resolveListSessionsPageSize(meta: Record | null | unde const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; -function formatThinkingLevelName(level: string): string { - switch (level) { - case "xhigh": - return "Extra High"; - case "adaptive": - return "Adaptive"; - default: - return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown"; - } -} - -function buildThinkingModeDescription(level: string): string | undefined { - if (level === "adaptive") { - return "Use the Gateway session default thought level."; - } - return undefined; -} - -function formatConfigValueName(value: string): string { - switch (value) { - case "xhigh": - return "Extra High"; - default: - return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown"; - } -} - -function buildSelectConfigOption(params: { - id: string; - name: string; - description: string; - currentValue: string; - values: readonly string[]; - category?: string; -}): SessionConfigOption { - return { - type: "select", - id: params.id, - name: params.name, - category: params.category, - description: params.description, - currentValue: params.currentValue, - options: params.values.map((value) => ({ - value, - name: formatConfigValueName(value), - })), - }; -} - -function buildSessionPresentation(params: { - row?: GatewaySessionPresentationRow; - overrides?: Partial; -}): SessionPresentation { - const row = { - ...params.row, - ...params.overrides, - }; - const availableLevelIds: string[] = row.thinkingLevels?.map((level) => level.id) ?? [ - ...BASE_THINKING_LEVELS, - ]; - const currentModeId = normalizeOptionalString(row.thinkingLevel) || "adaptive"; - if (!availableLevelIds.includes(currentModeId)) { - availableLevelIds.push(currentModeId); - } - - const modes: SessionModeState = { - currentModeId, - availableModes: availableLevelIds.map((level) => ({ - id: level, - name: formatThinkingLevelName(level), - description: buildThinkingModeDescription(level), - })), - }; - - const configOptions: SessionConfigOption[] = [ - buildSelectConfigOption({ - id: ACP_THOUGHT_LEVEL_CONFIG_ID, - name: "Thought level", - category: "thought_level", - description: - "Controls how much deliberate reasoning OpenClaw requests from the Gateway model.", - currentValue: currentModeId, - values: availableLevelIds, - }), - buildSelectConfigOption({ - id: ACP_FAST_MODE_CONFIG_ID, - name: "Fast mode", - description: "Controls whether OpenAI sessions use the Gateway fast-mode profile.", - currentValue: row.fastMode ? "on" : "off", - values: ["off", "on"], - }), - buildSelectConfigOption({ - id: ACP_VERBOSE_LEVEL_CONFIG_ID, - name: "Tool verbosity", - description: - "Controls how much tool progress and output detail OpenClaw keeps enabled for the session.", - currentValue: normalizeOptionalString(row.verboseLevel) || "off", - values: ["off", "on", "full"], - }), - buildSelectConfigOption({ - id: ACP_TRACE_LEVEL_CONFIG_ID, - name: "Plugin trace", - description: "Controls whether plugin-owned trace lines are shown for the session.", - currentValue: normalizeOptionalString(row.traceLevel) || "off", - values: ["off", "on"], - }), - buildSelectConfigOption({ - id: ACP_REASONING_LEVEL_CONFIG_ID, - name: "Reasoning stream", - description: "Controls whether reasoning-capable models emit reasoning text for the session.", - currentValue: normalizeOptionalString(row.reasoningLevel) || "off", - values: ["off", "on", "stream"], - }), - buildSelectConfigOption({ - id: ACP_RESPONSE_USAGE_CONFIG_ID, - name: "Usage detail", - description: - "Controls how much usage information OpenClaw attaches to responses for the session.", - currentValue: normalizeOptionalString(row.responseUsage) || "off", - values: ["off", "tokens", "full"], - }), - buildSelectConfigOption({ - id: ACP_ELEVATED_LEVEL_CONFIG_ID, - name: "Elevated actions", - description: "Controls how aggressively the session allows elevated execution behavior.", - currentValue: normalizeOptionalString(row.elevatedLevel) || "off", - values: ["off", "on", "ask", "full"], - }), - ]; - - return { configOptions, modes }; -} - function extractReplayChunks(message: GatewayTranscriptMessage): ReplayChunk[] { const role = typeof message.role === "string" ? message.role : ""; if (role !== "user" && role !== "assistant") { @@ -497,48 +299,6 @@ function extractReplayChunks(message: GatewayTranscriptMessage): ReplayChunk[] { return replayChunks; } -function buildSessionMetadata(params: { - row?: GatewaySessionPresentationRow; - sessionKey: string; -}): SessionMetadata { - const title = - normalizeOptionalString(params.row?.derivedTitle) || - normalizeOptionalString(params.row?.displayName) || - normalizeOptionalString(params.row?.label) || - params.sessionKey; - const updatedAt = timestampMsToIsoString(params.row?.updatedAt) ?? null; - return { - title, - updatedAt, - _meta: toAcpSessionLineageMeta( - params.row ?? { - key: params.sessionKey, - kind: "unknown", - }, - ), - }; -} - -function buildSessionUsageSnapshot( - row?: GatewaySessionPresentationRow, -): SessionUsageSnapshot | undefined { - const totalTokens = row?.totalTokens; - const contextTokens = row?.contextTokens; - if ( - row?.totalTokensFresh !== true || - typeof totalTokens !== "number" || - !Number.isFinite(totalTokens) || - typeof contextTokens !== "number" || - !Number.isFinite(contextTokens) || - contextTokens <= 0 - ) { - return undefined; - } - const size = Math.max(0, Math.floor(contextTokens)); - const used = Math.max(0, Math.min(Math.floor(totalTokens), size)); - return { size, used }; -} - function buildSystemInputProvenance(originSessionId: string) { return { kind: "external_user" as const, diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 9aa9391b1505..cfc76a26bc6a 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import Module from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { buildPluginLoaderAliasMap, type PluginSdkResolutionPreference } from "./sdk-alias.js"; +import { + buildPluginLoaderAliasMap, + listWorkspacePackageExportAliasEntries, + type PluginSdkResolutionPreference, +} from "./sdk-alias.js"; type ResolveFilename = ( request: string, @@ -74,26 +78,6 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [ ["read-response-with-limit", "read-response-with-limit.ts"], ], }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpaths: [ - ["", "index.ts"], - ["meta", "meta.ts"], - ["normalize-text", "normalize-text.ts"], - ["numeric-options", "numeric-options.ts"], - ["record-shared", "record-shared.ts"], - ["session", "session.ts"], - ["session-interaction-mode", "session-interaction-mode.ts"], - ["session-lineage-meta", "session-lineage-meta.ts"], - ["types", "types.ts"], - ["runtime/error-text", path.join("runtime", "error-text.ts")], - ["runtime/errors", path.join("runtime", "errors.ts")], - ["runtime/session-identifiers", path.join("runtime", "session-identifiers.ts")], - ["runtime/session-identity", path.join("runtime", "session-identity.ts")], - ["runtime/types", path.join("runtime", "types.ts")], - ], - }, { packageName: "@openclaw/llm-core", packageDir: "llm-core", @@ -306,7 +290,19 @@ function listInternalCorePackageNativeAliases( target: string; parentRoots: string[]; }> = []; - for (const entry of INTERNAL_CORE_PACKAGE_ALIASES) { + const internalCorePackageAliases = [ + ...INTERNAL_CORE_PACKAGE_ALIASES, + { + packageName: "@openclaw/acp-core", + packageDir: "acp-core", + subpaths: listWorkspacePackageExportAliasEntries({ + packageRoot, + packageName: "@openclaw/acp-core", + packageDir: "acp-core", + }).map((entry) => [entry.subpath, entry.srcFile] as const), + }, + ]; + for (const entry of internalCorePackageAliases) { for (const [subpath, srcFile] of entry.subpaths) { const request = subpath ? `${entry.packageName}/${subpath}` : entry.packageName; const target = path.join(packageRoot, "packages", entry.packageDir, "src", srcFile); diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 5169fe540731..94555d9435db 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -1752,6 +1752,28 @@ describe("plugin sdk alias helpers", () => { ).toBe(fs.realpathSync(modelCatalogCore.distFile)); }); + it("derives acp-core aliases from packaged root dist when package metadata is absent", () => { + const fixture = createPluginSdkAliasFixture(); + const sourcePluginEntry = writePluginEntry( + fixture.root, + bundledPluginFile("demo", "src/index.ts"), + ); + const acpRuntimeErrors = path.join(fixture.root, "dist", "acp-core", "runtime", "errors.js"); + mkdirSafeDir(path.dirname(acpRuntimeErrors)); + fs.writeFileSync(acpRuntimeErrors, "export {};\n", "utf-8"); + const cwdWithoutOpenClawPackage = makeTempDir(); + + const aliases = withCwd(cwdWithoutOpenClawPackage, () => + withEnv({ NODE_ENV: undefined }, () => + buildPluginLoaderAliasMap(sourcePluginEntry, undefined, undefined, "dist"), + ), + ); + + expect(fs.realpathSync(aliases["@openclaw/acp-core/runtime/errors"] ?? "")).toBe( + fs.realpathSync(acpRuntimeErrors), + ); + }); + it("aliases bundled plugin package public surfaces for source plugin transforms", () => { const { fixture, sourceApiPath, sourceRuntimeApiPath } = createBundledPluginPackagePublicSurfaceAliasFixture(); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 5806e6cf518a..292f112c2c98 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -34,6 +34,14 @@ type PluginSdkPackageJson = { version?: string; }; +type WorkspacePackageAliasEntry = { + packageName: string; + packageDir: string; + subpath: string; + srcFile: string; + distFile: string; +}; + const STARTUP_ARGV1 = process.argv[1]; const pluginSdkPackageJsonByRoot = new Map(); @@ -508,7 +516,7 @@ const JS_STATIC_RELATIVE_DEPENDENCY_PATTERN = // Jiti-loaded plugin code runs outside the Vitest/tsgo resolver, so every // workspace package import reachable from plugin SDK barrels needs an explicit // source/dist alias here to keep source checkouts and packaged builds aligned. -const WORKSPACE_PACKAGE_ALIAS_ENTRIES = [ +const WORKSPACE_PACKAGE_ALIAS_ENTRIES: WorkspacePackageAliasEntry[] = [ { packageName: "@openclaw/gateway-client", packageDir: "gateway-client", @@ -747,104 +755,6 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES = [ srcFile: "read-response-with-limit.ts", distFile: "read-response-with-limit.mjs", }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "", - srcFile: "index.ts", - distFile: "index.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "meta", - srcFile: "meta.ts", - distFile: "meta.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "normalize-text", - srcFile: "normalize-text.ts", - distFile: "normalize-text.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "numeric-options", - srcFile: "numeric-options.ts", - distFile: "numeric-options.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "record-shared", - srcFile: "record-shared.ts", - distFile: "record-shared.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "session", - srcFile: "session.ts", - distFile: "session.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "session-interaction-mode", - srcFile: "session-interaction-mode.ts", - distFile: "session-interaction-mode.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "session-lineage-meta", - srcFile: "session-lineage-meta.ts", - distFile: "session-lineage-meta.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "types", - srcFile: "types.ts", - distFile: "types.mjs", - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "runtime/error-text", - srcFile: path.join("runtime", "error-text.ts"), - distFile: path.join("runtime", "error-text.mjs"), - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "runtime/errors", - srcFile: path.join("runtime", "errors.ts"), - distFile: path.join("runtime", "errors.mjs"), - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "runtime/session-identifiers", - srcFile: path.join("runtime", "session-identifiers.ts"), - distFile: path.join("runtime", "session-identifiers.mjs"), - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "runtime/session-identity", - srcFile: path.join("runtime", "session-identity.ts"), - distFile: path.join("runtime", "session-identity.mjs"), - }, - { - packageName: "@openclaw/acp-core", - packageDir: "acp-core", - subpath: "runtime/types", - srcFile: path.join("runtime", "types.ts"), - distFile: path.join("runtime", "types.mjs"), - }, { packageName: "@openclaw/normalization-core", packageDir: "normalization-core", @@ -1105,6 +1015,117 @@ const ROOT_PACKAGED_WORKSPACE_PACKAGE_DIRS = new Set([ "terminal-core", ]); +function normalizePackageExportSubpath(exportKey: string): string | null { + if (exportKey === ".") { + return ""; + } + if (!exportKey.startsWith("./")) { + return null; + } + const subpath = exportKey.slice(2); + return subpath && !subpath.includes("..") ? subpath : null; +} + +function resolvePackageExportImportPath(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + return typeof record.import === "string" + ? record.import + : typeof record.default === "string" + ? record.default + : null; +} + +function listRootPackagedWorkspacePackageAliasEntries(params: { + packageRoot: string; + packageName: string; + packageDir: string; +}): WorkspacePackageAliasEntry[] { + const distRoot = path.join(params.packageRoot, "dist", params.packageDir); + if (!fs.existsSync(distRoot)) { + return []; + } + const entries: WorkspacePackageAliasEntry[] = []; + const visit = (dir: string, prefix = "") => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const relativePath = prefix ? path.join(prefix, entry.name) : entry.name; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath, relativePath); + continue; + } + if (!entry.isFile() || !relativePath.endsWith(".js")) { + continue; + } + const normalizedRelativePath = relativePath.split(path.sep).join("/"); + const subpath = + normalizedRelativePath === "index.js" ? "" : normalizedRelativePath.slice(0, -".js".length); + if (subpath.includes("..")) { + continue; + } + entries.push({ + packageName: params.packageName, + packageDir: params.packageDir, + subpath, + srcFile: `${subpath || "index"}.ts`, + distFile: relativePath, + }); + } + }; + visit(distRoot); + return entries.toSorted((a, b) => a.subpath.localeCompare(b.subpath)); +} + +export function listWorkspacePackageExportAliasEntries(params: { + packageRoot: string; + packageName: string; + packageDir: string; +}): WorkspacePackageAliasEntry[] { + const packageJsonPath = path.join( + params.packageRoot, + "packages", + params.packageDir, + "package.json", + ); + const fallbackPackageRoot = resolveOpenClawPackageRootSync({ cwd: process.cwd() }); + const packageJson = + tryReadJsonSync(packageJsonPath) ?? + (fallbackPackageRoot + ? tryReadJsonSync( + path.join(fallbackPackageRoot, "packages", params.packageDir, "package.json"), + ) + : null); + const exports = packageJson?.exports; + if (!exports || typeof exports !== "object" || Array.isArray(exports)) { + return listRootPackagedWorkspacePackageAliasEntries(params); + } + const entries: WorkspacePackageAliasEntry[] = []; + for (const [exportKey, value] of Object.entries(exports)) { + const subpath = normalizePackageExportSubpath(exportKey); + const importPath = resolvePackageExportImportPath(value); + if (subpath === null || !importPath?.startsWith("./dist/") || !importPath.endsWith(".mjs")) { + continue; + } + const distFile = importPath.slice("./dist/".length); + const srcFile = distFile.replace(/\.mjs$/u, ".ts"); + entries.push({ + packageName: params.packageName, + packageDir: params.packageDir, + subpath, + srcFile, + distFile, + }); + } + return entries.length > 0 + ? entries.toSorted((a, b) => a.subpath.localeCompare(b.subpath)) + : listRootPackagedWorkspacePackageAliasEntries(params); +} + function isUsableDistPluginSdkArtifact(candidate: string): boolean { if (!fs.existsSync(candidate)) { return false; @@ -1308,7 +1329,15 @@ function resolveWorkspacePackageAliasMap(params: { pluginSdkResolution: params.pluginSdkResolution, }); const aliasMap: Record = {}; - for (const entry of WORKSPACE_PACKAGE_ALIAS_ENTRIES) { + const workspacePackageAliasEntries = [ + ...WORKSPACE_PACKAGE_ALIAS_ENTRIES, + ...listWorkspacePackageExportAliasEntries({ + packageRoot, + packageName: "@openclaw/acp-core", + packageDir: "acp-core", + }), + ]; + for (const entry of workspacePackageAliasEntries) { const alias = entry.subpath ? `${entry.packageName}/${entry.subpath}` : entry.packageName; for (const kind of orderedKinds) { const candidates = diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index d16c550f2dee..8cd4c29e202f 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -505,6 +505,10 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { "agentic-control-plane-http-models", "agentic-control-plane-http-plugin-ws", "agentic-control-plane-runtime", + "agentic-control-plane-runtime-cron", + "agentic-control-plane-runtime-network", + "agentic-control-plane-runtime-server", + "agentic-control-plane-runtime-state", "agentic-control-plane-startup-runtime", ]); expect(controlPlaneShards).toEqual( diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 22d500ae5f85..e8e08ea89f36 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" }; import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" }; import { @@ -97,6 +98,14 @@ function sourcePackageAlias(packageId: string, subpath?: string) { }; } +function sourcePackageAliasesFromExports(packageId: string, exports: Record) { + return Object.keys(exports) + .map((exportKey) => (exportKey === "." ? undefined : exportKey.slice(2))) + .filter((subpath) => subpath === undefined || (subpath && !subpath.includes(".."))) + .toSorted((a, b) => (a ?? "").localeCompare(b ?? "")) + .map((subpath) => sourcePackageAlias(packageId, subpath)); +} + export function resolveSharedVitestWorkerConfig(params: { env?: Record; isCI?: boolean; @@ -401,20 +410,7 @@ export const sharedVitestConfig = { sourcePackageAlias("media-core", "read-byte-stream-with-limit"), sourcePackageAlias("media-core", "read-response-with-limit"), sourcePackageAlias("media-core"), - sourcePackageAlias("acp-core", "meta"), - sourcePackageAlias("acp-core", "normalize-text"), - sourcePackageAlias("acp-core", "numeric-options"), - sourcePackageAlias("acp-core", "record-shared"), - sourcePackageAlias("acp-core", "session"), - sourcePackageAlias("acp-core", "session-interaction-mode"), - sourcePackageAlias("acp-core", "session-lineage-meta"), - sourcePackageAlias("acp-core", "types"), - sourcePackageAlias("acp-core", "runtime/error-text"), - sourcePackageAlias("acp-core", "runtime/errors"), - sourcePackageAlias("acp-core", "runtime/session-identifiers"), - sourcePackageAlias("acp-core", "runtime/session-identity"), - sourcePackageAlias("acp-core", "runtime/types"), - sourcePackageAlias("acp-core"), + ...sourcePackageAliasesFromExports("acp-core", acpCorePackageJson.exports), ...sourcePluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`), diff --git a/tsdown.config.ts b/tsdown.config.ts index 07e490e7f689..6df4c1876ccb 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -457,24 +457,35 @@ function buildMediaCoreDistEntries(): Record { }; } -function buildAcpCoreDistEntries(): Record { - return { - "error-format": "packages/acp-core/src/error-format.ts", - index: "packages/acp-core/src/index.ts", - meta: "packages/acp-core/src/meta.ts", - "normalize-text": "packages/acp-core/src/normalize-text.ts", - "numeric-options": "packages/acp-core/src/numeric-options.ts", - "record-shared": "packages/acp-core/src/record-shared.ts", - session: "packages/acp-core/src/session.ts", - "session-interaction-mode": "packages/acp-core/src/session-interaction-mode.ts", - "session-lineage-meta": "packages/acp-core/src/session-lineage-meta.ts", - types: "packages/acp-core/src/types.ts", - "runtime/error-text": "packages/acp-core/src/runtime/error-text.ts", - "runtime/errors": "packages/acp-core/src/runtime/errors.ts", - "runtime/session-identifiers": "packages/acp-core/src/runtime/session-identifiers.ts", - "runtime/session-identity": "packages/acp-core/src/runtime/session-identity.ts", - "runtime/types": "packages/acp-core/src/runtime/types.ts", +function buildPackageDistEntriesFromExports(packageDir: string): Record { + const packageJsonPath = path.join("packages", packageDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + exports?: Record; }; + const entries: Record = {}; + for (const [exportKey, value] of Object.entries(packageJson.exports ?? {})) { + const entry = + exportKey === "." ? "index" : exportKey.startsWith("./") ? exportKey.slice(2) : ""; + if (!entry || entry.includes("..")) { + continue; + } + const importPath = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record).import + : value; + if (typeof importPath !== "string" || !importPath.startsWith("./dist/")) { + continue; + } + const sourcePath = importPath + .replace(/^\.\/dist\//u, `packages/${packageDir}/src/`) + .replace(/\.mjs$/u, ".ts"); + entries[entry] = sourcePath; + } + return Object.fromEntries(Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b))); +} + +function buildAcpCoreDistEntries(): Record { + return buildPackageDistEntriesFromExports("acp-core"); } function buildTerminalCoreDistEntries(): Record {