diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts index 599e5ee58f08..ec2fe3b02a9c 100644 --- a/packages/agent-core/src/agent-loop.test.ts +++ b/packages/agent-core/src/agent-loop.test.ts @@ -100,6 +100,94 @@ describe("agentLoop EventStream failures", () => { expectTerminalFailure(events, result); }); + + it("persists and replays interruption guidance after Agent aborts a rejected run", async () => { + let markStarted = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const agent = new Agent({ + initialState: { model }, + convertToLlm: (messages) => + messages.filter( + (message): message is Message => + message.role === "user" || + message.role === "assistant" || + message.role === "toolResult", + ), + streamFn: async (_model, _context, options) => { + markStarted(); + await new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new Error("provider aborted")), { + once: true, + }); + }); + throw new Error("unreachable"); + }, + }); + + const interrupted = agent.prompt("perform side effect"); + await started; + agent.abort(); + await interrupted; + + expect(agent.state.messages.at(-1)).toMatchObject({ + role: "custom", + customType: "openclaw:turn-aborted", + }); + + let replayedMessages: Message[] = []; + let transformedMessages: AgentMessage[] = []; + agent.transformContext = async (messages) => { + transformedMessages = messages; + return messages; + }; + agent.streamFn = async (_model, context) => { + replayedMessages = context.messages; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: "continued safely" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: TEST_USAGE, + stopReason: "stop", + timestamp: 2, + }, + }); + stream.end(); + }); + return stream; + }; + await agent.prompt("continue"); + + expect(transformedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "custom", + customType: "openclaw:turn-aborted", + }), + ]), + ); + expect(replayedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("may have partially executed"), + }), + ]), + }), + ]), + ); + }); }); describe("agentLoop continuation guards", () => { @@ -1160,8 +1248,15 @@ describe("agentLoop tool termination", () => { "assistant", "toolResult", "assistant", + "custom", ]); - expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(messages.at(-2)).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(messages.at(-1)).toMatchObject({ + role: "custom", + customType: "openclaw:turn-aborted", + display: false, + content: expect.stringContaining("may have partially executed"), + }); expect(events.map((event) => event.type)).toEqual([ "agent_start", "turn_start", @@ -1178,11 +1273,63 @@ describe("agentLoop tool termination", () => { "message_start", "message_end", "turn_end", + "message_start", + "message_end", "agent_end", ]); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); + it("skips interrupted-turn guidance when the abort reason marks a turn handoff", async () => { + const controller = new AbortController(); + let streamCalls = 0; + const streamFn: StreamFn = () => { + streamCalls += 1; + if (streamCalls > 1) { + throw new Error("model was called after abort"); + } + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message = makeAssistantMessage([ + { type: "toolCall", id: "call-yield", name: "yield_tool", arguments: {} }, + ]); + stream.push({ type: "done", reason: "toolUse", message }); + stream.end(); + }); + return stream; + }; + const yieldTool: AgentTool = { + name: "yield_tool", + label: "yield_tool", + description: "Yield the active run as a clean handoff", + parameters: Type.Object({}, { additionalProperties: false }), + execute: async () => { + controller.abort({ code: "sessions_yield", turnHandoff: true }); + return { + content: [{ type: "text", text: "yielded" }], + details: { yielded: true }, + }; + }, + }; + + const messages = await runAgentLoop( + [{ role: "user", content: "yield during tool", timestamp: 1 }], + { + systemPrompt: "", + messages: [], + tools: [yieldTool], + }, + config, + () => {}, + controller.signal, + streamFn, + ); + + expect(streamCalls).toBe(1); + expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(messages.some((message) => message.role === "custom")).toBe(false); + }); + it("does not start prepared parallel tools after the run aborts mid-batch", async () => { const controller = new AbortController(); const executed: string[] = []; @@ -1308,8 +1455,13 @@ describe("agentLoop tool termination", () => { "assistant", "toolResult", "assistant", + "custom", ]); - expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(messages.at(-2)).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(messages.at(-1)).toMatchObject({ + role: "custom", + customType: "openclaw:turn-aborted", + }); expect(events.map((event) => event.type)).toEqual([ "agent_start", "turn_start", @@ -1326,6 +1478,8 @@ describe("agentLoop tool termination", () => { "message_start", "message_end", "turn_end", + "message_start", + "message_end", "agent_end", ]); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index b4b4583f3827..99eb0944c616 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -12,6 +12,13 @@ import type { EventStream as SourceEventStream } from "../../llm-core/src/index. import { TranscriptNotContinuableError } from "./errors.js"; import { resolveAgentReasoningOption } from "./reasoning.js"; import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js"; +import { + appendInterruptedTurnMessage, + createFailureMessage, + createInterruptedTurnMessage, + isTurnHandoffAbort, + normalizeCoreContextMessages, +} from "./turn-interruption.js"; import type { AgentContext, AgentEvent, @@ -27,15 +34,6 @@ import { validateToolArguments } from "./validation.js"; /** Callback used by synchronous loop runners to publish agent lifecycle events. */ export type AgentEventSink = (event: AgentEvent) => Promise | void; -const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - const EventStreamConstructor: typeof SourceEventStream = LlmEventStream; type AssistantMessageUpdateEvent = Extract< @@ -118,7 +116,7 @@ export function agentLoop( stream.end(messages); }) .catch((error: unknown) => { - pushLoopFailure(stream, config, error, signal?.aborted === true); + pushLoopFailure(stream, config, error, signal); }); return stream; @@ -164,7 +162,7 @@ export function agentLoopContinue( stream.end(messages); }) .catch((error: unknown) => { - pushLoopFailure(stream, config, error, signal?.aborted === true); + pushLoopFailure(stream, config, error, signal); }); return stream; @@ -232,35 +230,25 @@ function createAgentStream(): EventStream { ); } -function createLoopFailureMessage( - config: AgentLoopConfig, - error: unknown, - aborted: boolean, -): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text: "" }], - api: config.model.api, - provider: config.model.provider, - model: config.model.id, - usage: EMPTY_USAGE, - stopReason: aborted ? "aborted" : "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - }; -} - function pushLoopFailure( stream: EventStream, config: AgentLoopConfig, error: unknown, - aborted: boolean, + signal: AbortSignal | undefined, ): void { - const failureMessage = createLoopFailureMessage(config, error, aborted); + const aborted = signal?.aborted === true; + const failureMessage = createFailureMessage(config.model, error, aborted); stream.push({ type: "message_start", message: failureMessage }); stream.push({ type: "message_end", message: failureMessage }); stream.push({ type: "turn_end", message: failureMessage, toolResults: [] }); - stream.push({ type: "agent_end", messages: [failureMessage] }); + const messages: AgentMessage[] = [failureMessage]; + if (aborted && !isTurnHandoffAbort(signal)) { + const interruption = createInterruptedTurnMessage(); + messages.push(interruption); + stream.push({ type: "message_start", message: interruption }); + stream.push({ type: "message_end", message: interruption }); + } + stream.push({ type: "agent_end", messages }); } /** @@ -287,8 +275,8 @@ async function runLoop( } // Persist an aborted assistant outcome so session post-processing does not // compact or continue from the preceding toolUse message. - const abortedMessage = createLoopFailureMessage( - config, + const abortedMessage = createFailureMessage( + config.model, signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted"), true, ); @@ -301,6 +289,9 @@ async function runLoop( await emit({ type: "message_end", message: abortedMessage }); await emit({ type: "turn_end", message: abortedMessage, toolResults: [] }); turnOpen = false; + if (!isTurnHandoffAbort(signal)) { + await appendInterruptedTurnMessage(newMessages, emit); + } await emit({ type: "agent_end", messages: newMessages }); return true; }; @@ -349,6 +340,9 @@ async function runLoop( if (message.stopReason === "error" || message.stopReason === "aborted") { await emit({ type: "turn_end", message, toolResults: [] }); + if (message.stopReason === "aborted" && signal?.aborted && !isTurnHandoffAbort(signal)) { + await appendInterruptedTurnMessage(newMessages, emit); + } await emit({ type: "agent_end", messages: newMessages }); return; } @@ -459,6 +453,7 @@ async function streamAssistantResponse( if (config.transformContext) { messages = await config.transformContext(messages, signal); } + messages = normalizeCoreContextMessages(messages); // Convert to LLM-compatible messages (AgentMessage[] → Message[]) const llmMessages = await config.convertToLlm(messages); diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index cf2feeceb80c..e8d32d163e09 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -12,6 +12,11 @@ import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; import { TranscriptNotContinuableError } from "./errors.js"; import { resolveAgentReasoningOption } from "./reasoning.js"; import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js"; +import { + appendInterruptedTurnMessage, + createFailureMessage, + isTurnHandoffAbort, +} from "./turn-interruption.js"; import type { AfterToolCallContext, AfterToolCallResult, @@ -38,15 +43,6 @@ function defaultConvertToLlm(messages: AgentMessage[]): Message[] { ); } -const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - const DEFAULT_MODEL = { id: "unknown", name: "unknown", @@ -347,8 +343,8 @@ export class Agent { } /** Abort the current run, if one is active. */ - abort(): void { - this.activeRun?.abortController.abort(); + abort(reason?: unknown): void { + this.activeRun?.abortController.abort(reason); } /** @@ -534,21 +530,15 @@ export class Agent { } private async handleRunFailure(error: unknown, aborted: boolean): Promise { - const failureMessage = { - role: "assistant", - content: [{ type: "text", text: "" }], - api: this.mutableState.model.api, - provider: this.mutableState.model.provider, - model: this.mutableState.model.id, - usage: EMPTY_USAGE, - stopReason: aborted ? "aborted" : "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - } satisfies AgentMessage; + const failureMessage = createFailureMessage(this.mutableState.model, error, aborted); await this.processEvents({ type: "message_start", message: failureMessage }); await this.processEvents({ type: "message_end", message: failureMessage }); await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); - await this.processEvents({ type: "agent_end", messages: [failureMessage] }); + const messages: AgentMessage[] = [failureMessage]; + if (aborted && !isTurnHandoffAbort(this.signal)) { + await appendInterruptedTurnMessage(messages, (event) => this.processEvents(event)); + } + await this.processEvents({ type: "agent_end", messages }); } private finishRun(): void { diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts index 94e9d9b4724b..1dfcb89fe2e1 100644 --- a/packages/agent-core/src/harness/agent-harness.ts +++ b/packages/agent-core/src/harness/agent-harness.ts @@ -8,6 +8,11 @@ import type { import { runAgentLoop } from "../agent-loop.js"; import { resolveAgentReasoningOption } from "../reasoning.js"; import { type AgentCoreRuntimeDeps, resolveAgentCoreStreamFn } from "../runtime-deps.js"; +import { + appendInterruptedTurnMessage, + createFailureMessage, + isTurnHandoffAbort, +} from "../turn-interruption.js"; import type { AgentContext, AgentEvent, @@ -65,27 +70,6 @@ function createUserMessage(text: string, images?: ImageContent[]): UserMessage { return { role: "user", content, timestamp: Date.now() }; } -function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text: "" }], - api: model.api, - provider: model.provider, - model: model.id, - stopReason: aborted ? "aborted" : "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }; -} - function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { return { ...streamOptions, @@ -625,8 +609,12 @@ export class CoreAgentHarness< { type: "turn_end", message: failureMessage, toolResults: [] }, signal, ); - await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); - return [failureMessage]; + const messages: AgentMessage[] = [failureMessage]; + if (aborted && !isTurnHandoffAbort(signal)) { + await appendInterruptedTurnMessage(messages, (event) => this.handleAgentEvent(event, signal)); + } + await this.handleAgentEvent({ type: "agent_end", messages }, signal); + return messages; } private async executeTurn( diff --git a/packages/agent-core/src/turn-interruption.ts b/packages/agent-core/src/turn-interruption.ts new file mode 100644 index 000000000000..a1272a218ac3 --- /dev/null +++ b/packages/agent-core/src/turn-interruption.ts @@ -0,0 +1,88 @@ +import type { AssistantMessage, Model } from "../../llm-core/src/index.js"; +import type { AgentEvent, AgentMessage } from "./types.js"; + +/** Canonical empty aborted/error assistant recorded when a run ends without output. */ +export function createFailureMessage( + model: Model, + error: unknown, + aborted: boolean, +): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +// Not re-exported from the package barrel on purpose: these helpers are +// internal loop/harness plumbing, not public agent-core API surface. +const INTERRUPTED_TURN_GUIDANCE = ` +The previous turn was interrupted. Any running background processes may still be active. If any tools or commands were aborted, they may have partially executed. +`; + +/** + * Aborts that end a turn as an intentional handoff (e.g. yield-style tools) + * mark it with an abort reason carrying `turnHandoff: true`. Interruption + * guidance is skipped for them: the next turn would otherwise be told tools + * may have partially executed after a clean, deliberate stop. + */ +export function isTurnHandoffAbort(signal: AbortSignal | undefined): boolean { + if (!signal?.aborted) { + return false; + } + const reason: unknown = signal.reason; + return ( + typeof reason === "object" && + reason !== null && + (reason as { turnHandoff?: unknown }).turnHandoff === true + ); +} + +export function createInterruptedTurnMessage(): AgentMessage { + return { + role: "custom", + customType: "openclaw:turn-aborted", + content: INTERRUPTED_TURN_GUIDANCE, + display: false, + timestamp: Date.now(), + }; +} + +export async function appendInterruptedTurnMessage( + messages: AgentMessage[], + emit: (event: AgentEvent) => Promise | void, +): Promise { + const interruption = createInterruptedTurnMessage(); + messages.push(interruption); + await emit({ type: "message_start", message: interruption }); + await emit({ type: "message_end", message: interruption }); +} + +export function normalizeCoreContextMessages(messages: AgentMessage[]): AgentMessage[] { + return messages.map((message) => { + if (message.role !== "custom" || message.customType !== "openclaw:turn-aborted") { + return message; + } + return { + role: "user", + content: + typeof message.content === "string" + ? [{ type: "text", text: message.content }] + : message.content, + timestamp: message.timestamp, + }; + }); +} diff --git a/src/agents/embedded-agent-runner/run/attempt-stream.ts b/src/agents/embedded-agent-runner/run/attempt-stream.ts index f6fb1144ceaf..1db1d2d09917 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream.ts @@ -23,7 +23,10 @@ import { import { wrapStreamFnWithDiagnosticModelCallEvents } from "./attempt.model-diagnostic-events.js"; import { resolveUnknownToolGuardThreshold } from "./attempt.run-decisions.js"; import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js"; -import { createYieldAbortedResponse } from "./attempt.sessions-yield.js"; +import { + createYieldAbortedResponse, + isSessionsYieldAbortReason, +} from "./attempt.sessions-yield.js"; import { wrapStreamFnHandleSensitiveStopReason } from "./attempt.stop-reason-recovery.js"; import { shouldRepairMalformedToolCallArguments, @@ -187,7 +190,7 @@ export function installEmbeddedAttemptStreamGuards(input: { const innerStreamFn = session.agent.streamFn; session.agent.streamFn = (model, context, options) => { const signal = input.abortSignal as AbortSignal & { reason?: unknown }; - if (input.isYieldDetected() && signal.aborted && signal.reason === "sessions_yield") { + if (input.isYieldDetected() && signal.aborted && isSessionsYieldAbortReason(signal.reason)) { return createYieldAbortedResponse(model) as unknown as Awaited< ReturnType >; diff --git a/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts index e8a372e46cbe..63e94ed46368 100644 --- a/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts +++ b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts @@ -1,8 +1,9 @@ +import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; +import type { AgentMessage } from "../../runtime/index.js"; /** * Handles sessions-yield interruption, persistence, and artifact cleanup. */ -import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; -import type { AgentMessage } from "../../runtime/index.js"; +import { isRunnerAbortError } from "../abort.js"; import { log } from "../logger.js"; import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js"; @@ -109,6 +110,25 @@ export function createYieldAbortedResponse(model: { }; } +// sessions_yield ends the turn as a clean handoff, not an interruption. +// turnHandoff:true tells agent-core to skip guidance +// (packages/agent-core/src/turn-interruption.ts); code keys the runner's +// own yield checks in attempt.ts and attempt-stream.ts. +export const SESSIONS_YIELD_ABORT_REASON = { code: "sessions_yield", turnHandoff: true } as const; + +/** True when a runner abort error was raised by the sessions_yield handoff. */ +export function isSessionsYieldAbortError(err: unknown): boolean { + return isRunnerAbortError(err) && err instanceof Error && isSessionsYieldAbortReason(err.cause); +} + +export function isSessionsYieldAbortReason(reason: unknown): boolean { + return ( + typeof reason === "object" && + reason !== null && + (reason as { code?: unknown }).code === "sessions_yield" + ); +} + // Queue a hidden steering message so agent runtime injects it before the next // LLM call once the current assistant turn finishes executing its tool calls. export function queueSessionsYieldInterruptMessage(activeSession: { diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index a5322438c5ab..3b5d1d4071f8 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -411,8 +411,10 @@ import { installPromptSubmissionLockRelease, } from "./attempt.session-lock.js"; import { + isSessionsYieldAbortError, persistSessionsYieldContextMessage, queueSessionsYieldInterruptMessage, + SESSIONS_YIELD_ABORT_REASON, stripSessionsYieldArtifacts, waitForSessionsYieldAbortSettle, } from "./attempt.sessions-yield.js"; @@ -1518,7 +1520,7 @@ export async function runEmbeddedAttempt( yieldDetected = true; yieldMessage = message; queueYieldInterruptForSession?.(); - runAbortController.abort("sessions_yield"); + runAbortController.abort(SESSIONS_YIELD_ABORT_REASON); abortSessionForYield?.(); }, }); @@ -2875,8 +2877,8 @@ export async function runEmbeddedAttempt( trackSettlePromise(inFlightPromptSettlePromises, promise); const trackAbortSettlePromise = (promise: Promise): Promise => trackSettlePromise(inFlightAbortSettlePromises, promise); - const abortActiveSession = (): Promise => - trackAbortSettlePromise(Promise.resolve(activeSession.abort())); + const abortActiveSession = (reason?: unknown): Promise => + trackAbortSettlePromise(Promise.resolve(activeSession.abort(reason))); abortActiveSessionForExternalSignal = abortActiveSession; buildAbortSettlePromise = (): Promise | null => { const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises]; @@ -2886,7 +2888,7 @@ export async function runEmbeddedAttempt( return Promise.allSettled(promises).then(() => undefined); }; abortSessionForYield = () => { - yieldAbortSettled = abortActiveSession(); + yieldAbortSettled = abortActiveSession(SESSIONS_YIELD_ABORT_REASON); }; queueYieldInterruptForSession = () => { queueSessionsYieldInterruptMessage(activeSession); @@ -4939,11 +4941,7 @@ export async function runEmbeddedAttempt( } } catch (err) { releaseLeasedSteering(err); - yieldAborted = - yieldDetected && - isRunnerAbortError(err) && - err instanceof Error && - err.cause === "sessions_yield"; + yieldAborted = yieldDetected && isSessionsYieldAbortError(err); cleanupYieldAborted = yieldAborted; if (yieldAborted) { aborted = false; diff --git a/src/agents/sessions/agent-session.ts b/src/agents/sessions/agent-session.ts index 7663596c19c0..7840223989d9 100644 --- a/src/agents/sessions/agent-session.ts +++ b/src/agents/sessions/agent-session.ts @@ -1599,12 +1599,10 @@ export class AgentSession { return this.sessionResourceLoader; } - /** - * Abort current operation and wait for agent to become idle. - */ - async abort(): Promise { + /** Abort the current run; yield callers pass a turnHandoff reason to skip interruption guidance. */ + async abort(reason?: unknown): Promise { this.abortRetry(); - this.agent.abort(); + this.agent.abort(reason); await this.agent.waitForIdle(); }