diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a4b2f607a..82bafe0b4 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -431,6 +431,7 @@ export async function runEmbeddedPiAgent( assistantTexts: attempt.assistantTexts, toolMetas: attempt.toolMetas, lastAssistant: attempt.lastAssistant, + lastToolError: attempt.lastToolError, config: params.config, sessionKey: params.sessionKey ?? params.sessionId, verboseLevel: params.verboseLevel, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 5017e8465..37c5fe818 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -433,6 +433,7 @@ export async function runEmbeddedAttempt( getMessagingToolSentTexts, getMessagingToolSentTargets, didSendViaMessagingTool, + getLastToolError, } = subscription; const queueHandle: EmbeddedPiQueueHandle = { @@ -665,6 +666,7 @@ export async function runEmbeddedAttempt( assistantTexts, toolMetas: toolMetasNormalized, lastAssistant, + lastToolError: getLastToolError?.(), didSendViaMessagingTool: didSendViaMessagingTool(), messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentTargets: getMessagingToolSentTargets(), diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 934620c6c..78766e112 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -111,4 +111,40 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe(errorJsonPretty.trim()); }); + + it("adds a fallback error when a tool fails and no assistant output exists", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + lastToolError: { toolName: "browser", error: "tab not found" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.isError).toBe(true); + expect(payloads[0]?.text).toContain("browser"); + expect(payloads[0]?.text).toContain("tab not found"); + }); + + it("does not add tool error fallback when assistant output exists", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: ["All good"], + toolMetas: [], + lastAssistant: { stopReason: "end_turn" } as AssistantMessage, + lastToolError: { toolName: "browser", error: "tab not found" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.text).toBe("All good"); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index d9b693098..2d831cd00 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -23,6 +23,7 @@ export function buildEmbeddedRunPayloads(params: { assistantTexts: string[]; toolMetas: ToolMetaEntry[]; lastAssistant: AssistantMessage | undefined; + lastToolError?: { toolName: string; meta?: string; error?: string }; config?: ClawdbotConfig; sessionKey: string; verboseLevel?: VerboseLevel; @@ -155,6 +156,19 @@ export function buildEmbeddedRunPayloads(params: { }); } + if (replyItems.length === 0 && params.lastToolError) { + 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) => ({ diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index f6606f3c7..b1ae7670f 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -74,6 +74,7 @@ export type EmbeddedRunAttemptResult = { assistantTexts: string[]; toolMetas: Array<{ toolName: string; meta?: string }>; lastAssistant: AssistantMessage | undefined; + lastToolError?: { toolName: string; meta?: string; error?: string }; didSendViaMessagingTool: boolean; messagingToolSentTexts: string[]; messagingToolSentTargets: MessagingToolSend[]; diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 30f6124b6..129042273 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -5,6 +5,7 @@ import { normalizeTextForComparison } from "./pi-embedded-helpers.js"; import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js"; import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; import { + extractToolErrorMessage, extractToolResultText, extractMessagingToolSend, isToolResultError, @@ -154,6 +155,14 @@ export function handleToolExecutionEnd( ctx.state.toolMetas.push({ toolName, meta }); ctx.state.toolMetaById.delete(toolCallId); ctx.state.toolSummaryById.delete(toolCallId); + if (isToolError) { + const errorMessage = extractToolErrorMessage(sanitizedResult); + ctx.state.lastToolError = { + toolName, + meta, + error: errorMessage, + }; + } // Commit messaging tool text on success, discard on error. const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 1800be109..1832b2bf1 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -14,11 +14,18 @@ export type EmbeddedSubscribeLogger = { warn: (message: string) => void; }; +export type ToolErrorSummary = { + toolName: string; + meta?: string; + error?: string; +}; + export type EmbeddedPiSubscribeState = { assistantTexts: string[]; toolMetas: Array<{ toolName?: string; meta?: string }>; toolMetaById: Map; toolSummaryById: Set; + lastToolError?: ToolErrorSummary; blockReplyBreak: "text_end" | "message_end"; reasoningMode: ReasoningLevel; diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 158138972..195a70a64 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -4,12 +4,44 @@ import { type MessagingToolSend } from "./pi-embedded-messaging.js"; import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; const TOOL_RESULT_MAX_CHARS = 8000; +const TOOL_ERROR_MAX_CHARS = 400; function truncateToolText(text: string): string { if (text.length <= TOOL_RESULT_MAX_CHARS) return text; return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; } +function normalizeToolErrorText(text: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) return undefined; + const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; + if (!firstLine) return undefined; + return firstLine.length > TOOL_ERROR_MAX_CHARS + ? `${truncateUtf16Safe(firstLine, TOOL_ERROR_MAX_CHARS)}…` + : firstLine; +} + +function readErrorCandidate(value: unknown): string | undefined { + if (typeof value === "string") return normalizeToolErrorText(value); + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (typeof record.message === "string") return normalizeToolErrorText(record.message); + if (typeof record.error === "string") return normalizeToolErrorText(record.error); + return undefined; +} + +function extractErrorField(value: unknown): string | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + const direct = + readErrorCandidate(record.error) ?? + readErrorCandidate(record.message) ?? + readErrorCandidate(record.reason); + if (direct) return direct; + const status = typeof record.status === "string" ? record.status.trim() : ""; + return status ? normalizeToolErrorText(status) : undefined; +} + export function sanitizeToolResult(result: unknown): unknown { if (!result || typeof result !== "object") return result; const record = result as Record; @@ -63,6 +95,25 @@ export function isToolResultError(result: unknown): boolean { return normalized === "error" || normalized === "timeout"; } +export function extractToolErrorMessage(result: unknown): string | undefined { + if (!result || typeof result !== "object") return undefined; + const record = result as Record; + const fromDetails = extractErrorField(record.details); + if (fromDetails) return fromDetails; + const fromRoot = extractErrorField(record); + if (fromRoot) return fromRoot; + const text = extractToolResultText(result); + if (!text) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + const fromJson = extractErrorField(parsed); + if (fromJson) return fromJson; + } catch { + // Fall through to first-line text fallback. + } + return normalizeToolErrorText(text); +} + export function extractMessagingToolSend( toolName: string, args: Record, diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index a48fff665..489a95043 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -35,6 +35,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar toolMetas: [], toolMetaById: new Map(), toolSummaryById: new Set(), + lastToolError: undefined, blockReplyBreak: params.blockReplyBreak ?? "text_end", reasoningMode, includeReasoning: reasoningMode === "on", @@ -380,6 +381,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar toolMetas.length = 0; toolMetaById.clear(); toolSummaryById.clear(); + state.lastToolError = undefined; messagingToolSentTexts.length = 0; messagingToolSentTextsNormalized.length = 0; messagingToolSentTargets.length = 0; @@ -425,6 +427,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // which is generated AFTER the tool sends the actual answer. didSendViaMessagingTool: () => messagingToolSentTexts.length > 0, + getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined), waitForCompactionRetry: () => { if (state.compactionInFlight || state.pendingCompactionRetry > 0) { ensureCompactionPromise();