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' return '等待用户 review'
if event_type == 'continuation_request': if event_type == 'continuation_request':
return '继续补全回复' 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 { if event_type in {
'prompt_length_check', 'prompt_length_check',
'prompt_length_recovery', 'prompt_length_recovery',
@@ -3449,6 +3459,11 @@ _RUN_EVENT_TYPES = {
'final_text_end', 'final_text_end',
'user_review_required', 'user_review_required',
'continuation_request', '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_check',
'prompt_length_recovery', 'prompt_length_recovery',
'auto_compact_summary', 'auto_compact_summary',
@@ -3478,12 +3493,19 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
'reason', 'reason',
'continuation_index', 'continuation_index',
'turn_index', 'turn_index',
'stage',
'input_ids',
'count',
'tool_call_count',
'tool_names',
'message',
'strategy', 'strategy',
'projected_input_tokens', 'projected_input_tokens',
'soft_input_limit_tokens', 'soft_input_limit_tokens',
'hard_input_limit_tokens', 'hard_input_limit_tokens',
'exceeds_hard_limit', 'exceeds_hard_limit',
'exceeds_soft_limit', 'exceeds_soft_limit',
'killed_processes',
} }
normalized = { normalized = {
key: _json_safe_limited(value, parent_key=key) key: _json_safe_limited(value, parent_key=key)
@@ -28,7 +28,7 @@
-> UI 展示 pending chip -> UI 展示 pending chip
-> 用户可编辑、删除、引导 -> 用户可编辑、删除、引导
-> 引导: kind=guidance,绑定当前 run_id -> 引导: kind=guidance,绑定当前 run_id
-> Agent loop 下一轮开始前消费 guidance -> Agent loop 在安全插入点消费 guidance
-> guidance 以 display=false 的 user message 注入 model_messages -> guidance 以 display=false 的 user message 注入 model_messages
``` ```
@@ -83,10 +83,10 @@ DELETE /api/sessions/{session_id}/input-queue/{item_id}
### Runtime 注入 ### Runtime 注入
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在每轮模型调用前消费 pending guidance `LocalCodingAgent` 提供 `runtime_guidance_provider`,在 Agent loop 的安全边界消费 pending guidance
```text ```text
agent loop boundary agent loop safe boundary
-> consume_session_guidance(session_id, run_id) -> consume_session_guidance(session_id, run_id)
-> append hidden user message: -> append hidden user message:
<runtime-guidance> <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,分别运行任务,输入不会串台。 1. 同一个账号同时打开两个 session,分别运行任务,输入不会串台。
2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。 2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。
3. A session 运行中输入后刷新,pending chip 仍存在。 3. A session 运行中输入后刷新,pending chip 仍存在。
4. 点击“引导”后,下一次模型调用前能消费 guidance,并在活动区记录注入事件。 4. 点击“引导”后,最近的安全插入点能消费 guidance,并在活动区记录注入事件。
5. 点击停止后,对应 run 的进程和 pending queue 都被取消 5. 如果 guidance 在工具执行前到达,旧工具计划不执行,并产生 `runtime_guidance_replan_before_tools` 活动事件
6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages` 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>, body: ReadableStream<Uint8Array>,
writer?: UIMessageStreamWriter<UIMessage>, writer?: UIMessageStreamWriter<UIMessage>,
streamState?: StreamState, 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( 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) { export async function GET(request: Request) {
const account = await getCurrentAccount(); 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 incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? ""; 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) { export async function GET(request: Request) {
const account = await getCurrentAccount(); 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 incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? ""; const sessionId = incoming.searchParams.get("session_id") ?? "";
@@ -1170,6 +1170,18 @@ function summarizeLiveRunEvents(
if (event.type === "final_text_start") { if (event.type === "final_text_start") {
lines.push("正在整理回复"); 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) { if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage); lines.push(runStatus.current_stage);
@@ -1065,6 +1065,18 @@ function summarizeRunEvents(runStatus: ClawActiveRunStatus) {
if (event.type === "user_review_required") { if (event.type === "user_review_required") {
lines.push("等待用户 review"); 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) { if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage); lines.push(runStatus.current_stage);
@@ -28,7 +28,7 @@
-> UI 展示 pending chip -> UI 展示 pending chip
-> 用户可编辑、删除、引导 -> 用户可编辑、删除、引导
-> 引导: kind=guidance,绑定当前 run_id -> 引导: kind=guidance,绑定当前 run_id
-> Agent loop 下一轮开始前消费 guidance -> Agent loop 在安全插入点消费 guidance
-> guidance 以 display=false 的 user message 注入 model_messages -> guidance 以 display=false 的 user message 注入 model_messages
``` ```
@@ -83,10 +83,10 @@ DELETE /api/sessions/{session_id}/input-queue/{item_id}
### Runtime 注入 ### Runtime 注入
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在每轮模型调用前消费 pending guidance `LocalCodingAgent` 提供 `runtime_guidance_provider`,在 Agent loop 的安全边界消费 pending guidance
```text ```text
agent loop boundary agent loop safe boundary
-> consume_session_guidance(session_id, run_id) -> consume_session_guidance(session_id, run_id)
-> append hidden user message: -> append hidden user message:
<runtime-guidance> <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,分别运行任务,输入不会串台。 1. 同一个账号同时打开两个 session,分别运行任务,输入不会串台。
2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。 2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。
3. A session 运行中输入后刷新,pending chip 仍存在。 3. A session 运行中输入后刷新,pending chip 仍存在。
4. 点击“引导”后,下一次模型调用前能消费 guidance,并在活动区记录注入事件。 4. 点击“引导”后,最近的安全插入点能消费 guidance,并在活动区记录注入事件。
5. 点击停止后,对应 run 的进程和 pending queue 都被取消 5. 如果 guidance 在工具执行前到达,旧工具计划不执行,并产生 `runtime_guidance_replan_before_tools` 活动事件
6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages` 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 import json
from pathlib import Path from pathlib import Path
import threading import threading
import time
from typing import Any, Callable from typing import Any, Callable
from uuid import uuid4 from uuid import uuid4
@@ -269,6 +270,45 @@ def _all_skill_tool_call_ids(transcript: tuple[dict[str, object], ...]) -> list[
return results 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 @dataclass
class LocalCodingAgent: class LocalCodingAgent:
model_config: ModelConfig model_config: ModelConfig
@@ -739,6 +779,7 @@ class LocalCodingAgent:
session, session,
stream_events, stream_events,
turn_index=turn_index, turn_index=turn_index,
stage='before_model',
) )
self._microcompact_session_if_needed( self._microcompact_session_if_needed(
session, session,
@@ -1036,6 +1077,32 @@ class LocalCodingAgent:
self.last_run_result = result self.last_run_result = result
return 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: if not turn.tool_calls:
assistant_response_segments.append(turn.content) assistant_response_segments.append(turn.content)
if self._is_empty_truncated_response(turn): if self._is_empty_truncated_response(turn):
@@ -1092,6 +1159,16 @@ class LocalCodingAgent:
) )
last_content = ''.join(assistant_response_segments) last_content = ''.join(assistant_response_segments)
continue 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) final_output = ''.join(assistant_response_segments)
if self._reached_auto_continuation_limit( if self._reached_auto_continuation_limit(
turn, turn,
@@ -1130,6 +1207,7 @@ class LocalCodingAgent:
self.last_run_result = result self.last_run_result = result
return result return result
replan_after_tool_guidance = False
for tool_call in turn.tool_calls: for tool_call in turn.tool_calls:
assistant_response_segments.clear() assistant_response_segments.clear()
continuation_count = 0 continuation_count = 0
@@ -1176,6 +1254,8 @@ class LocalCodingAgent:
self.last_run_result = result self.last_run_result = result
return result return result
tool_result = None tool_result = None
tool_guidance_items: tuple[dict[str, object], ...] = ()
tool_guidance_interrupted = False
tool_message_index = session.start_tool( tool_message_index = session.start_tool(
name=tool_call.name, name=tool_call.name,
tool_call_id=tool_call.id, tool_call_id=tool_call.id,
@@ -1279,11 +1359,19 @@ class LocalCodingAgent:
duplicate_skill_counts=duplicate_skill_counts, duplicate_skill_counts=duplicate_skill_counts,
) )
elif tool_result is None: 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( for update in execute_tool_streaming(
self.tool_registry, self.tool_registry,
tool_call.name, tool_call.name,
tool_call.arguments, tool_call.arguments,
self.tool_context, tool_context,
): ):
if update.kind == 'delta': if update.kind == 'delta':
session.append_tool_delta( session.append_tool_delta(
@@ -1301,8 +1389,81 @@ class LocalCodingAgent:
'delta': update.content, '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 continue
tool_result = update.result 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: if tool_result is None:
raise RuntimeError(f'Tool executor returned no final result for {tool_call.name}') raise RuntimeError(f'Tool executor returned no final result for {tool_call.name}')
if ( if (
@@ -1474,6 +1635,23 @@ 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_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: if tool_result.metadata.get('stop_agent') is True:
stop_reason = str( stop_reason = str(
tool_result.metadata.get('stop_reason') or 'tool_requested_stop' tool_result.metadata.get('stop_reason') or 'tool_requested_stop'
@@ -1561,6 +1739,9 @@ class LocalCodingAgent:
self.last_run_result = result self.last_run_result = result
return result return result
if replan_after_tool_guidance:
continue
final_output = self._build_max_turns_output( final_output = self._build_max_turns_output(
max_turns=self.runtime_config.max_turns, max_turns=self.runtime_config.max_turns,
last_content=last_content, last_content=last_content,
@@ -4286,10 +4467,34 @@ class LocalCodingAgent:
stream_events: list[dict[str, object]], stream_events: list[dict[str, object]],
*, *,
turn_index: int, 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 provider = self.runtime_guidance_provider
if provider is None: if provider is None:
return return ()
try: try:
items = tuple( items = tuple(
item item
@@ -4302,12 +4507,22 @@ class LocalCodingAgent:
{ {
'type': 'runtime_guidance_error', 'type': 'runtime_guidance_error',
'turn_index': turn_index, 'turn_index': turn_index,
'stage': stage,
'error': str(exc)[:500], 'error': str(exc)[:500],
} }
) )
return return ()
if not items: return items
return
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 = [ lines = [
'<runtime-guidance>', '<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] = [] item_ids: list[int] = []
for index, item in enumerate(items, start=1): for index, item in enumerate(items, start=1):
item_id = item.get('id') item_id = item.get('id')
@@ -4328,6 +4563,7 @@ class LocalCodingAgent:
metadata={ metadata={
'kind': 'runtime_guidance', 'kind': 'runtime_guidance',
'turn_index': turn_index, 'turn_index': turn_index,
'stage': stage,
'queue_item_ids': item_ids, 'queue_item_ids': item_ids,
}, },
message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}', message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}',
@@ -4337,12 +4573,174 @@ class LocalCodingAgent:
{ {
'type': 'runtime_guidance_injected', 'type': 'runtime_guidance_injected',
'turn_index': turn_index, 'turn_index': turn_index,
'stage': stage,
'input_ids': item_ids, 'input_ids': item_ids,
'count': len(items), 'count': len(items),
'message': f'已将 {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: def render_system_prompt(self) -> str:
prompt_context = self.build_prompt_context() prompt_context = self.build_prompt_context()
parts = self.build_system_prompt_parts(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 pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from src.agent_tool_core import ToolStreamUpdate
from src.agent_session import AgentMessage from src.agent_session import AgentMessage
from src.agent_runtime import LocalCodingAgent, MAX_AUTO_CONTINUATIONS from src.agent_runtime import LocalCodingAgent, MAX_AUTO_CONTINUATIONS
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
@@ -16,6 +17,7 @@ from src.agent_types import (
BudgetConfig, BudgetConfig,
ModelConfig, ModelConfig,
OutputSchemaConfig, OutputSchemaConfig,
ToolExecutionResult,
UsageStats, UsageStats,
) )
from src.compact import CompactionResult 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.final_output, 'The file contains hello world.')
self.assertEqual(result.tool_calls, 1) 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) 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: def test_agent_persists_max_turns_explanation_after_tool_result(self) -> None:
responses = [ responses = [
{ {
@@ -331,6 +648,72 @@ class AgentRuntimeTests(unittest.TestCase):
self.assertEqual(result.transcript[-1]['stop_reason'], 'max_turns') self.assertEqual(result.transcript[-1]['stop_reason'], 'max_turns')
self.assertIn('最大步骤上限', result.transcript[-1]['content']) 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: def test_agent_stops_after_data_agent_goal_requires_review(self) -> None:
responses = [ responses = [
{ {