From f3f76095d18e07a3a944f22a4b25993bbffe90a3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 23 Aug 2026 02:34:08 -0400 Subject: [PATCH] refac --- backend/open_webui/utils/middleware.py | 119 ++++++++++++------ backend/open_webui/utils/misc.py | 2 +- .../Markdown/ConsecutiveDetailsGroup.svelte | 54 ++++++++ .../components/common/ToolCallDisplay.svelte | 48 +++++++ 4 files changed, 183 insertions(+), 40 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 1e79630a09..ff7692c38c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -142,6 +142,48 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +def _is_tool_result_error(value: Any) -> bool: + if isinstance(value, str): + text = value.strip().lower() + if ( + text.startswith('error:') + or text.startswith('exception:') + or text.startswith('traceback') + or text.startswith('http error!') + ): + return True + + parsed = value + while isinstance(parsed, str): + try: + parsed = JSONCodec.loads(parsed) + except (JSONCodec.JSONDecodeError, TypeError, ValueError): + break + + if not isinstance(parsed, dict): + return False + + error = parsed.get('error') + if isinstance(error, str): + has_error = bool(error.strip()) + else: + has_error = isinstance(error, (dict, list)) and bool(error) + if has_error: + return True + + status = parsed.get('status') + if isinstance(status, str) and status.strip().lower() in {'error', 'failed'}: + return True + + if parsed.get('success') is False or parsed.get('ok') is False: + message = parsed.get('message') + return has_error or ( + bool(message.strip()) if isinstance(message, str) else isinstance(message, (dict, list)) and bool(message) + ) + + return False + + async def publish_chat_finished_event( request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None ): @@ -1299,7 +1341,7 @@ async def chat_completion_tools_handler( tool_result = await tool_function(**tool_function_params) except Exception as e: - tool_result = str(e) + tool_result = {'error': str(e)} tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, @@ -3076,7 +3118,7 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event ) result = await function(**params) except Exception as e: - result = str(e) + result = {'error': str(e)} result, files, embeds = await process_tool_result( request, @@ -3098,28 +3140,6 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event } -def append_tool_result_output(output: list[dict], result: dict) -> None: - output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] - display_files = [] - for file_item in result.get('files', []): - if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): - output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) - else: - display_files.append(file_item) - - output.append( - { - 'type': 'function_call_output', - 'id': output_id('fco'), - 'call_id': result.get('tool_call_id', ''), - 'output': output_parts, - 'status': 'completed', - **({'files': display_files} if display_files else {}), - **({'embeds': result.get('embeds')} if result.get('embeds') else {}), - } - ) - - async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool: chat_id = metadata.get('chat_id') message_id = metadata.get('message_id') or metadata.get('assistant_message_id') @@ -3186,9 +3206,27 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - event_emitter, tool_call, ) - item['status'] = 'completed' item['arguments'] = tool_call.get('function', {}).get('arguments', '{}') - append_tool_result_output(output, result) + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + item['status'] = 'failed' if _is_tool_result_error(result.get('content', '')) else 'completed' + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + display_files.append(file_item) + + output.append( + { + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': result.get('tool_call_id', ''), + 'output': output_parts, + 'status': item['status'], + **({'files': display_files} if display_files else {}), + **({'embeds': result.get('embeds')} if result.get('embeds') else {}), + } + ) changed = True if changed: @@ -5489,7 +5527,7 @@ async def streaming_chat_response_handler(response, ctx): ) result = await function(**params) except Exception as e: - result = str(e) + result = {'error': str(e)} return params, result, tool, tool_type, direct_tool delegate_calls = [ @@ -5575,19 +5613,13 @@ async def streaming_chat_response_handler(response, ctx): } ) - # Update function_call statuses and append function_call_output items - for tc in response_tool_calls: - call_id = tc.get('id', '') - # Mark function_call as completed - for item in output: - if item.get('type') == 'function_call' and item.get('call_id') == call_id: - item['status'] = 'completed' - # Update arguments with parsed/sanitized version - item['arguments'] = tc.get('function', {}).get('arguments', '{}') - break - + result_status_by_call_id = {} for result in results: output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + local_output_status = ( + 'failed' if _is_tool_result_error(result.get('content', '')) else 'completed' + ) + result_status_by_call_id[result.get('tool_call_id', '')] = local_output_status # Separate image data URIs (for LLM via input_image) from # other files (for frontend display via files attribute). @@ -5606,12 +5638,21 @@ async def streaming_chat_response_handler(response, ctx): 'id': output_id('fco'), 'call_id': result.get('tool_call_id', ''), 'output': output_parts, - 'status': 'completed', + 'status': local_output_status, **({'files': display_files} if display_files else {}), **({'embeds': result.get('embeds')} if result.get('embeds') else {}), } ) + # Update function_call statuses and parsed/sanitized arguments. + for tc in response_tool_calls: + call_id = tc.get('id', '') + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') == call_id: + item['status'] = result_status_by_call_id.get(call_id, 'completed') + item['arguments'] = tc.get('function', {}).get('arguments', '{}') + break + # Append a new empty message item for the next response output.append( { diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index c24e6568d8..41764dbebb 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -312,7 +312,7 @@ def convert_output_to_messages( for item in output if item.get('type') == 'function_call' and item.get('call_id') - and item.get('status') in {'completed', 'rejected'} + and item.get('status') in {'completed', 'failed', 'rejected'} } result_call_ids = { item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') diff --git a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte index be31c6d3e8..f3a2d0e265 100644 --- a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte +++ b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte @@ -21,6 +21,7 @@ export let id = ''; export let tokens: Array<{ summary?: string; + text?: string; attributes?: { type?: string; name?: string; @@ -50,6 +51,49 @@ } } + function isToolResultError(value: unknown): boolean { + if (typeof value === 'string') { + const text = value.trim().toLowerCase(); + if ( + text.startsWith('error:') || + text.startsWith('exception:') || + text.startsWith('traceback') || + text.startsWith('http error!') + ) { + return true; + } + } + + let parsed = value; + while (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + break; + } + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false; + + const result = parsed as Record; + const error = result.error; + if ( + (typeof error === 'string' && error.trim().length > 0) || + (typeof error === 'object' && error !== null) + ) { + return true; + } + + const status = typeof result.status === 'string' ? result.status.trim().toLowerCase() : ''; + if (status === 'error' || status === 'failed') return true; + + const message = result.message; + return ( + (result.success === false || result.ok === false) && + ((typeof message === 'string' && message.trim().length > 0) || + (typeof message === 'object' && message !== null)) + ); + } + $: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length; $: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length; $: pendingToolTokens = tokens.filter( @@ -66,6 +110,12 @@ $: hasRejected = tokens.some( (t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'rejected' ); + $: hasError = tokens.some( + (t) => + t?.attributes?.type === 'tool_calls' && + (t?.attributes?.status === 'failed' || + (t?.attributes?.done === 'true' && isToolResultError(decode(t?.text ?? '')))) + ); $: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length; @@ -157,6 +207,10 @@
+ {:else if toolCallCount > 0 && hasError} +
+ +
{:else if toolCallCount > 0}
diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte index a9d68e7699..179f2d74d4 100644 --- a/src/lib/components/common/ToolCallDisplay.svelte +++ b/src/lib/components/common/ToolCallDisplay.svelte @@ -90,6 +90,49 @@ } } + function isToolResultError(value: unknown): boolean { + if (typeof value === 'string') { + const text = value.trim().toLowerCase(); + if ( + text.startsWith('error:') || + text.startsWith('exception:') || + text.startsWith('traceback') || + text.startsWith('http error!') + ) { + return true; + } + } + + let parsed = value; + while (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + break; + } + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false; + + const result = parsed as Record; + const error = result.error; + if ( + (typeof error === 'string' && error.trim().length > 0) || + (typeof error === 'object' && error !== null) + ) { + return true; + } + + const status = typeof result.status === 'string' ? result.status.trim().toLowerCase() : ''; + if (status === 'error' || status === 'failed') return true; + + const message = result.message; + return ( + (result.success === false || result.ok === false) && + ((typeof message === 'string' && message.trim().length > 0) || + (typeof message === 'object' && message !== null)) + ); + } + export let resultContent: string = ''; $: result = resultContent || decode(attributes?.result ?? ''); @@ -110,6 +153,7 @@ $: isExecuting = !isDone && !isRejected && attributes?.status === 'completed'; $: isPreparing = !isDone && !isRejected && !needsApproval && !needsInput && !isExecuting; $: isActive = isPreparing || isExecuting; + $: isError = attributes?.status === 'failed' || (isDone && isToolResultError(result)); $: parsedArgs = parseArguments(args); $: parsedResult = parseJSONString(result); @@ -171,6 +215,10 @@
+ {:else if isError} +
+ +
{:else if isDone}