diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 7a09f4e..e5bcca3 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -579,6 +579,7 @@ class LocalCodingAgent: assistant_response_segments: list[str] = [] continuation_count = 0 active_skill_names: set[str] = set() + duplicate_skill_counts: dict[str, int] = {} delegated_tasks = sum( 1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent') ) @@ -1131,6 +1132,7 @@ class LocalCodingAgent: tool_result = self._execute_skill( tool_call.arguments, active_skill_names=active_skill_names, + duplicate_skill_counts=duplicate_skill_counts, ) elif tool_result is None: for update in execute_tool_streaming( @@ -1327,6 +1329,51 @@ class LocalCodingAgent: ) if history_entry is not None: file_history.append(history_entry) + if tool_result.metadata.get('stop_agent') is True: + stop_reason = str( + tool_result.metadata.get('stop_reason') or 'tool_requested_stop' + ) + stop_output = self._build_tool_requested_stop_output(tool_result) + stop_message_id = f'assistant_{len(session.messages)}' + session.append_assistant( + stop_output, + message_id=stop_message_id, + stop_reason=stop_reason, + ) + stream_events.append( + { + 'type': 'tool_requested_stop', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'assistant_message_id': stop_message_id, + 'metadata': dict(tool_result.metadata), + } + ) + _append_final_text_stream_events(stream_events, stop_output) + result = AgentRunResult( + final_output=stop_output, + turns=turn_index, + tool_calls=tool_calls, + transcript=session.transcript(), + events=tuple(stream_events), + usage=total_usage, + total_cost_usd=total_cost_usd, + stop_reason=stop_reason, + 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 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)}' @@ -2182,6 +2229,24 @@ class LocalCodingAgent: entry['history_kind'] = 'tool' return entry + def _build_tool_requested_stop_output(self, tool_result: ToolExecutionResult) -> str: + stop_reason = str(tool_result.metadata.get('stop_reason') or '') + if stop_reason == 'duplicate_skill_loop': + skill_name = str(tool_result.metadata.get('skill_name') or 'unknown') + duplicate_count = tool_result.metadata.get('duplicate_count') + return ( + f'检测到模型连续重复调用 Skill `{skill_name}`,本轮已暂停。\n\n' + f'- 重复次数:{duplicate_count}\n' + '- 已处理:首次 Skill 已成功注入,后续重复调用没有继续注入大段提示词。\n' + '- 建议:你可以直接回复“继续”,我会基于已经激活的 Skill 往下推进;' + '或者补充更明确的生成边界和数量。' + ) + return ( + '工具请求暂停当前任务,本轮已停止。\n\n' + f'- 工具:{tool_result.name}\n' + f'- 原因:{tool_result.content}' + ) + def _build_user_review_required_output(self, tool_result: ToolExecutionResult) -> str: fallback = ( '已生成待 review 的计划。本轮已暂停,请检查上面的计划;' @@ -2492,6 +2557,7 @@ class LocalCodingAgent: arguments: dict[str, object], *, active_skill_names: set[str] | None = None, + duplicate_skill_counts: dict[str, int] | None = None, ) -> ToolExecutionResult: """Execute a skill through the Skill tool. @@ -2519,12 +2585,22 @@ class LocalCodingAgent: if bundled is not None: skill_key = bundled.name.strip().lower() if active_skill_names is not None and skill_key in active_skill_names: + duplicate_count = 1 + if duplicate_skill_counts is not None: + duplicate_count = duplicate_skill_counts.get(skill_key, 0) + 1 + duplicate_skill_counts[skill_key] = duplicate_count + stop_agent = duplicate_count >= 2 return ToolExecutionResult( name='Skill', ok=True, content=( f'Skill "{bundled.name}" 已在本轮激活。' '请直接依据前面注入的 Skill 指南继续执行,不要重复调用 Skill 工具。' + if not stop_agent + else ( + f'Skill "{bundled.name}" 已在本轮重复调用 {duplicate_count} 次。' + '运行时已暂停,避免继续空转。' + ) ), metadata={ 'action': 'skill_duplicate', @@ -2533,6 +2609,15 @@ class LocalCodingAgent: 'skill_path': bundled.path, 'should_query': True, 'duplicate': True, + 'duplicate_count': duplicate_count, + **( + { + 'stop_agent': True, + 'stop_reason': 'duplicate_skill_loop', + } + if stop_agent + else {} + ), }, ) if active_skill_names is not None: diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 1ef4526..28dab35 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -1386,6 +1386,84 @@ class AgentRuntimeTests(unittest.TestCase): duplicate_payload = json.loads(str(tool_messages[1].get('content', '{}'))) self.assertIn('已在本轮激活', duplicate_payload.get('content', '')) + def test_agent_stops_repeated_duplicate_skill_loop(self) -> None: + skill_call = { + 'type': 'function', + 'function': { + 'name': 'Skill', + 'arguments': '{"skill": "verify"}', + }, + } + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will load the verification skill.', + 'tool_calls': [{**skill_call, 'id': 'call_1'}], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I accidentally load it again.', + 'tool_calls': [{**skill_call, 'id': 'call_2'}], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I am still stuck loading it again.', + 'tool_calls': [{**skill_call, 'id': 'call_3'}], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + ] + recorded_payloads: list[dict[str, object]] = [] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + with patch( + 'src.openai_compat.request.urlopen', + side_effect=make_recording_urlopen_side_effect(responses, recorded_payloads), + ): + 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, max_turns=50), + ) + result = agent.run('Use verify skill') + + self.assertEqual(len(recorded_payloads), 3) + self.assertEqual(result.stop_reason, 'duplicate_skill_loop') + self.assertIn('连续重复调用 Skill', result.final_output) + self.assertEqual(result.tool_calls, 3) + tool_messages = [ + message for message in result.transcript if message.get('role') == 'tool' + ] + self.assertEqual(tool_messages[-1].get('metadata', {}).get('stop_agent'), True) + self.assertEqual(tool_messages[-1].get('metadata', {}).get('duplicate_count'), 2) + self.assertTrue( + any(event.get('type') == 'tool_requested_stop' for event in result.events) + ) + def test_agent_records_file_history_for_write_tool(self) -> None: responses = [ {