From 8a8143167f2cbf50cf5df6a91f33782be3d2650f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 30 Mar 2026 17:45:58 +0300 Subject: [PATCH] feat(llm): report tool call errors --- apps/client/src/services/llm_chat.ts | 4 ++-- .../src/translations/en/translation.json | 2 ++ .../type_widgets/llm_chat/ChatMessage.tsx | 14 +++++++++---- .../widgets/type_widgets/llm_chat/LlmChat.css | 21 +++++++++++++++++++ .../type_widgets/llm_chat/llm_chat_types.ts | 1 + .../type_widgets/llm_chat/useLlmChat.ts | 3 ++- apps/server/src/services/llm/stream.ts | 12 +++++++---- packages/commons/src/lib/llm_api.ts | 2 +- 8 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index 594b6e4e34..47bfa00ebf 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -13,7 +13,7 @@ export interface StreamCallbacks { onChunk: (text: string) => void; onThinking?: (text: string) => void; onToolUse?: (toolName: string, input: Record) => void; - onToolResult?: (toolName: string, result: string) => void; + onToolResult?: (toolName: string, result: string, isError?: boolean) => void; onCitation?: (citation: LlmCitation) => void; onUsage?: (usage: LlmUsage) => void; onError: (error: string) => void; @@ -78,7 +78,7 @@ export async function streamChatCompletion( callbacks.onToolUse?.(data.toolName, data.toolInput); break; case "tool_result": - callbacks.onToolResult?.(data.toolName, data.result); + callbacks.onToolResult?.(data.toolName, data.result, data.isError); break; case "citation": if (data.citation) { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 5a90da43a2..ea88d25d9f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1627,6 +1627,8 @@ "tool_calls": "{{count}} tool call(s)", "input": "Input", "result": "Result", + "error": "Error", + "tool_error": "failed", "tokens_used": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens", "tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})", "tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens", diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index f1ae65b58e..99d10370af 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -18,11 +18,17 @@ interface Props { } function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { + const classes = [ + "llm-chat-tool-call-inline", + toolCall.isError && "llm-chat-tool-call-error" + ].filter(Boolean).join(" "); + return ( -
+
- + {toolCall.toolName} + {toolCall.isError && {t("llm_chat.tool_error")}}
@@ -30,8 +36,8 @@ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
{JSON.stringify(toolCall.input, null, 2)}
{toolCall.result && ( -
- {t("llm_chat.result")}: +
+ {toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}:
{(() => {
                             if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
                                 try {
diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
index 0ea1d28117..4a725012fe 100644
--- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
+++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
@@ -588,6 +588,27 @@
     margin-top: 0.5rem;
 }
 
+/* Tool call error styling */
+.llm-chat-tool-call-error {
+    border-left-color: var(--danger-color, #dc3545);
+}
+
+.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
+    color: var(--danger-color, #dc3545);
+}
+
+.llm-chat-tool-call-error-badge {
+    font-size: 0.75rem;
+    font-weight: 400;
+    font-family: var(--main-font-family);
+    color: var(--danger-color, #dc3545);
+    opacity: 0.8;
+}
+
+.llm-chat-tool-call-result-error pre {
+    color: var(--danger-color, #dc3545);
+}
+
 /* Token usage display */
 .llm-chat-usage {
     display: flex;
diff --git a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts
index c5bba57fc1..767a886dae 100644
--- a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts
+++ b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts
@@ -7,6 +7,7 @@ export interface ToolCall {
     toolName: string;
     input: Record;
     result?: string;
+    isError?: boolean;
 }
 
 /** A block of text content (rendered as Markdown for assistant messages). */
diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
index 6c5619a978..d8d93e3522 100644
--- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
+++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
@@ -270,12 +270,13 @@ export function useLlmChat(
                         }
                     });
                 },
-                onToolResult: (toolName, result) => {
+                onToolResult: (toolName, result, isError) => {
                     // Find the most recent tool_call block for this tool without a result
                     for (let i = contentBlocks.length - 1; i >= 0; i--) {
                         const block = contentBlocks[i];
                         if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) {
                             block.toolCall.result = result;
+                            block.toolCall.isError = isError;
                             break;
                         }
                     }
diff --git a/apps/server/src/services/llm/stream.ts b/apps/server/src/services/llm/stream.ts
index 9fe7552ef3..e66da16a5b 100644
--- a/apps/server/src/services/llm/stream.ts
+++ b/apps/server/src/services/llm/stream.ts
@@ -49,15 +49,19 @@ export async function* streamToChunks(result: StreamResult, options: StreamOptio
                     };
                     break;
 
-                case "tool-result":
+                case "tool-result": {
+                    const output = part.output;
+                    const isError = typeof output === "object" && output !== null && "error" in output;
                     yield {
                         type: "tool_result",
                         toolName: part.toolName,
-                        result: typeof part.output === "string"
-                            ? part.output
-                            : JSON.stringify(part.output)
+                        result: typeof output === "string"
+                            ? output
+                            : JSON.stringify(output),
+                        isError
                     };
                     break;
+                }
 
                 case "source":
                     // Citation from web search (only URL sources have url property)
diff --git a/packages/commons/src/lib/llm_api.ts b/packages/commons/src/lib/llm_api.ts
index 56e2d3b884..99a091c785 100644
--- a/packages/commons/src/lib/llm_api.ts
+++ b/packages/commons/src/lib/llm_api.ts
@@ -94,7 +94,7 @@ export type LlmStreamChunk =
     | { type: "text"; content: string }
     | { type: "thinking"; content: string }
     | { type: "tool_use"; toolName: string; toolInput: Record }
-    | { type: "tool_result"; toolName: string; result: string }
+    | { type: "tool_result"; toolName: string; result: string; isError?: boolean }
     | { type: "citation"; citation: LlmCitation }
     | { type: "usage"; usage: LlmUsage }
     | { type: "error"; error: string }