feat(agent): preserve interrupted turn context

Inspired by Codex: codex-rs/core/src/context/turn_aborted.rs and codex-rs/core/src/tasks/mod.rs.
This commit is contained in:
Ayaan Zaidi
2026-07-12 22:13:56 +05:30
parent ac31549853
commit 432da8b3cb
9 changed files with 334 additions and 100 deletions

View File

@@ -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<void>((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<void>((_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" });

View File

@@ -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> | 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<AgentEvent, AgentMessage[]> {
);
}
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<AgentEvent, AgentMessage[]>,
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);

View File

@@ -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<void> {
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 {

View File

@@ -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(

View File

@@ -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 = `<turn_aborted>
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.
</turn_aborted>`;
/**
* 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> | void,
): Promise<void> {
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,
};
});
}

View File

@@ -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<typeof innerStreamFn>
>;

View File

@@ -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 <turn_aborted> 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: {

View File

@@ -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<void>): Promise<void> =>
trackSettlePromise(inFlightAbortSettlePromises, promise);
const abortActiveSession = (): Promise<void> =>
trackAbortSettlePromise(Promise.resolve(activeSession.abort()));
const abortActiveSession = (reason?: unknown): Promise<void> =>
trackAbortSettlePromise(Promise.resolve(activeSession.abort(reason)));
abortActiveSessionForExternalSignal = abortActiveSession;
buildAbortSettlePromise = (): Promise<void> | 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;

View File

@@ -1599,12 +1599,10 @@ export class AgentSession {
return this.sessionResourceLoader;
}
/**
* Abort current operation and wait for agent to become idle.
*/
async abort(): Promise<void> {
/** Abort the current run; yield callers pass a turnHandoff reason to skip interruption guidance. */
async abort(reason?: unknown): Promise<void> {
this.abortRetry();
this.agent.abort();
this.agent.abort(reason);
await this.agent.waitForIdle();
}