diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 9de07ad3e4..e1978d7cfb 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1851,14 +1851,6 @@ async def resolve_chat_message_tool_call( db: AsyncSession = Depends(get_async_session), ): resolution = await resolve_tool_call_output(id, message_id, form_data, user, db=db) - if form_data.action != 'approve' and resolution['paused']: - return { - 'status': True, - 'chat_id': id, - 'message_id': message_id, - 'paused': True, - } - payload = await build_tool_approval_resume_payload(id, message_id, chat=resolution['chat']) result = await chat_completion(request, payload, user) return { @@ -2119,6 +2111,7 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De @app.post('/api/tasks/chat/{chat_id:path}/stop') async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): socket_id = get_temporary_chat_session_id(chat_id) + chat = None if socket_id: owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': @@ -2128,6 +2121,47 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De if chat is None or (chat.user_id != user.id and user.role != 'admin'): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) result = await stop_item_tasks(request.app.state.redis, chat_id) + + if not socket_id and str(result.get('message', '')).startswith('No tasks found'): + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) or {} + for message_id, message in messages_map.items(): + if message.get('role') != 'assistant' or message.get('done') is not False: + continue + + output = message.get('output') + if isinstance(output, list): + for item in output: + if item.get('type') == 'function_call' and item.get('status') in { + 'pending', + 'queued', + 'requires_approval', + }: + item['status'] = 'rejected' + item.pop('approved', None) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + {'done': True, **({'output': output} if isinstance(output, list) else {})}, + touch=False, + ) + result = { + 'status': True, + 'message': 'Finalized pending approval message.', + } + + event_emitter = await get_event_emitter( + { + 'user_id': chat.user_id, + 'chat_id': chat_id, + 'message_id': message_id, + }, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'done': True, 'output': output}}) + await event_emitter({'type': 'chat:tasks:cancel'}) + return result diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 213181275b..4ccf1823f5 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2063,16 +2063,6 @@ def process_messages_with_output( return processed -def strip_compaction_fields(messages: list[dict]) -> list[dict]: - stripped = [] - for message in messages: - clean = dict(message) - for key in ('id', 'files', 'output', 'contextSummary', 'context_summary', 'usage'): - clean.pop(key, None) - stripped.append(clean) - return stripped - - def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: tool_result_ids = { message.get('tool_call_id') @@ -2331,8 +2321,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception: log.exception('Context compaction failed; continuing with full chat history') - form_data['messages'] = strip_compaction_fields(form_data.get('messages', [])) - # Process messages with OR-aligned output items for clean LLM messages form_data['messages'] = process_messages_with_output( form_data.get('messages', []), @@ -3109,6 +3097,20 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - and item.get('call_id') not in result_call_ids ] if not approved_calls: + if metadata.get('params', {}).get('tool_approval_mode', 'full') == 'ask' and any( + item.get('type') == 'function_call' + and item.get('name') != 'ask_user' + and (item.get('call_id') or item.get('id')) + and item.get('status') == 'queued' + and item.get('approved') is not True + and (item.get('call_id') or item.get('id')) not in result_call_ids + for item in output + ): + event_emitter, _ = await get_event_emitter_and_caller(metadata) + await pause_for_tool_approval(chat_id, message_id, output, form_data, metadata) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'done': False, 'output': output}}) + return True return False event_emitter, event_caller = await get_event_emitter_and_caller(metadata) @@ -3146,6 +3148,21 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - result_call_ids = { item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') } + if metadata.get('params', {}).get('tool_approval_mode', 'full') == 'ask' and any( + item.get('type') == 'function_call' + and item.get('name') != 'ask_user' + and (item.get('call_id') or item.get('id')) + and item.get('status') == 'queued' + and item.get('approved') is not True + and (item.get('call_id') or item.get('id')) not in result_call_ids + for item in output + ): + await pause_for_tool_approval(chat_id, message_id, output, form_data, metadata) + result_call_ids = { + item.get('call_id') + for item in output + if item.get('type') == 'function_call_output' and item.get('call_id') + } paused = any( item.get('type') == 'function_call' and item.get('call_id') @@ -3207,6 +3224,7 @@ async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[di result_call_ids = { item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') } + has_pending_approval = False for item in output: if item.get('type') == 'function_call' and not item.get('call_id') and item.get('id'): item['call_id'] = item['id'] @@ -3217,7 +3235,11 @@ async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[di and item.get('call_id') not in result_call_ids and item.get('status') != 'rejected' ): - item['status'] = 'pending' + if not has_pending_approval: + item['status'] = 'pending' + has_pending_approval = True + elif item.get('status') == 'in_progress': + item['status'] = 'queued' await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ce79102005..003db10bcf 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -293,6 +293,7 @@ def convert_output_to_messages( pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' pending_reasoning_details = [] pending_tool_image_urls = [] + pending_tool_outputs = [] completed_call_ids = { item.get('call_id') for item in output @@ -347,9 +348,60 @@ def convert_output_to_messages( ) pending_tool_image_urls = [] + def flush_tool_outputs(): + nonlocal pending_tool_outputs + if not pending_tool_outputs: + return + + flush_pending() + for output_item in pending_tool_outputs: + output_parts = output_item.get('output', []) + content = '' + image_urls = [] + for part in output_parts: + if part.get('type') == 'input_text': + output_text = part.get('text', '') + content += str(output_text) if not isinstance(output_text, str) else output_text + elif part.get('type') == 'input_image': + url = part.get('image_url', '') + if url: + image_urls.append(url) + + if flatten_tool_images: + messages.append( + { + 'role': 'tool', + 'tool_call_id': output_item.get('call_id', ''), + 'content': content, + } + ) + pending_tool_image_urls.extend(image_urls) + elif image_urls: + messages.append( + { + 'role': 'tool', + 'tool_call_id': output_item.get('call_id', ''), + 'content': [ + {'type': 'input_text', 'text': content}, + *[{'type': 'input_image', 'image_url': url} for url in image_urls], + ], + } + ) + else: + messages.append( + { + 'role': 'tool', + 'tool_call_id': output_item.get('call_id', ''), + 'content': content, + } + ) + + pending_tool_outputs = [] + for item in output: item_type = item.get('type', '') - if item_type != 'function_call_output': + if item_type not in {'function_call', 'function_call_output'}: + flush_tool_outputs() flush_tool_images() if item_type == 'message': @@ -386,51 +438,7 @@ def convert_output_to_messages( if item.get('call_id') not in function_call_ids: continue - # Flush any pending content/tool_calls before adding tool result - flush_pending() - - # Extract text and images from output content parts - output_parts = item.get('output', []) - content = '' - image_urls = [] - for part in output_parts: - if part.get('type') == 'input_text': - output_text = part.get('text', '') - content += str(output_text) if not isinstance(output_text, str) else output_text - elif part.get('type') == 'input_image': - url = part.get('image_url', '') - if url: - image_urls.append(url) - - if flatten_tool_images: - messages.append( - { - 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), - 'content': content, - } - ) - if item.get('call_id') in function_call_ids: - pending_tool_image_urls.extend(image_urls) - elif image_urls: - messages.append( - { - 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), - 'content': [ - {'type': 'input_text', 'text': content}, - *[{'type': 'input_image', 'image_url': url} for url in image_urls], - ], - } - ) - else: - messages.append( - { - 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), - 'content': content, - } - ) + pending_tool_outputs.append(item) elif item_type == 'reasoning': reasoning_details = item.get('reasoning_details') if raw else None @@ -484,6 +492,7 @@ def convert_output_to_messages( pass # Flush remaining content/tool_calls + flush_tool_outputs() flush_tool_images() flush_pending()