Onur Solmaz a7d56e3554
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)
2026-02-26 11:00:09 +01:00

204 lines
7.2 KiB
TypeScript

import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import { formatAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import { toAcpRuntimeError } from "../../../acp/runtime/errors.js";
import { getAcpRuntimeBackend, requireAcpRuntimeBackend } from "../../../acp/runtime/registry.js";
import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js";
import { loadSessionStore } from "../../../config/sessions.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import {
ACP_DOCTOR_USAGE,
ACP_INSTALL_USAGE,
ACP_SESSIONS_USAGE,
formatAcpCapabilitiesText,
resolveAcpInstallCommandHint,
resolveConfiguredAcpBackendId,
stopWithText,
} from "./shared.js";
import { resolveBoundAcpThreadSessionKey } from "./targets.js";
export async function handleAcpDoctorAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_DOCTOR_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const registeredBackend = getAcpRuntimeBackend(backendId);
const managerSnapshot = getAcpSessionManager().getObservabilitySnapshot(params.cfg);
const lines = ["ACP doctor:", "-----", `configuredBackend: ${backendId}`];
lines.push(`activeRuntimeSessions: ${managerSnapshot.runtimeCache.activeSessions}`);
lines.push(`runtimeIdleTtlMs: ${managerSnapshot.runtimeCache.idleTtlMs}`);
lines.push(`evictedIdleRuntimes: ${managerSnapshot.runtimeCache.evictedTotal}`);
lines.push(`activeTurns: ${managerSnapshot.turns.active}`);
lines.push(`queueDepth: ${managerSnapshot.turns.queueDepth}`);
lines.push(
`turnLatencyMs: avg=${managerSnapshot.turns.averageLatencyMs}, max=${managerSnapshot.turns.maxLatencyMs}`,
);
lines.push(
`turnCounts: completed=${managerSnapshot.turns.completed}, failed=${managerSnapshot.turns.failed}`,
);
const errorStatsText =
Object.entries(managerSnapshot.errorsByCode)
.map(([code, count]) => `${code}=${count}`)
.join(", ") || "(none)";
lines.push(`errorCodes: ${errorStatsText}`);
if (registeredBackend) {
lines.push(`registeredBackend: ${registeredBackend.id}`);
} else {
lines.push("registeredBackend: (none)");
}
if (registeredBackend?.runtime.doctor) {
try {
const report = await registeredBackend.runtime.doctor();
lines.push(`runtimeDoctor: ${report.ok ? "ok" : "error"} (${report.message})`);
if (report.code) {
lines.push(`runtimeDoctorCode: ${report.code}`);
}
if (report.installCommand) {
lines.push(`runtimeDoctorInstall: ${report.installCommand}`);
}
for (const detail of report.details ?? []) {
lines.push(`runtimeDoctorDetail: ${detail}`);
}
} catch (error) {
lines.push(
`runtimeDoctor: error (${
toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Runtime doctor failed.",
}).message
})`,
);
}
}
try {
const backend = requireAcpRuntimeBackend(backendId);
const capabilities = backend.runtime.getCapabilities
? await backend.runtime.getCapabilities({})
: { controls: [] as string[], configOptionKeys: [] as string[] };
lines.push("healthy: yes");
lines.push(`capabilities: ${formatAcpCapabilitiesText(capabilities.controls ?? [])}`);
if ((capabilities.configOptionKeys?.length ?? 0) > 0) {
lines.push(`configKeys: ${capabilities.configOptionKeys?.join(", ")}`);
}
return stopWithText(lines.join("\n"));
} catch (error) {
const acpError = toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP backend doctor failed.",
});
lines.push("healthy: no");
lines.push(formatAcpRuntimeErrorText(acpError));
lines.push(`next: ${installHint}`);
lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`);
if (backendId.toLowerCase() === "acpx") {
lines.push("next: verify acpx is installed (`acpx --help`).");
}
return stopWithText(lines.join("\n"));
}
}
export function handleAcpInstallAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_INSTALL_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const lines = [
"ACP install:",
"-----",
`configuredBackend: ${backendId}`,
`run: ${installHint}`,
`then: openclaw config set plugins.entries.${backendId}.enabled true`,
"then: /acp doctor",
];
return stopWithText(lines.join("\n"));
}
function formatAcpSessionLine(params: {
key: string;
entry: SessionEntry;
currentSessionKey?: string;
threadId?: string;
}): string {
const acp = params.entry.acp;
if (!acp) {
return "";
}
const marker = params.currentSessionKey === params.key ? "*" : " ";
const label = params.entry.label?.trim() || acp.agent;
const threadText = params.threadId ? `, thread:${params.threadId}` : "";
return `${marker} ${label} (${acp.mode}, ${acp.state}, backend:${acp.backend}${threadText}) -> ${params.key}`;
}
export function handleAcpSessionsAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(ACP_SESSIONS_USAGE);
}
const currentSessionKey = resolveBoundAcpThreadSessionKey(params) || params.sessionKey;
if (!currentSessionKey) {
return stopWithText("⚠️ Missing session key.");
}
const { storePath } = resolveSessionStorePathForAcp({
cfg: params.cfg,
sessionKey: currentSessionKey,
});
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
} catch {
store = {};
}
const bindingContext = resolveAcpCommandBindingContext(params);
const normalizedChannel = bindingContext.channel;
const normalizedAccountId = bindingContext.accountId || undefined;
const bindingService = getSessionBindingService();
const rows = Object.entries(store)
.filter(([, entry]) => Boolean(entry?.acp))
.toSorted(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))
.slice(0, 20)
.map(([key, entry]) => {
const bindingThreadId = bindingService
.listBySession(key)
.find(
(binding) =>
(!normalizedChannel || binding.conversation.channel === normalizedChannel) &&
(!normalizedAccountId || binding.conversation.accountId === normalizedAccountId),
)?.conversation.conversationId;
return formatAcpSessionLine({
key,
entry,
currentSessionKey,
threadId: bindingThreadId,
});
})
.filter(Boolean);
if (rows.length === 0) {
return stopWithText("ACP sessions:\n-----\n(none)");
}
return stopWithText(["ACP sessions:", "-----", ...rows].join("\n"));
}