From 8a93319f1b0cea300bd420bc96ba8ef8f3e4a00b Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Wed, 13 May 2026 11:11:40 +0800 Subject: [PATCH] Fix ask-user review pause in web runs --- src/agent_runtime.py | 32 ++++++++++++++++++ src/agent_tools.py | 24 ++++++++++++- tests/test_agent_runtime.py | 61 ++++++++++++++++++++++++++++++++++ tests/test_ask_user_runtime.py | 25 ++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 426b83d..770a107 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -2325,8 +2325,40 @@ class LocalCodingAgent: plan = payload.get('plan') if isinstance(plan, dict): return self._format_generation_plan_review(plan) + ask_user = payload.get('ask_user') + if isinstance(ask_user, dict): + return self._format_ask_user_review(ask_user) return fallback + def _format_ask_user_review(self, ask_user: dict[str, object]) -> str: + question = str(ask_user.get('question') or '').strip() + header = str(ask_user.get('header') or '').strip() + question_id = str(ask_user.get('question_id') or '').strip() + choices = ask_user.get('choices') + lines: list[str] = [] + if header: + lines.append(header) + lines.append('') + lines.append(question or '需要你补充一个回答后才能继续。') + if isinstance(choices, list) and choices: + rendered_choices = [ + str(choice).strip() + for choice in choices + if isinstance(choice, str) and str(choice).strip() + ] + if rendered_choices: + lines.append('') + lines.append('请选择一个选项,或直接回复你的补充说明:') + for index, choice in enumerate(rendered_choices, start=1): + lines.append(f'{index}. {choice}') + else: + lines.append('') + lines.append('请直接回复你的答案,我会继续执行。') + if question_id: + lines.append('') + lines.append(f'内部问题 ID:`{question_id}`') + return '\n'.join(lines) + def _format_generation_goal_review(self, goal: dict[str, object]) -> str: target_summary = self._format_review_targets(goal) lines = [ diff --git a/src/agent_tools.py b/src/agent_tools.py index b3d8de5..550548e 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -2972,7 +2972,29 @@ def _ask_user_question(arguments: dict[str, Any], context: ToolExecutionContext) allow_free_text=allow_free_text, ) except LookupError as exc: - raise ToolExecutionError(str(exc)) from exc + payload = { + 'ask_user': { + 'question': question, + 'question_id': question_id, + 'header': header, + 'choices': list(choices), + 'allow_free_text': allow_free_text, + 'reason': str(exc), + } + } + return ( + json.dumps(payload, ensure_ascii=False, indent=2), + { + 'action': 'ask_user_question', + 'requires_user_review': True, + 'question': question, + 'question_id': question_id, + 'header': header, + 'choices': list(choices), + 'allow_free_text': allow_free_text, + 'reason': str(exc), + }, + ) lines = ['# Ask User', ''] if header: lines.append(f'- Header: {header}') diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 7a49f29..e3856be 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -485,6 +485,67 @@ class AgentRuntimeTests(unittest.TestCase): self.assertEqual(len(review_messages), 1) self.assertIn('生成参数', str(review_messages[0].get('content', ''))) + def test_agent_stops_after_ask_user_question_requires_review(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I need the user to choose a strategy.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'ask_user_question', + 'arguments': json.dumps( + { + 'question': '选择线上挖掘策略?', + 'choices': ['先宽召回', '先精确过滤'], + 'allow_free_text': True, + } + ), + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ] + } + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)): + agent = LocalCodingAgent( + model_config=ModelConfig( + model='Qwen/Qwen3-Coder-30B-A3B-Instruct', + base_url='http://127.0.0.1:8000/v1', + ), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + result = agent.run('Ask before mining') + + self.assertEqual(result.stop_reason, 'user_review_required') + self.assertEqual(result.tool_calls, 1) + self.assertIn('选择线上挖掘策略', result.final_output) + self.assertIn('先宽召回', result.final_output) + review_messages = [ + entry + for entry in result.transcript + if entry.get('role') == 'assistant' + and entry.get('stop_reason') == 'user_review_required' + ] + self.assertEqual(len(review_messages), 1) + self.assertTrue( + any( + event.get('type') == 'user_review_required' + and event.get('tool_name') == 'ask_user_question' + for event in result.events + ) + ) + def test_write_tool_is_blocked_without_permission(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: config = AgentRuntimeConfig(cwd=Path(tmp_dir)) diff --git a/tests/test_ask_user_runtime.py b/tests/test_ask_user_runtime.py index 585ed00..0955b56 100644 --- a/tests/test_ask_user_runtime.py +++ b/tests/test_ask_user_runtime.py @@ -53,3 +53,28 @@ class AskUserRuntimeTests(unittest.TestCase): self.assertIn('# Ask User', result.content) self.assertIn('safe', result.content) self.assertEqual(result.metadata.get('action'), 'ask_user_question') + + def test_ask_user_tool_requests_web_review_when_no_answer_is_queued(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = AskUserRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig(cwd=workspace), + ask_user_runtime=runtime, + ) + result = execute_tool( + default_tool_registry(), + 'ask_user_question', + { + 'question': 'Choose mining strategy', + 'choices': ['fast sample', 'strict filter'], + 'allow_free_text': True, + }, + context, + ) + + self.assertTrue(result.ok) + self.assertEqual(result.metadata.get('action'), 'ask_user_question') + self.assertTrue(result.metadata.get('requires_user_review')) + self.assertIn('Choose mining strategy', result.content) + self.assertIn('fast sample', result.content)