diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 1d1a904..62fb339 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -1117,6 +1117,39 @@ class LocalCodingAgent: ) if history_entry is not None: file_history.append(history_entry) + if tool_result.metadata.get('requires_user_review') is True: + stream_events.append( + { + 'type': 'user_review_required', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'metadata': dict(tool_result.metadata), + } + ) + result = AgentRunResult( + final_output=self._build_user_review_required_output(tool_result), + turns=turn_index + 1, + tool_calls=tool_calls, + transcript=session.transcript(), + events=tuple(stream_events), + usage=total_usage, + total_cost_usd=total_cost_usd, + stop_reason='user_review_required', + file_history=tuple(file_history), + session_id=session_id, + scratchpad_directory=( + str(scratchpad_directory) if scratchpad_directory is not None else None + ), + ) + result = self._append_runtime_after_turn_events( + result, + prompt=effective_prompt, + turn_index=turn_index, + ) + result = self._persist_session(session, result) + self.last_run_result = result + return result result = AgentRunResult( final_output=( @@ -1862,6 +1895,49 @@ class LocalCodingAgent: entry['history_kind'] = 'tool' return entry + def _build_user_review_required_output(self, tool_result: ToolExecutionResult) -> str: + fallback = ( + '已生成待 review 的计划。本轮已暂停,请检查上面的计划;' + '如果需要修改,请直接回复修改意见;如果认可,请回复“确认,开始生成”。' + ) + try: + payload = json.loads(tool_result.content) + except json.JSONDecodeError: + return fallback + plan = payload.get('plan') if isinstance(payload, dict) else None + if not isinstance(plan, dict): + return fallback + + lines = [ + '已生成待 review 的数据生成计划,本轮已暂停。', + '', + f"- plan_id: `{plan.get('plan_id', '')}`", + f"- revision: `{plan.get('revision', '')}`", + f"- dataset_label: {plan.get('dataset_label', '')}", + ] + targets = plan.get('target_definitions') + if isinstance(targets, list) and targets: + lines.append('- target_definitions:') + for item in targets: + if isinstance(item, dict): + lines.append( + f" - {item.get('name', '')}: `{item.get('target', '')}`;规则:{item.get('rule', '')}" + ) + elif plan.get('target'): + lines.append(f"- target: `{plan.get('target')}`") + lines.extend( + [ + f"- 生成数量: {plan.get('total_count', '')}", + f"- 单轮/多轮: {plan.get('turn_mix', '')}", + f"- 覆盖范围: {plan.get('coverage', '')}", + f"- 排除项: {plan.get('exclusions', '')}", + f"- 落盘路径: {plan.get('output_path', '')}", + '', + '请 review 这份计划:需要修改就直接回复修改意见;认可的话回复“确认,开始生成”。', + ] + ) + return '\n'.join(lines) + def _compact_prefix_count(self, session: AgentSessionState) -> int: prefix_count = 0 for message in session.messages: diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index ecdbc0d..acb925f 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -274,6 +274,59 @@ class AgentRuntimeTests(unittest.TestCase): self.assertGreaterEqual(len(result.transcript), 5) self.assertGreaterEqual(len(result.file_history), 0) + def test_agent_stops_after_data_agent_plan_requires_review(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will prepare a reviewable plan.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'data_agent_prepare_generation_plan', + 'arguments': json.dumps( + { + 'dataset_label': '地图和生活边界数据', + 'target': 'Agent(tag="life_service")', + 'total_count': 2, + 'turn_mix': '1 条单轮,1 条多轮', + 'coverage': '附近吃喝玩乐', + 'exclusions': '不要生成导航路线类 query', + 'output_path': 'tasks/demo/artifacts', + } + ), + }, + } + ], + }, + '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('Prepare data generation plan') + + self.assertEqual(result.stop_reason, 'user_review_required') + self.assertEqual(result.tool_calls, 1) + self.assertIn('本轮已暂停', result.final_output) + self.assertTrue( + any(event.get('type') == 'user_review_required' 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))