Stop repeated duplicate skill loops
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user