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:
Classic298
2026-08-31 06:17:21 +02:00
committed by GitHub
parent 95032b6c61
commit be958d7b04
2 changed files with 76 additions and 68 deletions

View File

@@ -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

View File

@@ -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')
):