Stop repeated duplicate skill loops

This commit is contained in:
武阳
2026-05-08 10:40:01 +08:00
parent 18cfb60539
commit 9e94648ae6
2 changed files with 163 additions and 0 deletions
+85
View File
@@ -579,6 +579,7 @@ class LocalCodingAgent:
assistant_response_segments: list[str] = [] assistant_response_segments: list[str] = []
continuation_count = 0 continuation_count = 0
active_skill_names: set[str] = set() active_skill_names: set[str] = set()
duplicate_skill_counts: dict[str, int] = {}
delegated_tasks = sum( delegated_tasks = sum(
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent') 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_result = self._execute_skill(
tool_call.arguments, tool_call.arguments,
active_skill_names=active_skill_names, active_skill_names=active_skill_names,
duplicate_skill_counts=duplicate_skill_counts,
) )
elif tool_result is None: elif tool_result is None:
for update in execute_tool_streaming( for update in execute_tool_streaming(
@@ -1327,6 +1329,51 @@ class LocalCodingAgent:
) )
if history_entry is not None: if history_entry is not None:
file_history.append(history_entry) 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: if tool_result.metadata.get('requires_user_review') is True:
review_output = self._build_user_review_required_output(tool_result) review_output = self._build_user_review_required_output(tool_result)
review_message_id = f'assistant_{len(session.messages)}' review_message_id = f'assistant_{len(session.messages)}'
@@ -2182,6 +2229,24 @@ class LocalCodingAgent:
entry['history_kind'] = 'tool' entry['history_kind'] = 'tool'
return entry 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: def _build_user_review_required_output(self, tool_result: ToolExecutionResult) -> str:
fallback = ( fallback = (
'已生成待 review 的计划。本轮已暂停,请检查上面的计划;' '已生成待 review 的计划。本轮已暂停,请检查上面的计划;'
@@ -2492,6 +2557,7 @@ class LocalCodingAgent:
arguments: dict[str, object], arguments: dict[str, object],
*, *,
active_skill_names: set[str] | None = None, active_skill_names: set[str] | None = None,
duplicate_skill_counts: dict[str, int] | None = None,
) -> ToolExecutionResult: ) -> ToolExecutionResult:
"""Execute a skill through the Skill tool. """Execute a skill through the Skill tool.
@@ -2519,12 +2585,22 @@ class LocalCodingAgent:
if bundled is not None: if bundled is not None:
skill_key = bundled.name.strip().lower() skill_key = bundled.name.strip().lower()
if active_skill_names is not None and skill_key in active_skill_names: 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( return ToolExecutionResult(
name='Skill', name='Skill',
ok=True, ok=True,
content=( content=(
f'Skill "{bundled.name}" 已在本轮激活。' f'Skill "{bundled.name}" 已在本轮激活。'
'请直接依据前面注入的 Skill 指南继续执行,不要重复调用 Skill 工具。' '请直接依据前面注入的 Skill 指南继续执行,不要重复调用 Skill 工具。'
if not stop_agent
else (
f'Skill "{bundled.name}" 已在本轮重复调用 {duplicate_count} 次。'
'运行时已暂停,避免继续空转。'
)
), ),
metadata={ metadata={
'action': 'skill_duplicate', 'action': 'skill_duplicate',
@@ -2533,6 +2609,15 @@ class LocalCodingAgent:
'skill_path': bundled.path, 'skill_path': bundled.path,
'should_query': True, 'should_query': True,
'duplicate': 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: if active_skill_names is not None:
+78
View File
@@ -1386,6 +1386,84 @@ class AgentRuntimeTests(unittest.TestCase):
duplicate_payload = json.loads(str(tool_messages[1].get('content', '{}'))) duplicate_payload = json.loads(str(tool_messages[1].get('content', '{}')))
self.assertIn('已在本轮激活', duplicate_payload.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: def test_agent_records_file_history_for_write_tool(self) -> None:
responses = [ responses = [
{ {