Support runtime guidance tool interrupts
This commit is contained in:
+404
-6
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -269,6 +270,45 @@ def _all_skill_tool_call_ids(transcript: tuple[dict[str, object], ...]) -> list[
|
||||
return results
|
||||
|
||||
|
||||
class _CombinedCancelEvent:
|
||||
"""Expose a single cancel flag backed by run cancel plus per-tool interrupt."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_cancel_event: Any | None,
|
||||
tool_interrupt_event: threading.Event,
|
||||
) -> None:
|
||||
self._run_cancel_event = run_cancel_event
|
||||
self._tool_interrupt_event = tool_interrupt_event
|
||||
|
||||
def is_set(self) -> bool:
|
||||
run_cancelled = (
|
||||
self._run_cancel_event is not None
|
||||
and bool(self._run_cancel_event.is_set())
|
||||
)
|
||||
return run_cancelled or self._tool_interrupt_event.is_set()
|
||||
|
||||
def set(self) -> None:
|
||||
self._tool_interrupt_event.set()
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
if self.is_set():
|
||||
return True
|
||||
if self._run_cancel_event is None:
|
||||
return self._tool_interrupt_event.wait(timeout)
|
||||
if timeout is None:
|
||||
while not self.is_set():
|
||||
self._tool_interrupt_event.wait(0.1)
|
||||
return True
|
||||
deadline = time.monotonic() + max(0.0, timeout)
|
||||
while not self.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return self.is_set()
|
||||
self._tool_interrupt_event.wait(min(0.1, remaining))
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalCodingAgent:
|
||||
model_config: ModelConfig
|
||||
@@ -739,6 +779,7 @@ class LocalCodingAgent:
|
||||
session,
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage='before_model',
|
||||
)
|
||||
self._microcompact_session_if_needed(
|
||||
session,
|
||||
@@ -1036,6 +1077,32 @@ class LocalCodingAgent:
|
||||
self.last_run_result = result
|
||||
return result
|
||||
|
||||
if turn.tool_calls:
|
||||
guidance_items = self._consume_runtime_guidance(
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage='before_tools',
|
||||
)
|
||||
if guidance_items:
|
||||
self._append_skipped_tool_results_for_guidance(
|
||||
session,
|
||||
stream_events,
|
||||
turn=turn,
|
||||
turn_index=turn_index,
|
||||
guidance_items=guidance_items,
|
||||
)
|
||||
self._append_runtime_guidance_items(
|
||||
session,
|
||||
stream_events,
|
||||
guidance_items,
|
||||
turn_index=turn_index,
|
||||
stage='before_tools',
|
||||
)
|
||||
assistant_response_segments.clear()
|
||||
continuation_count = 0
|
||||
last_content = turn.content
|
||||
continue
|
||||
|
||||
if not turn.tool_calls:
|
||||
assistant_response_segments.append(turn.content)
|
||||
if self._is_empty_truncated_response(turn):
|
||||
@@ -1092,6 +1159,16 @@ class LocalCodingAgent:
|
||||
)
|
||||
last_content = ''.join(assistant_response_segments)
|
||||
continue
|
||||
if self._inject_runtime_guidance(
|
||||
session,
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage='before_finish',
|
||||
):
|
||||
assistant_response_segments.clear()
|
||||
continuation_count = 0
|
||||
last_content = turn.content
|
||||
continue
|
||||
final_output = ''.join(assistant_response_segments)
|
||||
if self._reached_auto_continuation_limit(
|
||||
turn,
|
||||
@@ -1130,6 +1207,7 @@ class LocalCodingAgent:
|
||||
self.last_run_result = result
|
||||
return result
|
||||
|
||||
replan_after_tool_guidance = False
|
||||
for tool_call in turn.tool_calls:
|
||||
assistant_response_segments.clear()
|
||||
continuation_count = 0
|
||||
@@ -1176,6 +1254,8 @@ class LocalCodingAgent:
|
||||
self.last_run_result = result
|
||||
return result
|
||||
tool_result = None
|
||||
tool_guidance_items: tuple[dict[str, object], ...] = ()
|
||||
tool_guidance_interrupted = False
|
||||
tool_message_index = session.start_tool(
|
||||
name=tool_call.name,
|
||||
tool_call_id=tool_call.id,
|
||||
@@ -1279,11 +1359,19 @@ class LocalCodingAgent:
|
||||
duplicate_skill_counts=duplicate_skill_counts,
|
||||
)
|
||||
elif tool_result is None:
|
||||
tool_interrupt_event = threading.Event()
|
||||
tool_context = replace(
|
||||
self.tool_context,
|
||||
cancel_event=_CombinedCancelEvent(
|
||||
self.tool_context.cancel_event,
|
||||
tool_interrupt_event,
|
||||
),
|
||||
)
|
||||
for update in execute_tool_streaming(
|
||||
self.tool_registry,
|
||||
tool_call.name,
|
||||
tool_call.arguments,
|
||||
self.tool_context,
|
||||
tool_context,
|
||||
):
|
||||
if update.kind == 'delta':
|
||||
session.append_tool_delta(
|
||||
@@ -1301,8 +1389,81 @@ class LocalCodingAgent:
|
||||
'delta': update.content,
|
||||
}
|
||||
)
|
||||
if not tool_guidance_items:
|
||||
pending_guidance = self._consume_runtime_guidance(
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage='during_tool',
|
||||
)
|
||||
if pending_guidance:
|
||||
tool_guidance_items = pending_guidance
|
||||
if self._runtime_guidance_should_interrupt_tool(
|
||||
tool_call=tool_call,
|
||||
guidance_items=pending_guidance,
|
||||
):
|
||||
tool_guidance_interrupted = True
|
||||
tool_interrupt_event.set()
|
||||
self._interrupt_running_tool_for_guidance(
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
tool_call=tool_call,
|
||||
guidance_items=pending_guidance,
|
||||
)
|
||||
else:
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'runtime_guidance_deferred_after_tool',
|
||||
'turn_index': turn_index,
|
||||
'tool_name': tool_call.name,
|
||||
'tool_call_id': tool_call.id,
|
||||
'input_ids': self._runtime_guidance_item_ids(
|
||||
pending_guidance
|
||||
),
|
||||
'message': (
|
||||
'用户引导在工具执行中到达,运行时判断无需中断,'
|
||||
'将等待当前工具完成后重规划'
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
tool_result = update.result
|
||||
if tool_result is not None and not tool_guidance_items:
|
||||
tool_guidance_items = self._consume_runtime_guidance(
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage='after_tool',
|
||||
)
|
||||
if tool_guidance_items:
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'runtime_guidance_deferred_after_tool',
|
||||
'turn_index': turn_index,
|
||||
'tool_name': tool_call.name,
|
||||
'tool_call_id': tool_call.id,
|
||||
'input_ids': self._runtime_guidance_item_ids(
|
||||
tool_guidance_items
|
||||
),
|
||||
'message': (
|
||||
'用户引导在工具执行期间到达,但当前工具未提供中间输出;'
|
||||
'已在工具完成后准备重规划'
|
||||
),
|
||||
}
|
||||
)
|
||||
if tool_result is not None and tool_guidance_items:
|
||||
merged_metadata = dict(tool_result.metadata)
|
||||
merged_metadata['runtime_guidance_input_ids'] = (
|
||||
self._runtime_guidance_item_ids(tool_guidance_items)
|
||||
)
|
||||
if tool_guidance_interrupted:
|
||||
merged_metadata['runtime_guidance_interrupted_tool'] = True
|
||||
else:
|
||||
merged_metadata['runtime_guidance_deferred_after_tool'] = True
|
||||
tool_result = ToolExecutionResult(
|
||||
name=tool_result.name,
|
||||
ok=tool_result.ok,
|
||||
content=tool_result.content,
|
||||
metadata=merged_metadata,
|
||||
)
|
||||
if tool_result is None:
|
||||
raise RuntimeError(f'Tool executor returned no final result for {tool_call.name}')
|
||||
if (
|
||||
@@ -1474,6 +1635,23 @@ class LocalCodingAgent:
|
||||
)
|
||||
if history_entry is not None:
|
||||
file_history.append(history_entry)
|
||||
if tool_guidance_items:
|
||||
self._append_runtime_guidance_items(
|
||||
session,
|
||||
stream_events,
|
||||
tool_guidance_items,
|
||||
turn_index=turn_index,
|
||||
stage=(
|
||||
'during_tool_interrupted'
|
||||
if tool_guidance_interrupted
|
||||
else 'after_tool'
|
||||
),
|
||||
)
|
||||
assistant_response_segments.clear()
|
||||
continuation_count = 0
|
||||
last_content = turn.content
|
||||
replan_after_tool_guidance = True
|
||||
break
|
||||
if tool_result.metadata.get('stop_agent') is True:
|
||||
stop_reason = str(
|
||||
tool_result.metadata.get('stop_reason') or 'tool_requested_stop'
|
||||
@@ -1561,6 +1739,9 @@ class LocalCodingAgent:
|
||||
self.last_run_result = result
|
||||
return result
|
||||
|
||||
if replan_after_tool_guidance:
|
||||
continue
|
||||
|
||||
final_output = self._build_max_turns_output(
|
||||
max_turns=self.runtime_config.max_turns,
|
||||
last_content=last_content,
|
||||
@@ -4286,10 +4467,34 @@ class LocalCodingAgent:
|
||||
stream_events: list[dict[str, object]],
|
||||
*,
|
||||
turn_index: int,
|
||||
) -> None:
|
||||
stage: str,
|
||||
) -> bool:
|
||||
items = self._consume_runtime_guidance(
|
||||
stream_events,
|
||||
turn_index=turn_index,
|
||||
stage=stage,
|
||||
)
|
||||
if not items:
|
||||
return False
|
||||
self._append_runtime_guidance_items(
|
||||
session,
|
||||
stream_events,
|
||||
items,
|
||||
turn_index=turn_index,
|
||||
stage=stage,
|
||||
)
|
||||
return True
|
||||
|
||||
def _consume_runtime_guidance(
|
||||
self,
|
||||
stream_events: list[dict[str, object]],
|
||||
*,
|
||||
turn_index: int,
|
||||
stage: str,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
provider = self.runtime_guidance_provider
|
||||
if provider is None:
|
||||
return
|
||||
return ()
|
||||
try:
|
||||
items = tuple(
|
||||
item
|
||||
@@ -4302,12 +4507,22 @@ class LocalCodingAgent:
|
||||
{
|
||||
'type': 'runtime_guidance_error',
|
||||
'turn_index': turn_index,
|
||||
'stage': stage,
|
||||
'error': str(exc)[:500],
|
||||
}
|
||||
)
|
||||
return
|
||||
if not items:
|
||||
return
|
||||
return ()
|
||||
return items
|
||||
|
||||
def _append_runtime_guidance_items(
|
||||
self,
|
||||
session: AgentSessionState,
|
||||
stream_events: list[dict[str, object]],
|
||||
items: tuple[dict[str, object], ...],
|
||||
*,
|
||||
turn_index: int,
|
||||
stage: str,
|
||||
) -> None:
|
||||
lines = [
|
||||
'<runtime-guidance>',
|
||||
(
|
||||
@@ -4315,6 +4530,26 @@ class LocalCodingAgent:
|
||||
'吸收这些要求;如果它们与当前目标冲突,先说明冲突并选择最合理的继续方式。'
|
||||
),
|
||||
]
|
||||
if stage == 'before_tools':
|
||||
lines.append(
|
||||
'这批引导在工具执行前到达,运行时已经跳过上一批尚未执行的工具调用。'
|
||||
'请重新判断:继续原计划、调整工具参数、改走新计划,或说明冲突。'
|
||||
)
|
||||
elif stage == 'before_finish':
|
||||
lines.append(
|
||||
'这批引导在最终回复前到达。请判断它是补充最终输出、修正当前结论,'
|
||||
'还是应作为后续任务处理。'
|
||||
)
|
||||
elif stage == 'during_tool_interrupted':
|
||||
lines.append(
|
||||
'这批引导在工具执行中到达,运行时已经中断当前工具并停止继续执行同批旧工具计划。'
|
||||
'请结合已有工具输出和这批引导重新规划;如果需要重跑工具,请使用新的参数。'
|
||||
)
|
||||
elif stage == 'after_tool':
|
||||
lines.append(
|
||||
'这批引导在工具执行期间或刚完成后到达。当前工具结果已保留,'
|
||||
'运行时停止继续执行同批旧工具计划,请判断是否基于该结果继续、补充或重跑。'
|
||||
)
|
||||
item_ids: list[int] = []
|
||||
for index, item in enumerate(items, start=1):
|
||||
item_id = item.get('id')
|
||||
@@ -4328,6 +4563,7 @@ class LocalCodingAgent:
|
||||
metadata={
|
||||
'kind': 'runtime_guidance',
|
||||
'turn_index': turn_index,
|
||||
'stage': stage,
|
||||
'queue_item_ids': item_ids,
|
||||
},
|
||||
message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}',
|
||||
@@ -4337,12 +4573,174 @@ class LocalCodingAgent:
|
||||
{
|
||||
'type': 'runtime_guidance_injected',
|
||||
'turn_index': turn_index,
|
||||
'stage': stage,
|
||||
'input_ids': item_ids,
|
||||
'count': len(items),
|
||||
'message': f'已将 {len(items)} 条用户引导注入当前任务',
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _runtime_guidance_item_ids(
|
||||
items: tuple[dict[str, object], ...],
|
||||
) -> list[int]:
|
||||
return [
|
||||
int(item.get('id'))
|
||||
for item in items
|
||||
if isinstance(item.get('id'), int)
|
||||
and not isinstance(item.get('id'), bool)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _runtime_guidance_should_interrupt_tool(
|
||||
*,
|
||||
tool_call: ToolCall,
|
||||
guidance_items: tuple[dict[str, object], ...],
|
||||
) -> bool:
|
||||
combined = '\n'.join(
|
||||
str(item.get('content') or '').strip().lower()
|
||||
for item in guidance_items
|
||||
)
|
||||
if not combined:
|
||||
return False
|
||||
defer_markers = (
|
||||
'完成后',
|
||||
'结束后',
|
||||
'最后',
|
||||
'顺便',
|
||||
'同时整理',
|
||||
'补充输出',
|
||||
'finish',
|
||||
'after',
|
||||
'then',
|
||||
'also',
|
||||
)
|
||||
interrupt_markers = (
|
||||
'停',
|
||||
'中断',
|
||||
'取消',
|
||||
'暂停',
|
||||
'别跑',
|
||||
'别继续',
|
||||
'不要继续',
|
||||
'先别',
|
||||
'错了',
|
||||
'不对',
|
||||
'改成',
|
||||
'换成',
|
||||
'重新',
|
||||
'重来',
|
||||
'不用',
|
||||
'不是',
|
||||
'别用',
|
||||
'stop',
|
||||
'cancel',
|
||||
'interrupt',
|
||||
'abort',
|
||||
'wrong',
|
||||
'instead',
|
||||
'change to',
|
||||
'switch to',
|
||||
'restart',
|
||||
'redo',
|
||||
)
|
||||
if any(marker in combined for marker in interrupt_markers):
|
||||
return True
|
||||
if any(marker in combined for marker in defer_markers):
|
||||
return False
|
||||
return tool_call.name in {
|
||||
'bash',
|
||||
'python_exec',
|
||||
'jupyter_exec',
|
||||
'remote_bash',
|
||||
'remote_python_exec',
|
||||
'Agent',
|
||||
'delegate_agent',
|
||||
} and len(combined) >= 120
|
||||
|
||||
def _interrupt_running_tool_for_guidance(
|
||||
self,
|
||||
stream_events: list[dict[str, object]],
|
||||
*,
|
||||
turn_index: int,
|
||||
tool_call: ToolCall,
|
||||
guidance_items: tuple[dict[str, object], ...],
|
||||
) -> None:
|
||||
process_registry = self.tool_context.process_registry
|
||||
killed_processes = False
|
||||
if process_registry is not None:
|
||||
try:
|
||||
process_registry.kill_all()
|
||||
killed_processes = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'runtime_guidance_error',
|
||||
'turn_index': turn_index,
|
||||
'stage': 'during_tool',
|
||||
'tool_name': tool_call.name,
|
||||
'tool_call_id': tool_call.id,
|
||||
'error': str(exc)[:500],
|
||||
}
|
||||
)
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'runtime_guidance_interrupt_tool',
|
||||
'turn_index': turn_index,
|
||||
'tool_name': tool_call.name,
|
||||
'tool_call_id': tool_call.id,
|
||||
'input_ids': self._runtime_guidance_item_ids(guidance_items),
|
||||
'killed_processes': killed_processes,
|
||||
'message': '用户引导要求调整当前任务,已中断当前工具并准备重规划',
|
||||
}
|
||||
)
|
||||
|
||||
def _append_skipped_tool_results_for_guidance(
|
||||
self,
|
||||
session: AgentSessionState,
|
||||
stream_events: list[dict[str, object]],
|
||||
*,
|
||||
turn: AssistantTurn,
|
||||
turn_index: int,
|
||||
guidance_items: tuple[dict[str, object], ...],
|
||||
) -> None:
|
||||
input_ids = [
|
||||
int(item.get('id'))
|
||||
for item in guidance_items
|
||||
if isinstance(item.get('id'), int)
|
||||
and not isinstance(item.get('id'), bool)
|
||||
]
|
||||
for tool_call in turn.tool_calls:
|
||||
tool_result = ToolExecutionResult(
|
||||
name=tool_call.name,
|
||||
ok=False,
|
||||
content=(
|
||||
'工具尚未执行时收到了用户运行中引导。运行时跳过了这次旧工具调用,'
|
||||
'请结合后续 runtime guidance 重新规划。'
|
||||
),
|
||||
metadata={
|
||||
'action': 'runtime_guidance_replan',
|
||||
'runtime_guidance_skipped': True,
|
||||
'runtime_guidance_input_ids': input_ids,
|
||||
},
|
||||
)
|
||||
session.append_tool(
|
||||
tool_call.name,
|
||||
tool_call.id,
|
||||
serialize_tool_result(tool_result),
|
||||
display=False,
|
||||
)
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'runtime_guidance_replan_before_tools',
|
||||
'turn_index': turn_index,
|
||||
'input_ids': input_ids,
|
||||
'tool_call_count': len(turn.tool_calls),
|
||||
'tool_names': [tool_call.name for tool_call in turn.tool_calls],
|
||||
'message': '用户引导在工具执行前到达,已跳过旧工具计划并要求模型重规划',
|
||||
}
|
||||
)
|
||||
|
||||
def render_system_prompt(self) -> str:
|
||||
prompt_context = self.build_prompt_context()
|
||||
parts = self.build_system_prompt_parts(prompt_context)
|
||||
|
||||
Reference in New Issue
Block a user