Summary
Add a gateway RPC agent.abort so external clients can cancel runs started via the agent() RPC. Today they can't: chat.abort only tracks chat.send-initiated runs, and the underlying primitive abortEmbeddedPiRun(sessionId) has no RPC surface.
Problem to solve
agent() runs register into ACTIVE_EMBEDDED_RUNS (sessionId-keyed) at src/agents/pi-embedded-runner/run/attempt.ts:1572, but never into context.chatAbortControllers. chat.abort only consults chatAbortControllers, so calling chat.abort after an agent() run is a no-op.
- The agent handlers map at
src/gateway/server-methods/agent.ts:279 currently exposes only "agent", "agent.identity.get", and "agent.wait" — no cancel entry point.
- The only external-facing workaround is reaching into
globalThis[Symbol.for("openclaw.embeddedRunState")].activeRuns.get(sessionId)?.abort(). That works in-process but is an undocumented side door; true out-of-process clients (UI apps, ACP clients, iOS/macOS apps) cannot access process globals and are left without any cancellation path.
Proposed solution
Introduce a new RPC agent.abort with a params schema accepting either {runId} or {sessionId}. The UI layer typically has sessionId; some callers know only runId. Accepting both avoids pushing the resolution onto clients.
Internally the handler delegates to abortEmbeddedPiRun(sessionId) (already defined in src/agents/pi-embedded-runner/runs.ts:118-179), which handles the primary ACTIVE_EMBEDDED_RUNS lookup plus fallback to abortReplyRunBySessionId.
Response shape: {ok: true} on acceptance, {ok: false, reason} otherwise. Reasons should at minimum include not-found (sessionId not in ACTIVE_EMBEDDED_RUNS). The response represents "request accepted", not "run already terminated"; the end-of-run signal is left to the existing lifecycle:end event and the agent() RPC completion frame (which already carries result.meta.aborted).
Schema addition: AgentAbortParamsSchema in src/gateway/protocol/schema/agent.ts, sibling to the existing AgentParamsSchema (currently at L80-111).
Alternatives considered
- Register
agent() runs' AbortController into context.chatAbortControllers as well. Rejected because it conflates two different run kinds at the gateway layer; chat.send has concepts (S2C receipts, broadcast) the agent() RPC does not share.
- Document the
globalThis[Symbol.for(...)] side door as a public contract. Rejected because out-of-process clients cannot access process globals at all, so this does not solve the external-client gap.
- Rely entirely on client-side cancellation (reject the RPC promise, let the server-side run continue). Rejected because the abandoned run still consumes tokens and tool budget, and may continue writing to the session transcript.
Impact
- Affected: all external clients of the
agent() RPC — CoClaw UI, ACP clients, third-party control surfaces, and potentially the iOS/macOS apps if they ever adopt agent() for run initiation.
- Severity: Medium. Users can't reliably cancel long-running agent turns; background tokens and tool calls accumulate even after the user has given up.
- Frequency: Every cancel attempt on an
agent()-initiated run today. Workarounds exist only for in-process plugins.
- Consequence: Wasted LLM spend, surprising side effects from tool calls issued after the user stopped caring, and confusing UX when partial outputs keep arriving.
Evidence/examples
chat.abort handler and registration for contrast: src/gateway/server-methods/chat.ts:1313 registers into chatAbortControllers; the handler itself delegates to src/gateway/chat-abort.ts:76-108 (abortChatRunById).
- The side-door shape (for context, not a proposed contract):
globalThis[Symbol.for("openclaw.embeddedRunState")] exposes {activeRuns, snapshots, sessionIdsByKey, waiters, modelSwitchRequests} per runs.ts:51-58; EmbeddedPiQueueHandle.abort is synchronous and idempotent (runs.ts:20-27).
Additional information
Verified against OpenClaw commit d7cc6f7643 (v2026.4.14-beta.1+69).
A companion feature request covers the complementary in-process gap — exposing abortEmbeddedPiRun and friends on api.runtime.agent so bundled plugins can drop their side-door usage. The two requests are independent but serve the same goal of supported cancellation APIs.
Reported by the CoClaw team.
This issue was discovered while developing @coclaw/openclaw-coclaw, a CoClaw channel plugin for OpenClaw.
Summary
Add a gateway RPC
agent.abortso external clients can cancel runs started via theagent()RPC. Today they can't:chat.abortonly trackschat.send-initiated runs, and the underlying primitiveabortEmbeddedPiRun(sessionId)has no RPC surface.Problem to solve
agent()runs register intoACTIVE_EMBEDDED_RUNS(sessionId-keyed) atsrc/agents/pi-embedded-runner/run/attempt.ts:1572, but never intocontext.chatAbortControllers.chat.abortonly consultschatAbortControllers, so callingchat.abortafter anagent()run is a no-op.src/gateway/server-methods/agent.ts:279currently exposes only"agent","agent.identity.get", and"agent.wait"— no cancel entry point.globalThis[Symbol.for("openclaw.embeddedRunState")].activeRuns.get(sessionId)?.abort(). That works in-process but is an undocumented side door; true out-of-process clients (UI apps, ACP clients, iOS/macOS apps) cannot access process globals and are left without any cancellation path.Proposed solution
Introduce a new RPC
agent.abortwith a params schema accepting either{runId}or{sessionId}. The UI layer typically hassessionId; some callers know onlyrunId. Accepting both avoids pushing the resolution onto clients.Internally the handler delegates to
abortEmbeddedPiRun(sessionId)(already defined insrc/agents/pi-embedded-runner/runs.ts:118-179), which handles the primaryACTIVE_EMBEDDED_RUNSlookup plus fallback toabortReplyRunBySessionId.Response shape:
{ok: true}on acceptance,{ok: false, reason}otherwise. Reasons should at minimum includenot-found(sessionId not inACTIVE_EMBEDDED_RUNS). The response represents "request accepted", not "run already terminated"; the end-of-run signal is left to the existinglifecycle:endevent and theagent()RPC completion frame (which already carriesresult.meta.aborted).Schema addition:
AgentAbortParamsSchemainsrc/gateway/protocol/schema/agent.ts, sibling to the existingAgentParamsSchema(currently at L80-111).Alternatives considered
agent()runs' AbortController intocontext.chatAbortControllersas well. Rejected because it conflates two different run kinds at the gateway layer;chat.sendhas concepts (S2C receipts, broadcast) theagent()RPC does not share.globalThis[Symbol.for(...)]side door as a public contract. Rejected because out-of-process clients cannot access process globals at all, so this does not solve the external-client gap.Impact
agent()RPC — CoClaw UI, ACP clients, third-party control surfaces, and potentially the iOS/macOS apps if they ever adoptagent()for run initiation.agent()-initiated run today. Workarounds exist only for in-process plugins.Evidence/examples
chat.aborthandler and registration for contrast:src/gateway/server-methods/chat.ts:1313registers intochatAbortControllers; the handler itself delegates tosrc/gateway/chat-abort.ts:76-108(abortChatRunById).globalThis[Symbol.for("openclaw.embeddedRunState")]exposes{activeRuns, snapshots, sessionIdsByKey, waiters, modelSwitchRequests}perruns.ts:51-58;EmbeddedPiQueueHandle.abortis synchronous and idempotent (runs.ts:20-27).Additional information
Verified against OpenClaw commit
d7cc6f7643(v2026.4.14-beta.1+69).A companion feature request covers the complementary in-process gap — exposing
abortEmbeddedPiRunand friends onapi.runtime.agentso bundled plugins can drop their side-door usage. The two requests are independent but serve the same goal of supported cancellation APIs.Reported by the CoClaw team.
This issue was discovered while developing @coclaw/openclaw-coclaw, a CoClaw channel plugin for OpenClaw.