| import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; |
| import { resolveSessionAgentId } from "../../agents/agent-scope.js"; |
| import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; |
| import { |
| listSubagentRunsForController, |
| markSubagentRunTerminated, |
| } from "../../agents/subagent-registry.js"; |
| import { |
| resolveInternalSessionKey, |
| resolveMainSessionAlias, |
| } from "../../agents/tools/sessions-helpers.js"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import { |
| loadSessionStore, |
| resolveSessionStoreEntry, |
| resolveStorePath, |
| type SessionEntry, |
| updateSessionStore, |
| } from "../../config/sessions.js"; |
| import { logVerbose } from "../../globals.js"; |
| import { parseAgentSessionKey } from "../../routing/session-key.js"; |
| import { resolveCommandAuthorization } from "../command-auth.js"; |
| import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; |
| import type { FinalizedMsgContext, MsgContext } from "../templating.js"; |
| import { |
| applyAbortCutoffToSessionEntry, |
| resolveAbortCutoffFromContext, |
| shouldPersistAbortCutoff, |
| } from "./abort-cutoff.js"; |
| import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; |
| import { clearSessionQueues } from "./queue.js"; |
|
|
| export { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js"; |
|
|
| const ABORT_TRIGGERS = new Set([ |
| "stop", |
| "esc", |
| "abort", |
| "wait", |
| "exit", |
| "interrupt", |
| "detente", |
| "deten", |
| "detén", |
| "arrete", |
| "arrête", |
| "停止", |
| "やめて", |
| "止めて", |
| "रुको", |
| "توقف", |
| "стоп", |
| "остановись", |
| "останови", |
| "остановить", |
| "прекрати", |
| "halt", |
| "anhalten", |
| "aufhören", |
| "hoer auf", |
| "stopp", |
| "pare", |
| "stop openclaw", |
| "openclaw stop", |
| "stop action", |
| "stop current action", |
| "stop run", |
| "stop current run", |
| "stop agent", |
| "stop the agent", |
| "stop don't do anything", |
| "stop dont do anything", |
| "stop do not do anything", |
| "stop doing anything", |
| "do not do that", |
| "please stop", |
| "stop please", |
| ]); |
| const ABORT_MEMORY = new Map<string, boolean>(); |
| const ABORT_MEMORY_MAX = 2000; |
| const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; |
|
|
| function normalizeAbortTriggerText(text: string): string { |
| return text |
| .trim() |
| .toLowerCase() |
| .replace(/[’`]/g, "'") |
| .replace(/\s+/g, " ") |
| .replace(TRAILING_ABORT_PUNCTUATION_RE, "") |
| .trim(); |
| } |
|
|
| export function isAbortTrigger(text?: string): boolean { |
| if (!text) { |
| return false; |
| } |
| const normalized = normalizeAbortTriggerText(text); |
| return ABORT_TRIGGERS.has(normalized); |
| } |
|
|
| export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean { |
| if (!text) { |
| return false; |
| } |
| const normalized = normalizeCommandBody(text, options).trim(); |
| if (!normalized) { |
| return false; |
| } |
| const normalizedLower = normalized.toLowerCase(); |
| return ( |
| normalizedLower === "/stop" || |
| normalizeAbortTriggerText(normalizedLower) === "/stop" || |
| isAbortTrigger(normalizedLower) |
| ); |
| } |
|
|
| export function getAbortMemory(key: string): boolean | undefined { |
| const normalized = key.trim(); |
| if (!normalized) { |
| return undefined; |
| } |
| return ABORT_MEMORY.get(normalized); |
| } |
|
|
| function pruneAbortMemory(): void { |
| if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) { |
| return; |
| } |
| const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX; |
| let removed = 0; |
| for (const entryKey of ABORT_MEMORY.keys()) { |
| ABORT_MEMORY.delete(entryKey); |
| removed += 1; |
| if (removed >= excess) { |
| break; |
| } |
| } |
| } |
|
|
| export function setAbortMemory(key: string, value: boolean): void { |
| const normalized = key.trim(); |
| if (!normalized) { |
| return; |
| } |
| if (!value) { |
| ABORT_MEMORY.delete(normalized); |
| return; |
| } |
| |
| if (ABORT_MEMORY.has(normalized)) { |
| ABORT_MEMORY.delete(normalized); |
| } |
| ABORT_MEMORY.set(normalized, true); |
| pruneAbortMemory(); |
| } |
|
|
| export function getAbortMemorySizeForTest(): number { |
| return ABORT_MEMORY.size; |
| } |
|
|
| export function resetAbortMemoryForTest(): void { |
| ABORT_MEMORY.clear(); |
| } |
|
|
| export function formatAbortReplyText(stoppedSubagents?: number): string { |
| if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { |
| return "⚙️ Agent was aborted."; |
| } |
| const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents"; |
| return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`; |
| } |
|
|
| export function resolveSessionEntryForKey( |
| store: Record<string, SessionEntry> | undefined, |
| sessionKey: string | undefined, |
| ): { entry?: SessionEntry; key?: string; legacyKeys?: string[] } { |
| if (!store || !sessionKey) { |
| return {}; |
| } |
| const resolved = resolveSessionStoreEntry({ store, sessionKey }); |
| if (resolved.existing) { |
| return resolved.legacyKeys.length > 0 |
| ? { |
| entry: resolved.existing, |
| key: resolved.normalizedKey, |
| legacyKeys: resolved.legacyKeys, |
| } |
| : { |
| entry: resolved.existing, |
| key: resolved.normalizedKey, |
| }; |
| } |
| return {}; |
| } |
|
|
| function resolveAbortTargetKey(ctx: MsgContext): string | undefined { |
| const target = ctx.CommandTargetSessionKey?.trim(); |
| if (target) { |
| return target; |
| } |
| const sessionKey = ctx.SessionKey?.trim(); |
| return sessionKey || undefined; |
| } |
|
|
| function normalizeRequesterSessionKey( |
| cfg: OpenClawConfig, |
| key: string | undefined, |
| ): string | undefined { |
| const cleaned = key?.trim(); |
| if (!cleaned) { |
| return undefined; |
| } |
| const { mainKey, alias } = resolveMainSessionAlias(cfg); |
| return resolveInternalSessionKey({ key: cleaned, alias, mainKey }); |
| } |
|
|
| export function stopSubagentsForRequester(params: { |
| cfg: OpenClawConfig; |
| requesterSessionKey?: string; |
| }): { stopped: number } { |
| const requesterKey = normalizeRequesterSessionKey(params.cfg, params.requesterSessionKey); |
| if (!requesterKey) { |
| return { stopped: 0 }; |
| } |
| const runs = listSubagentRunsForController(requesterKey); |
| if (runs.length === 0) { |
| return { stopped: 0 }; |
| } |
|
|
| const storeCache = new Map<string, Record<string, SessionEntry>>(); |
| const seenChildKeys = new Set<string>(); |
| let stopped = 0; |
|
|
| for (const run of runs) { |
| const childKey = run.childSessionKey?.trim(); |
| if (!childKey || seenChildKeys.has(childKey)) { |
| continue; |
| } |
| seenChildKeys.add(childKey); |
|
|
| if (!run.endedAt) { |
| const cleared = clearSessionQueues([childKey]); |
| const parsed = parseAgentSessionKey(childKey); |
| const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); |
| let store = storeCache.get(storePath); |
| if (!store) { |
| store = loadSessionStore(storePath); |
| storeCache.set(storePath, store); |
| } |
| const entry = store[childKey]; |
| const sessionId = entry?.sessionId; |
| const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; |
| const markedTerminated = |
| markSubagentRunTerminated({ |
| runId: run.runId, |
| childSessionKey: childKey, |
| reason: "killed", |
| }) > 0; |
|
|
| if (markedTerminated || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0) { |
| stopped += 1; |
| } |
| } |
|
|
| |
| const cascadeResult = stopSubagentsForRequester({ |
| cfg: params.cfg, |
| requesterSessionKey: childKey, |
| }); |
| stopped += cascadeResult.stopped; |
| } |
|
|
| if (stopped > 0) { |
| logVerbose(`abort: stopped ${stopped} subagent run(s) for ${requesterKey}`); |
| } |
| return { stopped }; |
| } |
|
|
| export async function tryFastAbortFromMessage(params: { |
| ctx: FinalizedMsgContext; |
| cfg: OpenClawConfig; |
| }): Promise<{ handled: boolean; aborted: boolean; stoppedSubagents?: number }> { |
| const { ctx, cfg } = params; |
| const targetKey = resolveAbortTargetKey(ctx); |
| const agentId = resolveSessionAgentId({ |
| sessionKey: targetKey ?? ctx.SessionKey ?? "", |
| config: cfg, |
| }); |
| |
| const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""); |
| const isGroup = ctx.ChatType?.trim().toLowerCase() === "group"; |
| const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw; |
| const abortRequested = isAbortRequestText(stripped); |
| if (!abortRequested) { |
| return { handled: false, aborted: false }; |
| } |
|
|
| const commandAuthorized = ctx.CommandAuthorized; |
| const auth = resolveCommandAuthorization({ |
| ctx, |
| cfg, |
| commandAuthorized, |
| }); |
| if (!auth.isAuthorizedSender) { |
| return { handled: false, aborted: false }; |
| } |
|
|
| const abortKey = targetKey ?? auth.from ?? auth.to; |
| const requesterSessionKey = targetKey ?? ctx.SessionKey ?? abortKey; |
|
|
| if (targetKey) { |
| const storePath = resolveStorePath(cfg.session?.store, { agentId }); |
| const store = loadSessionStore(storePath); |
| const { entry, key, legacyKeys } = resolveSessionEntryForKey(store, targetKey); |
| const resolvedTargetKey = key ?? targetKey; |
| const acpManager = getAcpSessionManager(); |
| const acpResolution = acpManager.resolveSession({ |
| cfg, |
| sessionKey: resolvedTargetKey, |
| }); |
| if (acpResolution.kind !== "none") { |
| try { |
| await acpManager.cancelSession({ |
| cfg, |
| sessionKey: resolvedTargetKey, |
| reason: "fast-abort", |
| }); |
| } catch (error) { |
| logVerbose( |
| `abort: ACP cancel failed for ${resolvedTargetKey}: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } |
| } |
| const sessionId = entry?.sessionId; |
| const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; |
| const cleared = clearSessionQueues([resolvedTargetKey, sessionId]); |
| if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { |
| logVerbose( |
| `abort: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, |
| ); |
| } |
| const abortCutoff = shouldPersistAbortCutoff({ |
| commandSessionKey: ctx.SessionKey, |
| targetSessionKey: resolvedTargetKey, |
| }) |
| ? resolveAbortCutoffFromContext(ctx) |
| : undefined; |
| if (entry && key) { |
| entry.abortedLastRun = true; |
| applyAbortCutoffToSessionEntry(entry, abortCutoff); |
| entry.updatedAt = Date.now(); |
| store[key] = entry; |
| for (const legacyKey of legacyKeys ?? []) { |
| if (legacyKey !== key) { |
| delete store[legacyKey]; |
| } |
| } |
| await updateSessionStore(storePath, (nextStore) => { |
| const nextEntry = nextStore[key] ?? entry; |
| if (!nextEntry) { |
| return; |
| } |
| nextEntry.abortedLastRun = true; |
| applyAbortCutoffToSessionEntry(nextEntry, abortCutoff); |
| nextEntry.updatedAt = Date.now(); |
| nextStore[key] = nextEntry; |
| for (const legacyKey of legacyKeys ?? []) { |
| if (legacyKey !== key) { |
| delete nextStore[legacyKey]; |
| } |
| } |
| }); |
| } else if (abortKey) { |
| setAbortMemory(abortKey, true); |
| } |
| const { stopped } = stopSubagentsForRequester({ cfg, requesterSessionKey }); |
| return { handled: true, aborted, stoppedSubagents: stopped }; |
| } |
|
|
| if (abortKey) { |
| setAbortMemory(abortKey, true); |
| } |
| const { stopped } = stopSubagentsForRequester({ cfg, requesterSessionKey }); |
| return { handled: true, aborted: false, stoppedSubagents: stopped }; |
| } |
|
|