from collections.abc import Callable 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]: 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 if len(tool_calls) != 1: return ask_user_calls[0], 'Error: ask_user must be called by itself after research.' 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 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_call( 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 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) 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 {'call_id': call_id, 'error': error, 'item': item}