diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 62fb339..2055d2f 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -1118,17 +1118,25 @@ class LocalCodingAgent: if history_entry is not None: file_history.append(history_entry) if tool_result.metadata.get('requires_user_review') is True: + review_output = self._build_user_review_required_output(tool_result) + review_message_id = f'assistant_{len(session.messages)}' + session.append_assistant( + review_output, + message_id=review_message_id, + stop_reason='user_review_required', + ) 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, + 'review_message_id': review_message_id, 'metadata': dict(tool_result.metadata), } ) result = AgentRunResult( - final_output=self._build_user_review_required_output(tool_result), + final_output=review_output, turns=turn_index + 1, tool_calls=tool_calls, transcript=session.transcript(), @@ -1904,10 +1912,60 @@ class LocalCodingAgent: 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): + if not isinstance(payload, dict): return fallback + goal = payload.get('goal') + if isinstance(goal, dict): + return self._format_generation_goal_review(goal) + plan = payload.get('plan') + if isinstance(plan, dict): + return self._format_generation_plan_review(plan) + return fallback + def _format_generation_goal_review(self, goal: dict[str, object]) -> str: + lines = [ + '已生成待 review 的数据生成目标,本轮已暂停。', + '', + f"- goal_id: `{goal.get('goal_id', '')}`", + f"- revision: `{goal.get('revision', '')}`", + f"- dataset_label: {goal.get('dataset_label', '')}", + f"- 目标摘要: {goal.get('goal_summary', '')}", + ] + targets = goal.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 goal.get('target'): + lines.append(f"- target: `{goal.get('target')}`") + if goal.get('plan_hint'): + lines.append(f"- 计划提示: {goal.get('plan_hint')}") + open_questions = goal.get('open_questions') + if isinstance(open_questions, list) and open_questions: + lines.append('- 待确认问题:') + for item in open_questions: + if isinstance(item, str) and item: + lines.append(f' - {item}') + source_refs = goal.get('source_refs') + if isinstance(source_refs, list) and source_refs: + lines.append('- 来源:') + for item in source_refs: + if isinstance(item, str) and item: + lines.append(f' - {item}') + lines.extend( + [ + f"- 覆盖范围: {goal.get('coverage', '')}", + f"- 排除项: {goal.get('exclusions', '')}", + '', + '请 review 这个生成目标:需要修改就直接回复修改意见;认可的话回复“确认目标”。', + ] + ) + return '\n'.join(lines) + + def _format_generation_plan_review(self, plan: dict[str, object]) -> str: lines = [ '已生成待 review 的数据生成计划,本轮已暂停。', '', diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index acb925f..b04e477 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -274,29 +274,29 @@ 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: + def test_agent_stops_after_data_agent_goal_requires_review(self) -> None: responses = [ { 'choices': [ { 'message': { 'role': 'assistant', - 'content': 'I will prepare a reviewable plan.', + 'content': 'I will prepare a reviewable generation goal.', 'tool_calls': [ { 'id': 'call_1', 'type': 'function', 'function': { - 'name': 'data_agent_prepare_generation_plan', + 'name': 'data_agent_prepare_generation_goal', 'arguments': json.dumps( { 'dataset_label': '地图和生活边界数据', + 'goal_summary': '生成附近生活服务边界数据', 'target': 'Agent(tag="life_service")', - 'total_count': 2, - 'turn_mix': '1 条单轮,1 条多轮', + 'plan_hint': '建议先生成 10 条单轮,输出到 tasks/demo/records.jsonl;具体数量、轮次和路径在 generation plan 中确认。', 'coverage': '附近吃喝玩乐', 'exclusions': '不要生成导航路线类 query', - 'output_path': 'tasks/demo/artifacts', + 'source_refs': ['manual:user'], } ), }, @@ -318,14 +318,114 @@ class AgentRuntimeTests(unittest.TestCase): ), runtime_config=AgentRuntimeConfig(cwd=workspace), ) - result = agent.run('Prepare data generation plan') + result = agent.run('Prepare data generation goal') self.assertEqual(result.stop_reason, 'user_review_required') self.assertEqual(result.tool_calls, 1) self.assertIn('本轮已暂停', result.final_output) + self.assertIn('goal_id', result.final_output) + self.assertIn('计划提示', result.final_output) + self.assertIn('建议先生成 10 条单轮', 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.assertIn('数据生成目标', str(review_messages[0].get('content', ''))) self.assertTrue( any(event.get('type') == 'user_review_required' for event in result.events) ) + self.assertTrue( + any( + event.get('type') == 'user_review_required' + and isinstance(event.get('review_message_id'), str) + for event in result.events + ) + ) + + def test_agent_appends_review_message_after_data_agent_plan_requires_review(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + context = build_tool_context(AgentRuntimeConfig(cwd=workspace)) + goal_result = execute_tool( + default_tool_registry(), + 'data_agent_prepare_generation_goal', + { + 'dataset_label': '地图和生活边界数据', + 'goal_summary': '生成附近生活服务边界数据', + 'target': 'Agent(tag="life_service")', + 'coverage': '附近吃喝玩乐', + 'exclusions': '不要生成导航路线类 query', + 'source_refs': ['manual:user'], + }, + context, + ) + self.assertTrue(goal_result.ok, goal_result.content) + goal_id = json.loads(goal_result.content)['goal']['goal_id'] + confirm_result = execute_tool( + default_tool_registry(), + 'data_agent_confirm_generation_goal', + {'goal_id': goal_id, 'confirmation': '确认目标', 'reviewed_revision': 1}, + context, + ) + self.assertTrue(confirm_result.ok, confirm_result.content) + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will prepare a reviewable generation plan.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'data_agent_prepare_generation_plan', + 'arguments': json.dumps( + { + 'confirmed_goal_id': goal_id, + 'dataset_label': '地图和生活边界数据', + 'target': 'Agent(tag="life_service")', + 'total_count': 10, + 'turn_mix': '10 条单轮', + 'coverage': '附近吃喝玩乐', + 'exclusions': '不要生成导航路线类 query', + 'output_path': 'tasks/demo/records.jsonl', + } + ), + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ] + } + ] + 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.assertIn('数据生成计划', result.final_output) + self.assertIn('plan_id', 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.assertIn('数据生成计划', str(review_messages[0].get('content', ''))) def test_write_tool_is_blocked_without_permission(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: