Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
ace906539f fix: gate tool-error fallback replies (#1175) (thanks @vrknetha) 2026-01-18 14:07:34 +00:00
vrknetha
f039fdf8e9 Agents: surface tool failures without assistant output 2026-01-18 14:03:08 +00:00
10 changed files with 192 additions and 17 deletions

View File

@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
## 2026.1.18-3

View File

@ -431,6 +431,8 @@ export async function runEmbeddedPiAgent(
assistantTexts: attempt.assistantTexts,
toolMetas: attempt.toolMetas,
lastAssistant: attempt.lastAssistant,
lastToolError: attempt.lastToolError,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel,

View File

@ -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(),

View File

@ -111,4 +111,76 @@ 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");
});
it("adds tool error fallback when assistant output is NO_REPLY", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: ["NO_REPLY"],
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]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("browser");
expect(payloads[0]?.text).toContain("tab not found");
});
it("skips tool error fallback when messaging tool already sent", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
lastToolError: { toolName: "browser", error: "tab not found" },
didSendViaMessagingTool: true,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(0);
});
});

View File

@ -23,6 +23,8 @@ export function buildEmbeddedRunPayloads(params: {
assistantTexts: string[];
toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined;
lastToolError?: { toolName: string; meta?: string; error?: string };
didSendViaMessagingTool?: boolean;
config?: ClawdbotConfig;
sessionKey: string;
verboseLevel?: VerboseLevel;
@ -155,21 +157,46 @@ export function buildEmbeddedRunPayloads(params: {
});
}
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;
});
const buildPayloads = (items: typeof replyItems) => {
const hasAudioAsVoiceTag = items.some((item) => item.audioAsVoice);
return items
.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;
});
};
let payloads = buildPayloads(replyItems);
if (
payloads.length === 0 &&
params.lastToolError &&
params.didSendViaMessagingTool !== true
) {
const toolSummary = formatToolAggregate(
params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown },
);
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
payloads = buildPayloads([
{
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
isError: true,
},
]);
}
return payloads;
}

View File

@ -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[];

View File

@ -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);

View File

@ -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<string, string | undefined>;
toolSummaryById: Set<string>;
lastToolError?: ToolErrorSummary;
blockReplyBreak: "text_end" | "message_end";
reasoningMode: ReasoningLevel;

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
@ -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<string, unknown>;
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<string, unknown>,

View File

@ -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();