mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:51:37 +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:
51
src/auto-reply/reply/commands-acp/context.test.ts
Normal file
51
src/auto-reply/reply/commands-acp/context.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
|
||||
import {
|
||||
isAcpCommandDiscordChannel,
|
||||
resolveAcpCommandBindingContext,
|
||||
resolveAcpCommandConversationId,
|
||||
} from "./context.js";
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
describe("commands-acp context", () => {
|
||||
it("resolves channel/account/thread context from originating fields", () => {
|
||||
const params = buildCommandTestParams("/acp sessions", baseCfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:parent-1",
|
||||
AccountId: "work",
|
||||
MessageThreadId: "thread-42",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
threadId: "thread-42",
|
||||
conversationId: "thread-42",
|
||||
});
|
||||
expect(isAcpCommandDiscordChannel(params)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to default account and target-derived conversation id", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
To: "<#123456789>",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "123456789",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
expect(isAcpCommandDiscordChannel(params)).toBe(false);
|
||||
});
|
||||
});
|
||||
58
src/auto-reply/reply/commands-acp/context.ts
Normal file
58
src/auto-reply/reply/commands-acp/context.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
||||
return `${value}`.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
|
||||
const raw =
|
||||
params.ctx.OriginatingChannel ??
|
||||
params.command.channel ??
|
||||
params.ctx.Surface ??
|
||||
params.ctx.Provider;
|
||||
return normalizeString(raw).toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveAcpCommandAccountId(params: HandleCommandsParams): string {
|
||||
const accountId = normalizeString(params.ctx.AccountId);
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined {
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : "";
|
||||
return threadId || undefined;
|
||||
}
|
||||
|
||||
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
|
||||
return resolveConversationIdFromTargets({
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
|
||||
});
|
||||
}
|
||||
|
||||
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
|
||||
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
|
||||
}
|
||||
|
||||
export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
threadId?: string;
|
||||
conversationId?: string;
|
||||
} {
|
||||
return {
|
||||
channel: resolveAcpCommandChannel(params),
|
||||
accountId: resolveAcpCommandAccountId(params),
|
||||
threadId: resolveAcpCommandThreadId(params),
|
||||
conversationId: resolveAcpCommandConversationId(params),
|
||||
};
|
||||
}
|
||||
203
src/auto-reply/reply/commands-acp/diagnostics.ts
Normal file
203
src/auto-reply/reply/commands-acp/diagnostics.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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"));
|
||||
}
|
||||
588
src/auto-reply/reply/commands-acp/lifecycle.ts
Normal file
588
src/auto-reply/reply/commands-acp/lifecycle.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
|
||||
import {
|
||||
cleanupFailedAcpSpawn,
|
||||
type AcpSpawnRuntimeCloseHandle,
|
||||
} from "../../../acp/control-plane/spawn.js";
|
||||
import {
|
||||
isAcpEnabledByPolicy,
|
||||
resolveAcpAgentPolicyError,
|
||||
resolveAcpDispatchPolicyError,
|
||||
resolveAcpDispatchPolicyMessage,
|
||||
} from "../../../acp/policy.js";
|
||||
import { AcpRuntimeError } from "../../../acp/runtime/errors.js";
|
||||
import {
|
||||
resolveAcpSessionCwd,
|
||||
resolveAcpThreadSessionDetailLines,
|
||||
} from "../../../acp/runtime/session-identifiers.js";
|
||||
import {
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../../channels/thread-bindings-messages.js";
|
||||
import {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
resolveThreadBindingSessionTtlMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../../../channels/thread-bindings-policy.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { SessionAcpMeta } from "../../../config/sessions/types.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
import {
|
||||
resolveAcpCommandAccountId,
|
||||
resolveAcpCommandBindingContext,
|
||||
resolveAcpCommandThreadId,
|
||||
} from "./context.js";
|
||||
import {
|
||||
ACP_STEER_OUTPUT_LIMIT,
|
||||
collectAcpErrorText,
|
||||
parseSpawnInput,
|
||||
parseSteerInput,
|
||||
resolveCommandRequestId,
|
||||
stopWithText,
|
||||
type AcpSpawnThreadMode,
|
||||
withAcpCommandErrorBoundary,
|
||||
} from "./shared.js";
|
||||
import { resolveAcpTargetSessionKey } from "./targets.js";
|
||||
|
||||
async function bindSpawnedAcpSessionToThread(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
threadMode: AcpSpawnThreadMode;
|
||||
sessionMeta?: SessionAcpMeta;
|
||||
}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> {
|
||||
const { commandParams, threadMode } = params;
|
||||
if (threadMode === "off") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "internal: thread binding is disabled for this spawn",
|
||||
};
|
||||
}
|
||||
|
||||
const bindingContext = resolveAcpCommandBindingContext(commandParams);
|
||||
const channel = bindingContext.channel;
|
||||
if (!channel) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ACP thread binding requires a channel context.",
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveAcpCommandAccountId(commandParams);
|
||||
const spawnPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: commandParams.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
kind: "acp",
|
||||
});
|
||||
if (!spawnPolicy.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatThreadBindingDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "acp",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!spawnPolicy.spawnEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatThreadBindingSpawnDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "acp",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const bindingService = getSessionBindingService();
|
||||
const capabilities = bindingService.getCapabilities({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
});
|
||||
if (!capabilities.adapterAvailable) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings are unavailable for ${channel}.`,
|
||||
};
|
||||
}
|
||||
if (!capabilities.bindSupported) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings are unavailable for ${channel}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentThreadId = bindingContext.threadId ?? "";
|
||||
|
||||
if (threadMode === "here" && !currentThreadId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`,
|
||||
};
|
||||
}
|
||||
|
||||
const threadId = currentThreadId || undefined;
|
||||
const placement = threadId ? "current" : "child";
|
||||
if (!capabilities.placements.includes(placement)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings do not support ${placement} placement for ${channel}.`,
|
||||
};
|
||||
}
|
||||
const channelId = placement === "child" ? bindingContext.conversationId : undefined;
|
||||
|
||||
if (placement === "child" && !channelId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
|
||||
};
|
||||
}
|
||||
|
||||
const senderId = commandParams.command.senderId?.trim() || "";
|
||||
if (threadId) {
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: threadId,
|
||||
});
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
? existingBinding.metadata.boundBy.trim()
|
||||
: "";
|
||||
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Only ${boundBy} can rebind this thread.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
const conversationId = threadId || channelId;
|
||||
if (!conversationId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId,
|
||||
},
|
||||
placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
}),
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
boundBy: senderId || "unknown",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({
|
||||
cfg: commandParams.cfg,
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
}),
|
||||
sessionCwd: resolveAcpSessionCwd(params.sessionMeta),
|
||||
sessionDetails: resolveAcpThreadSessionDetailLines({
|
||||
sessionKey: params.sessionKey,
|
||||
meta: params.sessionMeta,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
binding,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
error: message || `Failed to bind a ${channel} thread/conversation to the new ACP session.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFailedSpawn(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
shouldDeleteSession: boolean;
|
||||
initializedRuntime?: AcpSpawnRuntimeCloseHandle;
|
||||
}) {
|
||||
await cleanupFailedAcpSpawn({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
shouldDeleteSession: params.shouldDeleteSession,
|
||||
deleteTranscript: false,
|
||||
runtimeCloseHandle: params.initializedRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpSpawnAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
if (!isAcpEnabledByPolicy(params.cfg)) {
|
||||
return stopWithText("ACP is disabled by policy (`acp.enabled=false`).");
|
||||
}
|
||||
|
||||
const parsed = parseSpawnInput(params, restTokens);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
|
||||
const spawn = parsed.value;
|
||||
const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId);
|
||||
if (agentPolicyError) {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: agentPolicyError,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "ACP target agent is not allowed by policy.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const acpManager = getAcpSessionManager();
|
||||
const sessionKey = `agent:${spawn.agentId}:acp:${randomUUID()}`;
|
||||
|
||||
let initializedBackend = "";
|
||||
let initializedMeta: SessionAcpMeta | undefined;
|
||||
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
|
||||
try {
|
||||
const initialized = await acpManager.initializeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
agent: spawn.agentId,
|
||||
mode: spawn.mode,
|
||||
cwd: spawn.cwd,
|
||||
});
|
||||
initializedRuntime = {
|
||||
runtime: initialized.runtime,
|
||||
handle: initialized.handle,
|
||||
};
|
||||
initializedBackend = initialized.handle.backend || initialized.meta.backend;
|
||||
initializedMeta = initialized.meta;
|
||||
} catch (err) {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: err,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Could not initialize ACP session runtime.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let binding: SessionBindingRecord | null = null;
|
||||
if (spawn.thread !== "off") {
|
||||
const bound = await bindSpawnedAcpSessionToThread({
|
||||
commandParams: params,
|
||||
sessionKey,
|
||||
agentId: spawn.agentId,
|
||||
label: spawn.label,
|
||||
threadMode: spawn.thread,
|
||||
sessionMeta: initializedMeta,
|
||||
});
|
||||
if (!bound.ok) {
|
||||
await cleanupFailedSpawn({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
shouldDeleteSession: true,
|
||||
initializedRuntime,
|
||||
});
|
||||
return stopWithText(`⚠️ ${bound.error}`);
|
||||
}
|
||||
binding = bound.binding;
|
||||
}
|
||||
|
||||
try {
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: sessionKey,
|
||||
...(spawn.label ? { label: spawn.label } : {}),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
} catch (err) {
|
||||
await cleanupFailedSpawn({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
shouldDeleteSession: true,
|
||||
initializedRuntime,
|
||||
});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return stopWithText(`⚠️ ACP spawn failed: ${message}`);
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`,
|
||||
];
|
||||
if (binding) {
|
||||
const currentThreadId = resolveAcpCommandThreadId(params) ?? "";
|
||||
const boundConversationId = binding.conversation.conversationId.trim();
|
||||
if (currentThreadId && boundConversationId === currentThreadId) {
|
||||
parts.push(`Bound this thread to ${sessionKey}.`);
|
||||
} else {
|
||||
parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`);
|
||||
}
|
||||
} else {
|
||||
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
|
||||
}
|
||||
|
||||
const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg);
|
||||
if (dispatchNote) {
|
||||
parts.push(`ℹ️ ${dispatchNote}`);
|
||||
}
|
||||
|
||||
return stopWithText(parts.join(" "));
|
||||
}
|
||||
|
||||
export async function handleAcpCancelAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const acpManager = getAcpSessionManager();
|
||||
const token = restTokens.join(" ").trim() || undefined;
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
const resolved = acpManager.resolveSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
});
|
||||
if (resolved.kind === "none") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${target.sessionKey}`,
|
||||
),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Session is not ACP-enabled.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resolved.kind === "stale") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: resolved.error,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: resolved.error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () =>
|
||||
await acpManager.cancelSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
reason: "manual-cancel",
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP cancel failed before completion.",
|
||||
onSuccess: () => stopWithText(`✅ Cancel requested for ACP session ${target.sessionKey}.`),
|
||||
});
|
||||
}
|
||||
|
||||
async function runAcpSteer(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
instruction: string;
|
||||
requestId: string;
|
||||
}): Promise<string> {
|
||||
const acpManager = getAcpSessionManager();
|
||||
let output = "";
|
||||
|
||||
await acpManager.runTurn({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
text: params.instruction,
|
||||
mode: "steer",
|
||||
requestId: params.requestId,
|
||||
onEvent: (event) => {
|
||||
if (event.type !== "text_delta") {
|
||||
return;
|
||||
}
|
||||
if (event.stream && event.stream !== "output") {
|
||||
return;
|
||||
}
|
||||
if (event.text) {
|
||||
output += event.text;
|
||||
if (output.length > ACP_STEER_OUTPUT_LIMIT) {
|
||||
output = `${output.slice(0, ACP_STEER_OUTPUT_LIMIT)}…`;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
export async function handleAcpSteerAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const dispatchPolicyError = resolveAcpDispatchPolicyError(params.cfg);
|
||||
if (dispatchPolicyError) {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: dispatchPolicyError,
|
||||
fallbackCode: "ACP_DISPATCH_DISABLED",
|
||||
fallbackMessage: dispatchPolicyError.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseSteerInput(restTokens);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const acpManager = getAcpSessionManager();
|
||||
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
const resolved = acpManager.resolveSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
});
|
||||
if (resolved.kind === "none") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${target.sessionKey}`,
|
||||
),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Session is not ACP-enabled.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resolved.kind === "stale") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: resolved.error,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: resolved.error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () =>
|
||||
await runAcpSteer({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
instruction: parsed.value.instruction,
|
||||
requestId: `${resolveCommandRequestId(params)}:steer`,
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP steer failed before completion.",
|
||||
onSuccess: (steerOutput) => {
|
||||
if (!steerOutput) {
|
||||
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.`);
|
||||
}
|
||||
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.\n${steerOutput}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpCloseAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const acpManager = getAcpSessionManager();
|
||||
const token = restTokens.join(" ").trim() || undefined;
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
const resolved = acpManager.resolveSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
});
|
||||
if (resolved.kind === "none") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${target.sessionKey}`,
|
||||
),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Session is not ACP-enabled.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resolved.kind === "stale") {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error: resolved.error,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: resolved.error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let runtimeNotice = "";
|
||||
try {
|
||||
const closed = await acpManager.closeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
reason: "manual-close",
|
||||
allowBackendUnavailable: true,
|
||||
clearMeta: true,
|
||||
});
|
||||
runtimeNotice = closed.runtimeNotice ? ` (${closed.runtimeNotice})` : "";
|
||||
} catch (error) {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP close failed before completion.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const removedBindings = await getSessionBindingService().unbind({
|
||||
targetSessionKey: target.sessionKey,
|
||||
reason: "manual",
|
||||
});
|
||||
|
||||
return stopWithText(
|
||||
`✅ Closed ACP session ${target.sessionKey}${runtimeNotice}. Removed ${removedBindings.length} binding${removedBindings.length === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
348
src/auto-reply/reply/commands-acp/runtime-options.ts
Normal file
348
src/auto-reply/reply/commands-acp/runtime-options.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
|
||||
import {
|
||||
parseRuntimeTimeoutSecondsInput,
|
||||
validateRuntimeConfigOptionInput,
|
||||
validateRuntimeCwdInput,
|
||||
validateRuntimeModeInput,
|
||||
validateRuntimeModelInput,
|
||||
validateRuntimePermissionProfileInput,
|
||||
} from "../../../acp/control-plane/runtime-options.js";
|
||||
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
import {
|
||||
ACP_CWD_USAGE,
|
||||
ACP_MODEL_USAGE,
|
||||
ACP_PERMISSIONS_USAGE,
|
||||
ACP_RESET_OPTIONS_USAGE,
|
||||
ACP_SET_MODE_USAGE,
|
||||
ACP_STATUS_USAGE,
|
||||
ACP_TIMEOUT_USAGE,
|
||||
formatAcpCapabilitiesText,
|
||||
formatRuntimeOptionsText,
|
||||
parseOptionalSingleTarget,
|
||||
parseSetCommandInput,
|
||||
parseSingleValueCommandInput,
|
||||
stopWithText,
|
||||
withAcpCommandErrorBoundary,
|
||||
} from "./shared.js";
|
||||
import { resolveAcpTargetSessionKey } from "./targets.js";
|
||||
|
||||
export async function handleAcpStatusAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseOptionalSingleTarget(restTokens, ACP_STATUS_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () =>
|
||||
await getAcpSessionManager().getSessionStatus({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not read ACP session status.",
|
||||
onSuccess: (status) => {
|
||||
const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({
|
||||
backend: status.backend,
|
||||
identity: status.identity,
|
||||
});
|
||||
const lines = [
|
||||
"ACP status:",
|
||||
"-----",
|
||||
`session: ${status.sessionKey}`,
|
||||
`backend: ${status.backend}`,
|
||||
`agent: ${status.agent}`,
|
||||
...sessionIdentifierLines,
|
||||
`sessionMode: ${status.mode}`,
|
||||
`state: ${status.state}`,
|
||||
`runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`,
|
||||
`capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`,
|
||||
`lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`,
|
||||
...(status.lastError ? [`lastError: ${status.lastError}`] : []),
|
||||
...(status.runtimeStatus?.summary ? [`runtime: ${status.runtimeStatus.summary}`] : []),
|
||||
...(status.runtimeStatus?.details
|
||||
? [`runtimeDetails: ${JSON.stringify(status.runtimeStatus.details)}`]
|
||||
: []),
|
||||
];
|
||||
return stopWithText(lines.join("\n"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpSetModeAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSingleValueCommandInput(restTokens, ACP_SET_MODE_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const runtimeMode = validateRuntimeModeInput(parsed.value.value);
|
||||
const options = await getAcpSessionManager().setSessionRuntimeMode({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
runtimeMode,
|
||||
});
|
||||
return {
|
||||
runtimeMode,
|
||||
options,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP runtime mode.",
|
||||
onSuccess: ({ runtimeMode, options }) =>
|
||||
stopWithText(
|
||||
`✅ Updated ACP runtime mode for ${target.sessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpSetAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSetCommandInput(restTokens);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
const key = parsed.value.key.trim();
|
||||
const value = parsed.value.value.trim();
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === "cwd") {
|
||||
const cwd = validateRuntimeCwdInput(value);
|
||||
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
patch: { cwd },
|
||||
});
|
||||
return {
|
||||
text: `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
};
|
||||
}
|
||||
const validated = validateRuntimeConfigOptionInput(key, value);
|
||||
const options = await getAcpSessionManager().setSessionConfigOption({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
key: validated.key,
|
||||
value: validated.value,
|
||||
});
|
||||
return {
|
||||
text: `✅ Updated ACP config option for ${target.sessionKey}: ${validated.key}=${validated.value}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP config option.",
|
||||
onSuccess: ({ text }) => stopWithText(text),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpCwdAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSingleValueCommandInput(restTokens, ACP_CWD_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const cwd = validateRuntimeCwdInput(parsed.value.value);
|
||||
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
patch: { cwd },
|
||||
});
|
||||
return {
|
||||
cwd,
|
||||
options,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP cwd.",
|
||||
onSuccess: ({ cwd, options }) =>
|
||||
stopWithText(
|
||||
`✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpPermissionsAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSingleValueCommandInput(restTokens, ACP_PERMISSIONS_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const permissionProfile = validateRuntimePermissionProfileInput(parsed.value.value);
|
||||
const options = await getAcpSessionManager().setSessionConfigOption({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
key: "approval_policy",
|
||||
value: permissionProfile,
|
||||
});
|
||||
return {
|
||||
permissionProfile,
|
||||
options,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP permissions profile.",
|
||||
onSuccess: ({ permissionProfile, options }) =>
|
||||
stopWithText(
|
||||
`✅ Updated ACP permissions profile for ${target.sessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpTimeoutAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSingleValueCommandInput(restTokens, ACP_TIMEOUT_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const timeoutSeconds = parseRuntimeTimeoutSecondsInput(parsed.value.value);
|
||||
const options = await getAcpSessionManager().setSessionConfigOption({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
key: "timeout",
|
||||
value: String(timeoutSeconds),
|
||||
});
|
||||
return {
|
||||
timeoutSeconds,
|
||||
options,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP timeout.",
|
||||
onSuccess: ({ timeoutSeconds, options }) =>
|
||||
stopWithText(
|
||||
`✅ Updated ACP timeout for ${target.sessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpModelAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseSingleValueCommandInput(restTokens, ACP_MODEL_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.value.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () => {
|
||||
const model = validateRuntimeModelInput(parsed.value.value);
|
||||
const options = await getAcpSessionManager().setSessionConfigOption({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
key: "model",
|
||||
value: model,
|
||||
});
|
||||
return {
|
||||
model,
|
||||
options,
|
||||
};
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not update ACP model.",
|
||||
onSuccess: ({ model, options }) =>
|
||||
stopWithText(
|
||||
`✅ Updated ACP model for ${target.sessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAcpResetOptionsAction(
|
||||
params: HandleCommandsParams,
|
||||
restTokens: string[],
|
||||
): Promise<CommandHandlerResult> {
|
||||
const parsed = parseOptionalSingleTarget(restTokens, ACP_RESET_OPTIONS_USAGE);
|
||||
if (!parsed.ok) {
|
||||
return stopWithText(`⚠️ ${parsed.error}`);
|
||||
}
|
||||
const target = await resolveAcpTargetSessionKey({
|
||||
commandParams: params,
|
||||
token: parsed.sessionToken,
|
||||
});
|
||||
if (!target.ok) {
|
||||
return stopWithText(`⚠️ ${target.error}`);
|
||||
}
|
||||
|
||||
return await withAcpCommandErrorBoundary({
|
||||
run: async () =>
|
||||
await getAcpSessionManager().resetSessionRuntimeOptions({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.sessionKey,
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "Could not reset ACP runtime options.",
|
||||
onSuccess: () => stopWithText(`✅ Reset ACP runtime options for ${target.sessionKey}.`),
|
||||
});
|
||||
}
|
||||
500
src/auto-reply/reply/commands-acp/shared.ts
Normal file
500
src/auto-reply/reply/commands-acp/shared.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
|
||||
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
|
||||
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
|
||||
import { normalizeAgentId } from "../../../routing/session-key.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js";
|
||||
|
||||
export const COMMAND = "/acp";
|
||||
export const ACP_SPAWN_USAGE =
|
||||
"Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
|
||||
export const ACP_STEER_USAGE =
|
||||
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
|
||||
export const ACP_SET_MODE_USAGE =
|
||||
"Usage: /acp set-mode <mode> [session-key|session-id|session-label]";
|
||||
export const ACP_SET_USAGE = "Usage: /acp set <key> <value> [session-key|session-id|session-label]";
|
||||
export const ACP_CWD_USAGE = "Usage: /acp cwd <path> [session-key|session-id|session-label]";
|
||||
export const ACP_PERMISSIONS_USAGE =
|
||||
"Usage: /acp permissions <profile> [session-key|session-id|session-label]";
|
||||
export const ACP_TIMEOUT_USAGE =
|
||||
"Usage: /acp timeout <seconds> [session-key|session-id|session-label]";
|
||||
export const ACP_MODEL_USAGE =
|
||||
"Usage: /acp model <model-id> [session-key|session-id|session-label]";
|
||||
export const ACP_RESET_OPTIONS_USAGE =
|
||||
"Usage: /acp reset-options [session-key|session-id|session-label]";
|
||||
export const ACP_STATUS_USAGE = "Usage: /acp status [session-key|session-id|session-label]";
|
||||
export const ACP_INSTALL_USAGE = "Usage: /acp install";
|
||||
export const ACP_DOCTOR_USAGE = "Usage: /acp doctor";
|
||||
export const ACP_SESSIONS_USAGE = "Usage: /acp sessions";
|
||||
export const ACP_STEER_OUTPUT_LIMIT = 800;
|
||||
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export type AcpAction =
|
||||
| "spawn"
|
||||
| "cancel"
|
||||
| "steer"
|
||||
| "close"
|
||||
| "sessions"
|
||||
| "status"
|
||||
| "set-mode"
|
||||
| "set"
|
||||
| "cwd"
|
||||
| "permissions"
|
||||
| "timeout"
|
||||
| "model"
|
||||
| "reset-options"
|
||||
| "doctor"
|
||||
| "install"
|
||||
| "help";
|
||||
|
||||
export type AcpSpawnThreadMode = "auto" | "here" | "off";
|
||||
|
||||
export type ParsedSpawnInput = {
|
||||
agentId: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
thread: AcpSpawnThreadMode;
|
||||
cwd?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type ParsedSteerInput = {
|
||||
sessionToken?: string;
|
||||
instruction: string;
|
||||
};
|
||||
|
||||
export type ParsedSingleValueCommandInput = {
|
||||
value: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
export type ParsedSetCommandInput = {
|
||||
key: string;
|
||||
value: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
export function stopWithText(text: string): CommandHandlerResult {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAcpAction(tokens: string[]): AcpAction {
|
||||
const action = tokens[0]?.trim().toLowerCase();
|
||||
if (
|
||||
action === "spawn" ||
|
||||
action === "cancel" ||
|
||||
action === "steer" ||
|
||||
action === "close" ||
|
||||
action === "sessions" ||
|
||||
action === "status" ||
|
||||
action === "set-mode" ||
|
||||
action === "set" ||
|
||||
action === "cwd" ||
|
||||
action === "permissions" ||
|
||||
action === "timeout" ||
|
||||
action === "model" ||
|
||||
action === "reset-options" ||
|
||||
action === "doctor" ||
|
||||
action === "install" ||
|
||||
action === "help"
|
||||
) {
|
||||
tokens.shift();
|
||||
return action;
|
||||
}
|
||||
return "help";
|
||||
}
|
||||
|
||||
function readOptionValue(params: { tokens: string[]; index: number; flag: string }):
|
||||
| {
|
||||
matched: true;
|
||||
value?: string;
|
||||
nextIndex: number;
|
||||
error?: string;
|
||||
}
|
||||
| { matched: false } {
|
||||
const token = params.tokens[params.index] ?? "";
|
||||
if (token === params.flag) {
|
||||
const nextValue = params.tokens[params.index + 1]?.trim() ?? "";
|
||||
if (!nextValue || nextValue.startsWith("--")) {
|
||||
return {
|
||||
matched: true,
|
||||
nextIndex: params.index + 1,
|
||||
error: `${params.flag} requires a value`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
value: nextValue,
|
||||
nextIndex: params.index + 2,
|
||||
};
|
||||
}
|
||||
if (token.startsWith(`${params.flag}=`)) {
|
||||
const value = token.slice(`${params.flag}=`.length).trim();
|
||||
if (!value) {
|
||||
return {
|
||||
matched: true,
|
||||
nextIndex: params.index + 1,
|
||||
error: `${params.flag} requires a value`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
value,
|
||||
nextIndex: params.index + 1,
|
||||
};
|
||||
}
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
|
||||
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
return "off";
|
||||
}
|
||||
const currentThreadId = resolveAcpCommandThreadId(params);
|
||||
return currentThreadId ? "here" : "auto";
|
||||
}
|
||||
|
||||
export function parseSpawnInput(
|
||||
params: HandleCommandsParams,
|
||||
tokens: string[],
|
||||
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
|
||||
let mode: AcpRuntimeSessionMode = "persistent";
|
||||
let thread = resolveDefaultSpawnThreadMode(params);
|
||||
let cwd: string | undefined;
|
||||
let label: string | undefined;
|
||||
let rawAgentId: string | undefined;
|
||||
|
||||
for (let i = 0; i < tokens.length; ) {
|
||||
const token = tokens[i] ?? "";
|
||||
|
||||
const modeOption = readOptionValue({ tokens, index: i, flag: "--mode" });
|
||||
if (modeOption.matched) {
|
||||
if (modeOption.error) {
|
||||
return { ok: false, error: `${modeOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
}
|
||||
const raw = modeOption.value?.trim().toLowerCase();
|
||||
if (raw !== "persistent" && raw !== "oneshot") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid --mode value "${modeOption.value}". Use persistent or oneshot.`,
|
||||
};
|
||||
}
|
||||
mode = raw;
|
||||
i = modeOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const threadOption = readOptionValue({ tokens, index: i, flag: "--thread" });
|
||||
if (threadOption.matched) {
|
||||
if (threadOption.error) {
|
||||
return { ok: false, error: `${threadOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
}
|
||||
const raw = threadOption.value?.trim().toLowerCase();
|
||||
if (raw !== "auto" && raw !== "here" && raw !== "off") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid --thread value "${threadOption.value}". Use auto, here, or off.`,
|
||||
};
|
||||
}
|
||||
thread = raw;
|
||||
i = threadOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cwdOption = readOptionValue({ tokens, index: i, flag: "--cwd" });
|
||||
if (cwdOption.matched) {
|
||||
if (cwdOption.error) {
|
||||
return { ok: false, error: `${cwdOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
}
|
||||
cwd = cwdOption.value?.trim();
|
||||
i = cwdOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelOption = readOptionValue({ tokens, index: i, flag: "--label" });
|
||||
if (labelOption.matched) {
|
||||
if (labelOption.error) {
|
||||
return { ok: false, error: `${labelOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
}
|
||||
label = labelOption.value?.trim();
|
||||
i = labelOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("--")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown option: ${token}. ${ACP_SPAWN_USAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!rawAgentId) {
|
||||
rawAgentId = token.trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unexpected argument: ${token}. ${ACP_SPAWN_USAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackAgent = params.cfg.acp?.defaultAgent?.trim() || "";
|
||||
const selectedAgent = (rawAgentId?.trim() || fallbackAgent).trim();
|
||||
if (!selectedAgent) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ACP target agent is required. Pass an agent id or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
|
||||
};
|
||||
}
|
||||
const normalizedAgentId = normalizeAgentId(selectedAgent);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
agentId: normalizedAgentId,
|
||||
mode,
|
||||
thread,
|
||||
cwd,
|
||||
label: label || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSteerInput(
|
||||
tokens: string[],
|
||||
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
|
||||
let sessionToken: string | undefined;
|
||||
const instructionTokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; ) {
|
||||
const sessionOption = readOptionValue({
|
||||
tokens,
|
||||
index: i,
|
||||
flag: "--session",
|
||||
});
|
||||
if (sessionOption.matched) {
|
||||
if (sessionOption.error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${sessionOption.error}. ${ACP_STEER_USAGE}`,
|
||||
};
|
||||
}
|
||||
sessionToken = sessionOption.value?.trim() || undefined;
|
||||
i = sessionOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
instructionTokens.push(tokens[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const instruction = instructionTokens.join(" ").trim();
|
||||
if (!instruction) {
|
||||
return {
|
||||
ok: false,
|
||||
error: ACP_STEER_USAGE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionToken,
|
||||
instruction,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSingleValueCommandInput(
|
||||
tokens: string[],
|
||||
usage: string,
|
||||
): { ok: true; value: ParsedSingleValueCommandInput } | { ok: false; error: string } {
|
||||
const value = tokens[0]?.trim() || "";
|
||||
if (!value) {
|
||||
return { ok: false, error: usage };
|
||||
}
|
||||
if (tokens.length > 2) {
|
||||
return { ok: false, error: usage };
|
||||
}
|
||||
const sessionToken = tokens[1]?.trim() || undefined;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
value,
|
||||
sessionToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSetCommandInput(
|
||||
tokens: string[],
|
||||
): { ok: true; value: ParsedSetCommandInput } | { ok: false; error: string } {
|
||||
const key = tokens[0]?.trim() || "";
|
||||
const value = tokens[1]?.trim() || "";
|
||||
if (!key || !value) {
|
||||
return {
|
||||
ok: false,
|
||||
error: ACP_SET_USAGE,
|
||||
};
|
||||
}
|
||||
if (tokens.length > 3) {
|
||||
return {
|
||||
ok: false,
|
||||
error: ACP_SET_USAGE,
|
||||
};
|
||||
}
|
||||
const sessionToken = tokens[2]?.trim() || undefined;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
key,
|
||||
value,
|
||||
sessionToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOptionalSingleTarget(
|
||||
tokens: string[],
|
||||
usage: string,
|
||||
): { ok: true; sessionToken?: string } | { ok: false; error: string } {
|
||||
if (tokens.length > 1) {
|
||||
return { ok: false, error: usage };
|
||||
}
|
||||
const token = tokens[0]?.trim() || "";
|
||||
return {
|
||||
ok: true,
|
||||
...(token ? { sessionToken: token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAcpHelpText(): string {
|
||||
return [
|
||||
"ACP commands:",
|
||||
"-----",
|
||||
"/acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
|
||||
"/acp cancel [session-key|session-id|session-label]",
|
||||
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
|
||||
"/acp close [session-key|session-id|session-label]",
|
||||
"/acp status [session-key|session-id|session-label]",
|
||||
"/acp set-mode <mode> [session-key|session-id|session-label]",
|
||||
"/acp set <key> <value> [session-key|session-id|session-label]",
|
||||
"/acp cwd <path> [session-key|session-id|session-label]",
|
||||
"/acp permissions <profile> [session-key|session-id|session-label]",
|
||||
"/acp timeout <seconds> [session-key|session-id|session-label]",
|
||||
"/acp model <model-id> [session-key|session-id|session-label]",
|
||||
"/acp reset-options [session-key|session-id|session-label]",
|
||||
"/acp doctor",
|
||||
"/acp install",
|
||||
"/acp sessions",
|
||||
"",
|
||||
"Notes:",
|
||||
"- /focus and /unfocus also work with ACP session keys.",
|
||||
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBackendId(cfg: OpenClawConfig): string {
|
||||
return cfg.acp?.backend?.trim() || "acpx";
|
||||
}
|
||||
|
||||
export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
|
||||
const configured = cfg.acp?.runtime?.installCommand?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const backendId = resolveConfiguredAcpBackendId(cfg).toLowerCase();
|
||||
if (backendId === "acpx") {
|
||||
const localPath = path.resolve(process.cwd(), "extensions/acpx");
|
||||
if (existsSync(localPath)) {
|
||||
return `openclaw plugins install ${localPath}`;
|
||||
}
|
||||
return "openclaw plugins install @openclaw/acpx";
|
||||
}
|
||||
return `Install and enable the plugin that provides ACP backend "${backendId}".`;
|
||||
}
|
||||
|
||||
export function formatRuntimeOptionsText(options: AcpSessionRuntimeOptions): string {
|
||||
const extras = options.backendExtras
|
||||
? Object.entries(options.backendExtras)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ")
|
||||
: "";
|
||||
const parts = [
|
||||
options.runtimeMode ? `runtimeMode=${options.runtimeMode}` : null,
|
||||
options.model ? `model=${options.model}` : null,
|
||||
options.cwd ? `cwd=${options.cwd}` : null,
|
||||
options.permissionProfile ? `permissionProfile=${options.permissionProfile}` : null,
|
||||
typeof options.timeoutSeconds === "number" ? `timeoutSeconds=${options.timeoutSeconds}` : null,
|
||||
extras ? `extras={${extras}}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
if (parts.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function formatAcpCapabilitiesText(controls: string[]): string {
|
||||
if (controls.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
return controls.toSorted().join(", ");
|
||||
}
|
||||
|
||||
export function resolveCommandRequestId(params: HandleCommandsParams): string {
|
||||
const value =
|
||||
params.ctx.MessageSidFull ??
|
||||
params.ctx.MessageSid ??
|
||||
params.ctx.MessageSidFirst ??
|
||||
params.ctx.MessageSidLast;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function collectAcpErrorText(params: {
|
||||
error: unknown;
|
||||
fallbackCode: AcpRuntimeError["code"];
|
||||
fallbackMessage: string;
|
||||
}): string {
|
||||
return toAcpRuntimeErrorText({
|
||||
error: params.error,
|
||||
fallbackCode: params.fallbackCode,
|
||||
fallbackMessage: params.fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withAcpCommandErrorBoundary<T>(params: {
|
||||
run: () => Promise<T>;
|
||||
fallbackCode: AcpRuntimeError["code"];
|
||||
fallbackMessage: string;
|
||||
onSuccess: (value: T) => CommandHandlerResult;
|
||||
}): Promise<CommandHandlerResult> {
|
||||
try {
|
||||
const result = await params.run();
|
||||
return params.onSuccess(result);
|
||||
} catch (error) {
|
||||
return stopWithText(
|
||||
collectAcpErrorText({
|
||||
error,
|
||||
fallbackCode: params.fallbackCode,
|
||||
fallbackMessage: params.fallbackMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/auto-reply/reply/commands-acp/targets.ts
Normal file
90
src/auto-reply/reply/commands-acp/targets.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { resolveAcpCommandBindingContext } from "./context.js";
|
||||
import { SESSION_ID_RE } from "./shared.js";
|
||||
|
||||
async function resolveSessionKeyByToken(token: string): Promise<string | null> {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const attempts: Array<Record<string, string>> = [{ key: trimmed }];
|
||||
if (SESSION_ID_RE.test(trimmed)) {
|
||||
attempts.push({ sessionId: trimmed });
|
||||
}
|
||||
attempts.push({ label: trimmed });
|
||||
|
||||
for (const params of attempts) {
|
||||
try {
|
||||
const resolved = await callGateway<{ key?: string }>({
|
||||
method: "sessions.resolve",
|
||||
params,
|
||||
timeoutMs: 8_000,
|
||||
});
|
||||
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
} catch {
|
||||
// Try next resolver strategy.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
|
||||
const bindingContext = resolveAcpCommandBindingContext(params);
|
||||
if (!bindingContext.channel || !bindingContext.conversationId) {
|
||||
return undefined;
|
||||
}
|
||||
const binding = getSessionBindingService().resolveByConversation({
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
});
|
||||
if (!binding || binding.targetKind !== "session") {
|
||||
return undefined;
|
||||
}
|
||||
return binding.targetSessionKey.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function resolveAcpTargetSessionKey(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
token?: string;
|
||||
}): Promise<{ ok: true; sessionKey: string } | { ok: false; error: string }> {
|
||||
const token = params.token?.trim() || "";
|
||||
if (token) {
|
||||
const resolved = await resolveSessionKeyByToken(token);
|
||||
if (!resolved) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unable to resolve session target: ${token}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, sessionKey: resolved };
|
||||
}
|
||||
|
||||
const threadBound = resolveBoundAcpThreadSessionKey(params.commandParams);
|
||||
if (threadBound) {
|
||||
return {
|
||||
ok: true,
|
||||
sessionKey: threadBound,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = resolveRequesterSessionKey(params.commandParams, {
|
||||
preferCommandTarget: true,
|
||||
});
|
||||
if (!fallback) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Missing session key.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
sessionKey: fallback,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user