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 { ClawdbotConfig } from "../../../config/config.js"; import { formatAssistantErrorText, getApiErrorPayloadFingerprint, isRawApiErrorPayload, normalizeTextForComparison, } from "../../pi-embedded-helpers.js"; import { extractAssistantText, extractAssistantThinking, formatReasoningMessage, } from "../../pi-embedded-utils.js"; import type { ToolResultFormat } from "../../pi-embedded-subscribe.js"; type ToolMetaEntry = { toolName: string; meta?: string }; export function buildEmbeddedRunPayloads(params: { assistantTexts: string[]; toolMetas: ToolMetaEntry[]; lastAssistant: AssistantMessage | undefined; lastToolError?: { toolName: string; meta?: string; error?: string }; config?: ClawdbotConfig; sessionKey: string; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; inlineToolResultsAllowed: boolean; }): Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[]; replyToId?: string; isError?: boolean; audioAsVoice?: boolean; replyToTag?: boolean; replyToCurrent?: boolean; }> { const replyItems: Array<{ text: string; media?: string[]; isError?: boolean; audioAsVoice?: boolean; replyToId?: string; replyToTag?: boolean; replyToCurrent?: boolean; }> = []; const useMarkdown = params.toolResultFormat === "markdown"; const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const errorText = params.lastAssistant ? formatAssistantErrorText(params.lastAssistant, { cfg: params.config, sessionKey: params.sessionKey, }) : undefined; const rawErrorMessage = lastAssistantErrored ? params.lastAssistant?.errorMessage?.trim() || undefined : undefined; const rawErrorFingerprint = rawErrorMessage ? getApiErrorPayloadFingerprint(rawErrorMessage) : null; const normalizedRawErrorText = rawErrorMessage ? normalizeTextForComparison(rawErrorMessage) : null; const normalizedErrorText = errorText ? normalizeTextForComparison(errorText) : null; 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 = params.lastAssistant && params.reasoningLevel === "on" ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) replyItems.push({ text: reasoningText }); 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 (rawErrorMessage && trimmed === rawErrorMessage) return true; if (normalizedRawErrorText) { const normalized = normalizeTextForComparison(trimmed); if (normalized && normalized === normalizedRawErrorText) return true; } if (rawErrorFingerprint) { const fingerprint = getApiErrorPayloadFingerprint(trimmed); if (fingerprint && fingerprint === rawErrorFingerprint) return true; } return isRawApiErrorPayload(trimmed); }; const answerTexts = ( params.assistantTexts.length ? params.assistantTexts : fallbackAnswerText ? [fallbackAnswerText] : [] ).filter((text) => !shouldSuppressRawErrorText(text)); 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, }); } if (params.lastToolError) { const hasUserFacingReply = replyItems.length > 0; // Check if this is a recoverable/internal tool error that shouldn't be shown to users // when there's already a user-facing reply (the model should have retried). const errorLower = (params.lastToolError.error ?? "").toLowerCase(); const isRecoverableError = errorLower.includes("required") || errorLower.includes("missing") || errorLower.includes("invalid") || errorLower.includes("must be") || errorLower.includes("must have") || errorLower.includes("needs") || errorLower.includes("requires"); // Show tool errors only when: // 1. There's no user-facing reply AND the error is not recoverable // Recoverable errors (validation, missing params) are already in the model's context // and shouldn't be surfaced to users since the model should retry. if (!hasUserFacingReply && !isRecoverableError) { const toolSummary = formatToolAggregate( params.lastToolError.toolName, params.lastToolError.meta ? [params.lastToolError.meta] : undefined, { markdown: useMarkdown }, ); const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : ""; replyItems.push({ text: `⚠️ ${toolSummary} failed${errorSuffix}`, 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 (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false; if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false; return true; }); }