209 lines
7.2 KiB
TypeScript
209 lines
7.2 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 { 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;
|
|
});
|
|
}
|