mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 11:56:01 +00:00
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:
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
88
packages/agent-core/src/turn-interruption.ts
Normal file
88
packages/agent-core/src/turn-interruption.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user