Files
openclaw/packages/agent-core/src/agent-loop.ts
SunnyShu 1b7aa90c97 fix(agents): preserve tool-call pairing after mid-turn abort (#116642)
* [AI] fix(agents): emit aborted tool results for skipped tool calls on mid-turn abort

When an abort fires mid-batch in executeToolCalls (after the assistant
message with tool_use is committed but before all tool_results are
written), the sequential and parallel dispatch loops break out and skip
the remaining tool calls. The committed assistant turn retains N tool_use
blocks but only M < N tool_results land in context.messages, leaving
orphaned tool_use that corrupts retries/continuation and triggers provider
400 errors on providers that do not synthesize missing results
(allowSyntheticToolResults=false, e.g. openai-completions/DeepSeek).

Emit aborted tool results (createErrorToolResult("Operation aborted")) for
the skipped tail in both executeToolCallsSequential and
executeToolCallsParallel so every tool_use keeps a paired tool_result.
This complements the existing write-side guard (which only covers
synthetic-enabled providers) and the persisted replay repair.

The aborted tail outcomes are routed through finalizeToolCallOutcome (via
a shared finalizeAbortedToolCall helper) so config.afterToolOutcome hooks
(audit, redaction, metadata, error-normalization) observe these skipped
calls just like every immediate or executed outcome, instead of bypassing
the outcome contract. Regression tests assert afterToolOutcome fires for
every skipped call in both dispatch modes.

Fixes #116379

Co-Authored-By: Maas <noreply@anthropic.com>

* [AI] fix(agents): emit tool_execution_start before aborted end for skipped calls

The abort-tail backfill added in #116379 emits tool_execution_end (and a
paired tool_result) for tool calls the dispatch loop never reached, but it
skipped the matching tool_execution_start. Channel/client subscribers that
pair start→end events received an end for an unknown tool-call id during
abort recovery.

Emit tool_execution_start for each skipped call before its aborted end/result,
mirroring the start event every dispatched (including immediate non-executed)
call already emits. Covers both sequential and parallel dispatch, with
regression assertions that every skipped call has a start before its end and
that start/end counts stay paired.

Co-Authored-By: Maas <noreply@anthropic.com>

* fix(agents): complete aborted tool tails safely

Fixes #116379

---------

Co-authored-by: Maas <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-01 03:30:13 +08:00

1404 lines
41 KiB
TypeScript

