Files
openclaw/src/agents/pi-embedded-runner/run/payloads.ts
2026-03-18 18:14:57 +00:00

349 lines
11 KiB
TypeScript

import type { AssistantMessage } from "@mariozechner/pi-ai";
import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { hasOutboundReplyContent } from "../../../plugin-sdk/reply-payload.js";
import {
BILLING_ERROR_USER_MESSAGE,
formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint,
isRawApiErrorPayload,
normalizeTextForComparison,
} from "../../pi-embedded-helpers.js";
import type { ToolResultFormat } from "../../pi-embedded-subscribe.js";
import {
extractAssistantText,
extractAssistantThinking,
formatReasoningMessage,
} from "../../pi-embedded-utils.js";
import { isLikelyMutatingToolName } from "../../tool-mutation.js";
type ToolMetaEntry = { toolName: string; meta?: string };
type LastToolError = {
toolName: string;
meta?: string;
error?: string;
mutatingAction?: boolean;
actionFingerprint?: string;
};
type ToolErrorWarningPolicy = {
showWarning: boolean;
includeDetails: boolean;
};
const RECOVERABLE_TOOL_ERROR_KEYWORDS = [
"required",
"missing",
"invalid",
"must be",
"must have",
"needs",
"requires",
] as const;
function isRecoverableToolError(error: string | undefined): boolean {
const errorLower = (error ?? "").toLowerCase();
return RECOVERABLE_TOOL_ERROR_KEYWORDS.some((keyword) => errorLower.includes(keyword));
}
function isVerboseToolDetailEnabled(level?: VerboseLevel): boolean {
return level === "on" || level === "full";
}
function resolveToolErrorWarningPolicy(params: {
lastToolError: LastToolError;
hasUserFacingReply: boolean;
suppressToolErrors: boolean;
suppressToolErrorWarnings?: boolean;
verboseLevel?: VerboseLevel;
}): ToolErrorWarningPolicy {
const includeDetails = isVerboseToolDetailEnabled(params.verboseLevel);
if (params.suppressToolErrorWarnings) {
return { showWarning: false, includeDetails };
}
const normalizedToolName = params.lastToolError.toolName.trim().toLowerCase();
if ((normalizedToolName === "exec" || normalizedToolName === "bash") && !includeDetails) {
return { showWarning: false, includeDetails };
}
// sessions_send timeouts and errors are transient inter-session communication
// issues — the message may still have been delivered. Suppress warnings to
// prevent raw error text from leaking into the chat surface (#23989).
if (normalizedToolName === "sessions_send") {
return { showWarning: false, includeDetails };
}
const isMutatingToolError =
params.lastToolError.mutatingAction ?? isLikelyMutatingToolName(params.lastToolError.toolName);
if (isMutatingToolError) {
return { showWarning: true, includeDetails };
}
if (params.suppressToolErrors) {
return { showWarning: false, includeDetails };
}
return {
showWarning: !params.hasUserFacingReply && !isRecoverableToolError(params.lastToolError.error),
includeDetails,
};
}
export function buildEmbeddedRunPayloads(params: {
assistantTexts: string[];
toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined;
lastToolError?: LastToolError;
config?: OpenClawConfig;
sessionKey: string;
provider?: string;
model?: string;
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
suppressToolErrorWarnings?: boolean;
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
}): Array<{
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
isError?: boolean;
isReasoning?: boolean;
audioAsVoice?: boolean;
replyToTag?: boolean;
replyToCurrent?: boolean;
}> {
const replyItems: Array<{
text: string;
media?: string[];
isError?: boolean;
isReasoning?: boolean;
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText =
params.lastAssistant && lastAssistantErrored
? suppressAssistantArtifacts
? undefined
: formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
: undefined;
const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined
: undefined;
const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage)
: null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage)
: null;
const normalizedErrorText = errorText ? normalizeTextForComparison(errorText) : null;
const normalizedGenericBillingErrorText = normalizeTextForComparison(BILLING_ERROR_USER_MESSAGE);
const genericErrorText = "The AI service returned an error. Please try again.";
if (errorText) {
replyItems.push({ text: errorText, isError: true });
}
const inlineToolResults =
params.inlineToolResultsAllowed && params.verboseLevel !== "off" && params.toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of params.toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : [], {
markdown: useMarkdown,
});
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(agg);
if (cleanedText) {
replyItems.push({
text: cleanedText,
media: mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
}
}
const reasoningText = suppressAssistantArtifacts
? ""
: params.lastAssistant && params.reasoningLevel === "on"
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
: "";
if (reasoningText) {
replyItems.push({ text: reasoningText, isReasoning: true });
}
const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : "";
const shouldSuppressRawErrorText = (text: string) => {
if (!lastAssistantErrored) {
return false;
}
const trimmed = text.trim();
if (!trimmed) {
return false;
}
if (errorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalizedErrorText && normalized === normalizedErrorText) {
return true;
}
if (trimmed === genericErrorText) {
return true;
}
if (
normalized &&
normalizedGenericBillingErrorText &&
normalized === normalizedGenericBillingErrorText
) {
return true;
}
}
if (rawErrorMessage && trimmed === rawErrorMessage) {
return true;
}
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) {
return true;
}
if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) {
return true;
}
}
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) {
return true;
}
}
if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) {
return true;
}
}
return isRawApiErrorPayload(trimmed);
};
const answerTexts = suppressAssistantArtifacts
? []
: (params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) {
continue;
}
replyItems.push({
text: cleanedText,
media: mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
hasUserFacingAssistantReply = true;
}
if (params.lastToolError) {
const warningPolicy = resolveToolErrorWarningPolicy({
lastToolError: params.lastToolError,
hasUserFacingReply: hasUserFacingAssistantReply,
suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors),
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
verboseLevel: params.verboseLevel,
});
// Always surface mutating tool failures so we do not silently confirm actions that did not happen.
// Otherwise, keep the previous behavior and only surface non-recoverable failures when no reply exists.
if (warningPolicy.showWarning) {
const toolSummary = formatToolAggregate(
params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown },
);
const errorSuffix =
warningPolicy.includeDetails && params.lastToolError.error
? `: ${params.lastToolError.error}`
: "";
const warningText = `⚠️ ${toolSummary} failed${errorSuffix}`;
const normalizedWarning = normalizeTextForComparison(warningText);
const duplicateWarning = normalizedWarning
? replyItems.some((item) => {
if (!item.text) {
return false;
}
const normalizedExisting = normalizeTextForComparison(item.text);
return normalizedExisting.length > 0 && normalizedExisting === normalizedWarning;
})
: false;
if (!duplicateWarning) {
replyItems.push({
text: warningText,
isError: true,
});
}
}
}
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
return replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
}))
.filter((p) => {
if (!hasOutboundReplyContent(p)) {
return false;
}
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) {
return false;
}
return true;
});
}