mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-01 19:50:41 +02:00
fix: a rejected ask_user call ending the turn with no reply (#29252)
* fix: a rejected ask_user call ending the turn with no reply The documented behaviour of the built-in ask_user tool is that a call breaking its rules comes back to the model as an error. Instead the reply stopped there: the error was recorded as the tool result, the model was never asked again, and the user was left with a dead chat and no answer. The rejection is now handed back like any other failed tool result, so the model sees it and can correct itself within the normal tool-call iteration limit. Any ordinary tool the model emitted in the same turn still runs. A call rejected for arriving alongside other ask_user calls also left those siblings without a result, which the UI shows as a tool call stuck on "Executing..." forever. Every invalid call now gets its own result. Two ask_user calls on their own also reported the wrong reason, saying the call must be made by itself rather than that only one is allowed per turn. Fixes #29077 * Keep the original ask_user validation order Restores the pre-existing check order and the unchanged output id fallback, so this change only alters the return shape needed for staging, and trims a comment that narrated the lines below it. * Correct the ask_user sibling-call error message * Shorten the ask_user sibling-call error message * Drop the untrue sibling-call claim from the ask_user error The ask_user error text told the user and the model "The others ran.", but that sentence is written into the turn output before any sibling tool call has executed, so it can be plainly false. Under a saved chat with tool approval set to ask, the turn pauses right afterwards and the siblings sit at pending/queued, so the user reads "The others ran" directly above the approval prompt for tools that have not run, and reads it again beside the rejection result if they decline. When the model sends two ask_user calls and nothing else, nothing runs at all and the sentence is emitted twice. The staging helper cannot see what happens to the sibling calls, so it no longer narrates it. The remaining two sentences hold in every flow: ask_user really is dropped from the executed calls whenever this error is set, and calling it on its own is always the right retry.
This commit is contained in:
@@ -6,17 +6,20 @@ from open_webui.utils.json_codec import JSONCodec
|
||||
ASK_USER_NAME = 'ask_user'
|
||||
|
||||
|
||||
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
|
||||
def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | None]:
|
||||
ask_user_calls = [
|
||||
tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME
|
||||
]
|
||||
if not ask_user_calls:
|
||||
return None, None
|
||||
return [], None
|
||||
if len(tool_calls) != 1:
|
||||
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
|
||||
return (
|
||||
ask_user_calls,
|
||||
'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.',
|
||||
)
|
||||
if len(ask_user_calls) != 1:
|
||||
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls[0], None
|
||||
return ask_user_calls, 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls, None
|
||||
|
||||
|
||||
def normalize_ask_user_request(arguments: dict) -> dict:
|
||||
@@ -77,68 +80,70 @@ def normalize_ask_user_request(arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def stage_ask_user_tool_call(
|
||||
def stage_ask_user_tool_calls(
|
||||
tool_calls: list[dict],
|
||||
output: list[dict],
|
||||
make_output_id: Callable[[str], str],
|
||||
) -> dict | None:
|
||||
tool_call, error = get_ask_user_tool_call(tool_calls)
|
||||
if not tool_call:
|
||||
return None
|
||||
) -> tuple[bool, str | None]:
|
||||
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
|
||||
if not ask_user_calls:
|
||||
return False, None
|
||||
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
for tool_call in ask_user_calls:
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
return {'call_id': call_id, 'error': error, 'item': item}
|
||||
# Every invalid call needs its own result, or the UI waits on it forever.
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
)
|
||||
|
||||
return True, error
|
||||
|
||||
@@ -81,7 +81,7 @@ from open_webui.tasks import clear_response_stream, save_response_stream
|
||||
from open_webui.utils.access_control import has_connection_access, has_permission
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_call
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_calls
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
@@ -5531,12 +5531,14 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
tool_call_iterations += 1
|
||||
|
||||
response_tool_calls = tool_calls.pop(0)
|
||||
ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id)
|
||||
if ask_user_stage:
|
||||
if ask_user_stage['error']:
|
||||
await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}})
|
||||
continue
|
||||
|
||||
ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id)
|
||||
if ask_user_error:
|
||||
response_tool_calls = [
|
||||
tool_call
|
||||
for tool_call in response_tool_calls
|
||||
if tool_call.get('function', {}).get('name') != 'ask_user'
|
||||
]
|
||||
elif ask_user_staged:
|
||||
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
|
||||
await pause_for_tool_approval(
|
||||
metadata['chat_id'],
|
||||
@@ -5568,7 +5570,8 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
|
||||
tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full')
|
||||
if (
|
||||
tool_approval_mode == 'ask'
|
||||
response_tool_calls
|
||||
and tool_approval_mode == 'ask'
|
||||
and is_saved_chat_id(metadata.get('chat_id'))
|
||||
and metadata.get('message_id')
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user