// Keep the runtime class on the public package specifier so OpenClaw and
// external consumers share one constructor identity.
import { EventStream as LlmEventStream } from "@openclaw/ai/event-stream";
import type {
AssistantMessage,
AssistantMessageEvent,
Context,
EventStream,
ToolResultMessage,
} from "@openclaw/llm-core";
import type { EventStream as SourceEventStream } from "@openclaw/llm-core";
import { TranscriptNotContinuableError } from "./errors.js";
import { uuidv7 } from "./harness/session/uuid.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
import {
type AgentToolExecutionContext,
runWithAgentToolExecutionContext,
} from "./tool-execution-context.js";
import {
appendInterruptedTurnMessage,
createFailureMessage,
createInterruptedTurnMessage,
isTurnHandoffAbort,
normalizeCoreContextMessages,
} from "./turn-interruption.js";
import type { ToolResultContentSource } from "./types.js";
import type {
AgentContext,
AgentEvent,
AgentLoopConfig,
AgentMessage,
AgentTool,
AgentToolCall,
AgentToolResult,
StreamFn,
} from "./types.js";
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 EventStreamConstructor: typeof SourceEventStream = LlmEventStream;
type AssistantMessageUpdateEvent = Extract<
AssistantMessageEvent,
{
type:
| "text_start"
| "text_delta"
| "text_end"
| "thinking_start"
| "thinking_delta"
| "thinking_end"
| "toolcall_start"
| "toolcall_delta"
| "toolcall_end";
}
>;
function appendTextDeltaToAssistantMessage(
message: AssistantMessage,
contentIndex: number,
delta: string,
): AssistantMessage {
const content = [...message.content];
const currentContent = content[contentIndex];
content[contentIndex] =
currentContent?.type === "text"
? { ...currentContent, text: currentContent.text + delta }
: { type: "text", text: delta };
return { ...message, content };
}
function resolveAssistantMessageUpdate(
event: AssistantMessageUpdateEvent,
currentMessage: AssistantMessage,
): AssistantMessage {
if ("partial" in event && event.partial) {
return event.partial;
}
if (event.type === "text_delta") {
return appendTextDeltaToAssistantMessage(currentMessage, event.contentIndex, event.delta);
}
return currentMessage;
}
function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessage {
if (message.stopReason === "toolUse") {
return message;
}
const content = message.content.filter((item) => item.type !== "toolCall");
return content.length === message.content.length ? message : { ...message, content };
}
function ensureToolTurnIdentity(message: AssistantMessage): AssistantMessage {
if (message.stopReason !== "toolUse" || message.responseId?.trim() || message.turnId?.trim()) {
return message;
}
// message_end persists this local identity before any tool can execute.
return { ...message, turnId: uuidv7() };
}
/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
*/
export function agentLoop(
prompts: AgentMessage[],
context: AgentContext,
config: AgentLoopConfig,
signal?: AbortSignal,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): EventStream<AgentEvent, AgentMessage[]> {
const stream = createAgentStream();
void runAgentLoop(
prompts,
context,
config,
async (event) => {
stream.push(event);
},
signal,
streamFn,
runtime,
)
.then((messages) => {
stream.end(messages);
})
.catch((error: unknown) => {
pushLoopFailure(stream, config, error, signal);
});
return stream;
}
/**
* Continue an agent loop from the current context without adding a new message.
* Used for retries - context already has user message or tool results.
*
* **Important:** The last message in context must convert to a `user` or `toolResult` message
* via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
* This cannot be validated here since `convertToLlm` is only called once per turn.
*/
export function agentLoopContinue(
context: AgentContext,
config: AgentLoopConfig,
signal?: AbortSignal,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): EventStream<AgentEvent, AgentMessage[]> {
const lastMessage = context.messages.at(-1);
if (!lastMessage) {
throw new Error("Cannot continue: no messages in context");
}
if (lastMessage.role === "assistant") {
throw new TranscriptNotContinuableError(lastMessage.role);
}
const stream = createAgentStream();
void runAgentLoopContinue(
context,
config,
async (event) => {
stream.push(event);
},
signal,
streamFn,
runtime,
)
.then((messages) => {
stream.end(messages);
})
.catch((error: unknown) => {
pushLoopFailure(stream, config, error, signal);
});
return stream;
}
/** Run a prompt-started loop and emit events through a caller-owned sink. */
export async function runAgentLoop(
prompts: AgentMessage[],
context: AgentContext,
config: AgentLoopConfig,
emit: AgentEventSink,
signal?: AbortSignal,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts];
const currentContext: AgentContext = {
...context,
messages: [...context.messages, ...prompts],
};
await emit({ type: "agent_start" });
await emit({ type: "turn_start" });
for (const prompt of prompts) {
await emit({ type: "message_start", message: prompt });
await emit({ type: "message_end", message: prompt });
}
await runLoop(currentContext, newMessages, config, signal, emit, streamFn, runtime);
return newMessages;
}
/** Continue an existing loop context and emit only newly produced messages. */
export async function runAgentLoopContinue(
context: AgentContext,
config: AgentLoopConfig,
emit: AgentEventSink,
signal?: AbortSignal,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): Promise<AgentMessage[]> {
const lastMessage = context.messages.at(-1);
if (!lastMessage) {
throw new Error("Cannot continue: no messages in context");
}
if (lastMessage.role === "assistant") {
throw new TranscriptNotContinuableError(lastMessage.role);
}
const newMessages: AgentMessage[] = [];
const currentContext: AgentContext = { ...context };
await emit({ type: "agent_start" });
await emit({ type: "turn_start" });
await runLoop(currentContext, newMessages, config, signal, emit, streamFn, runtime);
return newMessages;
}
function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
return new EventStreamConstructor<AgentEvent, AgentMessage[]>(
(event: AgentEvent) => event.type === "agent_end",
(event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
);
}
function pushLoopFailure(
stream: EventStream<AgentEvent, AgentMessage[]>,
config: AgentLoopConfig,
error: unknown,
signal: AbortSignal | undefined,
): void {
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: [] });
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 });
}
/**
* Main loop logic shared by agentLoop and agentLoopContinue.
*/
async function runLoop(
initialContext: AgentContext,
newMessages: AgentMessage[],
initialConfig: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): Promise<void> {
let currentContext = initialContext;
let config = initialConfig;
let firstTurn = true;
let turnOpen = true;
let turnTainted = isActiveTurnTainted(initialContext.messages);
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
const stopIfAborted = async (): Promise<boolean> => {
if (!signal?.aborted) {
return false;
}
// Persist an aborted assistant outcome so session post-processing does not
// compact or continue from the preceding toolUse message.
const abortedMessage = withAssistantTurnTaint(
createFailureMessage(
config.model,
signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted"),
true,
),
turnTainted,
);
newMessages.push(abortedMessage);
if (!turnOpen) {
await emit({ type: "turn_start" });
turnOpen = true;
}
await emit({ type: "message_start", message: abortedMessage });
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;
};
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (await stopIfAborted()) {
return;
}
if (!firstTurn) {
await emit({ type: "turn_start" });
turnOpen = true;
} else {
firstTurn = false;
}
// Process pending messages (inject before next assistant response)
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
if (message.role === "user") {
turnTainted = false;
}
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
}
if (await stopIfAborted()) {
return;
}
// Stream assistant response
const message = await streamAssistantResponse(
currentContext,
config,
signal,
emit,
streamFn,
runtime,
turnTainted,
);
newMessages.push(message);
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;
}
// Only completed toolUse turns dispatch; length/stop can carry partial stream blocks.
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (message.stopReason === "toolUse" && toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(
currentContext,
message,
config,
signal,
emit,
);
toolResults.push(...executedToolBatch.messages);
turnTainted ||= toolResults.some(toolResultTaintsTurn);
hasMoreToolCalls = !executedToolBatch.terminate;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
}
await emit({ type: "turn_end", message, toolResults });
turnOpen = false;
if (await stopIfAborted()) {
return;
}
const nextTurnContext = {
message,
toolResults,
context: currentContext,
newMessages,
};
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
if (nextTurnSnapshot) {
currentContext = nextTurnSnapshot.context ?? currentContext;
const nextModel = nextTurnSnapshot.model ?? config.model;
const nextThinkingLevel = nextTurnSnapshot.thinkingLevel ?? config.thinkingLevel;
const shouldResolveReasoning =
nextTurnSnapshot.thinkingLevel !== undefined ||
(nextTurnSnapshot.model !== undefined && nextThinkingLevel !== undefined);
const nextReasoning =
shouldResolveReasoning && nextThinkingLevel !== undefined
? resolveAgentReasoningOption(nextModel, nextThinkingLevel)
: config.reasoning;
config = Object.assign({}, config, {
model: nextModel,
thinkingLevel: nextThinkingLevel,
reasoning: nextReasoning,
});
}
if (await stopIfAborted()) {
return;
}
if (
await config.shouldStopAfterTurn?.({
message,
toolResults,
context: currentContext,
newMessages,
})
) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
pendingMessages = (await config.getSteeringMessages?.()) || [];
if (await stopIfAborted()) {
return;
}
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
// Follow-up messages arrive after a turn would otherwise end; route them through the
// same pending-message path so event ordering matches steering messages.
pendingMessages = followUpMessages;
continue;
}
// No more messages, exit
break;
}
await emit({ type: "agent_end", messages: newMessages });
}
/**
* Stream an assistant response from the LLM.
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
*/
async function streamAssistantResponse(
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
turnTainted = false,
): Promise<AssistantMessage> {
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages;
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);
// Build LLM context
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
const streamFunction = resolveAgentCoreStreamFn(runtime, streamFn);
// Resolve API key (important for expiring tokens)
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
const response = await streamFunction(config.model, llmContext, {
...config,
apiKey: resolvedApiKey,
signal,
});
let partialMessage: AssistantMessage | null = null;
let addedPartial = false;
for await (const event of response) {
switch (event.type) {
case "start": {
const message = event.partial;
partialMessage = message;
context.messages.push(message);
addedPartial = true;
await emit({ type: "message_start", message: { ...message } });
break;
}
case "text_start":
case "text_delta":
case "text_end":
case "thinking_start":
case "thinking_delta":
case "thinking_end":
case "toolcall_start":
case "toolcall_delta":
case "toolcall_end":
if (partialMessage) {
const message = resolveAssistantMessageUpdate(event, partialMessage);
partialMessage = message;
context.messages[context.messages.length - 1] = message;
await emit({
type: "message_update",
assistantMessageEvent: event,
message: { ...message },
});
}
break;
case "done":
case "error": {
const finalMessage = withAssistantTurnTaint(
ensureToolTurnIdentity(removeNonExecutableToolCalls(await response.result())),
turnTainted,
);
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
context.messages.push(finalMessage);
}
if (!addedPartial) {
await emit({ type: "message_start", message: { ...finalMessage } });
}
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}
}
}
const finalMessage = withAssistantTurnTaint(
ensureToolTurnIdentity(removeNonExecutableToolCalls(await response.result())),
turnTainted,
);
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
context.messages.push(finalMessage);
await emit({ type: "message_start", message: { ...finalMessage } });
}
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}
/**
* Execute tool calls from an assistant message.
*/
async function executeToolCalls(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
const resolvedToolCalls = new Map<AgentToolCall, ResolvedToolCallOutcome>();
let hasSequentialToolCall = false;
if (config.toolExecution !== "sequential") {
for (const toolCall of toolCalls) {
const resolution = await resolveToolCallTool(
currentContext,
assistantMessage,
toolCall,
config,
signal,
resolvedToolCalls,
);
if (resolution.kind === "resolved" && resolution.tool?.executionMode === "sequential") {
hasSequentialToolCall = true;
break;
}
if (signal?.aborted) {
break;
}
}
}
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(
currentContext,
assistantMessage,
toolCalls,
resolvedToolCalls,
config,
signal,
emit,
);
}
return executeToolCallsParallel(
currentContext,
assistantMessage,
toolCalls,
resolvedToolCalls,
config,
signal,
emit,
);
}
type ExecutedToolCallBatch = {
messages: ToolResultMessage[];
terminate: boolean;
};
type ResolvedToolCallOutcome =
| { kind: "resolved"; tool?: AgentTool }
| { kind: "error"; error: unknown };
function hidesToolCallFromChannelProgress(
context: AgentContext,
toolCall: AgentToolCall,
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
): boolean {
const resolution = resolvedToolCalls.get(toolCall);
const tool =
resolution?.kind === "resolved"
? resolution.tool
: context.tools?.find((candidate) => candidate.name === toolCall.name);
return tool?.hideFromChannelProgress === true;
}
async function executeToolCallsSequential(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCalls: AgentToolCall[],
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const finalizedCalls: FinalizedToolCallOutcome[] = [];
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const hideFromChannelProgress = hidesToolCallFromChannelProgress(
currentContext,
toolCall,
resolvedToolCalls,
);
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
});
const preparation = await prepareToolCall(
currentContext,
assistantMessage,
toolCall,
config,
signal,
resolvedToolCalls,
);
let finalized: FinalizedToolCallOutcome;
if (preparation.kind === "immediate") {
finalized = await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(preparation.resultContentSource
? { resultContentSource: preparation.resultContentSource }
: {}),
},
toolCall.arguments,
config,
signal,
);
} else {
const executed = await executePreparedToolCall(
preparation,
{ assistantMessage, toolCall: preparation.toolCall },
signal,
emit,
);
finalized = await finalizeExecutedToolCall(
currentContext,
assistantMessage,
preparation,
executed,
config,
signal,
);
}
await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
if (signal?.aborted) {
// Complete the skipped tail through the normal lifecycle and outcome hook
// so the committed tool-call turn stays paired and subscriber-safe.
for (let i = finalizedCalls.length; i < toolCalls.length; i++) {
const skippedToolCall = toolCalls[i];
if (!skippedToolCall) {
continue;
}
const completed = await completeAbortedToolCall(
currentContext,
assistantMessage,
skippedToolCall,
resolvedToolCalls,
config,
signal,
emit,
);
finalizedCalls.push(completed.finalized);
messages.push(completed.message);
}
break;
}
}
return {
messages,
terminate: shouldTerminateToolBatch(finalizedCalls),
};
}
async function executeToolCallsParallel(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCalls: AgentToolCall[],
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const finalizedCalls: FinalizedToolCallEntry[] = [];
for (const toolCall of toolCalls) {
const hideFromChannelProgress = hidesToolCallFromChannelProgress(
currentContext,
toolCall,
resolvedToolCalls,
);
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
});
const preparation = await prepareToolCall(
currentContext,
assistantMessage,
toolCall,
config,
signal,
resolvedToolCalls,
);
if (preparation.kind === "immediate") {
const finalized = await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(preparation.resultContentSource
? { resultContentSource: preparation.resultContentSource }
: {}),
},
toolCall.arguments,
config,
signal,
);
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
if (signal?.aborted) {
break;
}
continue;
}
finalizedCalls.push(async () => {
const executed = await executePreparedToolCall(
preparation,
{ assistantMessage, toolCall: preparation.toolCall },
signal,
emit,
);
const finalized = await finalizeExecutedToolCall(
currentContext,
assistantMessage,
preparation,
executed,
config,
signal,
);
await emitToolExecutionEnd(finalized, emit);
return finalized;
});
if (signal?.aborted) {
break;
}
}
const orderedFinalizedCalls = await Promise.all(
finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))),
);
const messages: ToolResultMessage[] = [];
for (const finalized of orderedFinalizedCalls) {
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
// Complete calls skipped before queueing through the same lifecycle contract
// as the sequential path.
if (signal?.aborted && orderedFinalizedCalls.length < toolCalls.length) {
for (let i = orderedFinalizedCalls.length; i < toolCalls.length; i++) {
const skippedToolCall = toolCalls[i];
if (!skippedToolCall) {
continue;
}
const completed = await completeAbortedToolCall(
currentContext,
assistantMessage,
skippedToolCall,
resolvedToolCalls,
config,
signal,
emit,
);
orderedFinalizedCalls.push(completed.finalized);
messages.push(completed.message);
}
}
return {
messages,
terminate: shouldTerminateToolBatch(orderedFinalizedCalls),
};
}
type PreparedToolCall = {
kind: "prepared";
toolCall: AgentToolCall;
tool: AgentTool;
args: unknown;
};
type ImmediateToolCallOutcome = {
kind: "immediate";
result: AgentToolResult<unknown>;
isError: boolean;
errorKind?: "argument-validation";
resultContentSource?: ToolResultContentSource;
};
type ExecutedToolCallOutcome = {
result: AgentToolResult<unknown>;
isError: boolean;
executionStarted: boolean;
};
type FinalizedToolCallOutcome = {
toolCall: AgentToolCall;
result: AgentToolResult<unknown>;
isError: boolean;
executionStarted: boolean;
errorKind?: "argument-validation";
hideFromChannelProgress?: boolean;
resultContentSource?: ToolResultContentSource;
};
type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return (
finalizedCalls.length > 0 &&
finalizedCalls.every((finalized) => finalized.result.terminate === true)
);
}
function prepareToolCallArguments(tool: AgentTool, toolCall: AgentToolCall): AgentToolCall {
if (!tool.prepareArguments) {
return toolCall;
}
const preparedArguments = tool.prepareArguments(toolCall.arguments);
if (preparedArguments === toolCall.arguments) {
return toolCall;
}
return {
...toolCall,
arguments: preparedArguments as Record<string, unknown>,
};
}
async function resolveToolCallTool(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCall: AgentToolCall,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
resolvedToolCalls?: Map<AgentToolCall, ResolvedToolCallOutcome>,
): Promise<ResolvedToolCallOutcome> {
const cached = resolvedToolCalls?.get(toolCall);
if (cached) {
return cached;
}
let resolution: ResolvedToolCallOutcome;
try {
let tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) {
const resolvedTool = await config.resolveDeferredTool?.(
{
assistantMessage,
toolCall,
context: currentContext,
},
signal,
);
// Keep execution and lifecycle/audit identity aligned with the original model call.
if (resolvedTool && resolvedTool.name !== toolCall.name) {
throw new Error(
`Deferred tool resolver returned "${resolvedTool.name}" for requested "${toolCall.name}"`,
);
}
tool = resolvedTool;
if (tool) {
// Make the recovered tool visible to later provider continuations in this run.
currentContext.tools = [...(currentContext.tools ?? []), tool];
}
}
resolution = { kind: "resolved", ...(tool ? { tool } : {}) };
} catch (error) {
resolution = { kind: "error", error };
}
resolvedToolCalls?.set(toolCall, resolution);
return resolution;
}
async function prepareToolCall(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCall: AgentToolCall,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
): Promise<PreparedToolCall | ImmediateToolCallOutcome> {
const resolution = await resolveToolCallTool(
currentContext,
assistantMessage,
toolCall,
config,
signal,
resolvedToolCalls,
);
if (resolution.kind === "error") {
return {
kind: "immediate",
result: createErrorToolResult(
signal?.aborted
? "Operation aborted"
: resolution.error instanceof Error
? resolution.error.message
: String(resolution.error),
),
isError: true,
};
}
const tool = resolution.tool;
if (!tool) {
return {
kind: "immediate",
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
isError: true,
};
}
let preparedToolCall: AgentToolCall;
try {
preparedToolCall = prepareToolCallArguments(tool, toolCall);
} catch (error) {
return {
kind: "immediate",
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
let validatedArgs: unknown;
try {
validatedArgs = validateToolArguments(tool, preparedToolCall);
} catch (error) {
return {
kind: "immediate",
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
errorKind: "argument-validation",
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
try {
if (config.beforeToolCall) {
const beforeResult = await config.beforeToolCall(
{
assistantMessage,
toolCall,
args: validatedArgs,
context: currentContext,
},
signal,
);
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
if (beforeResult?.block) {
return {
kind: "immediate",
result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
isError: true,
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
}
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
return {
kind: "prepared",
toolCall,
tool,
args: validatedArgs,
};
} catch (error) {
return {
kind: "immediate",
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
...(tool.resultContentSource ? { resultContentSource: tool.resultContentSource } : {}),
};
}
}
async function executePreparedToolCall(
prepared: PreparedToolCall,
executionContext: AgentToolExecutionContext,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
// Parallel batches prepare every call first. A later preflight abort must not
// let an earlier prepared, side-effectful tool start afterward.
if (signal?.aborted) {
return {
result: createErrorToolResult("Operation aborted"),
isError: true,
executionStarted: false,
};
}
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
try {
const result = await runWithAgentToolExecutionContext(executionContext, () =>
prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) {
return;
}
updateEvents.push(
Promise.resolve(
emit({
type: "tool_execution_update",
toolCallId: prepared.toolCall.id,
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
...(prepared.tool.hideFromChannelProgress === true
? { hideFromChannelProgress: true }
: {}),
}),
),
);
},
),
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false, executionStarted: true };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
executionStarted: true,
};
} finally {
acceptingUpdates = false;
}
}
async function finalizeExecutedToolCall(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
prepared: PreparedToolCall,
executed: ExecutedToolCallOutcome,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
): Promise<FinalizedToolCallOutcome> {
let result = executed.result;
let isError = executed.isError;
if (executed.executionStarted && config.afterToolCall) {
try {
const afterResult = await config.afterToolCall(
{
assistantMessage,
toolCall: prepared.toolCall,
args: prepared.args,
result,
isError,
context: currentContext,
},
signal,
);
if (afterResult) {
result = {
...result,
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
terminate: afterResult.terminate ?? result.terminate,
};
isError = afterResult.isError ?? isError;
}
} catch (error) {
result = createErrorToolResult(error instanceof Error ? error.message : String(error));
isError = true;
}
}
return await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall: prepared.toolCall,
result,
isError,
executionStarted: executed.executionStarted,
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
...(prepared.tool.resultContentSource
? { resultContentSource: prepared.tool.resultContentSource }
: {}),
},
prepared.args,
config,
signal,
);
}
async function finalizeToolCallOutcome(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
finalized: FinalizedToolCallOutcome,
args: unknown,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
): Promise<FinalizedToolCallOutcome> {
if (!config.afterToolOutcome) {
return finalized;
}
try {
const afterResult = await config.afterToolOutcome(
{
assistantMessage,
toolCall: finalized.toolCall,
args,
result: finalized.result,
isError: finalized.isError,
executionStarted: finalized.executionStarted,
...(finalized.errorKind ? { errorKind: finalized.errorKind } : {}),
context: currentContext,
},
signal,
);
if (!afterResult) {
return finalized;
}
return {
...finalized,
result: {
...finalized.result,
content: afterResult.content ?? finalized.result.content,
details: afterResult.details ?? finalized.result.details,
terminate: afterResult.terminate ?? finalized.result.terminate,
},
isError: afterResult.isError ?? finalized.isError,
};
} catch (error) {
const errorResult = createErrorToolResult(
error instanceof Error ? error.message : String(error),
);
return {
...finalized,
result: {
...errorResult,
...(finalized.result.terminate === undefined
? {}
: { terminate: finalized.result.terminate }),
},
isError: true,
};
}
}
async function completeAbortedToolCall(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCall: AgentToolCall,
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<{ finalized: FinalizedToolCallOutcome; message: ToolResultMessage }> {
const hideFromChannelProgress = hidesToolCallFromChannelProgress(
currentContext,
toolCall,
resolvedToolCalls,
);
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
});
const finalized = await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall,
result: createErrorToolResult("Operation aborted"),
isError: true,
executionStarted: false,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
toolCall.arguments,
config,
signal,
);
await emitToolExecutionEnd(finalized, emit);
const message = createToolResultMessage(finalized);
await emitToolResultMessage(message, emit);
return { finalized, message };
}
function createErrorToolResult(message: string): AgentToolResult<unknown> {
return {
content: [{ type: "text", text: message }],
details: {},
};
}
async function emitToolExecutionEnd(
finalized: FinalizedToolCallOutcome,
emit: AgentEventSink,
): Promise<void> {
await emit({
type: "tool_execution_end",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
result: finalized.result,
isError: finalized.isError,
executionStarted: finalized.executionStarted,
...(finalized.errorKind ? { errorKind: finalized.errorKind } : {}),
...(finalized.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
});
}
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
return withToolResultContentSource(
{
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
content: finalized.result.content ?? [],
details: finalized.result.details,
isError: finalized.isError,
timestamp: Date.now(),
},
finalized.resultContentSource,
);
}
type TurnTaintMetadata = {
resultContentSource?: ToolResultContentSource;
turnTainted?: true;
};
function readTurnTaintMetadata(message: AgentMessage): TurnTaintMetadata | undefined {
const metadata = (message as unknown as Record<string, unknown>)["__openclaw"];
return metadata && typeof metadata === "object" && !Array.isArray(metadata)
? (metadata as TurnTaintMetadata)
: undefined;
}
function toolResultTaintsTurn(message: ToolResultMessage): boolean {
return readTurnTaintMetadata(message)?.resultContentSource === "network";
}
function isActiveTurnTainted(messages: readonly AgentMessage[]): boolean {
for (const message of messages.toReversed()) {
if (message.role === "user") {
return false;
}
const metadata = readTurnTaintMetadata(message);
if (metadata?.turnTainted === true || metadata?.resultContentSource === "network") {
return true;
}
}
return false;
}
function withAssistantTurnTaint(message: AssistantMessage, tainted: boolean): AssistantMessage {
if (!tainted) {
return message;
}
return {
...message,
__openclaw: { ...readTurnTaintMetadata(message), turnTainted: true },
} as unknown as AssistantMessage;
}
function withToolResultContentSource(
message: ToolResultMessage,
source: ToolResultContentSource | undefined,
): ToolResultMessage {
if (!source) {
return message;
}
return {
...message,
__openclaw: { ...readTurnTaintMetadata(message), resultContentSource: source },
} as ToolResultMessage;
}
async function emitToolResultMessage(
toolResultMessage: ToolResultMessage,
emit: AgentEventSink,
): Promise<void> {
await emit({ type: "message_start", message: toolResultMessage });
await emit({ type: "message_end", message: toolResultMessage });
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */