refactor(agents): split embedded-agent subscribe tool handlers into concept modules (#121974)

This commit is contained in:
Peter Steinberger
2026-08-11 02:37:48 -07:00
committed by GitHub
parent 1016ebe56e
commit a4c7b1b7b5
7 changed files with 2137 additions and 2034 deletions
-1
View File
@@ -397,7 +397,6 @@ src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts
src/agents/embedded-agent-subscribe.handlers.messages.test.ts
src/agents/embedded-agent-subscribe.handlers.messages.ts
src/agents/embedded-agent-subscribe.handlers.tools.test.ts
src/agents/embedded-agent-subscribe.handlers.tools.ts
src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts
src/agents/embedded-agent-subscribe.tools.ts
src/agents/embedded-agent-subscribe.ts
@@ -218,11 +218,6 @@
"wrapped": "src/media/web-media.ts",
"wrapper": "src/plugins/runtime/runtime-web-channel-plugin.ts"
},
{
"name": "persistSessionEntry",
"wrapped": "src/agents/command/attempt-execution.shared.ts",
"wrapper": "src/agents/command/session-helpers.ts"
},
{
"name": "pruneStaleCommandPolls",
"wrapped": "src/agents/command-poll-backoff.ts",
@@ -0,0 +1,649 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
import type {
AgentCommandOutputEventData,
AgentItemEventData,
AgentPatchSummaryEventData,
} from "../infra/agent-activity-events.js";
import {
emitAgentApprovalEvent,
emitAgentCommandOutputEvent,
emitAgentPatchSummaryEvent,
} from "../infra/agent-activity-events.js";
import { emitAgentEvent, type AgentApprovalEventData } from "../infra/agent-events.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js";
import {
consumeAdjustedParamsForToolCall,
consumePreExecutionBlockedToolCall,
consumeStructuredReplaySafeToolCall,
consumeTrackedToolExecutionStarted,
} from "./agent-tools.before-tool-call.state.js";
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
readMessageToolSourceReplyText,
resolveMessageToolSourceReplyFinal,
} from "./embedded-agent-message-tool-source-reply.js";
import {
isMessagingTool,
isMessagingToolSendAction,
isMessagingToolTargetEvidenceAction,
} from "./embedded-agent-messaging.js";
import { mergeEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
import {
applyCurrentMessageProvider,
applyToolSendReceiptForExtraction,
buildPatchSummaryText,
buildProcessTerminalDiagnostic,
didShellCronAddSucceed,
emitToolResultOutput,
extractExecOutput,
extractLiveExecOutput,
hasMessagingRichContent,
hasTerminalControlCharacter,
isAsyncStartedToolResult,
isCronAddAction,
isMiddlewareToolResultError,
loadHookRunnerGlobal,
readApplyPatchSummary,
readAsyncStartedTaskIds,
readExecToolDetails,
readMessagingText,
readUpdatePlanResult,
resolveFallbackToolTerminalObserver,
} from "./embedded-agent-subscribe.handlers.tools.results.js";
import {
buildCommandItemId,
buildCommandItemTitle,
buildPatchItemId,
buildPatchItemTitle,
buildToolCallSummary,
buildToolItemId,
buildToolItemTitle,
buildToolStartKey,
emitAgentEventCallbackBestEffort,
emitTrackedItemEvent,
isExecToolName,
toolStartData,
} from "./embedded-agent-subscribe.handlers.tools.start.js";
import type { ToolHandlerContext } from "./embedded-agent-subscribe.handlers.types.js";
import {
collectMessagingMediaUrlsFromRecord,
collectMessagingMediaUrlsFromToolResult,
capLiveExecResult,
extractMessagingToolSourceReplyPayload,
extractToolErrorCode,
extractMessagingToolSend,
extractMessagingToolSendResult,
extractToolErrorMessage,
isToolResultError,
isToolResultTimedOut,
sanitizeToolResult,
} from "./embedded-agent-subscribe.tools.js";
import { parseExecApprovalResultText } from "./exec-approval-result.js";
import { readMcpAppChannelView } from "./mcp-ui-resource.js";
import type { AgentEvent } from "./runtime/index.js";
import {
createToolValidationErrorSummary,
summarizeToolValidationError,
} from "./tool-error-summary.js";
import { resolveFileMutationToolName } from "./tool-mutation-names.js";
import { normalizeToolName } from "./tool-policy.js";
import { cancelAskUserPromptDelivery } from "./tools/ask-user-tool.js";
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
/** Handles a tool-execution result and commits replay, media, hook, and error state. */
export async function handleToolExecutionEnd(
ctx: ToolHandlerContext,
evt: Extract<AgentEvent, { type: "tool_execution_end" }>,
) {
const rawToolName = evt.toolName;
const toolName = normalizeToolName(rawToolName);
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const toolCallId = evt.toolCallId;
ctx.state.liveEditDiffStateById.delete(toolCallId);
if (toolName === "ask_user") {
cancelAskUserPromptDelivery(toolCallId, ctx.params.sessionKey, ctx.params.runId);
}
const runId = ctx.params.runId;
const isError = evt.isError;
const result = evt.result;
const toolSendReceiptResult = ctx.consumeToolSendReceipt?.(toolCallId);
const observerIsError = isError || isToolResultError(result);
const sanitizedResult = sanitizeToolResult(result);
const approvalUnavailable =
isExecToolName(toolName) &&
readExecToolDetails(sanitizedResult)?.status === "approval-unavailable";
const isToolError = observerIsError && !approvalUnavailable;
if (!isToolError) {
const channelView = readMcpAppChannelView(result);
if (channelView) {
// A later successful app result supersedes the earlier launch target.
ctx.state.latestMcpAppChannelView = channelView;
}
}
try {
ctx.params.onAgentToolResult?.({
toolName,
result: sanitizedResult,
isError: observerIsError,
});
} catch (error) {
ctx.log.warn(`onAgentToolResult handler failed: tool=${toolName} error=${String(error)}`);
}
const eventResult = isExecToolName(toolName)
? capLiveExecResult(sanitizedResult)
: sanitizedResult;
const toolStartKey = buildToolStartKey(runId, toolCallId);
const startData = toolStartData.get(toolStartKey);
toolStartData.delete(toolStartKey);
ctx.state.execLiveUpdateStateById?.delete(toolCallId);
const initialCallSummary = ctx.state.toolMetaById.get(toolCallId);
const initialArgs =
startData?.args && typeof startData.args === "object"
? (startData.args as Record<string, unknown>)
: {};
const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId, runId);
const trackedExecutionStarted = consumeTrackedToolExecutionStarted(toolCallId, runId);
const executionPrevented = consumePreExecutionBlockedToolCall(toolCallId, runId);
const structuredReplaySafe = consumeStructuredReplaySafeToolCall(toolCallId, runId);
const startArgs =
adjustedArgs && typeof adjustedArgs === "object"
? (adjustedArgs as Record<string, unknown>)
: initialArgs;
const callSummary = buildToolCallSummary(
toolName,
startArgs,
initialCallSummary?.meta,
initialCallSummary?.instanceReplaySafe === true,
structuredReplaySafe,
);
// A racing observer can consume the active wrapper boundary. Settled and
// custom producers use their terminal fact, while policy blocks override it.
const executionStarted =
(trackedExecutionStarted ?? evt.executionStarted ?? true) && !executionPrevented;
const attemptedPotentialSideEffect = !callSummary.replaySafe && executionStarted;
const meta = callSummary.meta;
const asyncStarted = !isToolError && isAsyncStartedToolResult(sanitizedResult);
const asyncTaskIds = asyncStarted ? readAsyncStartedTaskIds(sanitizedResult) : {};
ctx.state.toolMetas.push({
toolName,
meta,
replaySafe: callSummary.replaySafe,
isError: observerIsError,
...(asyncStarted ? { asyncStarted: true, ...asyncTaskIds } : {}),
});
const acceptedSessionSpawn =
toolName === "sessions_spawn" && !isToolError
? normalizeAcceptedSessionSpawnResult(sanitizedResult)
: null;
if (acceptedSessionSpawn) {
ctx.state.acceptedSessionSpawns.push(acceptedSessionSpawn);
}
ctx.state.toolMetaById.delete(toolCallId);
ctx.state.toolSummaryById.delete(toolCallId);
const errorMessage = isToolError ? extractToolErrorMessage(sanitizedResult) : undefined;
const errorCode = isToolError ? extractToolErrorCode(sanitizedResult) : undefined;
const terminalDiagnostic = isToolError
? buildProcessTerminalDiagnostic(toolName, startArgs, sanitizedResult)
: undefined;
const terminalErrorMessage =
terminalDiagnostic && errorMessage && hasTerminalControlCharacter(errorMessage)
? undefined
: errorMessage;
const validationErrorSummary =
isToolError && evt.executionStarted === false && evt.errorKind === "argument-validation"
? createToolValidationErrorSummary(toolName)
: undefined;
const terminal = (ctx.params.observeToolTerminal ?? resolveFallbackToolTerminalObserver(ctx))({
toolCallId,
toolName,
arguments: startArgs,
...(meta ? { meta } : {}),
executionStarted,
outcome: isToolError ? "failure" : "success",
...(isToolError
? {
failure: {
...(errorCode ? { errorCode } : {}),
...(terminalErrorMessage ? { error: terminalErrorMessage } : {}),
...(terminalDiagnostic ? { terminalDiagnostic } : {}),
...(validationErrorSummary ? { validationErrorSummary } : {}),
timedOut: isToolResultTimedOut(sanitizedResult) || undefined,
middlewareError: isMiddlewareToolResultError(sanitizedResult) || undefined,
},
}
: {}),
});
ctx.state.lastToolError = terminal.lastToolError;
const toolErrorSummary = ctx.state.lastToolError
? summarizeToolValidationError(ctx.state.lastToolError)
: undefined;
if (asyncStarted) {
ctx.state.hadDeterministicSideEffect = true;
}
if (attemptedPotentialSideEffect || acceptedSessionSpawn || asyncStarted) {
ctx.state.replayState = mergeEmbeddedRunReplayState(ctx.state.replayState, {
replayInvalid: true,
hadPotentialSideEffects: true,
});
}
// Commit messaging tool evidence on success, discard on error.
const messagingArgs = applyCurrentMessageProvider(toolName, startArgs, ctx.params.messageChannel);
const isMessagingInvocation = isMessagingTool(toolName);
const isMessagingSend = isMessagingInvocation && isMessagingToolSendAction(toolName, startArgs);
const hasMessagingTargetEvidence =
isMessagingInvocation && isMessagingToolTargetEvidenceAction(toolName, startArgs);
const didDeliverMessagingResult =
isMessagingInvocation &&
isDeliveredMessagingToolResult({
toolName,
args: startArgs,
result,
hookResult: toolSendReceiptResult,
isError: isToolError,
});
const messageText = isMessagingSend ? readMessagingText(startArgs) : undefined;
const argumentMediaUrls = isMessagingSend ? collectMessagingMediaUrlsFromRecord(startArgs) : [];
const hasRichContent = isMessagingSend && hasMessagingRichContent(startArgs);
const messageTarget = hasMessagingTargetEvidence
? extractMessagingToolSend(toolName, messagingArgs, {
config: ctx.params.config,
currentChannelId: ctx.params.currentChannelId,
currentMessagingTarget: ctx.params.currentMessagingTarget,
currentThreadId:
ctx.params.currentThreadId ?? parseSessionThreadInfoFast(ctx.params.sessionKey).threadId,
currentMessageId: ctx.params.currentMessageId,
replyToMode: ctx.params.replyToMode,
hasRepliedRef: startData?.hasRepliedRef,
})
: undefined;
const committedMediaUrls =
didDeliverMessagingResult && isMessagingSend
? [...argumentMediaUrls, ...collectMessagingMediaUrlsFromToolResult(result)]
: [];
const deliveredCurrentSourceReply =
didDeliverMessagingResult &&
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
toolName,
args: startArgs,
result,
isError: isToolError,
});
const sourceReplyFinal = deliveredCurrentSourceReply
? resolveMessageToolSourceReplyFinal(startArgs)
: undefined;
ctx.state.pendingMessagingTexts.delete(toolCallId);
ctx.state.pendingMessagingTargets.delete(toolCallId);
ctx.state.pendingMessagingMediaUrls.delete(toolCallId);
if (didDeliverMessagingResult && messageText) {
ctx.state.messagingToolSentTexts.push(messageText);
ctx.state.messagingToolSentTextsNormalized.push(normalizeTextForComparison(messageText));
ctx.log.debug(`Committed messaging text: tool=${toolName} len=${messageText.length}`);
ctx.trimMessagingToolSent();
}
if (didDeliverMessagingResult && messageTarget) {
const extractionResult = applyToolSendReceiptForExtraction(result, toolSendReceiptResult);
const confirmedTarget = extractMessagingToolSendResult(messageTarget, extractionResult);
ctx.state.messagingToolSentTargets.push({
...confirmedTarget,
...(messageText ? { text: messageText } : {}),
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
...(hasRichContent ? { hasRichContent: true as const } : {}),
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
});
ctx.trimMessagingToolSent();
}
if (deliveredCurrentSourceReply) {
ctx.state.messageToolOnlySourceReplyDelivered = true;
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
const normalizedSourceReplyText = sourceReplyText
? normalizeTextForComparison(sourceReplyText)
: "";
if (normalizedSourceReplyText) {
ctx.state.currentSourceMessagingToolSentTextsNormalized.push(normalizedSourceReplyText);
ctx.trimMessagingToolSent();
}
ctx.params.onDeliveredMessageToolOnlySourceReply?.();
}
if (didDeliverMessagingResult && isMessagingSend) {
if (committedMediaUrls.length > 0) {
ctx.state.messagingToolSentMediaUrls.push(...committedMediaUrls);
ctx.trimMessagingToolSent();
}
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
if (sourceReplyPayload) {
ctx.state.messagingToolSourceReplyPayloads.push({
...sourceReplyPayload,
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
});
ctx.trimMessagingToolSent();
}
}
// Track committed reminders only when cron.add completed successfully.
if (
!isToolError &&
((isAutomationsToolName(toolName) && isCronAddAction(startArgs)) ||
(isExecToolName(toolName) && didShellCronAddSucceed(startArgs, result)))
) {
ctx.state.successfulCronAdds += 1;
}
if (!isToolError && toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const details =
result && typeof result === "object" ? (result as { details?: unknown }).details : undefined;
const response = normalizeHeartbeatToolResponse(details);
if (response) {
const isFirstHeartbeatResponse = ctx.state.heartbeatToolResponse === undefined;
ctx.state.heartbeatToolResponse = response;
if (isFirstHeartbeatResponse) {
runBestEffortCallback({
label: "heartbeat tool response",
log: ctx.log,
callback: () => ctx.params.onHeartbeatToolResponse?.(response),
});
}
}
}
const planUpdate =
!isToolError && toolName === "update_plan" ? readUpdatePlanResult(sanitizedResult) : undefined;
if (planUpdate) {
const planEvent = {
stream: "plan" as const,
data: {
phase: "update",
title: "Plan updated",
source: "openclaw",
...planUpdate,
},
};
emitAgentEvent({ runId: ctx.params.runId, ...planEvent });
emitAgentEventCallbackBestEffort(ctx, planEvent);
}
emitAgentEvent({
runId: ctx.params.runId,
stream: "tool",
data: {
phase: "result",
name: toolName,
toolCallId,
meta,
isError: isToolError,
commandBearing: callSummary.commandBearing,
result: eventResult,
...(toolErrorSummary ? { toolErrorSummary } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
const endedAt = Date.now();
const itemId = buildToolItemId(toolCallId);
const itemData: AgentItemEventData = {
itemId,
phase: "end",
kind: "tool",
title: buildToolItemTitle(toolName, meta),
status: isToolError ? "failed" : "completed",
name: toolName,
meta,
commandBearing: callSummary.commandBearing,
toolCallId,
startedAt: startData?.startTime,
endedAt,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(callSummary.commandBearing && !isExecToolName(toolName)
? { suppressChannelProgress: true }
: {}),
...(isToolError && extractToolErrorMessage(sanitizedResult)
? { error: extractToolErrorMessage(sanitizedResult) }
: {}),
};
emitTrackedItemEvent(ctx, itemData);
emitAgentEventCallbackBestEffort(ctx, {
stream: "tool",
data: {
phase: "result",
name: toolName,
toolCallId,
meta,
isError: isToolError,
commandBearing: callSummary.commandBearing,
...(toolErrorSummary ? { toolErrorSummary } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
if (isExecToolName(toolName)) {
// Use sanitizedResult so `aggregated` is redacted before reaching command_output.
const execDetails = readExecToolDetails(sanitizedResult);
const commandItemId = buildCommandItemId(toolCallId);
if (
execDetails?.status === "approval-pending" ||
execDetails?.status === "approval-unavailable"
) {
const approvalStatus = execDetails.status === "approval-pending" ? "pending" : "unavailable";
const approvalData: AgentApprovalEventData = {
phase: "requested",
kind: "exec",
status: approvalStatus,
title:
approvalStatus === "pending"
? "Command approval requested"
: "Command approval unavailable",
itemId: commandItemId,
toolCallId,
...(execDetails.status === "approval-pending"
? {
approvalId: execDetails.approvalId,
approvalSlug: execDetails.approvalSlug,
}
: {}),
command: execDetails.command,
host: execDetails.host,
...(execDetails.status === "approval-unavailable" ? { reason: execDetails.reason } : {}),
message: execDetails.warningText,
};
emitAgentApprovalEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: approvalData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "approval",
data: approvalData,
});
emitTrackedItemEvent(ctx, {
itemId: commandItemId,
phase: "end",
kind: "command",
title: buildCommandItemTitle(toolName, meta),
status: "blocked",
name: toolName,
meta,
toolCallId,
startedAt: startData?.startTime,
endedAt,
...(execDetails.status === "approval-pending"
? {
approvalId: execDetails.approvalId,
approvalSlug: execDetails.approvalSlug,
summary: "Awaiting approval before command can run.",
}
: {
summary: "Command is blocked because no interactive approval route is available.",
}),
});
} else {
const output = extractLiveExecOutput(eventResult);
const rawOutput = extractExecOutput(sanitizedResult);
const commandStatus =
execDetails?.status === "failed" || isToolError ? "failed" : "completed";
emitTrackedItemEvent(ctx, {
itemId: commandItemId,
phase: "end",
kind: "command",
title: buildCommandItemTitle(toolName, meta),
status: commandStatus,
name: toolName,
meta,
toolCallId,
startedAt: startData?.startTime,
endedAt,
...(output ? { summary: output } : {}),
...(isToolError && extractToolErrorMessage(sanitizedResult)
? { error: extractToolErrorMessage(sanitizedResult) }
: {}),
});
const outputData: AgentCommandOutputEventData = {
itemId: commandItemId,
phase: "end",
title: buildCommandItemTitle(toolName, meta),
toolCallId,
name: toolName,
...(output ? { output } : {}),
status: commandStatus,
...(execDetails && "exitCode" in execDetails ? { exitCode: execDetails.exitCode } : {}),
...(execDetails && "durationMs" in execDetails
? { durationMs: execDetails.durationMs }
: {}),
...(execDetails && "cwd" in execDetails && typeof execDetails.cwd === "string"
? { cwd: execDetails.cwd }
: {}),
};
emitAgentCommandOutputEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: outputData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "command_output",
data: outputData,
});
if (typeof rawOutput === "string") {
const parsedApprovalResult = parseExecApprovalResultText(rawOutput);
if (parsedApprovalResult.kind === "denied") {
const approvalData: AgentApprovalEventData = {
phase: "resolved",
kind: "exec",
status: normalizeOptionalLowercaseString(parsedApprovalResult.metadata)?.includes(
"approval-request-failed",
)
? "failed"
: "denied",
title: "Command approval resolved",
itemId: commandItemId,
toolCallId,
message: parsedApprovalResult.body || parsedApprovalResult.raw,
};
emitAgentApprovalEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: approvalData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "approval",
data: approvalData,
});
}
}
}
}
if (resolveFileMutationToolName(toolName) === "apply_patch") {
const patchSummary = readApplyPatchSummary(sanitizedResult);
const patchItemId = buildPatchItemId(toolCallId);
const summaryText = patchSummary ? buildPatchSummaryText(patchSummary) : undefined;
emitTrackedItemEvent(ctx, {
itemId: patchItemId,
phase: "end",
kind: "patch",
title: buildPatchItemTitle(meta),
status: isToolError ? "failed" : "completed",
name: toolName,
meta,
toolCallId,
startedAt: startData?.startTime,
endedAt,
...(summaryText ? { summary: summaryText } : {}),
...(isToolError && extractToolErrorMessage(sanitizedResult)
? { error: extractToolErrorMessage(sanitizedResult) }
: {}),
});
if (patchSummary) {
const patchData: AgentPatchSummaryEventData = {
itemId: patchItemId,
phase: "end",
title: buildPatchItemTitle(meta),
toolCallId,
name: toolName,
added: patchSummary.added,
modified: patchSummary.modified,
deleted: patchSummary.deleted,
summary: summaryText ?? buildPatchSummaryText(patchSummary),
};
emitAgentPatchSummaryEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: patchData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "patch",
data: patchData,
});
}
}
ctx.log.debug(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
await emitToolResultOutput({
ctx,
toolName,
rawToolName,
meta,
isToolError,
result,
sanitizedResult,
});
await Promise.resolve(ctx.params.onToolStreamBoundary?.()).catch((error: unknown) => {
ctx.log.debug(`embedded run tool stream boundary callback failed: ${String(error)}`);
});
// Run after_tool_call plugin hook (fire-and-forget)
const hookRunnerAfter = ctx.hookRunner ?? (await loadHookRunnerGlobal()).getGlobalHookRunner();
if (hookRunnerAfter?.hasHooks("after_tool_call")) {
const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
const hookEvent: PluginHookAfterToolCallEvent = {
toolName,
params: startArgs,
runId,
toolCallId,
result: sanitizedResult,
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
durationMs,
};
void hookRunnerAfter
.runAfterToolCall(hookEvent, {
toolName,
agentId: ctx.params.agentId,
sessionKey: ctx.params.sessionKey,
sessionId: ctx.params.sessionId,
runId,
toolCallId,
})
.catch((err: unknown) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});
}
}
@@ -0,0 +1,160 @@
import {
asOptionalObjectRecord,
asOptionalRecord as readRecordField,
} from "@openclaw/normalization-core/record-coerce";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import type {
AgentCommandOutputEventData,
AgentItemEventData,
} from "../infra/agent-activity-events.js";
import { emitAgentCommandOutputEvent } from "../infra/agent-activity-events.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { extractLiveExecOutput } from "./embedded-agent-subscribe.handlers.tools.results.js";
import {
buildCommandItemId,
buildCommandItemTitle,
buildToolItemId,
buildToolItemTitle,
emitAgentEventCallbackBestEffort,
emitTrackedItemEvent,
isExecToolName,
} from "./embedded-agent-subscribe.handlers.tools.start.js";
import type { ToolHandlerContext } from "./embedded-agent-subscribe.handlers.types.js";
import {
capLiveExecResult,
sanitizeToolResult,
truncateLiveExecOutput,
} from "./embedded-agent-subscribe.tools.js";
import type { AgentEvent } from "./runtime/index.js";
import { normalizeToolName } from "./tool-policy.js";
type ChannelToolProgress = {
text: string;
};
const LIVE_EXEC_UPDATE_MIN_INTERVAL_MS = 250;
function readChannelToolProgress(result: unknown): ChannelToolProgress | undefined {
const progress = readRecordField(asOptionalObjectRecord(result)?.progress);
// Only typed progress crosses into UI; tool output/details may contain private data.
if (progress?.visibility !== "channel" || progress.privacy !== "public") {
return undefined;
}
const text = readStringValue(progress.text)?.trim();
if (!text) {
return undefined;
}
return { text: truncateLiveExecOutput(text) };
}
function shouldEmitLiveExecUpdate(ctx: ToolHandlerContext, toolCallId: string): boolean {
const now = Date.now();
const state = ctx.state.execLiveUpdateStateById ?? new Map<string, { lastEmittedAtMs: number }>();
ctx.state.execLiveUpdateStateById = state;
const previous = state.get(toolCallId);
if (previous && now - previous.lastEmittedAtMs < LIVE_EXEC_UPDATE_MIN_INTERVAL_MS) {
return false;
}
state.set(toolCallId, { lastEmittedAtMs: now });
return true;
}
/** Handles partial tool output and emits throttled live UI updates. */
export function handleToolExecutionUpdate(
ctx: ToolHandlerContext,
evt: AgentEvent & {
toolName: string;
toolCallId: string;
partialResult?: unknown;
hideFromChannelProgress?: boolean;
},
) {
const toolName = normalizeToolName(evt.toolName);
const toolCallId = evt.toolCallId;
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const partial = evt.partialResult;
const sanitized = sanitizeToolResult(partial);
const isExecTool = isExecToolName(toolName);
const liveResult = isExecTool ? capLiveExecResult(sanitized) : sanitized;
const toolProgress = isExecTool ? undefined : readChannelToolProgress(liveResult);
// Typed progress already has a sanitized path; suppress duplicate raw previews.
const emitDetailedLiveUpdate =
!toolProgress && (!isExecTool || shouldEmitLiveExecUpdate(ctx, toolCallId));
if (emitDetailedLiveUpdate) {
emitAgentEvent({
runId: ctx.params.runId,
stream: "tool",
data: {
phase: "update",
name: toolName,
toolCallId,
partialResult: liveResult,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
}
const itemData: AgentItemEventData = {
itemId: buildToolItemId(toolCallId),
phase: "update",
kind: "tool",
title: buildToolItemTitle(toolName, ctx.state.toolMetaById.get(toolCallId)?.meta),
status: "running",
name: toolName,
toolCallId,
commandBearing: ctx.state.toolMetaById.get(toolCallId)?.commandBearing,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(ctx.state.toolMetaById.get(toolCallId)?.commandBearing && !isExecTool
? { suppressChannelProgress: true }
: {}),
...(toolProgress
? { progressText: toolProgress.text }
: { meta: ctx.state.toolMetaById.get(toolCallId)?.meta }),
};
emitTrackedItemEvent(ctx, itemData);
if (!toolProgress) {
emitAgentEventCallbackBestEffort(ctx, {
stream: "tool",
data: {
phase: "update",
name: toolName,
toolCallId,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
}
if (isExecTool) {
const output = extractLiveExecOutput(liveResult);
const commandData: AgentItemEventData = {
itemId: buildCommandItemId(toolCallId),
phase: "update",
kind: "command",
title: buildCommandItemTitle(toolName, ctx.state.toolMetaById.get(toolCallId)?.meta),
status: "running",
name: toolName,
meta: ctx.state.toolMetaById.get(toolCallId)?.meta,
toolCallId,
...(emitDetailedLiveUpdate && output ? { progressText: output } : {}),
};
emitTrackedItemEvent(ctx, commandData);
if (emitDetailedLiveUpdate && output) {
const outputData: AgentCommandOutputEventData = {
itemId: commandData.itemId,
phase: "delta",
title: commandData.title,
toolCallId,
name: toolName,
output,
status: "running",
};
emitAgentCommandOutputEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: outputData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "command_output",
data: outputData,
});
}
}
}
@@ -0,0 +1,701 @@
import {
asOptionalObjectRecord,
asOptionalRecord as readRecordField,
} from "@openclaw/normalization-core/record-coerce";
import {
normalizeOptionalLowercaseString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { type AgentPlanStep, normalizeAgentPlanSteps } from "../channels/streaming.js";
import { consumeRootOptionToken } from "../infra/cli-root-options.js";
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
import {
parseInteractiveParam,
parseJsonMessageParam,
} from "../infra/outbound/message-action-params.js";
import { hasReplyPayloadContent } from "../interactive/payload.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { hasTopLevelShellControlOperator, splitShellArgs } from "../utils/shell-argv.js";
import type { ApplyPatchSummary } from "./apply-patch.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import type { ToolHandlerContext } from "./embedded-agent-subscribe.handlers.types.js";
import {
extractToolResultMediaArtifact,
extractToolResultText,
filterToolResultMediaUrls,
truncateLiveExecOutput,
} from "./embedded-agent-subscribe.tools.js";
import type { ProcessTerminalDiagnostic } from "./tool-error-summary.js";
import { readToolResultDetails } from "./tool-result-error.js";
import { createToolTerminalObserver } from "./tool-terminal-outcome.js";
type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js");
type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js");
const execApprovalReplyModuleLoader = createLazyImportLoader<ExecApprovalReplyModule>(
() => import("../infra/exec-approval-reply.js"),
);
const hookRunnerGlobalModuleLoader = createLazyImportLoader<HookRunnerGlobalModule>(
() => import("../plugins/hook-runner-global.js"),
);
const fallbackToolTerminalObservers = new WeakMap<
ToolHandlerContext["state"],
ReturnType<typeof createToolTerminalObserver>
>();
export function resolveFallbackToolTerminalObserver(ctx: ToolHandlerContext) {
const existing = fallbackToolTerminalObservers.get(ctx.state);
if (existing) {
return existing;
}
const created = createToolTerminalObserver(ctx.params.runId);
fallbackToolTerminalObservers.set(ctx.state, created);
return created;
}
export function readUpdatePlanResult(
result: unknown,
): { explanation?: string; steps: AgentPlanStep[] } | undefined {
const details = readToolResultDetails(result);
if (details?.status !== "updated" || !Array.isArray(details.plan)) {
return undefined;
}
const steps = normalizeAgentPlanSteps(details.plan) ?? [];
const explanation = readStringValue(details.explanation);
return { ...(explanation ? { explanation } : {}), steps };
}
export function isMiddlewareToolResultError(result: unknown): boolean {
if (!result || typeof result !== "object") {
return false;
}
const details = (result as { details?: unknown }).details;
return Boolean(
details &&
typeof details === "object" &&
!Array.isArray(details) &&
(details as { middlewareError?: unknown }).middlewareError === true,
);
}
export function hasTerminalControlCharacter(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
return true;
}
}
return false;
}
const PROCESS_TERMINATION_REASONS = new Set([
"manual-cancel",
"overall-timeout",
"no-output-timeout",
"spawn-error",
"signal",
"exit",
]);
function readSafeProcessSessionId(value: unknown): string | undefined {
const sessionId = readStringValue(value)?.trim();
if (!sessionId || sessionId.length > 160 || hasTerminalControlCharacter(sessionId)) {
return undefined;
}
return sessionId;
}
export function buildProcessTerminalDiagnostic(
toolName: string,
args: Record<string, unknown>,
sanitizedResult: unknown,
): ProcessTerminalDiagnostic | undefined {
if (toolName !== "process") {
return undefined;
}
const action = normalizeOptionalLowercaseString(args.action);
if (action !== "poll" && action !== "log") {
return undefined;
}
const details = readToolResultDetails(sanitizedResult);
const sessionId = readSafeProcessSessionId(details?.sessionId);
if (!sessionId) {
return undefined;
}
const exitReason = normalizeOptionalLowercaseString(details?.exitReason);
const hasCanonicalExitReason = PROCESS_TERMINATION_REASONS.has(exitReason ?? "");
if (action === "log" && !hasCanonicalExitReason) {
return undefined;
}
const timeoutKind =
exitReason === "overall-timeout" || exitReason === "no-output-timeout" ? exitReason : undefined;
let reason: ProcessTerminalDiagnostic["reason"] | undefined;
if (details?.timedOut === true || timeoutKind) {
reason = { kind: "timeout", ...(timeoutKind ? { timeoutKind } : {}) };
} else if (
(typeof details?.exitSignal === "string" &&
details.exitSignal.trim().length > 0 &&
details.exitSignal.trim().length <= 32) ||
(typeof details?.exitSignal === "number" && Number.isFinite(details.exitSignal))
) {
const signal =
typeof details.exitSignal === "string" ? details.exitSignal.trim() : details.exitSignal;
if (!hasTerminalControlCharacter(String(signal))) {
reason = { kind: "signal", signal };
}
} else if (
typeof details?.exitCode === "number" &&
Number.isSafeInteger(details.exitCode) &&
details.exitCode !== 0
) {
reason = { kind: "exit", exitCode: details.exitCode };
}
if (!reason) {
return undefined;
}
return {
kind: "process",
sessionId,
reason,
};
}
function loadExecApprovalReply(): Promise<ExecApprovalReplyModule> {
return execApprovalReplyModuleLoader.load();
}
export function loadHookRunnerGlobal(): Promise<HookRunnerGlobalModule> {
return hookRunnerGlobalModuleLoader.load();
}
export function isCronAddAction(args: unknown): boolean {
if (!args || typeof args !== "object") {
return false;
}
const action = (args as Record<string, unknown>).action;
return normalizeOptionalLowercaseString(action) === "add";
}
export function applyCurrentMessageProvider(
toolName: string,
args: Record<string, unknown>,
currentProvider: string | undefined,
): Record<string, unknown> {
if (
toolName !== "message" ||
readStringValue(args.provider) ||
readStringValue(args.channel) ||
!currentProvider
) {
return args;
}
return { ...args, provider: currentProvider };
}
export function applyToolSendReceiptForExtraction(
result: unknown,
receiptResult: unknown,
): unknown {
const toolSend = readToolResultDetails(receiptResult)?.toolSend;
if (toolSend === undefined) {
return result;
}
return {
...readRecordField(result),
details: {
...readToolResultDetails(result),
toolSend,
},
};
}
export function isAsyncStartedToolResult(result: unknown): boolean {
const details = readToolResultDetails(result);
return details?.async === true && details.status === "started";
}
export function readAsyncStartedTaskIds(result: unknown): {
asyncTaskRunId?: string;
asyncTaskId?: string;
} {
const details = readToolResultDetails(result);
if (!details) {
return {};
}
const nestedTask = readRecordField(details.task);
const asyncTaskRunId = readStringValue(details.runId) ?? readStringValue(nestedTask?.runId);
const asyncTaskId = readStringValue(details.taskId) ?? readStringValue(nestedTask?.taskId);
return {
...(asyncTaskRunId ? { asyncTaskRunId } : {}),
...(asyncTaskId ? { asyncTaskId } : {}),
};
}
export function readExecToolDetails(result: unknown): ExecToolDetails | null {
const details = readToolResultDetails(result);
if (!details || typeof details.status !== "string") {
return null;
}
return details as ExecToolDetails;
}
export function extractExecOutput(result: unknown): string | undefined {
const execDetails = readExecToolDetails(result);
const output =
execDetails && "aggregated" in execDetails
? execDetails.aggregated
: extractToolResultText(result);
return typeof output === "string" ? output : undefined;
}
export function extractLiveExecOutput(result: unknown): string | undefined {
const output = extractExecOutput(result);
return typeof output === "string" ? truncateLiveExecOutput(output) : undefined;
}
function isOpenClawExecutable(token: string | undefined): boolean {
const executable = normalizeOptionalLowercaseString(token);
return executable?.split(/[\\/]/).at(-1) === "openclaw";
}
function isOpenClawPackageSpec(token: string | undefined): boolean {
const packageSpec = normalizeOptionalLowercaseString(token);
return packageSpec?.startsWith("openclaw@") === true && packageSpec.length > "openclaw@".length;
}
function skipOpenClawPackageRunner(
tokens: string[],
startIndex: number,
): { commandIndex: number; acceptsPackageSpec: boolean } {
let commandIndex = startIndex;
let acceptsPackageSpec = false;
let runner = normalizeOptionalLowercaseString(tokens[commandIndex]);
if (
runner === "corepack" &&
normalizeOptionalLowercaseString(tokens[commandIndex + 1]) === "pnpm"
) {
commandIndex += 1;
runner = "pnpm";
}
if (runner === "pnpm") {
const subcommand = normalizeOptionalLowercaseString(tokens[commandIndex + 1]);
if (subcommand === "exec" || subcommand === "dlx") {
commandIndex += 2;
acceptsPackageSpec = subcommand === "dlx";
} else {
commandIndex = startIndex;
}
} else if (runner === "npx" || runner === "bunx") {
commandIndex += 1;
acceptsPackageSpec = true;
while (true) {
const option = normalizeOptionalLowercaseString(tokens[commandIndex]);
if (
option === "-y" ||
option === "--yes" ||
option === "--no-install" ||
option === "--bun"
) {
commandIndex += 1;
continue;
}
if (option === "-p" || option === "--package") {
commandIndex += 2;
continue;
}
if (option?.startsWith("--package=") || option?.startsWith("--yes=")) {
commandIndex += 1;
continue;
}
break;
}
}
if (tokens[commandIndex] === "--") {
commandIndex += 1;
}
return { commandIndex, acceptsPackageSpec };
}
function isOpenClawCronAddShellCommand(args: unknown): boolean {
const record = asOptionalObjectRecord(args);
const command = readStringValue(record?.command) ?? readStringValue(record?.cmd);
if (!command || hasTopLevelShellControlOperator(command)) {
return false;
}
const tokens = splitShellArgs(command);
if (!tokens || tokens.length < 3) {
return false;
}
// Compound shell programs need a real shell AST; only count direct CLI invocations.
let commandIndex = 0;
if (normalizeOptionalLowercaseString(tokens[commandIndex]) === "env") {
commandIndex += 1;
}
while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(tokens[commandIndex] ?? "")) {
commandIndex += 1;
}
const packageRunner = skipOpenClawPackageRunner(tokens, commandIndex);
commandIndex = packageRunner.commandIndex;
let cliArgIndex = commandIndex + 1;
for (
let consumed = consumeRootOptionToken(tokens, cliArgIndex);
consumed > 0;
consumed = consumeRootOptionToken(tokens, cliArgIndex)
) {
cliArgIndex += consumed;
}
const action = normalizeOptionalLowercaseString(tokens[cliArgIndex + 1]);
const actionArgs = tokens.slice(cliArgIndex + 2);
return (
(isOpenClawExecutable(tokens[commandIndex]) ||
(packageRunner.acceptsPackageSpec && isOpenClawPackageSpec(tokens[commandIndex]))) &&
(normalizeOptionalLowercaseString(tokens[cliArgIndex]) === "cron" ||
normalizeOptionalLowercaseString(tokens[cliArgIndex]) === "automations") &&
(action === "add" || action === "create") &&
!actionArgs.some((token) => token === "-h" || token === "--help")
);
}
export function didShellCronAddSucceed(args: unknown, result: unknown): boolean {
if (!isOpenClawCronAddShellCommand(args)) {
return false;
}
const details = readExecToolDetails(result);
return details?.status === "completed" && details.exitCode === 0;
}
export function readApplyPatchSummary(result: unknown): ApplyPatchSummary | null {
const details = readToolResultDetails(result);
const summary =
details?.summary && typeof details.summary === "object" && !Array.isArray(details.summary)
? (details.summary as Record<string, unknown>)
: null;
if (!summary) {
return null;
}
const added = Array.isArray(summary.added)
? summary.added.filter((entry): entry is string => typeof entry === "string")
: [];
const modified = Array.isArray(summary.modified)
? summary.modified.filter((entry): entry is string => typeof entry === "string")
: [];
const deleted = Array.isArray(summary.deleted)
? summary.deleted.filter((entry): entry is string => typeof entry === "string")
: [];
return { added, modified, deleted };
}
function shouldSuppressStructuredMediaToolOutput(params: {
toolName: string;
rawToolName: string;
isToolError: boolean;
hasDeliverableStructuredMedia: boolean;
builtinToolNames?: ReadonlySet<string>;
}): boolean {
return (
params.toolName === "tts" &&
params.rawToolName.trim() === "tts" &&
params.builtinToolNames?.has("tts") === true &&
!params.isToolError &&
params.hasDeliverableStructuredMedia
);
}
export function buildPatchSummaryText(summary: ApplyPatchSummary): string {
const parts: string[] = [];
if (summary.added.length > 0) {
parts.push(`${summary.added.length} added`);
}
if (summary.modified.length > 0) {
parts.push(`${summary.modified.length} modified`);
}
if (summary.deleted.length > 0) {
parts.push(`${summary.deleted.length} deleted`);
}
return parts.length > 0 ? parts.join(", ") : "no file changes recorded";
}
export function readMessagingText(record: Record<string, unknown>): string | undefined {
for (const key of ["content", "message", "text", "body"]) {
const value = readStringValue(record[key]);
if (value) {
return value;
}
}
return undefined;
}
export function hasMessagingRichContent(record: Record<string, unknown>): boolean {
const payload = {
presentation: record.presentation,
interactive: record.interactive,
channelData: record.channelData,
};
try {
parseJsonMessageParam(payload, "presentation");
parseInteractiveParam(payload);
} catch {
return false;
}
return hasReplyPayloadContent(payload);
}
function queuePendingToolMedia(
ctx: ToolHandlerContext,
mediaReply: { mediaUrls: string[]; audioAsVoice?: boolean; trustedLocalMedia?: boolean },
) {
const seen = new Set(ctx.state.pendingToolMediaUrls.map((url) => url.trim()));
for (const mediaUrl of mediaReply.mediaUrls) {
const normalized = mediaUrl.trim();
if (!normalized) {
continue;
}
if (mediaReply.trustedLocalMedia) {
ctx.state.pendingToolMediaTrustByUrl.set(normalized, true);
} else if (!ctx.state.pendingToolMediaTrustByUrl.has(normalized)) {
ctx.state.pendingToolMediaTrustByUrl.set(normalized, false);
}
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
ctx.state.pendingToolMediaUrls.push(normalized);
}
if (mediaReply.audioAsVoice) {
ctx.state.pendingToolAudioAsVoice = true;
}
}
function readExecApprovalPendingDetails(result: unknown): {
approvalId: string;
approvalSlug: string;
expiresAtMs?: number;
allowedDecisions?: readonly ExecApprovalDecision[];
host: "gateway" | "node";
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-pending") {
return null;
}
const approvalId = readStringValue(details.approvalId) ?? "";
const approvalSlug = readStringValue(details.approvalSlug) ?? "";
const command = typeof details.command === "string" ? details.command : "";
const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null;
if (!approvalId || !approvalSlug || !command || !host) {
return null;
}
return {
approvalId,
approvalSlug,
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
allowedDecisions: Array.isArray(details.allowedDecisions)
? details.allowedDecisions.filter(
(decision): decision is ExecApprovalDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
)
: undefined,
host,
command,
cwd: readStringValue(details.cwd),
nodeId: readStringValue(details.nodeId),
warningText: readStringValue(details.warningText),
};
}
function readExecApprovalUnavailableDetails(result: unknown): {
reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route";
warningText?: string;
channel?: string;
channelLabel?: string;
accountId?: string;
sentApproverDms?: boolean;
host?: "gateway" | "node";
nodeId?: string;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-unavailable") {
return null;
}
const reason =
details.reason === "initiating-platform-disabled" ||
details.reason === "initiating-platform-unsupported" ||
details.reason === "no-approval-route"
? details.reason
: null;
if (!reason) {
return null;
}
return {
reason,
warningText: readStringValue(details.warningText),
channel: readStringValue(details.channel),
channelLabel: readStringValue(details.channelLabel),
accountId: readStringValue(details.accountId),
sentApproverDms: details.sentApproverDms === true,
host: details.host === "gateway" || details.host === "node" ? details.host : undefined,
nodeId: readStringValue(details.nodeId),
};
}
export async function emitToolResultOutput(params: {
ctx: ToolHandlerContext;
toolName: string;
rawToolName: string;
meta?: string;
isToolError: boolean;
result: unknown;
sanitizedResult: unknown;
}) {
const { ctx, toolName, rawToolName, meta, isToolError, result, sanitizedResult } = params;
const recordApprovalPromptDeliveryFailure = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
ctx.log.warn(`failed to deliver exec approval prompt: ${message}`);
const approvalMeta = meta ? `${meta} · approval prompt delivery` : "approval prompt delivery";
ctx.state.lastToolError = (
ctx.params.observeToolTerminal ?? resolveFallbackToolTerminalObserver(ctx)
)({
toolName,
meta: approvalMeta,
executionStarted: false,
outcome: "failure",
failure: { error: `Approval prompt delivery failed: ${message}` },
}).lastToolError;
ctx.state.deterministicApprovalPromptSent = false;
};
const hasStructuredMedia = Boolean(
result &&
typeof result === "object" &&
(result as { details?: unknown }).details &&
typeof (result as { details?: unknown }).details === "object" &&
!Array.isArray((result as { details?: unknown }).details) &&
typeof ((result as { details?: { media?: unknown } }).details?.media ?? undefined) ===
"object" &&
!Array.isArray((result as { details?: { media?: unknown } }).details?.media),
);
const approvalPending = readExecApprovalPendingDetails(result);
if (!isToolError && approvalPending) {
if (!ctx.params.onToolResult) {
return;
}
ctx.state.deterministicApprovalPromptPending = true;
try {
const { buildTypedExecApprovalPendingReplyPayload } = await loadExecApprovalReply();
await ctx.params.onToolResult(
buildTypedExecApprovalPendingReplyPayload({
approvalId: approvalPending.approvalId,
approvalSlug: approvalPending.approvalSlug,
allowedDecisions: approvalPending.allowedDecisions,
command: approvalPending.command,
cwd: approvalPending.cwd,
host: approvalPending.host,
nodeId: approvalPending.nodeId,
expiresAtMs: approvalPending.expiresAtMs,
warningText: approvalPending.warningText,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch (error) {
recordApprovalPromptDeliveryFailure(error);
} finally {
ctx.state.deterministicApprovalPromptPending = false;
}
return;
}
const approvalUnavailable = readExecApprovalUnavailableDetails(result);
if (!isToolError && approvalUnavailable) {
if (!ctx.params.onToolResult) {
return;
}
ctx.state.deterministicApprovalPromptPending = true;
try {
const { buildExecApprovalUnavailableReplyPayload } = await loadExecApprovalReply();
await ctx.params.onToolResult?.(
buildExecApprovalUnavailableReplyPayload({
reason: approvalUnavailable.reason,
warningText: approvalUnavailable.warningText,
channel: approvalUnavailable.channel,
channelLabel: approvalUnavailable.channelLabel,
accountId: approvalUnavailable.accountId,
sentApproverDms: approvalUnavailable.sentApproverDms,
host: approvalUnavailable.host,
nodeId: approvalUnavailable.nodeId,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch (error) {
recordApprovalPromptDeliveryFailure(error);
} finally {
ctx.state.deterministicApprovalPromptPending = false;
}
return;
}
const outputText = extractToolResultText(sanitizedResult);
const mediaReply = isToolError ? undefined : extractToolResultMediaArtifact(result);
const mediaUrls = mediaReply
? filterToolResultMediaUrls(
rawToolName,
mediaReply.mediaUrls,
result,
ctx.trustedLocalMediaToolNames,
)
: [];
const shouldEmitOutput =
!shouldSuppressStructuredMediaToolOutput({
toolName,
rawToolName,
isToolError,
hasDeliverableStructuredMedia: hasStructuredMedia && mediaUrls.length > 0,
builtinToolNames: ctx.builtinToolNames,
}) && ctx.shouldEmitToolOutput();
if (shouldEmitOutput) {
if (outputText) {
ctx.emitToolOutput(rawToolName, meta, outputText, hasStructuredMedia ? undefined : result);
}
if (!hasStructuredMedia) {
return;
}
}
if (isToolError) {
return;
}
if (!mediaReply) {
return;
}
if (mediaUrls.length === 0) {
return;
}
queuePendingToolMedia(ctx, {
mediaUrls,
...(mediaReply.audioAsVoice ? { audioAsVoice: true } : {}),
...(mediaReply.trustedLocalMedia ? { trustedLocalMedia: true } : {}),
});
}
@@ -0,0 +1,620 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import {
normalizeOptionalLowercaseString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
import type { AgentItemEventData } from "../infra/agent-activity-events.js";
import { emitAgentItemEvent } from "../infra/agent-activity-events.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { REQUIRED_PARAM_GROUPS, type RequiredParamGroup } from "./agent-tools.params.js";
import { sanitizeForConsole } from "./console-sanitize.js";
import {
isMessagingTool,
isMessagingToolSendAction,
isMessagingToolTargetEvidenceAction,
} from "./embedded-agent-messaging.js";
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
import {
applyCurrentMessageProvider,
readMessagingText,
} from "./embedded-agent-subscribe.handlers.tools.results.js";
import type {
ToolCallSummary,
ToolHandlerContext,
} from "./embedded-agent-subscribe.handlers.types.js";
import {
collectMessagingMediaUrlsFromRecord,
extractMessagingToolSend,
sanitizeToolArgs,
} from "./embedded-agent-subscribe.tools.js";
import { inferToolMetaFromArgs } from "./embedded-agent-utils.js";
import { buildAgentHarnessQuestionPromptPayload } from "./harness/user-input-bridge.js";
import type { AgentEvent } from "./runtime/index.js";
import { isCommandBearingToolCall } from "./tool-display.js";
import { resolveFileMutationToolName } from "./tool-mutation-names.js";
import { buildToolMutationState } from "./tool-mutation.js";
import { normalizeToolName } from "./tool-policy.js";
import {
cancelAskUserPromptDelivery,
normalizeAskUserParams,
reserveAskUserPromptDelivery,
settleAskUserPromptDelivery,
waitForAskUserPromptReady,
} from "./tools/ask-user-tool.js";
const TRACE_REQUIRED_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path"], label: "path" }],
write: REQUIRED_PARAM_GROUPS.write,
edit: REQUIRED_PARAM_GROUPS.edit,
} satisfies Record<string, readonly RequiredParamGroup[]>;
function buildAskUserPromptPayload(
toolCallId: string,
sessionKey: string | undefined,
runId: string,
args: unknown,
) {
try {
const { questions, timeoutSeconds } = normalizeAskUserParams(args);
const reservation = reserveAskUserPromptDelivery({
toolCallId,
sessionKey,
runId,
questions,
timeoutSeconds,
});
if (!reservation) {
return undefined;
}
return reservation;
} catch {
// Argument validation owns malformed calls; do not deliver an unusable prompt first.
return undefined;
}
}
function getRequiredParamGroupsForTool(
toolName: string,
): readonly RequiredParamGroup[] | undefined {
return TRACE_REQUIRED_PARAM_GROUPS[toolName as keyof typeof TRACE_REQUIRED_PARAM_GROUPS];
}
function collectMissingRequiredParamLabels(toolName: string, args: unknown): string[] {
const groups = getRequiredParamGroupsForTool(toolName);
if (!groups?.length) {
return [];
}
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
if (!record) {
return groups.map((group) => group.label ?? group.keys.join(" or "));
}
return groups
.filter((group) => {
const satisfied =
group.validator?.(record) ??
group.keys.some((key) => {
const value = record[key];
return typeof value === "string" && (group.allowEmpty || value.trim().length > 0);
});
return !satisfied;
})
.map((group) => group.label ?? group.keys.join(" or "));
}
function buildToolExecutionStartTraceMeta(params: {
ctx: ToolHandlerContext;
toolName: string;
toolCallId: string;
args: unknown;
}): Record<string, unknown> {
const args = params.args;
const argsType = Array.isArray(args) ? "array" : typeof args;
const argsKeys =
args && typeof args === "object" && !Array.isArray(args)
? Object.keys(args as Record<string, unknown>).toSorted()
: undefined;
const requiredParamsMissing = collectMissingRequiredParamLabels(params.toolName, args);
return {
event: "embedded_tool_execution_start",
tags: ["tool_start", "embedded", "trace"],
runId: params.ctx.params.runId,
toolName: params.toolName,
toolCallId: params.toolCallId,
argsType,
...(argsKeys?.length ? { argsKeys } : {}),
...(params.ctx.params.sessionKey ? { sessionKey: params.ctx.params.sessionKey } : {}),
...(params.ctx.params.sessionId ? { sessionId: params.ctx.params.sessionId } : {}),
...(params.ctx.params.agentId ? { agentId: params.ctx.params.agentId } : {}),
...(requiredParamsMissing.length ? { requiredParamsMissing } : {}),
};
}
function traceToolExecutionStart(params: {
ctx: ToolHandlerContext;
toolName: string;
toolCallId: string;
args: unknown;
}) {
if (!params.ctx.log.trace || params.ctx.log.isEnabled?.("trace") !== true) {
return;
}
params.ctx.log.trace(
"embedded run tool start",
buildToolExecutionStartTraceMeta({
ctx: params.ctx,
toolName: params.toolName,
toolCallId: params.toolCallId,
args: params.args,
}),
);
}
const TOOL_START_WARNING_PREVIEW_MAX_CHARS = 200;
function buildToolStartWarningArgsPreview(rawArgsPreview: string | undefined): string | undefined {
if (rawArgsPreview == null) {
return undefined;
}
// Bound before regex normalization so malformed tool args cannot make warning work unbounded.
const wasTruncated = rawArgsPreview.length > TOOL_START_WARNING_PREVIEW_MAX_CHARS;
const bounded = truncateUtf16Safe(rawArgsPreview, TOOL_START_WARNING_PREVIEW_MAX_CHARS);
const preview = sanitizeForConsole(bounded, TOOL_START_WARNING_PREVIEW_MAX_CHARS);
return wasTruncated && preview ? `${preview}` : preview;
}
type ToolStartRecord = {
startTime: number;
args: unknown;
hasRepliedRef?: { value: boolean };
};
/** Track tool execution start data for after_tool_call hook. */
export const toolStartData = new Map<string, ToolStartRecord>();
export function buildToolStartKey(runId: string, toolCallId: string): string {
return `${runId}:${toolCallId}`;
}
/** Returns the number of active tool executions tracked for one embedded run. */
export function countActiveToolExecutions(runId: string): number {
const prefix = `${runId}:`;
let count = 0;
for (const key of toolStartData.keys()) {
if (key.startsWith(prefix)) {
count += 1;
}
}
return count;
}
/** Cleans up tool start data for a run that has been unsubscribed or aborted. */
export function cleanupRunToolStartData(runId: string): void {
const prefix = `${runId}:`;
for (const key of toolStartData.keys()) {
if (key.startsWith(prefix)) {
toolStartData.delete(key);
}
}
}
export function buildToolCallSummary(
toolName: string,
args: unknown,
meta: string | undefined,
instanceReplaySafe: boolean,
structuredReplaySafe: boolean,
): ToolCallSummary {
const mutation = buildToolMutationState(toolName, args, meta);
return {
meta,
commandBearing: isCommandBearingToolCall(toolName, args),
instanceReplaySafe,
mutatingAction: mutation.mutatingAction,
replaySafe:
(instanceReplaySafe && !mutation.mutatingAction) ||
(structuredReplaySafe && mutation.replaySafe),
actionFingerprint: mutation.actionFingerprint,
fileTarget: mutation.fileTarget,
};
}
export function buildToolItemId(toolCallId: string): string {
return `tool:${toolCallId}`;
}
export function buildToolItemTitle(toolName: string, meta?: string): string {
return meta ? `${toolName} ${meta}` : toolName;
}
export function isExecToolName(toolName: string): boolean {
return toolName === "exec" || toolName === "bash";
}
export function buildCommandItemId(toolCallId: string): string {
return `command:${toolCallId}`;
}
export function buildPatchItemId(toolCallId: string): string {
return `patch:${toolCallId}`;
}
export function buildCommandItemTitle(toolName: string, meta?: string): string {
return meta ? `command ${meta}` : `${toolName} command`;
}
export function buildPatchItemTitle(meta?: string): string {
return meta ? `patch ${meta}` : "apply patch";
}
export function emitTrackedItemEvent(ctx: ToolHandlerContext, itemData: AgentItemEventData): void {
if (itemData.phase === "start") {
ctx.state.itemActiveIds.add(itemData.itemId);
ctx.state.itemStartedCount += 1;
} else if (itemData.phase === "end") {
ctx.state.itemActiveIds.delete(itemData.itemId);
ctx.state.itemCompletedCount += 1;
}
emitAgentItemEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: itemData,
});
emitAgentEventCallbackBestEffort(ctx, {
stream: "item",
data: itemData,
});
}
function emitExecutionPhaseBestEffort(
ctx: ToolHandlerContext,
info: Parameters<NonNullable<ToolHandlerContext["params"]["onExecutionPhase"]>>[0],
): void {
runBestEffortCallback({
label: "tool execution phase",
log: ctx.log,
callback: () => ctx.params.onExecutionPhase?.(info),
});
}
export function emitAgentEventCallbackBestEffort(
ctx: ToolHandlerContext,
event: Parameters<NonNullable<ToolHandlerContext["params"]["onAgentEvent"]>>[0],
): void {
runBestEffortCallback({
label: "tool agent event",
log: ctx.log,
callback: () => ctx.params.onAgentEvent?.(event),
});
}
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
const normalized = normalizeOptionalLowercaseString(toolName);
if (normalized !== "exec" && normalized !== "bash") {
return meta;
}
if (!args || typeof args !== "object") {
return meta;
}
const record = args as Record<string, unknown>;
const flags: string[] = [];
if (record.pty === true) {
flags.push("pty");
}
if (record.elevated === true) {
flags.push("elevated");
}
if (flags.length === 0) {
return meta;
}
const suffix = flags.join(" · ");
return meta ? `${meta} · ${suffix}` : suffix;
}
/** Handles a tool-execution start event and emits UI/telemetry start state. */
export function handleToolExecutionStart(
ctx: ToolHandlerContext,
evt: AgentEvent & {
toolName: string;
toolCallId: string;
args: unknown;
replaySafe?: boolean;
hideFromChannelProgress?: boolean;
},
): void | Promise<void> {
const startToolName = normalizeToolName(evt.toolName);
ctx.state.liveEditDiffStateById.delete(evt.toolCallId);
const askUserPromptReservation =
startToolName === "ask_user" && ctx.params.onToolResult
? buildAskUserPromptPayload(evt.toolCallId, ctx.params.sessionKey, ctx.params.runId, evt.args)
: undefined;
const cancelAskUserPromptReservation = () => {
if (askUserPromptReservation) {
cancelAskUserPromptDelivery(evt.toolCallId, ctx.params.sessionKey, ctx.params.runId);
}
};
const continueAfterBlockReplyFlush = (): void | Promise<void> => {
let onBlockReplyFlushResult: void | Promise<void>;
try {
onBlockReplyFlushResult = ctx.params.onBlockReplyFlush?.({
reason: "tool_start",
assistantMessageIndex: ctx.state.assistantMessageIndex,
});
} catch (error) {
cancelAskUserPromptReservation();
throw error;
}
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
return onBlockReplyFlushResult.then(
() => continueToolExecutionStart(),
(error: unknown) => {
cancelAskUserPromptReservation();
throw error;
},
);
}
return continueToolExecutionStart();
};
const continueToolExecutionStart = (): void | Promise<void> => {
const rawToolName = evt.toolName;
const toolName = normalizeToolName(rawToolName);
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const toolCallId = evt.toolCallId;
const args = evt.args;
const runId = ctx.params.runId;
ctx.state.toolExecutionSinceLastBlockReply = true;
emitExecutionPhaseBestEffort(ctx, {
phase: "tool_execution_started",
tool: toolName,
toolCallId,
source: "embedded-agent",
});
const startedAt = Date.now();
toolStartData.set(buildToolStartKey(runId, toolCallId), {
startTime: startedAt,
args,
...(ctx.params.hasRepliedRef
? { hasRepliedRef: { value: ctx.params.hasRepliedRef.value } }
: {}),
});
traceToolExecutionStart({ ctx, toolName, toolCallId, args });
if (toolName === "read") {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const filePathValue =
typeof record.path === "string"
? record.path
: typeof record.file_path === "string"
? record.file_path
: "";
const filePath = filePathValue.trim();
if (!filePath) {
const argsType = typeof args;
const rawArgsPreview = readStringValue(args);
const argsPreview = buildToolStartWarningArgsPreview(rawArgsPreview);
const safeRunId = sanitizeForConsole(runId) ?? "-";
const safeSessionKey = sanitizeForConsole(ctx.params.sessionKey);
const safeSessionId = sanitizeForConsole(ctx.params.sessionId);
const safeAgentId = sanitizeForConsole(ctx.params.agentId);
const consoleMessageParts = [
"read tool called without path:",
`runId=${safeRunId}`,
`toolCallId=${sanitizeForConsole(toolCallId) ?? "tool-call"}`,
`argsType=${argsType}`,
];
if (safeSessionKey) {
consoleMessageParts.push(`sessionKey=${safeSessionKey}`);
}
if (safeSessionId) {
consoleMessageParts.push(`sessionId=${safeSessionId}`);
}
if (safeAgentId) {
consoleMessageParts.push(`agentId=${safeAgentId}`);
}
if (argsPreview) {
consoleMessageParts.push(`argsPreview=${argsPreview}`);
}
const consoleMessage = consoleMessageParts.join(" ");
const message = `read tool called without path: toolCallId=${toolCallId} argsType=${argsType}${
argsPreview ? ` argsPreview=${argsPreview}` : ""
}`;
ctx.log.warn(message, {
event: "embedded_read_tool_start_warning",
tags: ["tool_start", "read", "embedded", "validation"],
runId: ctx.params.runId,
toolCallId,
argsType,
...(safeSessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
...(safeSessionId ? { sessionId: ctx.params.sessionId } : {}),
...(safeAgentId ? { agentId: ctx.params.agentId } : {}),
...(argsPreview ? { argsPreview } : {}),
consoleMessage,
});
}
}
const meta = extendExecMeta(
toolName,
args,
inferToolMetaFromArgs(toolName, args, {
detailMode: ctx.params.toolProgressDetail ?? "explain",
}),
);
const instanceReplaySafe =
evt.replaySafe === true ||
ctx.params.replaySafeToolNames?.has(rawToolName) === true ||
ctx.params.replaySafeToolNames?.has(toolName) === true;
const callSummary = buildToolCallSummary(toolName, args, meta, instanceReplaySafe, false);
ctx.state.toolMetaById.set(toolCallId, callSummary);
ctx.log.debug(
`embedded run tool start: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
const shouldEmitToolEvents = ctx.shouldEmitToolResult();
emitAgentEvent({
runId: ctx.params.runId,
stream: "tool",
data: {
phase: "start",
name: toolName,
toolCallId,
args: sanitizeToolArgs(args) as Record<string, unknown>,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
const itemData: AgentItemEventData = {
itemId: buildToolItemId(toolCallId),
phase: "start",
kind: "tool",
title: buildToolItemTitle(toolName, meta),
status: "running",
name: toolName,
meta,
commandBearing: callSummary.commandBearing,
toolCallId,
startedAt,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(callSummary.commandBearing && !isExecToolName(toolName)
? { suppressChannelProgress: true }
: {}),
};
emitTrackedItemEvent(ctx, itemData);
// Best-effort typing signal; do not block tool summaries on slow emitters.
emitAgentEventCallbackBestEffort(ctx, {
stream: "tool",
data: {
phase: "start",
name: toolName,
toolCallId,
args: sanitizeToolArgs(args) as Record<string, unknown>,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
if (isExecToolName(toolName)) {
emitTrackedItemEvent(ctx, {
itemId: buildCommandItemId(toolCallId),
phase: "start",
kind: "command",
title: buildCommandItemTitle(toolName, meta),
status: "running",
name: toolName,
meta,
toolCallId,
startedAt,
});
} else if (resolveFileMutationToolName(toolName) === "apply_patch") {
emitTrackedItemEvent(ctx, {
itemId: buildPatchItemId(toolCallId),
phase: "start",
kind: "patch",
title: buildPatchItemTitle(meta),
status: "running",
name: toolName,
meta,
toolCallId,
startedAt,
});
}
if (
ctx.params.onToolResult &&
shouldEmitToolEvents &&
!ctx.state.toolSummaryById.has(toolCallId)
) {
ctx.state.toolSummaryById.add(toolCallId);
ctx.emitToolSummary(toolName, meta, callSummary.commandBearing);
}
// Track messaging tool sends (pending until confirmed in tool_execution_end).
if (isMessagingTool(toolName)) {
const argsRecord = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const isMessagingSend = isMessagingToolSendAction(toolName, argsRecord);
if (isMessagingToolTargetEvidenceAction(toolName, argsRecord)) {
const telemetryArgs = applyCurrentMessageProvider(
toolName,
argsRecord,
ctx.params.messageChannel,
);
const sendTarget = extractMessagingToolSend(toolName, telemetryArgs, {
config: ctx.params.config,
currentChannelId: ctx.params.currentChannelId,
currentMessagingTarget: ctx.params.currentMessagingTarget,
currentThreadId:
ctx.params.currentThreadId ??
parseSessionThreadInfoFast(ctx.params.sessionKey).threadId,
currentMessageId: ctx.params.currentMessageId,
replyToMode: ctx.params.replyToMode,
hasRepliedRef: ctx.params.hasRepliedRef,
});
if (sendTarget) {
ctx.state.pendingMessagingTargets.set(toolCallId, sendTarget);
}
}
if (isMessagingSend) {
const text = readMessagingText(argsRecord);
if (text) {
ctx.state.pendingMessagingTexts.set(toolCallId, text);
ctx.log.debug(`Tracking pending messaging text: tool=${toolName} len=${text.length}`);
}
// Track media URLs from messaging tool args (pending until tool_execution_end).
const mediaUrls = collectMessagingMediaUrlsFromRecord(argsRecord);
if (mediaUrls.length > 0) {
ctx.state.pendingMessagingMediaUrls.set(toolCallId, mediaUrls);
}
}
}
if (toolName === "ask_user" && ctx.params.onToolResult) {
const payload = askUserPromptReservation;
if (payload) {
const questionId = payload.questionId;
void waitForAskUserPromptReady(questionId)
.then((questions) => {
if (!questions) {
return;
}
return ctx.params.onToolResult?.(
buildAgentHarnessQuestionPromptPayload({
questionId,
questions: questions.map(({ questionId: id, ...question }) => ({
...question,
id,
})),
options: { intro: "Question for you:" },
}),
);
})
.then(
() => settleAskUserPromptDelivery(questionId),
(error: unknown) => {
settleAskUserPromptDelivery(questionId, error);
ctx.log.warn(`failed to deliver ask_user prompt: ${String(error)}`);
},
);
}
}
};
// Flush pending block replies to preserve message boundaries before tool execution.
let flushBlockReplyBufferResult: void | Promise<void>;
try {
flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer();
} catch (error) {
cancelAskUserPromptReservation();
throw error;
}
if (isPromiseLike<void>(flushBlockReplyBufferResult)) {
return flushBlockReplyBufferResult.then(
() => continueAfterBlockReplyFlush(),
(error: unknown) => {
cancelAskUserPromptReservation();
throw error;
},
);
}
return continueAfterBlockReplyFlush();
}
File diff suppressed because it is too large Load Diff