Guard skill and continuation loops

This commit is contained in:
武阳
2026-05-07 20:26:59 +08:00
parent b1868a6401
commit de6bb12ae5
2 changed files with 347 additions and 11 deletions
+175 -10
View File
@@ -71,6 +71,10 @@ from .session_env_vars import clear_session_env_vars
RuntimeEventSink = Callable[[dict[str, object]], None]
# 防止模型在截断续写时无限自我延长,保留少量自动续写空间即可。
MAX_AUTO_CONTINUATIONS = 3
CONTINUATION_FINISH_REASONS = {'length', 'max_tokens'}
class _RuntimeEventBuffer(list[dict[str, object]]):
def __init__(self, event_sink: RuntimeEventSink | None = None) -> None:
@@ -573,6 +577,8 @@ class LocalCodingAgent:
file_history = list(existing_file_history)
stream_events: list[dict[str, object]] = _RuntimeEventBuffer(event_sink)
assistant_response_segments: list[str] = []
continuation_count = 0
active_skill_names: set[str] = set()
delegated_tasks = sum(
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
)
@@ -743,12 +749,42 @@ class LocalCodingAgent:
if not turn.tool_calls:
assistant_response_segments.append(turn.content)
if self._should_continue_response(turn):
if self._is_empty_truncated_response(turn):
final_output = self._build_empty_truncated_response_output()
session.append_assistant(
final_output,
message_id=f'assistant_{len(session.messages)}',
stop_reason='empty_truncated_response',
)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=final_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='empty_truncated_response',
file_history=tuple(file_history),
session_id=session_id,
scratchpad_directory=(
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._persist_session(session, result)
self.last_run_result = result
return result
if self._should_continue_response(
turn,
continuation_count=continuation_count,
):
continuation_count += 1
session.append_user(
self._build_continuation_prompt(),
metadata={
'kind': 'continuation_request',
'continuation_index': len(assistant_response_segments),
'continuation_index': continuation_count,
},
message_id=f'continuation_{turn_index}',
)
@@ -756,12 +792,24 @@ class LocalCodingAgent:
{
'type': 'continuation_request',
'reason': turn.finish_reason,
'continuation_index': len(assistant_response_segments),
'continuation_index': continuation_count,
}
)
last_content = ''.join(assistant_response_segments)
continue
final_output = ''.join(assistant_response_segments)
if self._reached_auto_continuation_limit(
turn,
continuation_count=continuation_count,
):
stream_events.append(
{
'type': 'continuation_limit_reached',
'reason': turn.finish_reason,
'max_continuations': MAX_AUTO_CONTINUATIONS,
}
)
final_output = self._append_continuation_limit_note(final_output)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=final_output,
@@ -846,12 +894,47 @@ class LocalCodingAgent:
if not turn.tool_calls:
assistant_response_segments.append(turn.content)
if self._should_continue_response(turn):
if self._is_empty_truncated_response(turn):
final_output = self._build_empty_truncated_response_output()
session.append_assistant(
final_output,
message_id=f'assistant_{len(session.messages)}',
stop_reason='empty_truncated_response',
)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=final_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='empty_truncated_response',
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 self._should_continue_response(
turn,
continuation_count=continuation_count,
):
continuation_count += 1
session.append_user(
self._build_continuation_prompt(),
metadata={
'kind': 'continuation_request',
'continuation_index': len(assistant_response_segments),
'continuation_index': continuation_count,
},
message_id=f'continuation_{turn_index}',
)
@@ -859,12 +942,24 @@ class LocalCodingAgent:
{
'type': 'continuation_request',
'reason': turn.finish_reason,
'continuation_index': len(assistant_response_segments),
'continuation_index': continuation_count,
}
)
last_content = ''.join(assistant_response_segments)
continue
final_output = ''.join(assistant_response_segments)
if self._reached_auto_continuation_limit(
turn,
continuation_count=continuation_count,
):
stream_events.append(
{
'type': 'continuation_limit_reached',
'reason': turn.finish_reason,
'max_continuations': MAX_AUTO_CONTINUATIONS,
}
)
final_output = self._append_continuation_limit_note(final_output)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=final_output,
@@ -892,6 +987,7 @@ class LocalCodingAgent:
for tool_call in turn.tool_calls:
assistant_response_segments.clear()
continuation_count = 0
tool_calls += 1
if tool_call.name in ('Agent', 'delegate_agent'):
delegated_tasks += self._delegated_task_units(tool_call.arguments)
@@ -1032,7 +1128,10 @@ class LocalCodingAgent:
tool_result = self._execute_delegate_agent(tool_call.arguments)
elif tool_call.name == 'Skill':
if tool_result is None:
tool_result = self._execute_skill(tool_call.arguments)
tool_result = self._execute_skill(
tool_call.arguments,
active_skill_names=active_skill_names,
)
elif tool_result is None:
for update in execute_tool_streaming(
self.tool_registry,
@@ -1431,8 +1530,52 @@ class LocalCodingAgent:
)
return tuple(parsed)
def _should_continue_response(self, turn: AssistantTurn) -> bool:
return turn.finish_reason in {'length', 'max_tokens'}
def _should_continue_response(
self,
turn: AssistantTurn,
*,
continuation_count: int = 0,
) -> bool:
if turn.finish_reason not in CONTINUATION_FINISH_REASONS:
return False
if continuation_count >= MAX_AUTO_CONTINUATIONS:
return False
return bool((turn.content or '').strip())
def _is_empty_truncated_response(self, turn: AssistantTurn) -> bool:
return (
turn.finish_reason in CONTINUATION_FINISH_REASONS
and not (turn.content or '').strip()
and not turn.tool_calls
)
def _reached_auto_continuation_limit(
self,
turn: AssistantTurn,
*,
continuation_count: int,
) -> bool:
return (
turn.finish_reason in CONTINUATION_FINISH_REASONS
and bool((turn.content or '').strip())
and continuation_count >= MAX_AUTO_CONTINUATIONS
)
def _append_continuation_limit_note(self, text: str) -> str:
note = (
f'[系统提示] 已达到自动续写上限 {MAX_AUTO_CONTINUATIONS} 次,'
'本轮先暂停,避免模型无限续写。你可以回复“继续”再接着处理。'
)
if not text.strip():
return note
return f'{text.rstrip()}\n\n{note}'
def _build_empty_truncated_response_output(self) -> str:
return (
'模型返回了空的截断响应,本轮已暂停。\n\n'
'这通常表示模型后端输出被截断但没有返回可展示内容。'
'你可以回复“继续”让我接着处理,或补充更具体的指令重新发起。'
)
def _build_continuation_prompt(self) -> str:
return (
@@ -2347,6 +2490,8 @@ class LocalCodingAgent:
def _execute_skill(
self,
arguments: dict[str, object],
*,
active_skill_names: set[str] | None = None,
) -> ToolExecutionResult:
"""Execute a skill through the Skill tool.
@@ -2372,6 +2517,26 @@ class LocalCodingAgent:
# 1. Check bundled skills first
bundled = find_bundled_skill(skill_name, cwd=self.runtime_config.cwd)
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:
return ToolExecutionResult(
name='Skill',
ok=True,
content=(
f'Skill "{bundled.name}" 已在本轮激活。'
'请直接依据前面注入的 Skill 指南继续执行,不要重复调用 Skill 工具。'
),
metadata={
'action': 'skill_duplicate',
'skill_name': bundled.name,
'source': bundled.source,
'skill_path': bundled.path,
'should_query': True,
'duplicate': True,
},
)
if active_skill_names is not None:
active_skill_names.add(skill_key)
prompt = bundled.get_prompt(self, args.strip())
return ToolExecutionResult(
name='Skill',
@@ -2379,7 +2544,7 @@ class LocalCodingAgent:
content=prompt,
metadata={
'action': 'skill',
'skill_name': skill_name,
'skill_name': bundled.name,
'source': bundled.source,
'skill_path': bundled.path,
'should_query': True,