mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 02:21:25 +00:00
feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc * docs: expand ACP implementation specification * feat(acp): route ACP sessions through core dispatch and lifecycle cleanup * feat(acp): add /acp commands and Discord spawn gate * ACP: add acpx runtime plugin backend * fix(subagents): defer transient lifecycle errors before announce * Agents: harden ACP sessions_spawn and tighten spawn guidance * Agents: require explicit ACP target for runtime spawns * docs: expand ACP control-plane implementation plan * ACP: harden metadata seeding and spawn guidance * ACP: centralize runtime control-plane manager and fail-closed dispatch * ACP: harden runtime manager and unify spawn helpers * Commands: route ACP sessions through ACP runtime in agent command * ACP: require persisted metadata for runtime spawns * Sessions: preserve ACP metadata when updating entries * Plugins: harden ACP backend registry across loaders * ACPX: make availability probe compatible with adapters * E2E: add manual Discord ACP plain-language smoke script * ACPX: preserve streamed spacing across Discord delivery * Docs: add ACP Discord streaming strategy * ACP: harden Discord stream buffering for thread replies * ACP: reuse shared block reply pipeline for projector * ACP: unify streaming config and adopt coalesceIdleMs * Docs: add temporary ACP production hardening plan * Docs: trim temporary ACP hardening plan goals * Docs: gate ACP thread controls by backend capabilities * ACP: add capability-gated runtime controls and /acp operator commands * Docs: remove temporary ACP hardening plan * ACP: fix spawn target validation and close cache cleanup * ACP: harden runtime dispatch and recovery paths * ACP: split ACP command/runtime internals and centralize policy * ACP: harden runtime lifecycle, validation, and observability * ACP: surface runtime and backend session IDs in thread bindings * docs: add temp plan for binding-service migration * ACP: migrate thread binding flows to SessionBindingService * ACP: address review feedback and preserve prompt wording * ACPX plugin: pin runtime dependency and prefer bundled CLI * Discord: complete binding-service migration cleanup and restore ACP plan * Docs: add standalone ACP agents guide * ACP: route harness intents to thread-bound ACP sessions * ACP: fix spawn thread routing and queue-owner stall * ACP: harden startup reconciliation and command bypass handling * ACP: fix dispatch bypass type narrowing * ACP: align runtime metadata to agentSessionId * ACP: normalize session identifier handling and labels * ACP: mark thread banner session ids provisional until first reply * ACP: stabilize session identity mapping and startup reconciliation * ACP: add resolved session-id notices and cwd in thread intros * Discord: prefix thread meta notices consistently * Discord: unify ACP/thread meta notices with gear prefix * Discord: split thread persona naming from meta formatting * Extensions: bump acpx plugin dependency to 0.1.9 * Agents: gate ACP prompt guidance behind acp.enabled * Docs: remove temp experiment plan docs * Docs: scope streaming plan to holy grail refactor * Docs: refactor ACP agents guide for human-first flow * Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow * Docs/Skill: add OpenCode and Pi to ACP harness lists * Docs/Skill: align ACP harness list with current acpx registry * Dev/Test: move ACP plain-language smoke script and mark as keep * Docs/Skill: reorder ACP harness lists with Pi first * ACP: split control-plane manager into core/types/utils modules * Docs: refresh ACP thread-bound agents plan * ACP: extract dispatch lane and split manager domains * ACP: centralize binding context and remove reverse deps * Infra: unify system message formatting * ACP: centralize error boundaries and session id rendering * ACP: enforce init concurrency cap and strict meta clear * Tests: fix ACP dispatch binding mock typing * Tests: fix Discord thread-binding mock drift and ACP request id * ACP: gate slash bypass and persist cleared overrides * ACPX: await pre-abort cancel before runTurn return * Extension: pin acpx runtime dependency to 0.1.11 * Docs: add pinned acpx install strategy for ACP extension * Extensions/acpx: enforce strict local pinned startup * Extensions/acpx: tighten acp-router install guidance * ACPX: retry runtime test temp-dir cleanup * Extensions/acpx: require proactive ACPX repair for thread spawns * Extensions/acpx: require restart offer after acpx reinstall * extensions/acpx: remove workspace protocol devDependency * extensions/acpx: bump pinned acpx to 0.1.13 * extensions/acpx: sync lockfile after dependency bump * ACPX: make runtime spawn Windows-safe * fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
1314
src/acp/control-plane/manager.core.ts
Normal file
1314
src/acp/control-plane/manager.core.ts
Normal file
File diff suppressed because it is too large
Load Diff
159
src/acp/control-plane/manager.identity-reconcile.ts
Normal file
159
src/acp/control-plane/manager.identity-reconcile.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
|
||||
import {
|
||||
createIdentityFromStatus,
|
||||
identityEquals,
|
||||
mergeSessionIdentity,
|
||||
resolveRuntimeHandleIdentifiersFromIdentity,
|
||||
resolveSessionIdentityFromMeta,
|
||||
} from "../runtime/session-identity.js";
|
||||
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeStatus } from "../runtime/types.js";
|
||||
import type { SessionAcpMeta, SessionEntry } from "./manager.types.js";
|
||||
import { hasLegacyAcpIdentityProjection } from "./manager.utils.js";
|
||||
|
||||
export async function reconcileManagerRuntimeSessionIdentifiers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
meta: SessionAcpMeta;
|
||||
runtimeStatus?: AcpRuntimeStatus;
|
||||
failOnStatusError: boolean;
|
||||
setCachedHandle: (sessionKey: string, handle: AcpRuntimeHandle) => void;
|
||||
writeSessionMeta: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
mutate: (
|
||||
current: SessionAcpMeta | undefined,
|
||||
entry: SessionEntry | undefined,
|
||||
) => SessionAcpMeta | null | undefined;
|
||||
failOnError?: boolean;
|
||||
}) => Promise<SessionEntry | null>;
|
||||
}): Promise<{
|
||||
handle: AcpRuntimeHandle;
|
||||
meta: SessionAcpMeta;
|
||||
runtimeStatus?: AcpRuntimeStatus;
|
||||
}> {
|
||||
let runtimeStatus = params.runtimeStatus;
|
||||
if (!runtimeStatus && params.runtime.getStatus) {
|
||||
try {
|
||||
runtimeStatus = await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
await params.runtime.getStatus!({
|
||||
handle: params.handle,
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not read ACP runtime status.",
|
||||
});
|
||||
} catch (error) {
|
||||
if (params.failOnStatusError) {
|
||||
throw error;
|
||||
}
|
||||
logVerbose(
|
||||
`acp-manager: failed to refresh ACP runtime status for ${params.sessionKey}: ${String(error)}`,
|
||||
);
|
||||
return {
|
||||
handle: params.handle,
|
||||
meta: params.meta,
|
||||
runtimeStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const currentIdentity = resolveSessionIdentityFromMeta(params.meta);
|
||||
const nextIdentity =
|
||||
mergeSessionIdentity({
|
||||
current: currentIdentity,
|
||||
incoming: createIdentityFromStatus({
|
||||
status: runtimeStatus,
|
||||
now,
|
||||
}),
|
||||
now,
|
||||
}) ?? currentIdentity;
|
||||
const handleIdentifiers = resolveRuntimeHandleIdentifiersFromIdentity(nextIdentity);
|
||||
const handleChanged =
|
||||
handleIdentifiers.backendSessionId !== params.handle.backendSessionId ||
|
||||
handleIdentifiers.agentSessionId !== params.handle.agentSessionId;
|
||||
const nextHandle: AcpRuntimeHandle = handleChanged
|
||||
? {
|
||||
...params.handle,
|
||||
...(handleIdentifiers.backendSessionId
|
||||
? { backendSessionId: handleIdentifiers.backendSessionId }
|
||||
: {}),
|
||||
...(handleIdentifiers.agentSessionId
|
||||
? { agentSessionId: handleIdentifiers.agentSessionId }
|
||||
: {}),
|
||||
}
|
||||
: params.handle;
|
||||
if (handleChanged) {
|
||||
params.setCachedHandle(params.sessionKey, nextHandle);
|
||||
}
|
||||
|
||||
const metaChanged =
|
||||
!identityEquals(currentIdentity, nextIdentity) || hasLegacyAcpIdentityProjection(params.meta);
|
||||
if (!metaChanged) {
|
||||
return {
|
||||
handle: nextHandle,
|
||||
meta: params.meta,
|
||||
runtimeStatus,
|
||||
};
|
||||
}
|
||||
const nextMeta: SessionAcpMeta = {
|
||||
backend: params.meta.backend,
|
||||
agent: params.meta.agent,
|
||||
runtimeSessionName: params.meta.runtimeSessionName,
|
||||
...(nextIdentity ? { identity: nextIdentity } : {}),
|
||||
mode: params.meta.mode,
|
||||
...(params.meta.runtimeOptions ? { runtimeOptions: params.meta.runtimeOptions } : {}),
|
||||
...(params.meta.cwd ? { cwd: params.meta.cwd } : {}),
|
||||
lastActivityAt: now,
|
||||
state: params.meta.state,
|
||||
...(params.meta.lastError ? { lastError: params.meta.lastError } : {}),
|
||||
};
|
||||
if (!identityEquals(currentIdentity, nextIdentity)) {
|
||||
const currentAgentSessionId = currentIdentity?.agentSessionId ?? "<none>";
|
||||
const nextAgentSessionId = nextIdentity?.agentSessionId ?? "<none>";
|
||||
const currentAcpxSessionId = currentIdentity?.acpxSessionId ?? "<none>";
|
||||
const nextAcpxSessionId = nextIdentity?.acpxSessionId ?? "<none>";
|
||||
const currentAcpxRecordId = currentIdentity?.acpxRecordId ?? "<none>";
|
||||
const nextAcpxRecordId = nextIdentity?.acpxRecordId ?? "<none>";
|
||||
logVerbose(
|
||||
`acp-manager: session identity updated for ${params.sessionKey} ` +
|
||||
`(agentSessionId ${currentAgentSessionId} -> ${nextAgentSessionId}, ` +
|
||||
`acpxSessionId ${currentAcpxSessionId} -> ${nextAcpxSessionId}, ` +
|
||||
`acpxRecordId ${currentAcpxRecordId} -> ${nextAcpxRecordId})`,
|
||||
);
|
||||
}
|
||||
await params.writeSessionMeta({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
mutate: (current, entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const base = current ?? entry.acp;
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
backend: base.backend,
|
||||
agent: base.agent,
|
||||
runtimeSessionName: base.runtimeSessionName,
|
||||
...(nextIdentity ? { identity: nextIdentity } : {}),
|
||||
mode: base.mode,
|
||||
...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}),
|
||||
...(base.cwd ? { cwd: base.cwd } : {}),
|
||||
state: base.state,
|
||||
lastActivityAt: now,
|
||||
...(base.lastError ? { lastError: base.lastError } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
handle: nextHandle,
|
||||
meta: nextMeta,
|
||||
runtimeStatus,
|
||||
};
|
||||
}
|
||||
118
src/acp/control-plane/manager.runtime-controls.ts
Normal file
118
src/acp/control-plane/manager.runtime-controls.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
|
||||
import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle } from "../runtime/types.js";
|
||||
import type { SessionAcpMeta } from "./manager.types.js";
|
||||
import { createUnsupportedControlError } from "./manager.utils.js";
|
||||
import type { CachedRuntimeState } from "./runtime-cache.js";
|
||||
import {
|
||||
buildRuntimeConfigOptionPairs,
|
||||
buildRuntimeControlSignature,
|
||||
normalizeText,
|
||||
resolveRuntimeOptionsFromMeta,
|
||||
} from "./runtime-options.js";
|
||||
|
||||
export async function resolveManagerRuntimeCapabilities(params: {
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
}): Promise<AcpRuntimeCapabilities> {
|
||||
let reported: AcpRuntimeCapabilities | undefined;
|
||||
if (params.runtime.getCapabilities) {
|
||||
reported = await withAcpRuntimeErrorBoundary({
|
||||
run: async () => await params.runtime.getCapabilities!({ handle: params.handle }),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not read ACP runtime capabilities.",
|
||||
});
|
||||
}
|
||||
const controls = new Set<AcpRuntimeCapabilities["controls"][number]>(reported?.controls ?? []);
|
||||
if (params.runtime.setMode) {
|
||||
controls.add("session/set_mode");
|
||||
}
|
||||
if (params.runtime.setConfigOption) {
|
||||
controls.add("session/set_config_option");
|
||||
}
|
||||
if (params.runtime.getStatus) {
|
||||
controls.add("session/status");
|
||||
}
|
||||
const normalizedKeys = (reported?.configOptionKeys ?? [])
|
||||
.map((entry) => normalizeText(entry))
|
||||
.filter(Boolean) as string[];
|
||||
return {
|
||||
controls: [...controls].toSorted(),
|
||||
...(normalizedKeys.length > 0 ? { configOptionKeys: normalizedKeys } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyManagerRuntimeControls(params: {
|
||||
sessionKey: string;
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
meta: SessionAcpMeta;
|
||||
getCachedRuntimeState: (sessionKey: string) => CachedRuntimeState | null;
|
||||
}): Promise<void> {
|
||||
const options = resolveRuntimeOptionsFromMeta(params.meta);
|
||||
const signature = buildRuntimeControlSignature(options);
|
||||
const cached = params.getCachedRuntimeState(params.sessionKey);
|
||||
if (cached?.appliedControlSignature === signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilities = await resolveManagerRuntimeCapabilities({
|
||||
runtime: params.runtime,
|
||||
handle: params.handle,
|
||||
});
|
||||
const backend = params.handle.backend || params.meta.backend;
|
||||
const runtimeMode = normalizeText(options.runtimeMode);
|
||||
const configOptions = buildRuntimeConfigOptionPairs(options);
|
||||
const advertisedKeys = new Set(
|
||||
(capabilities.configOptionKeys ?? [])
|
||||
.map((entry) => normalizeText(entry))
|
||||
.filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () => {
|
||||
if (runtimeMode) {
|
||||
if (!capabilities.controls.includes("session/set_mode") || !params.runtime.setMode) {
|
||||
throw createUnsupportedControlError({
|
||||
backend,
|
||||
control: "session/set_mode",
|
||||
});
|
||||
}
|
||||
await params.runtime.setMode({
|
||||
handle: params.handle,
|
||||
mode: runtimeMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (configOptions.length > 0) {
|
||||
if (
|
||||
!capabilities.controls.includes("session/set_config_option") ||
|
||||
!params.runtime.setConfigOption
|
||||
) {
|
||||
throw createUnsupportedControlError({
|
||||
backend,
|
||||
control: "session/set_config_option",
|
||||
});
|
||||
}
|
||||
for (const [key, value] of configOptions) {
|
||||
if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNSUPPORTED_CONTROL",
|
||||
`ACP backend "${backend}" does not accept config key "${key}".`,
|
||||
);
|
||||
}
|
||||
await params.runtime.setConfigOption({
|
||||
handle: params.handle,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not apply ACP runtime options before turn execution.",
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
cached.appliedControlSignature = signature;
|
||||
}
|
||||
}
|
||||
1250
src/acp/control-plane/manager.test.ts
Normal file
1250
src/acp/control-plane/manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
29
src/acp/control-plane/manager.ts
Normal file
29
src/acp/control-plane/manager.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { AcpSessionManager } from "./manager.core.js";
|
||||
|
||||
export { AcpSessionManager } from "./manager.core.js";
|
||||
export type {
|
||||
AcpCloseSessionInput,
|
||||
AcpCloseSessionResult,
|
||||
AcpInitializeSessionInput,
|
||||
AcpManagerObservabilitySnapshot,
|
||||
AcpRunTurnInput,
|
||||
AcpSessionResolution,
|
||||
AcpSessionRuntimeOptions,
|
||||
AcpSessionStatus,
|
||||
AcpStartupIdentityReconcileResult,
|
||||
} from "./manager.types.js";
|
||||
|
||||
let ACP_SESSION_MANAGER_SINGLETON: AcpSessionManager | null = null;
|
||||
|
||||
export function getAcpSessionManager(): AcpSessionManager {
|
||||
if (!ACP_SESSION_MANAGER_SINGLETON) {
|
||||
ACP_SESSION_MANAGER_SINGLETON = new AcpSessionManager();
|
||||
}
|
||||
return ACP_SESSION_MANAGER_SINGLETON;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetAcpSessionManagerForTests() {
|
||||
ACP_SESSION_MANAGER_SINGLETON = null;
|
||||
},
|
||||
};
|
||||
141
src/acp/control-plane/manager.types.ts
Normal file
141
src/acp/control-plane/manager.types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
SessionAcpIdentity,
|
||||
AcpSessionRuntimeOptions,
|
||||
SessionAcpMeta,
|
||||
SessionEntry,
|
||||
} from "../../config/sessions/types.js";
|
||||
import type { AcpRuntimeError } from "../runtime/errors.js";
|
||||
import { requireAcpRuntimeBackend } from "../runtime/registry.js";
|
||||
import {
|
||||
listAcpSessionEntries,
|
||||
readAcpSessionEntry,
|
||||
upsertAcpSessionMeta,
|
||||
} from "../runtime/session-meta.js";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimePromptMode,
|
||||
AcpRuntimeSessionMode,
|
||||
AcpRuntimeStatus,
|
||||
} from "../runtime/types.js";
|
||||
|
||||
export type AcpSessionResolution =
|
||||
| {
|
||||
kind: "none";
|
||||
sessionKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "stale";
|
||||
sessionKey: string;
|
||||
error: AcpRuntimeError;
|
||||
}
|
||||
| {
|
||||
kind: "ready";
|
||||
sessionKey: string;
|
||||
meta: SessionAcpMeta;
|
||||
};
|
||||
|
||||
export type AcpInitializeSessionInput = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
cwd?: string;
|
||||
backendId?: string;
|
||||
};
|
||||
|
||||
export type AcpRunTurnInput = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
text: string;
|
||||
mode: AcpRuntimePromptMode;
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: AcpRuntimeEvent) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type AcpCloseSessionInput = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
reason: string;
|
||||
clearMeta?: boolean;
|
||||
allowBackendUnavailable?: boolean;
|
||||
requireAcpSession?: boolean;
|
||||
};
|
||||
|
||||
export type AcpCloseSessionResult = {
|
||||
runtimeClosed: boolean;
|
||||
runtimeNotice?: string;
|
||||
metaCleared: boolean;
|
||||
};
|
||||
|
||||
export type AcpSessionStatus = {
|
||||
sessionKey: string;
|
||||
backend: string;
|
||||
agent: string;
|
||||
identity?: SessionAcpIdentity;
|
||||
state: SessionAcpMeta["state"];
|
||||
mode: AcpRuntimeSessionMode;
|
||||
runtimeOptions: AcpSessionRuntimeOptions;
|
||||
capabilities: AcpRuntimeCapabilities;
|
||||
runtimeStatus?: AcpRuntimeStatus;
|
||||
lastActivityAt: number;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export type AcpManagerObservabilitySnapshot = {
|
||||
runtimeCache: {
|
||||
activeSessions: number;
|
||||
idleTtlMs: number;
|
||||
evictedTotal: number;
|
||||
lastEvictedAt?: number;
|
||||
};
|
||||
turns: {
|
||||
active: number;
|
||||
queueDepth: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
averageLatencyMs: number;
|
||||
maxLatencyMs: number;
|
||||
};
|
||||
errorsByCode: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AcpStartupIdentityReconcileResult = {
|
||||
checked: number;
|
||||
resolved: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
export type ActiveTurnState = {
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
abortController: AbortController;
|
||||
cancelPromise?: Promise<void>;
|
||||
};
|
||||
|
||||
export type TurnLatencyStats = {
|
||||
completed: number;
|
||||
failed: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
|
||||
export type AcpSessionManagerDeps = {
|
||||
listAcpSessions: typeof listAcpSessionEntries;
|
||||
readSessionEntry: typeof readAcpSessionEntry;
|
||||
upsertSessionMeta: typeof upsertAcpSessionMeta;
|
||||
requireRuntimeBackend: typeof requireAcpRuntimeBackend;
|
||||
};
|
||||
|
||||
export const DEFAULT_DEPS: AcpSessionManagerDeps = {
|
||||
listAcpSessions: listAcpSessionEntries,
|
||||
readSessionEntry: readAcpSessionEntry,
|
||||
upsertSessionMeta: upsertAcpSessionMeta,
|
||||
requireRuntimeBackend: requireAcpRuntimeBackend,
|
||||
};
|
||||
|
||||
export type { AcpSessionRuntimeOptions, SessionAcpMeta, SessionEntry };
|
||||
64
src/acp/control-plane/manager.utils.ts
Normal file
64
src/acp/control-plane/manager.utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
|
||||
|
||||
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
return normalizeAgentId(parsed?.agentId ?? fallback);
|
||||
}
|
||||
|
||||
export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
|
||||
return new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`ACP metadata is missing for ${sessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSessionKey(sessionKey: string): string {
|
||||
return sessionKey.trim();
|
||||
}
|
||||
|
||||
export function normalizeActorKey(sessionKey: string): string {
|
||||
return sessionKey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeAcpErrorCode(code: string | undefined): AcpRuntimeError["code"] {
|
||||
if (!code) {
|
||||
return "ACP_TURN_FAILED";
|
||||
}
|
||||
const normalized = code.trim().toUpperCase();
|
||||
for (const allowed of ACP_ERROR_CODES) {
|
||||
if (allowed === normalized) {
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
return "ACP_TURN_FAILED";
|
||||
}
|
||||
|
||||
export function createUnsupportedControlError(params: {
|
||||
backend: string;
|
||||
control: string;
|
||||
}): AcpRuntimeError {
|
||||
return new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNSUPPORTED_CONTROL",
|
||||
`ACP backend "${params.backend}" does not support ${params.control}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRuntimeIdleTtlMs(cfg: OpenClawConfig): number {
|
||||
const ttlMinutes = cfg.acp?.runtime?.ttlMinutes;
|
||||
if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(ttlMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
export function hasLegacyAcpIdentityProjection(meta: SessionAcpMeta): boolean {
|
||||
const raw = meta as Record<string, unknown>;
|
||||
return (
|
||||
Object.hasOwn(raw, "backendSessionId") ||
|
||||
Object.hasOwn(raw, "agentSessionId") ||
|
||||
Object.hasOwn(raw, "sessionIdsProvisional")
|
||||
);
|
||||
}
|
||||
62
src/acp/control-plane/runtime-cache.test.ts
Normal file
62
src/acp/control-plane/runtime-cache.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntime } from "../runtime/types.js";
|
||||
import type { AcpRuntimeHandle } from "../runtime/types.js";
|
||||
import type { CachedRuntimeState } from "./runtime-cache.js";
|
||||
import { RuntimeCache } from "./runtime-cache.js";
|
||||
|
||||
function mockState(sessionKey: string): CachedRuntimeState {
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(async () => ({
|
||||
sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: `runtime:${sessionKey}`,
|
||||
})),
|
||||
runTurn: vi.fn(async function* () {
|
||||
yield { type: "done" as const };
|
||||
}),
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
} as unknown as AcpRuntime;
|
||||
return {
|
||||
runtime,
|
||||
handle: {
|
||||
sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: `runtime:${sessionKey}`,
|
||||
} as AcpRuntimeHandle,
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
};
|
||||
}
|
||||
|
||||
describe("RuntimeCache", () => {
|
||||
it("tracks idle candidates with touch-aware lookups", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cache = new RuntimeCache();
|
||||
const actor = "agent:codex:acp:s1";
|
||||
cache.set(actor, mockState(actor), { now: 1_000 });
|
||||
|
||||
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 1_999 })).toHaveLength(0);
|
||||
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 2_000 })).toHaveLength(1);
|
||||
|
||||
cache.get(actor, { now: 2_500 });
|
||||
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_200 })).toHaveLength(0);
|
||||
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_500 })).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns snapshot entries with idle durations", () => {
|
||||
const cache = new RuntimeCache();
|
||||
cache.set("a", mockState("a"), { now: 10 });
|
||||
cache.set("b", mockState("b"), { now: 100 });
|
||||
|
||||
const snapshot = cache.snapshot({ now: 1_100 });
|
||||
const byActor = new Map(snapshot.map((entry) => [entry.actorKey, entry]));
|
||||
expect(byActor.get("a")?.idleMs).toBe(1_090);
|
||||
expect(byActor.get("b")?.idleMs).toBe(1_000);
|
||||
});
|
||||
});
|
||||
99
src/acp/control-plane/runtime-cache.ts
Normal file
99
src/acp/control-plane/runtime-cache.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeSessionMode } from "../runtime/types.js";
|
||||
|
||||
export type CachedRuntimeState = {
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
backend: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
cwd?: string;
|
||||
appliedControlSignature?: string;
|
||||
};
|
||||
|
||||
type RuntimeCacheEntry = {
|
||||
state: CachedRuntimeState;
|
||||
lastTouchedAt: number;
|
||||
};
|
||||
|
||||
export type CachedRuntimeSnapshot = {
|
||||
actorKey: string;
|
||||
state: CachedRuntimeState;
|
||||
lastTouchedAt: number;
|
||||
idleMs: number;
|
||||
};
|
||||
|
||||
export class RuntimeCache {
|
||||
private readonly cache = new Map<string, RuntimeCacheEntry>();
|
||||
|
||||
size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
has(actorKey: string): boolean {
|
||||
return this.cache.has(actorKey);
|
||||
}
|
||||
|
||||
get(
|
||||
actorKey: string,
|
||||
params: {
|
||||
touch?: boolean;
|
||||
now?: number;
|
||||
} = {},
|
||||
): CachedRuntimeState | null {
|
||||
const entry = this.cache.get(actorKey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (params.touch !== false) {
|
||||
entry.lastTouchedAt = params.now ?? Date.now();
|
||||
}
|
||||
return entry.state;
|
||||
}
|
||||
|
||||
peek(actorKey: string): CachedRuntimeState | null {
|
||||
return this.get(actorKey, { touch: false });
|
||||
}
|
||||
|
||||
getLastTouchedAt(actorKey: string): number | null {
|
||||
return this.cache.get(actorKey)?.lastTouchedAt ?? null;
|
||||
}
|
||||
|
||||
set(
|
||||
actorKey: string,
|
||||
state: CachedRuntimeState,
|
||||
params: {
|
||||
now?: number;
|
||||
} = {},
|
||||
): void {
|
||||
this.cache.set(actorKey, {
|
||||
state,
|
||||
lastTouchedAt: params.now ?? Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
clear(actorKey: string): void {
|
||||
this.cache.delete(actorKey);
|
||||
}
|
||||
|
||||
snapshot(params: { now?: number } = {}): CachedRuntimeSnapshot[] {
|
||||
const now = params.now ?? Date.now();
|
||||
const entries: CachedRuntimeSnapshot[] = [];
|
||||
for (const [actorKey, entry] of this.cache.entries()) {
|
||||
entries.push({
|
||||
actorKey,
|
||||
state: entry.state,
|
||||
lastTouchedAt: entry.lastTouchedAt,
|
||||
idleMs: Math.max(0, now - entry.lastTouchedAt),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
collectIdleCandidates(params: { maxIdleMs: number; now?: number }): CachedRuntimeSnapshot[] {
|
||||
if (!Number.isFinite(params.maxIdleMs) || params.maxIdleMs <= 0) {
|
||||
return [];
|
||||
}
|
||||
const now = params.now ?? Date.now();
|
||||
return this.snapshot({ now }).filter((entry) => entry.idleMs >= params.maxIdleMs);
|
||||
}
|
||||
}
|
||||
349
src/acp/control-plane/runtime-options.ts
Normal file
349
src/acp/control-plane/runtime-options.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { AcpRuntimeError } from "../runtime/errors.js";
|
||||
|
||||
const MAX_RUNTIME_MODE_LENGTH = 64;
|
||||
const MAX_MODEL_LENGTH = 200;
|
||||
const MAX_PERMISSION_PROFILE_LENGTH = 80;
|
||||
const MAX_CWD_LENGTH = 4096;
|
||||
const MIN_TIMEOUT_SECONDS = 1;
|
||||
const MAX_TIMEOUT_SECONDS = 24 * 60 * 60;
|
||||
const MAX_BACKEND_OPTION_KEY_LENGTH = 64;
|
||||
const MAX_BACKEND_OPTION_VALUE_LENGTH = 512;
|
||||
const MAX_BACKEND_EXTRAS = 32;
|
||||
|
||||
const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i;
|
||||
|
||||
function failInvalidOption(message: string): never {
|
||||
throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message);
|
||||
}
|
||||
|
||||
function validateNoControlChars(value: string, field: string): string {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code < 32 || code === 127) {
|
||||
failInvalidOption(`${field} must not include control characters.`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateBoundedText(params: { value: unknown; field: string; maxLength: number }): string {
|
||||
const normalized = normalizeText(params.value);
|
||||
if (!normalized) {
|
||||
failInvalidOption(`${params.field} must not be empty.`);
|
||||
}
|
||||
if (normalized.length > params.maxLength) {
|
||||
failInvalidOption(`${params.field} must be at most ${params.maxLength} characters.`);
|
||||
}
|
||||
return validateNoControlChars(normalized, params.field);
|
||||
}
|
||||
|
||||
function validateBackendOptionKey(rawKey: unknown): string {
|
||||
const key = validateBoundedText({
|
||||
value: rawKey,
|
||||
field: "ACP config key",
|
||||
maxLength: MAX_BACKEND_OPTION_KEY_LENGTH,
|
||||
});
|
||||
if (!SAFE_OPTION_KEY_RE.test(key)) {
|
||||
failInvalidOption(
|
||||
"ACP config key must use letters, numbers, dots, colons, underscores, or dashes.",
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function validateBackendOptionValue(rawValue: unknown): string {
|
||||
return validateBoundedText({
|
||||
value: rawValue,
|
||||
field: "ACP config value",
|
||||
maxLength: MAX_BACKEND_OPTION_VALUE_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRuntimeModeInput(rawMode: unknown): string {
|
||||
return validateBoundedText({
|
||||
value: rawMode,
|
||||
field: "Runtime mode",
|
||||
maxLength: MAX_RUNTIME_MODE_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRuntimeModelInput(rawModel: unknown): string {
|
||||
return validateBoundedText({
|
||||
value: rawModel,
|
||||
field: "Model id",
|
||||
maxLength: MAX_MODEL_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRuntimePermissionProfileInput(rawProfile: unknown): string {
|
||||
return validateBoundedText({
|
||||
value: rawProfile,
|
||||
field: "Permission profile",
|
||||
maxLength: MAX_PERMISSION_PROFILE_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRuntimeCwdInput(rawCwd: unknown): string {
|
||||
const cwd = validateBoundedText({
|
||||
value: rawCwd,
|
||||
field: "Working directory",
|
||||
maxLength: MAX_CWD_LENGTH,
|
||||
});
|
||||
if (!isAbsolute(cwd)) {
|
||||
failInvalidOption(`Working directory must be an absolute path. Received "${cwd}".`);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function validateRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
|
||||
if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) {
|
||||
failInvalidOption("Timeout must be a positive integer in seconds.");
|
||||
}
|
||||
const timeout = Math.round(rawTimeout);
|
||||
if (timeout < MIN_TIMEOUT_SECONDS || timeout > MAX_TIMEOUT_SECONDS) {
|
||||
failInvalidOption(
|
||||
`Timeout must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS} seconds.`,
|
||||
);
|
||||
}
|
||||
return timeout;
|
||||
}
|
||||
|
||||
export function parseRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
|
||||
const normalized = normalizeText(rawTimeout);
|
||||
if (!normalized || !/^\d+$/.test(normalized)) {
|
||||
failInvalidOption("Timeout must be a positive integer in seconds.");
|
||||
}
|
||||
return validateRuntimeTimeoutSecondsInput(Number.parseInt(normalized, 10));
|
||||
}
|
||||
|
||||
export function validateRuntimeConfigOptionInput(
|
||||
rawKey: unknown,
|
||||
rawValue: unknown,
|
||||
): {
|
||||
key: string;
|
||||
value: string;
|
||||
} {
|
||||
return {
|
||||
key: validateBackendOptionKey(rawKey),
|
||||
value: validateBackendOptionValue(rawValue),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRuntimeOptionPatch(
|
||||
patch: Partial<AcpSessionRuntimeOptions> | undefined,
|
||||
): Partial<AcpSessionRuntimeOptions> {
|
||||
if (!patch) {
|
||||
return {};
|
||||
}
|
||||
const rawPatch = patch as Record<string, unknown>;
|
||||
const allowedKeys = new Set([
|
||||
"runtimeMode",
|
||||
"model",
|
||||
"cwd",
|
||||
"permissionProfile",
|
||||
"timeoutSeconds",
|
||||
"backendExtras",
|
||||
]);
|
||||
for (const key of Object.keys(rawPatch)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
failInvalidOption(`Unknown runtime option "${key}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const next: Partial<AcpSessionRuntimeOptions> = {};
|
||||
if (Object.hasOwn(rawPatch, "runtimeMode")) {
|
||||
if (rawPatch.runtimeMode === undefined) {
|
||||
next.runtimeMode = undefined;
|
||||
} else {
|
||||
next.runtimeMode = validateRuntimeModeInput(rawPatch.runtimeMode);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(rawPatch, "model")) {
|
||||
if (rawPatch.model === undefined) {
|
||||
next.model = undefined;
|
||||
} else {
|
||||
next.model = validateRuntimeModelInput(rawPatch.model);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(rawPatch, "cwd")) {
|
||||
if (rawPatch.cwd === undefined) {
|
||||
next.cwd = undefined;
|
||||
} else {
|
||||
next.cwd = validateRuntimeCwdInput(rawPatch.cwd);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(rawPatch, "permissionProfile")) {
|
||||
if (rawPatch.permissionProfile === undefined) {
|
||||
next.permissionProfile = undefined;
|
||||
} else {
|
||||
next.permissionProfile = validateRuntimePermissionProfileInput(rawPatch.permissionProfile);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(rawPatch, "timeoutSeconds")) {
|
||||
if (rawPatch.timeoutSeconds === undefined) {
|
||||
next.timeoutSeconds = undefined;
|
||||
} else {
|
||||
next.timeoutSeconds = validateRuntimeTimeoutSecondsInput(rawPatch.timeoutSeconds);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(rawPatch, "backendExtras")) {
|
||||
const rawExtras = rawPatch.backendExtras;
|
||||
if (rawExtras === undefined) {
|
||||
next.backendExtras = undefined;
|
||||
} else if (!rawExtras || typeof rawExtras !== "object" || Array.isArray(rawExtras)) {
|
||||
failInvalidOption("Backend extras must be a key/value object.");
|
||||
} else {
|
||||
const entries = Object.entries(rawExtras);
|
||||
if (entries.length > MAX_BACKEND_EXTRAS) {
|
||||
failInvalidOption(`Backend extras must include at most ${MAX_BACKEND_EXTRAS} entries.`);
|
||||
}
|
||||
const extras: Record<string, string> = {};
|
||||
for (const [entryKey, entryValue] of entries) {
|
||||
const { key, value } = validateRuntimeConfigOptionInput(entryKey, entryValue);
|
||||
extras[key] = value;
|
||||
}
|
||||
next.backendExtras = Object.keys(extras).length > 0 ? extras : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function normalizeRuntimeOptions(
|
||||
options: AcpSessionRuntimeOptions | undefined,
|
||||
): AcpSessionRuntimeOptions {
|
||||
const runtimeMode = normalizeText(options?.runtimeMode);
|
||||
const model = normalizeText(options?.model);
|
||||
const cwd = normalizeText(options?.cwd);
|
||||
const permissionProfile = normalizeText(options?.permissionProfile);
|
||||
let timeoutSeconds: number | undefined;
|
||||
if (typeof options?.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds)) {
|
||||
const rounded = Math.round(options.timeoutSeconds);
|
||||
if (rounded > 0) {
|
||||
timeoutSeconds = rounded;
|
||||
}
|
||||
}
|
||||
const backendExtrasEntries = Object.entries(options?.backendExtras ?? {})
|
||||
.map(([key, value]) => [normalizeText(key), normalizeText(value)] as const)
|
||||
.filter(([key, value]) => Boolean(key && value)) as Array<[string, string]>;
|
||||
const backendExtras =
|
||||
backendExtrasEntries.length > 0 ? Object.fromEntries(backendExtrasEntries) : undefined;
|
||||
return {
|
||||
...(runtimeMode ? { runtimeMode } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(permissionProfile ? { permissionProfile } : {}),
|
||||
...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}),
|
||||
...(backendExtras ? { backendExtras } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeRuntimeOptions(params: {
|
||||
current?: AcpSessionRuntimeOptions;
|
||||
patch?: Partial<AcpSessionRuntimeOptions>;
|
||||
}): AcpSessionRuntimeOptions {
|
||||
const current = normalizeRuntimeOptions(params.current);
|
||||
const patch = normalizeRuntimeOptions(validateRuntimeOptionPatch(params.patch));
|
||||
const mergedExtras = {
|
||||
...current.backendExtras,
|
||||
...patch.backendExtras,
|
||||
};
|
||||
return normalizeRuntimeOptions({
|
||||
...current,
|
||||
...patch,
|
||||
...(Object.keys(mergedExtras).length > 0 ? { backendExtras: mergedExtras } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRuntimeOptionsFromMeta(meta: SessionAcpMeta): AcpSessionRuntimeOptions {
|
||||
const normalized = normalizeRuntimeOptions(meta.runtimeOptions);
|
||||
if (normalized.cwd || !meta.cwd) {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeRuntimeOptions({
|
||||
...normalized,
|
||||
cwd: meta.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeOptionsEqual(
|
||||
a: AcpSessionRuntimeOptions | undefined,
|
||||
b: AcpSessionRuntimeOptions | undefined,
|
||||
): boolean {
|
||||
return JSON.stringify(normalizeRuntimeOptions(a)) === JSON.stringify(normalizeRuntimeOptions(b));
|
||||
}
|
||||
|
||||
export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): string {
|
||||
const normalized = normalizeRuntimeOptions(options);
|
||||
const extras = Object.entries(normalized.backendExtras ?? {}).toSorted(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
return JSON.stringify({
|
||||
runtimeMode: normalized.runtimeMode ?? null,
|
||||
model: normalized.model ?? null,
|
||||
permissionProfile: normalized.permissionProfile ?? null,
|
||||
timeoutSeconds: normalized.timeoutSeconds ?? null,
|
||||
backendExtras: extras,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRuntimeConfigOptionPairs(
|
||||
options: AcpSessionRuntimeOptions,
|
||||
): Array<[string, string]> {
|
||||
const normalized = normalizeRuntimeOptions(options);
|
||||
const pairs = new Map<string, string>();
|
||||
if (normalized.model) {
|
||||
pairs.set("model", normalized.model);
|
||||
}
|
||||
if (normalized.permissionProfile) {
|
||||
pairs.set("approval_policy", normalized.permissionProfile);
|
||||
}
|
||||
if (typeof normalized.timeoutSeconds === "number") {
|
||||
pairs.set("timeout", String(normalized.timeoutSeconds));
|
||||
}
|
||||
for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) {
|
||||
if (!pairs.has(key)) {
|
||||
pairs.set(key, value);
|
||||
}
|
||||
}
|
||||
return [...pairs.entries()];
|
||||
}
|
||||
|
||||
export function inferRuntimeOptionPatchFromConfigOption(
|
||||
key: string,
|
||||
value: string,
|
||||
): Partial<AcpSessionRuntimeOptions> {
|
||||
const validated = validateRuntimeConfigOptionInput(key, value);
|
||||
const normalizedKey = validated.key.toLowerCase();
|
||||
if (normalizedKey === "model") {
|
||||
return { model: validateRuntimeModelInput(validated.value) };
|
||||
}
|
||||
if (
|
||||
normalizedKey === "approval_policy" ||
|
||||
normalizedKey === "permission_profile" ||
|
||||
normalizedKey === "permissions"
|
||||
) {
|
||||
return { permissionProfile: validateRuntimePermissionProfileInput(validated.value) };
|
||||
}
|
||||
if (normalizedKey === "timeout" || normalizedKey === "timeout_seconds") {
|
||||
return { timeoutSeconds: parseRuntimeTimeoutSecondsInput(validated.value) };
|
||||
}
|
||||
if (normalizedKey === "cwd") {
|
||||
return { cwd: validateRuntimeCwdInput(validated.value) };
|
||||
}
|
||||
return {
|
||||
backendExtras: {
|
||||
[validated.key]: validated.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
53
src/acp/control-plane/session-actor-queue.ts
Normal file
53
src/acp/control-plane/session-actor-queue.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export class SessionActorQueue {
|
||||
private readonly tailBySession = new Map<string, Promise<void>>();
|
||||
private readonly pendingBySession = new Map<string, number>();
|
||||
|
||||
getTailMapForTesting(): Map<string, Promise<void>> {
|
||||
return this.tailBySession;
|
||||
}
|
||||
|
||||
getTotalPendingCount(): number {
|
||||
let total = 0;
|
||||
for (const count of this.pendingBySession.values()) {
|
||||
total += count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
getPendingCountForSession(actorKey: string): number {
|
||||
return this.pendingBySession.get(actorKey) ?? 0;
|
||||
}
|
||||
|
||||
async run<T>(actorKey: string, op: () => Promise<T>): Promise<T> {
|
||||
const previous = this.tailBySession.get(actorKey) ?? Promise.resolve();
|
||||
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
|
||||
let release: () => void = () => {};
|
||||
const marker = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queuedTail = previous
|
||||
.catch(() => {
|
||||
// Keep actor queue alive after an operation failure.
|
||||
})
|
||||
.then(() => marker);
|
||||
this.tailBySession.set(actorKey, queuedTail);
|
||||
|
||||
await previous.catch(() => {
|
||||
// Previous failures should not block newer commands.
|
||||
});
|
||||
try {
|
||||
return await op();
|
||||
} finally {
|
||||
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
|
||||
if (pending <= 0) {
|
||||
this.pendingBySession.delete(actorKey);
|
||||
} else {
|
||||
this.pendingBySession.set(actorKey, pending);
|
||||
}
|
||||
release();
|
||||
if (this.tailBySession.get(actorKey) === queuedTail) {
|
||||
this.tailBySession.delete(actorKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/acp/control-plane/spawn.ts
Normal file
77
src/acp/control-plane/spawn.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { getAcpSessionManager } from "./manager.js";
|
||||
|
||||
export type AcpSpawnRuntimeCloseHandle = {
|
||||
runtime: {
|
||||
close: (params: {
|
||||
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
|
||||
reason: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
|
||||
};
|
||||
|
||||
export async function cleanupFailedAcpSpawn(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
shouldDeleteSession: boolean;
|
||||
deleteTranscript: boolean;
|
||||
runtimeCloseHandle?: AcpSpawnRuntimeCloseHandle;
|
||||
}): Promise<void> {
|
||||
if (params.runtimeCloseHandle) {
|
||||
await params.runtimeCloseHandle.runtime
|
||||
.close({
|
||||
handle: params.runtimeCloseHandle.handle,
|
||||
reason: "spawn-failed",
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`acp-spawn: runtime cleanup close failed for ${params.sessionKey}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const acpManager = getAcpSessionManager();
|
||||
await acpManager
|
||||
.closeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
reason: "spawn-failed",
|
||||
allowBackendUnavailable: true,
|
||||
requireAcpSession: false,
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`acp-spawn: manager cleanup close failed for ${params.sessionKey}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
await getSessionBindingService()
|
||||
.unbind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
reason: "spawn-failed",
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`acp-spawn: binding cleanup unbind failed for ${params.sessionKey}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (!params.shouldDeleteSession) {
|
||||
return;
|
||||
}
|
||||
await callGateway({
|
||||
method: "sessions.delete",
|
||||
params: {
|
||||
key: params.sessionKey,
|
||||
deleteTranscript: params.deleteTranscript,
|
||||
emitLifecycleHooks: false,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
}).catch(() => {
|
||||
// Best-effort cleanup only.
|
||||
});
|
||||
}
|
||||
59
src/acp/policy.test.ts
Normal file
59
src/acp/policy.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
isAcpAgentAllowedByPolicy,
|
||||
isAcpDispatchEnabledByPolicy,
|
||||
isAcpEnabledByPolicy,
|
||||
resolveAcpAgentPolicyError,
|
||||
resolveAcpDispatchPolicyError,
|
||||
resolveAcpDispatchPolicyMessage,
|
||||
resolveAcpDispatchPolicyState,
|
||||
} from "./policy.js";
|
||||
|
||||
describe("acp policy", () => {
|
||||
it("treats ACP as enabled by default", () => {
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
expect(isAcpEnabledByPolicy(cfg)).toBe(true);
|
||||
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false);
|
||||
expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled");
|
||||
});
|
||||
|
||||
it("reports ACP disabled state when acp.enabled is false", () => {
|
||||
const cfg = {
|
||||
acp: {
|
||||
enabled: false,
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
expect(isAcpEnabledByPolicy(cfg)).toBe(false);
|
||||
expect(resolveAcpDispatchPolicyState(cfg)).toBe("acp_disabled");
|
||||
expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.enabled=false");
|
||||
expect(resolveAcpDispatchPolicyError(cfg)?.code).toBe("ACP_DISPATCH_DISABLED");
|
||||
});
|
||||
|
||||
it("reports dispatch-disabled state when dispatch gate is false", () => {
|
||||
const cfg = {
|
||||
acp: {
|
||||
enabled: true,
|
||||
dispatch: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false);
|
||||
expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled");
|
||||
expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.dispatch.enabled=false");
|
||||
});
|
||||
|
||||
it("applies allowlist filtering for ACP agents", () => {
|
||||
const cfg = {
|
||||
acp: {
|
||||
allowedAgents: ["Codex", "claude-code"],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
expect(isAcpAgentAllowedByPolicy(cfg, "codex")).toBe(true);
|
||||
expect(isAcpAgentAllowedByPolicy(cfg, "claude-code")).toBe(true);
|
||||
expect(isAcpAgentAllowedByPolicy(cfg, "gemini")).toBe(false);
|
||||
expect(resolveAcpAgentPolicyError(cfg, "gemini")?.code).toBe("ACP_SESSION_INIT_FAILED");
|
||||
expect(resolveAcpAgentPolicyError(cfg, "codex")).toBeNull();
|
||||
});
|
||||
});
|
||||
69
src/acp/policy.ts
Normal file
69
src/acp/policy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { OpenClawConfig } from "../config/config.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 =
|
||||
"ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`).";
|
||||
|
||||
export type AcpDispatchPolicyState = "enabled" | "acp_disabled" | "dispatch_disabled";
|
||||
|
||||
export function isAcpEnabledByPolicy(cfg: OpenClawConfig): boolean {
|
||||
return cfg.acp?.enabled !== false;
|
||||
}
|
||||
|
||||
export function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchPolicyState {
|
||||
if (!isAcpEnabledByPolicy(cfg)) {
|
||||
return "acp_disabled";
|
||||
}
|
||||
if (cfg.acp?.dispatch?.enabled !== true) {
|
||||
return "dispatch_disabled";
|
||||
}
|
||||
return "enabled";
|
||||
}
|
||||
|
||||
export function isAcpDispatchEnabledByPolicy(cfg: OpenClawConfig): boolean {
|
||||
return resolveAcpDispatchPolicyState(cfg) === "enabled";
|
||||
}
|
||||
|
||||
export function resolveAcpDispatchPolicyMessage(cfg: OpenClawConfig): string | null {
|
||||
const state = resolveAcpDispatchPolicyState(cfg);
|
||||
if (state === "acp_disabled") {
|
||||
return ACP_DISABLED_MESSAGE;
|
||||
}
|
||||
if (state === "dispatch_disabled") {
|
||||
return ACP_DISPATCH_DISABLED_MESSAGE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAcpDispatchPolicyError(cfg: OpenClawConfig): AcpRuntimeError | null {
|
||||
const message = resolveAcpDispatchPolicyMessage(cfg);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return new AcpRuntimeError("ACP_DISPATCH_DISABLED", message);
|
||||
}
|
||||
|
||||
export function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean {
|
||||
const allowed = (cfg.acp?.allowedAgents ?? [])
|
||||
.map((entry) => normalizeAgentId(entry))
|
||||
.filter(Boolean);
|
||||
if (allowed.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowed.includes(normalizeAgentId(agentId));
|
||||
}
|
||||
|
||||
export function resolveAcpAgentPolicyError(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): AcpRuntimeError | null {
|
||||
if (isAcpAgentAllowedByPolicy(cfg, agentId)) {
|
||||
return null;
|
||||
}
|
||||
return new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`ACP agent "${normalizeAgentId(agentId)}" is not allowed by policy.`,
|
||||
);
|
||||
}
|
||||
114
src/acp/runtime/adapter-contract.testkit.ts
Normal file
114
src/acp/runtime/adapter-contract.testkit.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "vitest";
|
||||
import { toAcpRuntimeError } from "./errors.js";
|
||||
import type { AcpRuntime, AcpRuntimeEvent } from "./types.js";
|
||||
|
||||
export type AcpRuntimeAdapterContractParams = {
|
||||
createRuntime: () => Promise<AcpRuntime> | AcpRuntime;
|
||||
agentId?: string;
|
||||
successPrompt?: string;
|
||||
errorPrompt?: string;
|
||||
assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise<void>;
|
||||
assertErrorOutcome?: (params: {
|
||||
events: AcpRuntimeEvent[];
|
||||
thrown: unknown;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export async function runAcpRuntimeAdapterContract(
|
||||
params: AcpRuntimeAdapterContractParams,
|
||||
): Promise<void> {
|
||||
const runtime = await params.createRuntime();
|
||||
const sessionKey = `agent:${params.agentId ?? "codex"}:acp:contract-${randomUUID()}`;
|
||||
const agent = params.agentId ?? "codex";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent,
|
||||
mode: "persistent",
|
||||
});
|
||||
expect(handle.sessionKey).toBe(sessionKey);
|
||||
expect(handle.backend.trim()).not.toHaveLength(0);
|
||||
expect(handle.runtimeSessionName.trim()).not.toHaveLength(0);
|
||||
|
||||
const successEvents: AcpRuntimeEvent[] = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: params.successPrompt ?? "contract-success",
|
||||
mode: "prompt",
|
||||
requestId: `contract-success-${randomUUID()}`,
|
||||
})) {
|
||||
successEvents.push(event);
|
||||
}
|
||||
expect(
|
||||
successEvents.some(
|
||||
(event) =>
|
||||
event.type === "done" ||
|
||||
event.type === "text_delta" ||
|
||||
event.type === "status" ||
|
||||
event.type === "tool_call",
|
||||
),
|
||||
).toBe(true);
|
||||
await params.assertSuccessEvents?.(successEvents);
|
||||
|
||||
if (runtime.getStatus) {
|
||||
const status = await runtime.getStatus({ handle });
|
||||
expect(status).toBeDefined();
|
||||
expect(typeof status).toBe("object");
|
||||
}
|
||||
if (runtime.setMode) {
|
||||
await runtime.setMode({
|
||||
handle,
|
||||
mode: "contract",
|
||||
});
|
||||
}
|
||||
if (runtime.setConfigOption) {
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "contract_key",
|
||||
value: "contract_value",
|
||||
});
|
||||
}
|
||||
|
||||
let errorThrown: unknown = null;
|
||||
const errorEvents: AcpRuntimeEvent[] = [];
|
||||
const errorPrompt = params.errorPrompt?.trim();
|
||||
if (errorPrompt) {
|
||||
try {
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: errorPrompt,
|
||||
mode: "prompt",
|
||||
requestId: `contract-error-${randomUUID()}`,
|
||||
})) {
|
||||
errorEvents.push(event);
|
||||
}
|
||||
} catch (error) {
|
||||
errorThrown = error;
|
||||
}
|
||||
const sawErrorEvent = errorEvents.some((event) => event.type === "error");
|
||||
expect(Boolean(errorThrown) || sawErrorEvent).toBe(true);
|
||||
if (errorThrown) {
|
||||
const acpError = toAcpRuntimeError({
|
||||
error: errorThrown,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP runtime contract expected an error turn failure.",
|
||||
});
|
||||
expect(acpError.code.length).toBeGreaterThan(0);
|
||||
expect(acpError.message.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
await params.assertErrorOutcome?.({
|
||||
events: errorEvents,
|
||||
thrown: errorThrown,
|
||||
});
|
||||
|
||||
await runtime.cancel({
|
||||
handle,
|
||||
reason: "contract-cancel",
|
||||
});
|
||||
await runtime.close({
|
||||
handle,
|
||||
reason: "contract-close",
|
||||
});
|
||||
}
|
||||
19
src/acp/runtime/error-text.test.ts
Normal file
19
src/acp/runtime/error-text.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAcpRuntimeErrorText } from "./error-text.js";
|
||||
import { AcpRuntimeError } from "./errors.js";
|
||||
|
||||
describe("formatAcpRuntimeErrorText", () => {
|
||||
it("adds actionable next steps for known ACP runtime error codes", () => {
|
||||
const text = formatAcpRuntimeErrorText(
|
||||
new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"),
|
||||
);
|
||||
expect(text).toContain("ACP error (ACP_BACKEND_MISSING): backend missing");
|
||||
expect(text).toContain("next:");
|
||||
});
|
||||
|
||||
it("returns consistent ACP error envelope for runtime failures", () => {
|
||||
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
|
||||
expect(text).toContain("ACP error (ACP_TURN_FAILED): turn failed");
|
||||
expect(text).toContain("next:");
|
||||
});
|
||||
});
|
||||
45
src/acp/runtime/error-text.ts
Normal file
45
src/acp/runtime/error-text.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { type AcpRuntimeErrorCode, AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
|
||||
|
||||
function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefined {
|
||||
if (error.code === "ACP_BACKEND_MISSING" || error.code === "ACP_BACKEND_UNAVAILABLE") {
|
||||
return "Run `/acp doctor`, install/enable the backend plugin, then retry.";
|
||||
}
|
||||
if (error.code === "ACP_DISPATCH_DISABLED") {
|
||||
return "Enable `acp.dispatch.enabled=true` to allow thread-message ACP turns.";
|
||||
}
|
||||
if (error.code === "ACP_SESSION_INIT_FAILED") {
|
||||
return "If this session is stale, recreate it with `/acp spawn` and rebind the thread.";
|
||||
}
|
||||
if (error.code === "ACP_INVALID_RUNTIME_OPTION") {
|
||||
return "Use `/acp status` to inspect options and pass valid values.";
|
||||
}
|
||||
if (error.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") {
|
||||
return "This backend does not support that control; use a supported command.";
|
||||
}
|
||||
if (error.code === "ACP_TURN_FAILED") {
|
||||
return "Retry, or use `/acp cancel` and send the message again.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string {
|
||||
const next = resolveAcpRuntimeErrorNextStep(error);
|
||||
if (!next) {
|
||||
return `ACP error (${error.code}): ${error.message}`;
|
||||
}
|
||||
return `ACP error (${error.code}): ${error.message}\nnext: ${next}`;
|
||||
}
|
||||
|
||||
export function toAcpRuntimeErrorText(params: {
|
||||
error: unknown;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
fallbackMessage: string;
|
||||
}): string {
|
||||
return formatAcpRuntimeErrorText(
|
||||
toAcpRuntimeError({
|
||||
error: params.error,
|
||||
fallbackCode: params.fallbackCode,
|
||||
fallbackMessage: params.fallbackMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
33
src/acp/runtime/errors.test.ts
Normal file
33
src/acp/runtime/errors.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js";
|
||||
|
||||
describe("withAcpRuntimeErrorBoundary", () => {
|
||||
it("wraps generic errors with fallback code and source message", async () => {
|
||||
await expect(
|
||||
withAcpRuntimeErrorBoundary({
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "fallback",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "AcpRuntimeError",
|
||||
code: "ACP_TURN_FAILED",
|
||||
message: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through existing ACP runtime errors", async () => {
|
||||
const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing");
|
||||
await expect(
|
||||
withAcpRuntimeErrorBoundary({
|
||||
run: async () => {
|
||||
throw existing;
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "fallback",
|
||||
}),
|
||||
).rejects.toBe(existing);
|
||||
});
|
||||
});
|
||||
61
src/acp/runtime/errors.ts
Normal file
61
src/acp/runtime/errors.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const ACP_ERROR_CODES = [
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
"ACP_BACKEND_UNSUPPORTED_CONTROL",
|
||||
"ACP_DISPATCH_DISABLED",
|
||||
"ACP_INVALID_RUNTIME_OPTION",
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"ACP_TURN_FAILED",
|
||||
] as const;
|
||||
|
||||
export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number];
|
||||
|
||||
export class AcpRuntimeError extends Error {
|
||||
readonly code: AcpRuntimeErrorCode;
|
||||
override readonly cause?: unknown;
|
||||
|
||||
constructor(code: AcpRuntimeErrorCode, message: string, options?: { cause?: unknown }) {
|
||||
super(message);
|
||||
this.name = "AcpRuntimeError";
|
||||
this.code = code;
|
||||
this.cause = options?.cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
|
||||
return value instanceof AcpRuntimeError;
|
||||
}
|
||||
|
||||
export function toAcpRuntimeError(params: {
|
||||
error: unknown;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
fallbackMessage: string;
|
||||
}): AcpRuntimeError {
|
||||
if (params.error instanceof AcpRuntimeError) {
|
||||
return params.error;
|
||||
}
|
||||
if (params.error instanceof Error) {
|
||||
return new AcpRuntimeError(params.fallbackCode, params.error.message, {
|
||||
cause: params.error,
|
||||
});
|
||||
}
|
||||
return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, {
|
||||
cause: params.error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withAcpRuntimeErrorBoundary<T>(params: {
|
||||
run: () => Promise<T>;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
fallbackMessage: string;
|
||||
}): Promise<T> {
|
||||
try {
|
||||
return await params.run();
|
||||
} catch (error) {
|
||||
throw toAcpRuntimeError({
|
||||
error,
|
||||
fallbackCode: params.fallbackCode,
|
||||
fallbackMessage: params.fallbackMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
99
src/acp/runtime/registry.test.ts
Normal file
99
src/acp/runtime/registry.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AcpRuntimeError } from "./errors.js";
|
||||
import {
|
||||
__testing,
|
||||
getAcpRuntimeBackend,
|
||||
registerAcpRuntimeBackend,
|
||||
requireAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "./registry.js";
|
||||
import type { AcpRuntime } from "./types.js";
|
||||
|
||||
function createRuntimeStub(): AcpRuntime {
|
||||
return {
|
||||
ensureSession: vi.fn(async (input) => ({
|
||||
sessionKey: input.sessionKey,
|
||||
backend: "stub",
|
||||
runtimeSessionName: `${input.sessionKey}:runtime`,
|
||||
})),
|
||||
runTurn: vi.fn(async function* () {
|
||||
// no-op stream
|
||||
}),
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("acp runtime registry", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetAcpRuntimeBackendsForTests();
|
||||
});
|
||||
|
||||
it("registers and resolves backends by id", () => {
|
||||
const runtime = createRuntimeStub();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime });
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
expect(backend?.id).toBe("acpx");
|
||||
expect(backend?.runtime).toBe(runtime);
|
||||
});
|
||||
|
||||
it("prefers a healthy backend when resolving without explicit id", () => {
|
||||
const unhealthyRuntime = createRuntimeStub();
|
||||
const healthyRuntime = createRuntimeStub();
|
||||
|
||||
registerAcpRuntimeBackend({
|
||||
id: "unhealthy",
|
||||
runtime: unhealthyRuntime,
|
||||
healthy: () => false,
|
||||
});
|
||||
registerAcpRuntimeBackend({
|
||||
id: "healthy",
|
||||
runtime: healthyRuntime,
|
||||
healthy: () => true,
|
||||
});
|
||||
|
||||
const backend = getAcpRuntimeBackend();
|
||||
expect(backend?.id).toBe("healthy");
|
||||
});
|
||||
|
||||
it("throws a typed missing-backend error when no backend is registered", () => {
|
||||
expect(() => requireAcpRuntimeBackend()).toThrowError(AcpRuntimeError);
|
||||
expect(() => requireAcpRuntimeBackend()).toThrowError(/ACP runtime backend is not configured/i);
|
||||
});
|
||||
|
||||
it("throws a typed unavailable error when the requested backend is unhealthy", () => {
|
||||
registerAcpRuntimeBackend({
|
||||
id: "acpx",
|
||||
runtime: createRuntimeStub(),
|
||||
healthy: () => false,
|
||||
});
|
||||
|
||||
try {
|
||||
requireAcpRuntimeBackend("acpx");
|
||||
throw new Error("expected requireAcpRuntimeBackend to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AcpRuntimeError);
|
||||
expect((err as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
it("unregisters a backend by id", () => {
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: createRuntimeStub() });
|
||||
unregisterAcpRuntimeBackend("acpx");
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps backend state on a global registry for cross-loader access", () => {
|
||||
const runtime = createRuntimeStub();
|
||||
const sharedState = __testing.getAcpRuntimeRegistryGlobalStateForTests();
|
||||
|
||||
sharedState.backendsById.set("acpx", {
|
||||
id: "acpx",
|
||||
runtime,
|
||||
});
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
expect(backend?.runtime).toBe(runtime);
|
||||
});
|
||||
});
|
||||
118
src/acp/runtime/registry.ts
Normal file
118
src/acp/runtime/registry.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { AcpRuntimeError } from "./errors.js";
|
||||
import type { AcpRuntime } from "./types.js";
|
||||
|
||||
export type AcpRuntimeBackend = {
|
||||
id: string;
|
||||
runtime: AcpRuntime;
|
||||
healthy?: () => boolean;
|
||||
};
|
||||
|
||||
type AcpRuntimeRegistryGlobalState = {
|
||||
backendsById: Map<string, AcpRuntimeBackend>;
|
||||
};
|
||||
|
||||
const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState");
|
||||
|
||||
function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
|
||||
return {
|
||||
backendsById: new Map<string, AcpRuntimeBackend>(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
|
||||
const runtimeGlobal = globalThis as typeof globalThis & {
|
||||
[ACP_RUNTIME_REGISTRY_STATE_KEY]?: AcpRuntimeRegistryGlobalState;
|
||||
};
|
||||
if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) {
|
||||
runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = createAcpRuntimeRegistryGlobalState();
|
||||
}
|
||||
return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY];
|
||||
}
|
||||
|
||||
const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById;
|
||||
|
||||
function normalizeBackendId(id: string | undefined): string {
|
||||
return id?.trim().toLowerCase() || "";
|
||||
}
|
||||
|
||||
function isBackendHealthy(backend: AcpRuntimeBackend): boolean {
|
||||
if (!backend.healthy) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return backend.healthy();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void {
|
||||
const id = normalizeBackendId(backend.id);
|
||||
if (!id) {
|
||||
throw new Error("ACP runtime backend id is required");
|
||||
}
|
||||
if (!backend.runtime) {
|
||||
throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`);
|
||||
}
|
||||
ACP_BACKENDS_BY_ID.set(id, {
|
||||
...backend,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterAcpRuntimeBackend(id: string): void {
|
||||
const normalized = normalizeBackendId(id);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
ACP_BACKENDS_BY_ID.delete(normalized);
|
||||
}
|
||||
|
||||
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null {
|
||||
const normalized = normalizeBackendId(id);
|
||||
if (normalized) {
|
||||
return ACP_BACKENDS_BY_ID.get(normalized) ?? null;
|
||||
}
|
||||
if (ACP_BACKENDS_BY_ID.size === 0) {
|
||||
return null;
|
||||
}
|
||||
for (const backend of ACP_BACKENDS_BY_ID.values()) {
|
||||
if (isBackendHealthy(backend)) {
|
||||
return backend;
|
||||
}
|
||||
}
|
||||
return ACP_BACKENDS_BY_ID.values().next().value ?? null;
|
||||
}
|
||||
|
||||
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend {
|
||||
const normalized = normalizeBackendId(id);
|
||||
const backend = getAcpRuntimeBackend(normalized || undefined);
|
||||
if (!backend) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||
);
|
||||
}
|
||||
if (!isBackendHealthy(backend)) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
"ACP runtime backend is currently unavailable. Try again in a moment.",
|
||||
);
|
||||
}
|
||||
if (normalized && backend.id !== normalized) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_MISSING",
|
||||
`ACP runtime backend "${normalized}" is not registered.`,
|
||||
);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetAcpRuntimeBackendsForTests() {
|
||||
ACP_BACKENDS_BY_ID.clear();
|
||||
},
|
||||
getAcpRuntimeRegistryGlobalStateForTests() {
|
||||
return resolveAcpRuntimeRegistryGlobalState();
|
||||
},
|
||||
};
|
||||
89
src/acp/runtime/session-identifiers.test.ts
Normal file
89
src/acp/runtime/session-identifiers.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveAcpSessionCwd,
|
||||
resolveAcpSessionIdentifierLinesFromIdentity,
|
||||
resolveAcpThreadSessionDetailLines,
|
||||
} from "./session-identifiers.js";
|
||||
|
||||
describe("session identifier helpers", () => {
|
||||
it("hides unresolved identifiers from thread intro details while pending", () => {
|
||||
const lines = resolveAcpThreadSessionDetailLines({
|
||||
sessionKey: "agent:codex:acp:pending-1",
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
identity: {
|
||||
state: "pending",
|
||||
source: "ensure",
|
||||
lastUpdatedAt: Date.now(),
|
||||
acpxSessionId: "acpx-123",
|
||||
agentSessionId: "inner-123",
|
||||
},
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a Codex resume hint when agent identity is resolved", () => {
|
||||
const lines = resolveAcpThreadSessionDetailLines({
|
||||
sessionKey: "agent:codex:acp:resolved-1",
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
lastUpdatedAt: Date.now(),
|
||||
acpxSessionId: "acpx-123",
|
||||
agentSessionId: "inner-123",
|
||||
},
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(lines).toContain("agent session id: inner-123");
|
||||
expect(lines).toContain("acpx session id: acpx-123");
|
||||
expect(lines).toContain(
|
||||
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows pending identity text for status rendering", () => {
|
||||
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
|
||||
backend: "acpx",
|
||||
mode: "status",
|
||||
identity: {
|
||||
state: "pending",
|
||||
source: "status",
|
||||
lastUpdatedAt: Date.now(),
|
||||
agentSessionId: "inner-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
|
||||
});
|
||||
|
||||
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
|
||||
const cwd = resolveAcpSessionCwd({
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
runtimeOptions: {
|
||||
cwd: "/repo/new",
|
||||
},
|
||||
cwd: "/repo/old",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
});
|
||||
expect(cwd).toBe("/repo/new");
|
||||
});
|
||||
});
|
||||
131
src/acp/runtime/session-identifiers.ts
Normal file
131
src/acp/runtime/session-identifiers.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { SessionAcpIdentity, SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { isSessionIdentityPending, resolveSessionIdentityFromMeta } from "./session-identity.js";
|
||||
|
||||
export const ACP_SESSION_IDENTITY_RENDERER_VERSION = "v1";
|
||||
export type AcpSessionIdentifierRenderMode = "status" | "thread";
|
||||
|
||||
type SessionResumeHintResolver = (params: { agentSessionId: string }) => string;
|
||||
|
||||
const ACP_AGENT_RESUME_HINT_BY_KEY = new Map<string, SessionResumeHintResolver>([
|
||||
[
|
||||
"codex",
|
||||
({ agentSessionId }) =>
|
||||
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
|
||||
],
|
||||
[
|
||||
"openai-codex",
|
||||
({ agentSessionId }) =>
|
||||
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
|
||||
],
|
||||
[
|
||||
"codex-cli",
|
||||
({ agentSessionId }) =>
|
||||
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
|
||||
],
|
||||
]);
|
||||
|
||||
function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeAgentHintKey(value: unknown): string | undefined {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized.toLowerCase().replace(/[\s_]+/g, "-");
|
||||
}
|
||||
|
||||
function resolveAcpAgentResumeHintLine(params: {
|
||||
agentId?: string;
|
||||
agentSessionId?: string;
|
||||
}): string | undefined {
|
||||
const agentSessionId = normalizeText(params.agentSessionId);
|
||||
const agentKey = normalizeAgentHintKey(params.agentId);
|
||||
if (!agentSessionId || !agentKey) {
|
||||
return undefined;
|
||||
}
|
||||
const resolver = ACP_AGENT_RESUME_HINT_BY_KEY.get(agentKey);
|
||||
return resolver ? resolver({ agentSessionId }) : undefined;
|
||||
}
|
||||
|
||||
export function resolveAcpSessionIdentifierLines(params: {
|
||||
sessionKey: string;
|
||||
meta?: SessionAcpMeta;
|
||||
}): string[] {
|
||||
const backend = normalizeText(params.meta?.backend) ?? "backend";
|
||||
const identity = resolveSessionIdentityFromMeta(params.meta);
|
||||
return resolveAcpSessionIdentifierLinesFromIdentity({
|
||||
backend,
|
||||
identity,
|
||||
mode: "status",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAcpSessionIdentifierLinesFromIdentity(params: {
|
||||
backend: string;
|
||||
identity?: SessionAcpIdentity;
|
||||
mode?: AcpSessionIdentifierRenderMode;
|
||||
}): string[] {
|
||||
const backend = normalizeText(params.backend) ?? "backend";
|
||||
const mode = params.mode ?? "status";
|
||||
const identity = params.identity;
|
||||
const agentSessionId = normalizeText(identity?.agentSessionId);
|
||||
const acpxSessionId = normalizeText(identity?.acpxSessionId);
|
||||
const acpxRecordId = normalizeText(identity?.acpxRecordId);
|
||||
const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId);
|
||||
if (isSessionIdentityPending(identity) && hasIdentifier) {
|
||||
if (mode === "status") {
|
||||
return ["session ids: pending (available after the first reply)"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (agentSessionId) {
|
||||
lines.push(`agent session id: ${agentSessionId}`);
|
||||
}
|
||||
if (acpxSessionId) {
|
||||
lines.push(`${backend} session id: ${acpxSessionId}`);
|
||||
}
|
||||
if (acpxRecordId) {
|
||||
lines.push(`${backend} record id: ${acpxRecordId}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined {
|
||||
const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd);
|
||||
if (runtimeCwd) {
|
||||
return runtimeCwd;
|
||||
}
|
||||
return normalizeText(meta?.cwd);
|
||||
}
|
||||
|
||||
export function resolveAcpThreadSessionDetailLines(params: {
|
||||
sessionKey: string;
|
||||
meta?: SessionAcpMeta;
|
||||
}): string[] {
|
||||
const meta = params.meta;
|
||||
const identity = resolveSessionIdentityFromMeta(meta);
|
||||
const backend = normalizeText(meta?.backend) ?? "backend";
|
||||
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
|
||||
backend,
|
||||
identity,
|
||||
mode: "thread",
|
||||
});
|
||||
if (lines.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
const hint = resolveAcpAgentResumeHintLine({
|
||||
agentId: meta?.agent,
|
||||
agentSessionId: identity?.agentSessionId,
|
||||
});
|
||||
if (hint) {
|
||||
lines.push(hint);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
210
src/acp/runtime/session-identity.ts
Normal file
210
src/acp/runtime/session-identity.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
SessionAcpIdentity,
|
||||
SessionAcpIdentitySource,
|
||||
SessionAcpMeta,
|
||||
} from "../../config/sessions/types.js";
|
||||
import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js";
|
||||
|
||||
function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined {
|
||||
if (value !== "pending" && value !== "resolved") {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined {
|
||||
if (value !== "ensure" && value !== "status" && value !== "event") {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentity(
|
||||
identity: SessionAcpIdentity | undefined,
|
||||
): SessionAcpIdentity | undefined {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const state = normalizeIdentityState(identity.state);
|
||||
const source = normalizeIdentitySource(identity.source);
|
||||
const acpxRecordId = normalizeText(identity.acpxRecordId);
|
||||
const acpxSessionId = normalizeText(identity.acpxSessionId);
|
||||
const agentSessionId = normalizeText(identity.agentSessionId);
|
||||
const lastUpdatedAt =
|
||||
typeof identity.lastUpdatedAt === "number" && Number.isFinite(identity.lastUpdatedAt)
|
||||
? identity.lastUpdatedAt
|
||||
: undefined;
|
||||
const hasAnyId = Boolean(acpxRecordId || acpxSessionId || agentSessionId);
|
||||
if (!state && !source && !hasAnyId && lastUpdatedAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = Boolean(acpxSessionId || agentSessionId);
|
||||
const normalizedState = state ?? (resolved ? "resolved" : "pending");
|
||||
return {
|
||||
state: normalizedState,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(acpxSessionId ? { acpxSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
source: source ?? "status",
|
||||
lastUpdatedAt: lastUpdatedAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionIdentityFromMeta(
|
||||
meta: SessionAcpMeta | undefined,
|
||||
): SessionAcpIdentity | undefined {
|
||||
if (!meta) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIdentity(meta.identity);
|
||||
}
|
||||
|
||||
export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean {
|
||||
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
|
||||
}
|
||||
|
||||
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
|
||||
if (!identity) {
|
||||
return true;
|
||||
}
|
||||
return identity.state === "pending";
|
||||
}
|
||||
|
||||
export function identityEquals(
|
||||
left: SessionAcpIdentity | undefined,
|
||||
right: SessionAcpIdentity | undefined,
|
||||
): boolean {
|
||||
const a = normalizeIdentity(left);
|
||||
const b = normalizeIdentity(right);
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.state === b.state &&
|
||||
a.acpxRecordId === b.acpxRecordId &&
|
||||
a.acpxSessionId === b.acpxSessionId &&
|
||||
a.agentSessionId === b.agentSessionId &&
|
||||
a.source === b.source
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeSessionIdentity(params: {
|
||||
current: SessionAcpIdentity | undefined;
|
||||
incoming: SessionAcpIdentity | undefined;
|
||||
now: number;
|
||||
}): SessionAcpIdentity | undefined {
|
||||
const current = normalizeIdentity(params.current);
|
||||
const incoming = normalizeIdentity(params.incoming);
|
||||
if (!current) {
|
||||
if (!incoming) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...incoming, lastUpdatedAt: params.now };
|
||||
}
|
||||
if (!incoming) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const currentResolved = current.state === "resolved";
|
||||
const incomingResolved = incoming.state === "resolved";
|
||||
const allowIncomingValue = !currentResolved || incomingResolved;
|
||||
const nextRecordId =
|
||||
allowIncomingValue && incoming.acpxRecordId ? incoming.acpxRecordId : current.acpxRecordId;
|
||||
const nextAcpxSessionId =
|
||||
allowIncomingValue && incoming.acpxSessionId ? incoming.acpxSessionId : current.acpxSessionId;
|
||||
const nextAgentSessionId =
|
||||
allowIncomingValue && incoming.agentSessionId
|
||||
? incoming.agentSessionId
|
||||
: current.agentSessionId;
|
||||
|
||||
const nextResolved = Boolean(nextAcpxSessionId || nextAgentSessionId);
|
||||
const nextState: SessionAcpIdentity["state"] = nextResolved
|
||||
? "resolved"
|
||||
: currentResolved
|
||||
? "resolved"
|
||||
: incoming.state;
|
||||
const nextSource = allowIncomingValue ? incoming.source : current.source;
|
||||
const next: SessionAcpIdentity = {
|
||||
state: nextState,
|
||||
...(nextRecordId ? { acpxRecordId: nextRecordId } : {}),
|
||||
...(nextAcpxSessionId ? { acpxSessionId: nextAcpxSessionId } : {}),
|
||||
...(nextAgentSessionId ? { agentSessionId: nextAgentSessionId } : {}),
|
||||
source: nextSource,
|
||||
lastUpdatedAt: params.now,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createIdentityFromEnsure(params: {
|
||||
handle: AcpRuntimeHandle;
|
||||
now: number;
|
||||
}): SessionAcpIdentity | undefined {
|
||||
const acpxRecordId = normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId);
|
||||
const acpxSessionId = normalizeText(params.handle.backendSessionId);
|
||||
const agentSessionId = normalizeText(params.handle.agentSessionId);
|
||||
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
state: "pending",
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(acpxSessionId ? { acpxSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
source: "ensure",
|
||||
lastUpdatedAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIdentityFromStatus(params: {
|
||||
status: AcpRuntimeStatus | undefined;
|
||||
now: number;
|
||||
}): SessionAcpIdentity | undefined {
|
||||
if (!params.status) {
|
||||
return undefined;
|
||||
}
|
||||
const details = params.status.details;
|
||||
const acpxRecordId =
|
||||
normalizeText((params.status as { acpxRecordId?: unknown }).acpxRecordId) ??
|
||||
normalizeText(details?.acpxRecordId);
|
||||
const acpxSessionId =
|
||||
normalizeText(params.status.backendSessionId) ??
|
||||
normalizeText(details?.backendSessionId) ??
|
||||
normalizeText(details?.acpxSessionId);
|
||||
const agentSessionId =
|
||||
normalizeText(params.status.agentSessionId) ?? normalizeText(details?.agentSessionId);
|
||||
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = Boolean(acpxSessionId || agentSessionId);
|
||||
return {
|
||||
state: resolved ? "resolved" : "pending",
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(acpxSessionId ? { acpxSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
source: "status",
|
||||
lastUpdatedAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeHandleIdentifiersFromIdentity(
|
||||
identity: SessionAcpIdentity | undefined,
|
||||
): { backendSessionId?: string; agentSessionId?: string } {
|
||||
if (!identity) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}),
|
||||
...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}),
|
||||
};
|
||||
}
|
||||
165
src/acp/runtime/session-meta.ts
Normal file
165
src/acp/runtime/session-meta.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import path from "node:path";
|
||||
import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
type SessionAcpMeta,
|
||||
type SessionEntry,
|
||||
} from "../../config/sessions/types.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
|
||||
export type AcpSessionStoreEntry = {
|
||||
cfg: OpenClawConfig;
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
storeSessionKey: string;
|
||||
entry?: SessionEntry;
|
||||
acp?: SessionAcpMeta;
|
||||
storeReadFailed?: boolean;
|
||||
};
|
||||
|
||||
function resolveStoreSessionKey(store: Record<string, SessionEntry>, sessionKey: string): string {
|
||||
const normalized = sessionKey.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (store[normalized]) {
|
||||
return normalized;
|
||||
}
|
||||
const lower = normalized.toLowerCase();
|
||||
if (store[lower]) {
|
||||
return lower;
|
||||
}
|
||||
for (const key of Object.keys(store)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function resolveSessionStorePathForAcp(params: {
|
||||
sessionKey: string;
|
||||
cfg?: OpenClawConfig;
|
||||
}): { cfg: OpenClawConfig; storePath: string } {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const parsed = parseAgentSessionKey(params.sessionKey);
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: parsed?.agentId,
|
||||
});
|
||||
return { cfg, storePath };
|
||||
}
|
||||
|
||||
export function readAcpSessionEntry(params: {
|
||||
sessionKey: string;
|
||||
cfg?: OpenClawConfig;
|
||||
}): AcpSessionStoreEntry | null {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const { cfg, storePath } = resolveSessionStorePathForAcp({
|
||||
sessionKey,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
let store: Record<string, SessionEntry>;
|
||||
let storeReadFailed = false;
|
||||
try {
|
||||
store = loadSessionStore(storePath);
|
||||
} catch {
|
||||
storeReadFailed = true;
|
||||
store = {};
|
||||
}
|
||||
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
|
||||
const entry = store[storeSessionKey];
|
||||
return {
|
||||
cfg,
|
||||
storePath,
|
||||
sessionKey,
|
||||
storeSessionKey,
|
||||
entry,
|
||||
acp: entry?.acp,
|
||||
storeReadFailed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAcpSessionEntries(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
}): Promise<AcpSessionStoreEntry[]> {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const stateDir = resolveStateDir(process.env);
|
||||
const sessionDirs = await resolveAgentSessionDirs(stateDir);
|
||||
const entries: AcpSessionStoreEntry[] = [];
|
||||
|
||||
for (const sessionsDir of sessionDirs) {
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
let store: Record<string, SessionEntry>;
|
||||
try {
|
||||
store = loadSessionStore(storePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const [sessionKey, entry] of Object.entries(store)) {
|
||||
if (!entry?.acp) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
cfg,
|
||||
storePath,
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
entry,
|
||||
acp: entry.acp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function upsertAcpSessionMeta(params: {
|
||||
sessionKey: string;
|
||||
cfg?: OpenClawConfig;
|
||||
mutate: (
|
||||
current: SessionAcpMeta | undefined,
|
||||
entry: SessionEntry | undefined,
|
||||
) => SessionAcpMeta | null | undefined;
|
||||
}): Promise<SessionEntry | null> {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const { storePath } = resolveSessionStorePathForAcp({
|
||||
sessionKey,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
return await updateSessionStore(
|
||||
storePath,
|
||||
(store) => {
|
||||
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
|
||||
const currentEntry = store[storeSessionKey];
|
||||
const nextMeta = params.mutate(currentEntry?.acp, currentEntry);
|
||||
if (nextMeta === undefined) {
|
||||
return currentEntry ?? null;
|
||||
}
|
||||
if (nextMeta === null && !currentEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextEntry = mergeSessionEntry(currentEntry, {
|
||||
acp: nextMeta ?? undefined,
|
||||
});
|
||||
if (nextMeta === null) {
|
||||
delete nextEntry.acp;
|
||||
}
|
||||
store[storeSessionKey] = nextEntry;
|
||||
return nextEntry;
|
||||
},
|
||||
{
|
||||
activeSessionKey: sessionKey.toLowerCase(),
|
||||
},
|
||||
);
|
||||
}
|
||||
110
src/acp/runtime/types.ts
Normal file
110
src/acp/runtime/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export type AcpRuntimePromptMode = "prompt" | "steer";
|
||||
|
||||
export type AcpRuntimeSessionMode = "persistent" | "oneshot";
|
||||
|
||||
export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status";
|
||||
|
||||
export type AcpRuntimeHandle = {
|
||||
sessionKey: string;
|
||||
backend: string;
|
||||
runtimeSessionName: string;
|
||||
/** Effective runtime working directory for this ACP session, if exposed by adapter/runtime. */
|
||||
cwd?: string;
|
||||
/** Backend-local record identifier, if exposed by adapter/runtime (for example acpx record id). */
|
||||
acpxRecordId?: string;
|
||||
/** Backend-level ACP session identifier, if exposed by adapter/runtime. */
|
||||
backendSessionId?: string;
|
||||
/** Upstream harness session identifier, if exposed by adapter/runtime. */
|
||||
agentSessionId?: string;
|
||||
};
|
||||
|
||||
export type AcpRuntimeEnsureInput = {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeTurnInput = {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
mode: AcpRuntimePromptMode;
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type AcpRuntimeCapabilities = {
|
||||
controls: AcpRuntimeControl[];
|
||||
/**
|
||||
* Optional backend-advertised option keys for session/set_config_option.
|
||||
* Empty/undefined means "backend accepts keys, but did not advertise a strict list".
|
||||
*/
|
||||
configOptionKeys?: string[];
|
||||
};
|
||||
|
||||
export type AcpRuntimeStatus = {
|
||||
summary?: string;
|
||||
/** Backend-local record identifier, if exposed by adapter/runtime. */
|
||||
acpxRecordId?: string;
|
||||
/** Backend-level ACP session identifier, if known at status time. */
|
||||
backendSessionId?: string;
|
||||
/** Upstream harness session identifier, if known at status time. */
|
||||
agentSessionId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeDoctorReport = {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
message: string;
|
||||
installCommand?: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
export type AcpRuntimeEvent =
|
||||
| {
|
||||
type: "text_delta";
|
||||
text: string;
|
||||
stream?: "output" | "thought";
|
||||
}
|
||||
| {
|
||||
type: "status";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "tool_call";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "done";
|
||||
stopReason?: string;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export interface AcpRuntime {
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
|
||||
getCapabilities?(input: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): Promise<AcpRuntimeCapabilities> | AcpRuntimeCapabilities;
|
||||
|
||||
getStatus?(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus>;
|
||||
|
||||
setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
|
||||
setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
|
||||
doctor?(): Promise<AcpRuntimeDoctorReport>;
|
||||
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user