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 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.
150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
from collections.abc import Callable
|
|
|
|
from open_webui.utils.json_codec import JSONCodec
|
|
|
|
|
|
ASK_USER_NAME = 'ask_user'
|
|
|
|
|
|
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
|
|
if len(tool_calls) != 1:
|
|
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, 'Error: only one ask_user call is allowed per turn.'
|
|
return ask_user_calls, None
|
|
|
|
|
|
def normalize_ask_user_request(arguments: dict) -> dict:
|
|
questions = arguments.get('questions')
|
|
if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
|
|
raise ValueError('ask_user requires 1-3 questions.')
|
|
|
|
normalized_questions = []
|
|
seen_ids = set()
|
|
allow_other = bool(arguments.get('allow_other', True))
|
|
for index, question in enumerate(questions):
|
|
if not isinstance(question, dict):
|
|
raise ValueError('Each question must be an object.')
|
|
|
|
question_id = str(question.get('id') or '').strip()[:64]
|
|
if not question_id:
|
|
raise ValueError('Each question requires a non-empty id.')
|
|
if question_id in seen_ids:
|
|
raise ValueError(f'Duplicate question id: {question_id}')
|
|
seen_ids.add(question_id)
|
|
|
|
options = question.get('options')
|
|
if not isinstance(options, list) or not 2 <= len(options) <= 3:
|
|
raise ValueError('Each question requires 2-3 options.')
|
|
|
|
normalized_options = []
|
|
for option in options:
|
|
if not isinstance(option, dict):
|
|
raise ValueError('Each option must be an object.')
|
|
label = str(option.get('label') or '').strip()[:80]
|
|
description = str(option.get('description') or '').strip()[:240]
|
|
if not label or not description:
|
|
raise ValueError('Each option requires a label and description.')
|
|
normalized_options.append({'label': label, 'description': description})
|
|
|
|
question_text = str(question.get('question') or '').strip()[:500]
|
|
if not question_text:
|
|
raise ValueError('Each question requires question text.')
|
|
|
|
normalized_questions.append(
|
|
{
|
|
'id': question_id,
|
|
'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}',
|
|
'question': question_text,
|
|
'options': normalized_options,
|
|
'allow_other': bool(question.get('allow_other', allow_other)),
|
|
}
|
|
)
|
|
|
|
timeout_ms = arguments.get('timeout_ms', 120_000)
|
|
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 60_000 <= timeout_ms <= 240_000:
|
|
timeout_ms = 120_000
|
|
|
|
return {
|
|
'questions': normalized_questions,
|
|
'allow_other': allow_other,
|
|
'timeout_ms': timeout_ms,
|
|
}
|
|
|
|
|
|
def stage_ask_user_tool_calls(
|
|
tool_calls: list[dict],
|
|
output: list[dict],
|
|
make_output_id: Callable[[str], str],
|
|
) -> tuple[bool, str | None]:
|
|
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
|
|
if not ask_user_calls:
|
|
return False, None
|
|
|
|
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}'
|
|
|
|
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'}
|
|
)
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
if existing_item:
|
|
existing_item.update(item)
|
|
else:
|
|
output.append(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
|