Support runtime guidance tool interrupts

This commit is contained in:
wuyang6
2026-06-23 15:20:59 +08:00
parent 712bccf814
commit 45792c8fd5
10 changed files with 955 additions and 24 deletions
+22
View File
@@ -3426,6 +3426,16 @@ def _runtime_event_stage(event: dict[str, object]) -> str:
return '等待用户 review'
if event_type == 'continuation_request':
return '继续补全回复'
if event_type == 'runtime_guidance_injected':
return '已吸收运行中引导'
if event_type == 'runtime_guidance_replan_before_tools':
return '引导已触发工具前重规划'
if event_type == 'runtime_guidance_interrupt_tool':
return '引导已中断当前工具'
if event_type == 'runtime_guidance_deferred_after_tool':
return '引导将在工具后重规划'
if event_type == 'runtime_guidance_error':
return '运行中引导处理失败'
if event_type in {
'prompt_length_check',
'prompt_length_recovery',
@@ -3449,6 +3459,11 @@ _RUN_EVENT_TYPES = {
'final_text_end',
'user_review_required',
'continuation_request',
'runtime_guidance_injected',
'runtime_guidance_replan_before_tools',
'runtime_guidance_interrupt_tool',
'runtime_guidance_deferred_after_tool',
'runtime_guidance_error',
'prompt_length_check',
'prompt_length_recovery',
'auto_compact_summary',
@@ -3478,12 +3493,19 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
'reason',
'continuation_index',
'turn_index',
'stage',
'input_ids',
'count',
'tool_call_count',
'tool_names',
'message',
'strategy',
'projected_input_tokens',
'soft_input_limit_tokens',
'hard_input_limit_tokens',
'exceeds_hard_limit',
'exceeds_soft_limit',
'killed_processes',
}
normalized = {
key: _json_safe_limited(value, parent_key=key)
@@ -28,7 +28,7 @@
-> UI 展示 pending chip
-> 用户可编辑、删除、引导
-> 引导: kind=guidance,绑定当前 run_id
-> Agent loop 下一轮开始前消费 guidance
-> Agent loop 在安全插入点消费 guidance
-> guidance 以 display=false 的 user message 注入 model_messages
```
@@ -83,10 +83,10 @@ DELETE /api/sessions/{session_id}/input-queue/{item_id}
### Runtime 注入
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在每轮模型调用前消费 pending guidance
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在 Agent loop 的安全边界消费 pending guidance
```text
agent loop boundary
agent loop safe boundary
-> consume_session_guidance(session_id, run_id)
-> append hidden user message:
<runtime-guidance>
@@ -96,7 +96,35 @@ agent loop boundary
-> 继续模型调用
```
这个注入点必须在 tool_result 已经写回之后、下一次模型调用之前,不能插在 tool_use 和 tool_result 中间。
可用插入点:
| 插入点 | 时机 | 处理策略 |
|--------|------|----------|
| `before_model` | 每次模型调用前 | 常规消费,适合上一轮工具完成后的补充 |
| `before_tools` | 模型已经给出工具计划,但工具还没开始执行 | 运行时先为上一批未执行工具补 synthetic `tool_result`,标记为 `runtime_guidance_replan`,再注入 guidance,让模型重新判断是否继续原计划、调整参数或换计划 |
| `during_tool_interrupted` | 工具已经开始执行,且用户引导明显要求停止、改目标、换参数、纠错 | 运行时给当前工具传入单工具 interrupt event,并通过 process registry 终止当前工具进程;当前工具结果落盘后注入 guidance,让模型重规划 |
| `after_tool` | 工具执行期间或刚完成后收到补充型 guidance,或工具没有中间输出无法及时中断 | 保留当前工具结果,停止继续执行同批旧工具计划,注入 guidance 让模型判断继续、补充或重跑 |
| `before_finish` | 模型准备给最终回复前 | 注入 guidance,让模型判断是修正最终输出、补充信息,还是转为后续任务 |
约束:
- 不把 guidance 插在 `tool_use``tool_result` 中间。
- `before_tools` 不直接删除 assistant 的工具计划,而是补一组“未执行、被 runtime 跳过”的 tool result,保证 Anthropic/Bedrock 的消息顺序合法。
- `during_tool_interrupted` 不复用整轮 run cancel event,而是构造“整轮取消 OR 当前工具中断”的组合 cancel event 传给当前工具,避免把用户引导误判成整轮取消。
- 立即中断依赖工具合作:bash / python / Jupyter / 远端执行等接入 cancel_event 或 process registry 的工具可以被终止;纯同步且没有中间输出的工具只能在返回后进入 `after_tool` 重规划。
### 引导策略判断
前端不暴露复杂按钮,用户仍然只点击“引导”。系统内部按安全点自动处理:
| 用户引导类型 | 默认策略 |
|--------------|----------|
| 工具未开始前的纠偏、改目标、改参数 | `before_tools` 重规划 |
| 工具完成后的补充要求 | `before_model` 注入下一次模型调用 |
| 即将结束前的格式、总结、补充输出要求 | `before_finish` 注入并继续一轮 |
| 已经运行中的长工具纠偏 | 明显停止/改目标/纠错类引导触发 `during_tool_interrupted`;补充输出类引导进入 `after_tool` |
模型负责在收到 `<runtime-guidance>` 后判断如何吸收:继续原计划、调整计划、说明冲突或转为后续任务;运行时只负责选择合法插入点和维护消息协议。
## 前端交互
@@ -120,6 +148,7 @@ agent loop boundary
1. 同一个账号同时打开两个 session,分别运行任务,输入不会串台。
2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。
3. A session 运行中输入后刷新,pending chip 仍存在。
4. 点击“引导”后,下一次模型调用前能消费 guidance,并在活动区记录注入事件。
5. 点击停止后,对应 run 的进程和 pending queue 都被取消
6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`
4. 点击“引导”后,最近的安全插入点能消费 guidance,并在活动区记录注入事件。
5. 如果 guidance 在工具执行前到达,旧工具计划不执行,并产生 `runtime_guidance_replan_before_tools` 活动事件
6. 点击停止后,对应 run 的进程和 pending queue 都被取消
7. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`
+45 -1
View File
@@ -441,7 +441,7 @@ async function startClawBackendRun(
}
}
async function consumeClawStream(
async function _consumeClawStream(
body: ReadableStream<Uint8Array>,
writer?: UIMessageStreamWriter<UIMessage>,
streamState?: StreamState,
@@ -622,6 +622,50 @@ function writeRuntimeEvent(
: "工具调用完成,等待最终结果汇总。",
});
}
if (event.type === "runtime_guidance_injected") {
writeReasoningDelta(
writer,
{
type: "reasoning-delta",
id: "reasoning-1",
delta: "\n已吸收运行中引导。",
},
streamState,
);
}
if (event.type === "runtime_guidance_replan_before_tools") {
writeReasoningDelta(
writer,
{
type: "reasoning-delta",
id: "reasoning-1",
delta: "\n引导已触发工具前重规划。",
},
streamState,
);
}
if (event.type === "runtime_guidance_interrupt_tool") {
writeReasoningDelta(
writer,
{
type: "reasoning-delta",
id: "reasoning-1",
delta: "\n引导已中断当前工具,准备重规划。",
},
streamState,
);
}
if (event.type === "runtime_guidance_deferred_after_tool") {
writeReasoningDelta(
writer,
{
type: "reasoning-delta",
id: "reasoning-1",
delta: "\n引导将在当前工具完成后重规划。",
},
streamState,
);
}
}
function writeReasoningDelta(
@@ -4,7 +4,8 @@ const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
if (!account)
return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
@@ -4,7 +4,8 @@ const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
if (!account)
return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
@@ -1170,6 +1170,18 @@ function summarizeLiveRunEvents(
if (event.type === "final_text_start") {
lines.push("正在整理回复");
}
if (event.type === "runtime_guidance_injected") {
lines.push("已吸收运行中引导");
}
if (event.type === "runtime_guidance_replan_before_tools") {
lines.push("引导已触发工具前重规划");
}
if (event.type === "runtime_guidance_interrupt_tool") {
lines.push("引导已中断当前工具");
}
if (event.type === "runtime_guidance_deferred_after_tool") {
lines.push("引导将在工具后重规划");
}
}
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage);
@@ -1065,6 +1065,18 @@ function summarizeRunEvents(runStatus: ClawActiveRunStatus) {
if (event.type === "user_review_required") {
lines.push("等待用户 review");
}
if (event.type === "runtime_guidance_injected") {
lines.push("已吸收运行中引导");
}
if (event.type === "runtime_guidance_replan_before_tools") {
lines.push("引导已触发工具前重规划");
}
if (event.type === "runtime_guidance_interrupt_tool") {
lines.push("引导已中断当前工具");
}
if (event.type === "runtime_guidance_deferred_after_tool") {
lines.push("引导将在工具后重规划");
}
}
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage);
@@ -28,7 +28,7 @@
-> UI 展示 pending chip
-> 用户可编辑、删除、引导
-> 引导: kind=guidance,绑定当前 run_id
-> Agent loop 下一轮开始前消费 guidance
-> Agent loop 在安全插入点消费 guidance
-> guidance 以 display=false 的 user message 注入 model_messages
```
@@ -83,10 +83,10 @@ DELETE /api/sessions/{session_id}/input-queue/{item_id}
### Runtime 注入
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在每轮模型调用前消费 pending guidance
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在 Agent loop 的安全边界消费 pending guidance
```text
agent loop boundary
agent loop safe boundary
-> consume_session_guidance(session_id, run_id)
-> append hidden user message:
<runtime-guidance>
@@ -96,7 +96,35 @@ agent loop boundary
-> 继续模型调用
```
这个注入点必须在 tool_result 已经写回之后、下一次模型调用之前,不能插在 tool_use 和 tool_result 中间。
可用插入点:
| 插入点 | 时机 | 处理策略 |
|--------|------|----------|
| `before_model` | 每次模型调用前 | 常规消费,适合上一轮工具完成后的补充 |
| `before_tools` | 模型已经给出工具计划,但工具还没开始执行 | 运行时先为上一批未执行工具补 synthetic `tool_result`,标记为 `runtime_guidance_replan`,再注入 guidance,让模型重新判断是否继续原计划、调整参数或换计划 |
| `during_tool_interrupted` | 工具已经开始执行,且用户引导明显要求停止、改目标、换参数、纠错 | 运行时给当前工具传入单工具 interrupt event,并通过 process registry 终止当前工具进程;当前工具结果落盘后注入 guidance,让模型重规划 |
| `after_tool` | 工具执行期间或刚完成后收到补充型 guidance,或工具没有中间输出无法及时中断 | 保留当前工具结果,停止继续执行同批旧工具计划,注入 guidance 让模型判断继续、补充或重跑 |
| `before_finish` | 模型准备给最终回复前 | 注入 guidance,让模型判断是修正最终输出、补充信息,还是转为后续任务 |
约束:
- 不把 guidance 插在 `tool_use``tool_result` 中间。
- `before_tools` 不直接删除 assistant 的工具计划,而是补一组“未执行、被 runtime 跳过”的 tool result,保证 Anthropic/Bedrock 的消息顺序合法。
- `during_tool_interrupted` 不复用整轮 run cancel event,而是构造“整轮取消 OR 当前工具中断”的组合 cancel event 传给当前工具,避免把用户引导误判成整轮取消。
- 立即中断依赖工具合作:bash / python / Jupyter / 远端执行等接入 cancel_event 或 process registry 的工具可以被终止;纯同步且没有中间输出的工具只能在返回后进入 `after_tool` 重规划。
### 引导策略判断
前端不暴露复杂按钮,用户仍然只点击“引导”。系统内部按安全点自动处理:
| 用户引导类型 | 默认策略 |
|--------------|----------|
| 工具未开始前的纠偏、改目标、改参数 | `before_tools` 重规划 |
| 工具完成后的补充要求 | `before_model` 注入下一次模型调用 |
| 即将结束前的格式、总结、补充输出要求 | `before_finish` 注入并继续一轮 |
| 已经运行中的长工具纠偏 | 明显停止/改目标/纠错类引导触发 `during_tool_interrupted`;补充输出类引导进入 `after_tool` |
模型负责在收到 `<runtime-guidance>` 后判断如何吸收:继续原计划、调整计划、说明冲突或转为后续任务;运行时只负责选择合法插入点和维护消息协议。
## 前端交互
@@ -120,6 +148,7 @@ agent loop boundary
1. 同一个账号同时打开两个 session,分别运行任务,输入不会串台。
2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。
3. A session 运行中输入后刷新,pending chip 仍存在。
4. 点击“引导”后,下一次模型调用前能消费 guidance,并在活动区记录注入事件。
5. 点击停止后,对应 run 的进程和 pending queue 都被取消
6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`
4. 点击“引导”后,最近的安全插入点能消费 guidance,并在活动区记录注入事件。
5. 如果 guidance 在工具执行前到达,旧工具计划不执行,并产生 `runtime_guidance_replan_before_tools` 活动事件
6. 点击停止后,对应 run 的进程和 pending queue 都被取消
7. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`
+404 -6
View File
@@ -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)
+384 -1
View File
@@ -7,6 +7,7 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from src.agent_tool_core import ToolStreamUpdate
from src.agent_session import AgentMessage
from src.agent_runtime import LocalCodingAgent, MAX_AUTO_CONTINUATIONS
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
@@ -16,6 +17,7 @@ from src.agent_types import (
BudgetConfig,
ModelConfig,
OutputSchemaConfig,
ToolExecutionResult,
UsageStats,
)
from src.compact import CompactionResult
@@ -282,9 +284,324 @@ class AgentRuntimeTests(unittest.TestCase):
self.assertEqual(result.final_output, 'The file contains hello world.')
self.assertEqual(result.tool_calls, 1)
self.assertGreaterEqual(len(result.transcript), 5)
transcript_roles = [entry.get('role') for entry in result.transcript]
self.assertEqual(transcript_roles, ['user', 'assistant', 'tool', 'assistant'])
self.assertIn('I will inspect the file first.', result.transcript[1]['content'])
self.assertIn('hello world', result.transcript[2]['content'])
self.assertIn('The file contains hello world.', result.transcript[3]['content'])
self.assertGreaterEqual(len(result.file_history), 0)
def test_runtime_guidance_before_tools_skips_stale_tool_plan(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'I will inspect the old file.',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'read_file',
'arguments': '{"path": "old.txt"}',
},
}
],
},
'finish_reason': 'tool_calls',
}
]
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Replanned with the guidance.',
},
'finish_reason': 'stop',
}
]
},
]
recorded_payloads: list[dict[str, object]] = []
provider_calls = 0
def guidance_provider() -> tuple[dict[str, object], ...]:
nonlocal provider_calls
provider_calls += 1
if provider_calls == 2:
return ({'id': 7, 'content': 'Use new.txt instead.'},)
return ()
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / 'old.txt').write_text('old content\n', encoding='utf-8')
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),
)
agent.runtime_guidance_provider = guidance_provider
result = agent.run('Inspect old.txt')
self.assertEqual(result.final_output, 'Replanned with the guidance.')
self.assertEqual(result.tool_calls, 0)
self.assertFalse(result.file_history)
replan_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_replan_before_tools'
]
self.assertEqual(len(replan_events), 1)
injected_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_injected'
]
self.assertEqual(injected_events[-1].get('stage'), 'before_tools')
self.assertEqual(len(recorded_payloads), 2)
second_messages = recorded_payloads[1]['messages']
assert isinstance(second_messages, list)
roles = [
message.get('role')
for message in second_messages
if isinstance(message, dict)
]
self.assertEqual(roles[-3:], ['assistant', 'tool', 'user'])
self.assertIn('runtime_guidance_replan', str(second_messages[-2]))
self.assertIn('Use new.txt instead.', str(second_messages[-1]))
def test_runtime_guidance_interrupts_running_streaming_tool(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'I will run the long tool.',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'long_tool',
'arguments': '{}',
},
}
],
},
'finish_reason': 'tool_calls',
}
]
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Replanned after the interruption.',
},
'finish_reason': 'stop',
}
]
},
]
recorded_payloads: list[dict[str, object]] = []
provider_calls = 0
cancel_observed_after_guidance: list[bool] = []
def guidance_provider() -> tuple[dict[str, object], ...]:
nonlocal provider_calls
provider_calls += 1
if provider_calls == 3:
return ({'id': 12, 'content': '停一下,改成只生成 3 条。'},)
return ()
def fake_execute_tool_streaming(registry, name, arguments, context): # noqa: ANN001
self.assertEqual(name, 'long_tool')
yield ToolStreamUpdate(kind='delta', content='working\n', stream='stdout')
cancel_observed_after_guidance.append(bool(context.cancel_event.is_set()))
yield ToolStreamUpdate(
kind='result',
result=ToolExecutionResult(
name=name,
ok=False,
content='Tool saw cancellation.',
metadata={'cancel_seen': bool(context.cancel_event.is_set())},
),
)
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,
),
),
patch(
'src.agent_runtime.execute_tool_streaming',
side_effect=fake_execute_tool_streaming,
),
):
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),
)
agent.runtime_guidance_provider = guidance_provider
result = agent.run('Run a long tool')
self.assertEqual(result.final_output, 'Replanned after the interruption.')
self.assertEqual(result.tool_calls, 1)
self.assertEqual(cancel_observed_after_guidance, [True])
interrupt_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_interrupt_tool'
]
self.assertEqual(len(interrupt_events), 1)
injected_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_injected'
]
self.assertEqual(injected_events[-1].get('stage'), 'during_tool_interrupted')
self.assertEqual(len(recorded_payloads), 2)
second_messages = recorded_payloads[1]['messages']
assert isinstance(second_messages, list)
roles = [
message.get('role')
for message in second_messages
if isinstance(message, dict)
]
self.assertEqual(roles[-3:], ['assistant', 'tool', 'user'])
self.assertIn('runtime_guidance_interrupted_tool', str(second_messages[-2]))
self.assertIn('改成只生成 3 条', str(second_messages[-1]))
def test_runtime_guidance_during_tool_can_defer_until_tool_result(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'I will run the long tool.',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'long_tool',
'arguments': '{}',
},
}
],
},
'finish_reason': 'tool_calls',
}
]
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Replanned after the tool result.',
},
'finish_reason': 'stop',
}
]
},
]
recorded_payloads: list[dict[str, object]] = []
provider_calls = 0
cancel_observed_after_guidance: list[bool] = []
def guidance_provider() -> tuple[dict[str, object], ...]:
nonlocal provider_calls
provider_calls += 1
if provider_calls == 3:
return ({'id': 13, 'content': '完成后顺便整理成 Markdown。'},)
return ()
def fake_execute_tool_streaming(registry, name, arguments, context): # noqa: ANN001
self.assertEqual(name, 'long_tool')
yield ToolStreamUpdate(kind='delta', content='working\n', stream='stdout')
cancel_observed_after_guidance.append(bool(context.cancel_event.is_set()))
yield ToolStreamUpdate(
kind='result',
result=ToolExecutionResult(
name=name,
ok=True,
content='Tool completed.',
metadata={'cancel_seen': bool(context.cancel_event.is_set())},
),
)
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,
),
),
patch(
'src.agent_runtime.execute_tool_streaming',
side_effect=fake_execute_tool_streaming,
),
):
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),
)
agent.runtime_guidance_provider = guidance_provider
result = agent.run('Run a long tool')
self.assertEqual(result.final_output, 'Replanned after the tool result.')
self.assertEqual(cancel_observed_after_guidance, [False])
deferred_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_deferred_after_tool'
]
self.assertEqual(len(deferred_events), 1)
interrupt_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_interrupt_tool'
]
self.assertFalse(interrupt_events)
injected_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_injected'
]
self.assertEqual(injected_events[-1].get('stage'), 'after_tool')
second_messages = recorded_payloads[1]['messages']
assert isinstance(second_messages, list)
self.assertIn('runtime_guidance_deferred_after_tool', str(second_messages[-2]))
self.assertIn('完成后顺便整理成 Markdown', str(second_messages[-1]))
def test_agent_persists_max_turns_explanation_after_tool_result(self) -> None:
responses = [
{
@@ -331,6 +648,72 @@ class AgentRuntimeTests(unittest.TestCase):
self.assertEqual(result.transcript[-1]['stop_reason'], 'max_turns')
self.assertIn('最大步骤上限', result.transcript[-1]['content'])
def test_runtime_guidance_before_finish_continues_instead_of_finishing(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Draft answer.',
},
'finish_reason': 'stop',
}
]
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Revised final answer.',
},
'finish_reason': 'stop',
}
]
},
]
recorded_payloads: list[dict[str, object]] = []
provider_calls = 0
def guidance_provider() -> tuple[dict[str, object], ...]:
nonlocal provider_calls
provider_calls += 1
if provider_calls == 2:
return ({'id': 9, 'content': 'Add the timing summary before finalizing.'},)
return ()
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),
)
agent.runtime_guidance_provider = guidance_provider
result = agent.run('Prepare an answer')
self.assertEqual(result.final_output, 'Revised final answer.')
injected_events = [
event
for event in result.events
if event.get('type') == 'runtime_guidance_injected'
]
self.assertEqual(injected_events[-1].get('stage'), 'before_finish')
self.assertEqual(len(recorded_payloads), 2)
second_messages = recorded_payloads[1]['messages']
assert isinstance(second_messages, list)
self.assertEqual(second_messages[-2].get('role'), 'assistant')
self.assertEqual(second_messages[-2].get('content'), 'Draft answer.')
self.assertEqual(second_messages[-1].get('role'), 'user')
self.assertIn('Add the timing summary', str(second_messages[-1].get('content')))
def test_agent_stops_after_data_agent_goal_requires_review(self) -> None:
responses = [
{