Agents: surface tool failures without assistant output
This commit is contained in:
@@ -431,6 +431,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
assistantTexts: attempt.assistantTexts,
|
assistantTexts: attempt.assistantTexts,
|
||||||
toolMetas: attempt.toolMetas,
|
toolMetas: attempt.toolMetas,
|
||||||
lastAssistant: attempt.lastAssistant,
|
lastAssistant: attempt.lastAssistant,
|
||||||
|
lastToolError: attempt.lastToolError,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
verboseLevel: params.verboseLevel,
|
verboseLevel: params.verboseLevel,
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ export async function runEmbeddedAttempt(
|
|||||||
getMessagingToolSentTexts,
|
getMessagingToolSentTexts,
|
||||||
getMessagingToolSentTargets,
|
getMessagingToolSentTargets,
|
||||||
didSendViaMessagingTool,
|
didSendViaMessagingTool,
|
||||||
|
getLastToolError,
|
||||||
} = subscription;
|
} = subscription;
|
||||||
|
|
||||||
const queueHandle: EmbeddedPiQueueHandle = {
|
const queueHandle: EmbeddedPiQueueHandle = {
|
||||||
@@ -665,6 +666,7 @@ export async function runEmbeddedAttempt(
|
|||||||
assistantTexts,
|
assistantTexts,
|
||||||
toolMetas: toolMetasNormalized,
|
toolMetas: toolMetasNormalized,
|
||||||
lastAssistant,
|
lastAssistant,
|
||||||
|
lastToolError: getLastToolError?.(),
|
||||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||||
|
|||||||
@@ -111,4 +111,40 @@ describe("buildEmbeddedRunPayloads", () => {
|
|||||||
expect(payloads).toHaveLength(1);
|
expect(payloads).toHaveLength(1);
|
||||||
expect(payloads[0]?.text).toBe(errorJsonPretty.trim());
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: ToolMetaEntry[];
|
toolMetas: ToolMetaEntry[];
|
||||||
lastAssistant: AssistantMessage | undefined;
|
lastAssistant: AssistantMessage | undefined;
|
||||||
|
lastToolError?: { toolName: string; meta?: string; error?: string };
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
verboseLevel?: VerboseLevel;
|
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);
|
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
|
||||||
return replyItems
|
return replyItems
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type EmbeddedRunAttemptResult = {
|
|||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: Array<{ toolName: string; meta?: string }>;
|
toolMetas: Array<{ toolName: string; meta?: string }>;
|
||||||
lastAssistant: AssistantMessage | undefined;
|
lastAssistant: AssistantMessage | undefined;
|
||||||
|
lastToolError?: { toolName: string; meta?: string; error?: string };
|
||||||
didSendViaMessagingTool: boolean;
|
didSendViaMessagingTool: boolean;
|
||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
|||||||
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
||||||
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
||||||
import {
|
import {
|
||||||
|
extractToolErrorMessage,
|
||||||
extractToolResultText,
|
extractToolResultText,
|
||||||
extractMessagingToolSend,
|
extractMessagingToolSend,
|
||||||
isToolResultError,
|
isToolResultError,
|
||||||
@@ -154,6 +155,14 @@ export function handleToolExecutionEnd(
|
|||||||
ctx.state.toolMetas.push({ toolName, meta });
|
ctx.state.toolMetas.push({ toolName, meta });
|
||||||
ctx.state.toolMetaById.delete(toolCallId);
|
ctx.state.toolMetaById.delete(toolCallId);
|
||||||
ctx.state.toolSummaryById.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.
|
// Commit messaging tool text on success, discard on error.
|
||||||
const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId);
|
const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId);
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ export type EmbeddedSubscribeLogger = {
|
|||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolErrorSummary = {
|
||||||
|
toolName: string;
|
||||||
|
meta?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmbeddedPiSubscribeState = {
|
export type EmbeddedPiSubscribeState = {
|
||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: Array<{ toolName?: string; meta?: string }>;
|
toolMetas: Array<{ toolName?: string; meta?: string }>;
|
||||||
toolMetaById: Map<string, string | undefined>;
|
toolMetaById: Map<string, string | undefined>;
|
||||||
toolSummaryById: Set<string>;
|
toolSummaryById: Set<string>;
|
||||||
|
lastToolError?: ToolErrorSummary;
|
||||||
|
|
||||||
blockReplyBreak: "text_end" | "message_end";
|
blockReplyBreak: "text_end" | "message_end";
|
||||||
reasoningMode: ReasoningLevel;
|
reasoningMode: ReasoningLevel;
|
||||||
|
|||||||
@@ -4,12 +4,44 @@ import { type MessagingToolSend } from "./pi-embedded-messaging.js";
|
|||||||
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
||||||
|
|
||||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||||
|
const TOOL_ERROR_MAX_CHARS = 400;
|
||||||
|
|
||||||
function truncateToolText(text: string): string {
|
function truncateToolText(text: string): string {
|
||||||
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||||
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
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 {
|
export function sanitizeToolResult(result: unknown): unknown {
|
||||||
if (!result || typeof result !== "object") return result;
|
if (!result || typeof result !== "object") return result;
|
||||||
const record = result as Record<string, unknown>;
|
const record = result as Record<string, unknown>;
|
||||||
@@ -63,6 +95,25 @@ export function isToolResultError(result: unknown): boolean {
|
|||||||
return normalized === "error" || normalized === "timeout";
|
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(
|
export function extractMessagingToolSend(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
toolMetas: [],
|
toolMetas: [],
|
||||||
toolMetaById: new Map(),
|
toolMetaById: new Map(),
|
||||||
toolSummaryById: new Set(),
|
toolSummaryById: new Set(),
|
||||||
|
lastToolError: undefined,
|
||||||
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
||||||
reasoningMode,
|
reasoningMode,
|
||||||
includeReasoning: reasoningMode === "on",
|
includeReasoning: reasoningMode === "on",
|
||||||
@@ -380,6 +381,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
toolMetas.length = 0;
|
toolMetas.length = 0;
|
||||||
toolMetaById.clear();
|
toolMetaById.clear();
|
||||||
toolSummaryById.clear();
|
toolSummaryById.clear();
|
||||||
|
state.lastToolError = undefined;
|
||||||
messagingToolSentTexts.length = 0;
|
messagingToolSentTexts.length = 0;
|
||||||
messagingToolSentTextsNormalized.length = 0;
|
messagingToolSentTextsNormalized.length = 0;
|
||||||
messagingToolSentTargets.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!")
|
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
||||||
// which is generated AFTER the tool sends the actual answer.
|
// which is generated AFTER the tool sends the actual answer.
|
||||||
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
|
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
|
||||||
|
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
|
||||||
waitForCompactionRetry: () => {
|
waitForCompactionRetry: () => {
|
||||||
if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
|
if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
|
||||||
ensureCompactionPromise();
|
ensureCompactionPromise();
|
||||||
|
|||||||
Reference in New Issue
Block a user