Support runtime guidance tool interrupts
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user