zcbot/core/loop.py

890 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。
loop 不直接 print —— 进度通过 sink.emit(event) 上抛。Sink 决定怎么呈现
(本地 console / SSE / 日志)。事件类型见 core/sinks.py 头部说明。
LLM 调用走 `chat_stream`(流式),chunk 之间 poll cancel_check 实现快速中断。
content delta 即时 emit `text` 事件让前端打字机渲染;chunks 攒齐后用
`litellm.stream_chunk_builder` 拼回完整 response 给 tool_calls 解析 + usage 记账。
"""
from __future__ import annotations
import hashlib
import json
import re
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from uuid import UUID
import litellm
from .capabilities import ModelCapabilities
from .context import (
CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO,
calibrated_chars_per_token,
filter_reasoning_for_replay,
clamp_ratio,
prepare_messages_with_stats,
)
from .context_fold import maybe_fold
from .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM
from .llm_transport import (
extract_delta_content,
extract_delta_reasoning,
extract_usage_details,
robust_stream,
)
from .salvage import salvage_tool_arguments
from .session import Session
from .task_actions import DeferredTaskActions
from .storage import (
record_chat_usage,
record_salvaged_tool_call,
)
from . import pptx_guard
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
_PPTX_GUARD_TOOLS = ("shell", "run_python")
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
# 错误签名归一:同一类工具报错在不同参数/路径/数字下抹平,让「反复撞同一堵墙」
# 能被识别成同一签名(如 old_str 每次微调但结果始终 "old_str not found in <path>")。
# 与 core/toolfail._normalize 同源思路,此处保持 loop 自包含(不反向依赖 toolfail)。
_ERR_PATH_RE = re.compile(r"(?:[A-Za-z]:)?(?:[/\\][\w.\-一-鿿*]+){2,}")
_ERR_NUM_RE = re.compile(r"\d+")
_NONZERO_EXIT_RE = re.compile(r"\[exit ([1-9]\d*)\]\s*$")
def _norm_err_line(line: str) -> str:
"""抹平错误行里的路径/数字,得到稳定签名(截断 120"""
line = _ERR_PATH_RE.sub("<path>", line)
line = _ERR_NUM_RE.sub("N", line)
return line.strip()[:120]
def _tool_error_signature(name: str, result: str) -> Optional[str]:
"""返回重复守卫使用的错误签名;非错误返回 None。
通用工具维持既有 ``[Error]`` 契约。额外只识别 ``run_python`` 的
``Traceback ... [exit N]``,使 Python 运行期同错能进入 err-streak普通 shell
的 grep 未命中、质量门 exit 1 不在此扩面,避免把正常检查迭代当成撞墙。
"""
head = result.lstrip()
if head.startswith("[Error"):
return _norm_err_line((head.splitlines() or [""])[0])
if (
name != "run_python"
or "Traceback (most recent call last):" not in result
or _NONZERO_EXIT_RE.search(result) is None
):
return None
lines = [
line.strip()
for line in result.splitlines()[:-1]
if line.strip() and line.strip() not in ("[stdout]", "[stderr]")
]
return _norm_err_line(lines[-1]) if lines else None
class _RepeatGuard:
"""检测「同名同参 + 无产出」的病理性重复调用,断掉死循环。
背景(2026-06-08 DB 实测):高轮数烧 token 的 task 里,单个工具被用**完全相同的
参数**重复调用几十上百次(`document_search` 122 次、空参数 `shell{}` 51 次、反复
`glob` 同一个不存在的路径)。loop 原本对此零防护,照单全收直到撞 max_iterations。
命门是只惩罚「无产出」重复,绝不误伤正常迭代:
- 同参但**每次结果不同**(改了脚本后重跑 run_python、修 bug 后重跑构建)→ 有产出,
计数清零,永不拦。
- 同参且**结果是 `[Error]`、`run_python` traceback 非零退出,或与之前某次
一字不差**(空 `{}` 缺参、反复撞同一个错)→ 无产出,累计。
累计 >= SOFT 注入软提示(模型当轮就看到);>= HARD 直接拦截不执行,逼它换路。
顺带堵掉 `llm_transport.malformed_tool_calls` 的洞:大参数畸形退化成合法空 `{}` 时,executor 每次
返回同一句「缺少必填参数」→ 走 dup 分支被这同一机制拦下,无需单独特判空 `{}`。
第二道判据(2026-07,失败面板 #2:edit `old_str not found` 单 task 反复撞墙):模型每次
**微调参数**(old_str 改一点)重试同一操作,精确 args 指纹每次不同 → 上面的 arg 判据不累计,
只能等 _STALL_LIMIT 慢慢兜。补一条**按工具 + 归一化错误签名的连续 streak**:同一工具连续返回
同一类错误(路径/数字抹平后相同)且跨 >= 2 个不同 args → 认定「换法子撞同一堵墙」,SOFT 注入
定向提示、HARD 拦截一次(拦后重置到 SOFT,非永久封死,给换路后的重试留活口)。任一非错误结果
(真推进了)立即清零该工具的 streak。
状态活在单次 task run 内(AgentLoop 实例持有),不跨 task。
"""
SOFT = 2 # 无产出重复累计 >= SOFT:在结果尾部注入软提示
HARD = 4 # 无产出重复累计 >= HARD:下一次同参调用直接拦截不执行
def __init__(self) -> None:
# key -> {"hashes": set[str], "unproductive": int, "n": int, "blocked": int}
self._h: Dict[str, dict] = {}
# name -> {"esig": str, "count": int, "args": set[str]}:按工具的连续同类错误 streak
self._err_streak: Dict[str, dict] = {}
@staticmethod
def _key(name: str, args: Any) -> str:
try:
canon = json.dumps(args, sort_keys=True, ensure_ascii=False)
except (TypeError, ValueError):
canon = repr(args)
return name + "\x00" + canon
def _state(self, name: str, args: Any) -> dict:
return self._h.setdefault(
self._key(name, args),
{"hashes": set(), "unproductive": 0, "n": 0, "blocked": 0},
)
def should_block(self, name: str, args: Any) -> bool:
"""执行前调用:该指纹已累计 >= HARD 次无产出重复 → 拦截(不执行)。"""
st = self._h.get(self._key(name, args))
return bool(st and st["unproductive"] >= self.HARD)
def register_block(self, name: str, args: Any) -> Tuple[int, int]:
"""记一次拦截,返回 (已执行次数 n, 累计拦截次数 blocked)。"""
st = self._state(name, args)
st["blocked"] += 1
return st["n"], st["blocked"]
def record(self, name: str, args: Any, result: str) -> Tuple[int, bool]:
"""执行后调用:登记结果。返回 (该指纹「无产出重复」计数, 本次是否有净产出)。
净产出 = 非 `[Error]` 且非与历史一字不差的重复结果 —— 供全局「无进展」熔断判定:
一步里只要有一次净产出就算在推进。
"""
st = self._state(name, args)
h = hashlib.sha1(
result.encode("utf-8", "replace"), usedforsecurity=False
).hexdigest()
esig = _tool_error_signature(name, result)
is_err = esig is not None
dup = h in st["hashes"]
if st["n"] >= 1:
if is_err or dup:
st["unproductive"] += 1
else:
# 新的非错误结果 = 有产出 → 清零,正常迭代不会被累积成拦截
st["unproductive"] = 0
st["hashes"].add(h)
st["n"] += 1
# 第二道判据:按工具的连续同类错误 streak(跨不同 args 撞同一堵墙)
if is_err:
s = self._err_streak.get(name)
if s and s["esig"] == esig:
s["count"] += 1
s["args"].add(self._key(name, args))
else:
self._err_streak[name] = {"esig": esig, "count": 1, "args": {self._key(name, args)}}
else:
self._err_streak.pop(name, None) # 真推进了 → 清零该工具 streak
return st["unproductive"], not (is_err or dup)
def err_streak(self, name: str) -> Tuple[int, int, str]:
"""返回 (连续同类错误次数, 涉及的不同 args 数, 错误签名);无则 (0,0,'')。"""
s = self._err_streak.get(name)
if not s:
return 0, 0, ""
return s["count"], len(s["args"]), s["esig"]
def should_block_err(self, name: str) -> bool:
"""执行前:该工具已连续 >= HARD 次同类错误且跨 >= 2 个不同 args → 拦截一次。"""
cnt, n_args, _ = self.err_streak(name)
return cnt >= self.HARD and n_args >= 2
def register_err_block(self, name: str) -> Tuple[int, str]:
"""记一次 err-streak 拦截,返回 (拦截时的连续次数, 错误签名);拦后重置到 SOFT
(保留 esig/args)—— 非永久封死,让模型换路后的下一次重试还能进来。"""
s = self._err_streak.get(name)
if not s:
return 0, ""
cnt, esig = s["count"], s["esig"]
s["count"] = self.SOFT
return cnt, esig
class AgentLoop:
def __init__(
self,
llm: LLM,
executor: Executor,
session: Session,
capabilities: ModelCapabilities,
user_id: UUID,
working_dir: Path,
sink: Optional[Any] = None,
max_iterations: Optional[int] = None,
cancel_check: Optional[Callable[[], bool]] = None,
skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None,
deferred_actions: Optional[DeferredTaskActions] = None,
) -> None:
self.llm = llm
self.executor = executor
self.session = session
self.caps = capabilities
self.user_id = user_id # usage_events 写入时按 user 维度聚合
# ExecCtx 字段:user_id / task_id 已在,working_dir 单独传 —— 供 docker backend
# (Step 3)拼 `--workdir /workspace/<wd_name>` 与临时文件命名空间使用。
self.working_dir = working_dir
self.max_iterations = max_iterations or capabilities.max_iterations
self.sink = sink
# 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(task_id)`;
# CLI 路径不设(None → 永不 cancel)。check 点在 ① 每轮 LLM 前 ② stream chunk 间
# ③ tool_calls 之间。chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」
# (几十秒)降到「单 chunk 间隔」(~100ms)。
self.cancel_check = cancel_check
# skill 定向模型(agent_builder 注入):load_skill 成功后以 (skill_name, 当前 profile)
# 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。
# None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。
self.skill_model_switch = skill_model_switch
# rename_working_dir 等不能在 executor 仍握旧 cwd 时落地的动作,由 web worker
# 在 agent.run 正常结束后消费。CLI/旧测试不传时保留空容器。
self.deferred_actions = deferred_actions or DeferredTaskActions()
# 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
self._stall = 0
# Structured deliverables accumulated across tool steps in the current user turn.
# They are persisted on the final assistant message, not mixed into provider payloads.
self._pending_artifact_refs: list[dict] = []
def _emit(self, event: dict) -> None:
if self.sink is not None:
self.sink.emit(event)
def _is_cancelled(self) -> bool:
return bool(self.cancel_check and self.cancel_check())
def _fill_cancelled_tool_results(self, remaining: list) -> None:
"""给未执行的 tool_call 补 cancelled tool result,保 LiteLLM 协议完整。
每个 assistant tool_call 必须有对应的 tool message,否则 resume 时 LLM 报错。
"""
for tc in remaining:
self.session.append({
"role": "tool",
"tool_call_id": tc.id,
"content": _CANCELLED_TOOL_PLACEHOLDER,
})
def run(self, user_message: str) -> str:
"""运行一个尚未落库的用户轮次CLI、渠道与调度任务的兼容入口"""
return self._run(user_message)
def run_persisted_turn(self) -> str:
"""运行已由调用方原子持久化的用户轮次Web 生命周期入口)。"""
if not self.session.messages or self.session.messages[-1].get("role") != "user":
raise RuntimeError("persisted turn requires the latest message to be user")
return self._run(None)
def _run(self, user_message: Optional[str]) -> str:
self._pending_artifact_refs = []
self._maybe_fold_context()
if user_message is not None:
self.session.append({"role": "user", "content": user_message})
for _ in range(self.max_iterations):
if self._is_cancelled():
self._emit({"type": "cancelled"})
return "[cancelled]"
start = time.monotonic()
response, cancelled_mid_stream = self._stream_llm()
elapsed = time.monotonic() - start
if cancelled_mid_stream:
# 流中途收到 cancel:已接收的 chunk 丢弃,不入库不记账(部分 assistant
# 内容也不持久化,下次 resume 上下文干净)。response 可能是 None。
self._emit({"type": "cancelled"})
return "[cancelled]"
assert response is not None
msg = response.choices[0].message
tool_calls = getattr(msg, "tool_calls", None) or []
asst_msg_id = self.session.append(
msg,
artifact_refs=(list(self._pending_artifact_refs) if not tool_calls else None),
)
usage_details = extract_usage_details(getattr(response, "usage", None))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
# 用本轮实报 prompt_tokens 刷新 chars/token 校准比值(下一轮门槛/占用环即用)。
if pt > 0 and self._last_sent_chars > 0:
self._ctx_chars_per_token = clamp_ratio(self._last_sent_chars / pt)
# 记账(0006):一行 usage_event + 回填 messages.tokens_in/out + model_profile。
# 任何失败都吞掉(litellm cost map miss / DB 异常),不阻塞主 loop;
# message 仍在 session/DB 里,后续重启不影响。
model_profile = f"{self.caps.family}.{self.caps.variant}"
try:
record_chat_usage(
task_id=self.session.task_id,
user_id=self.user_id,
message_id=asst_msg_id,
model_profile=model_profile,
prompt_tokens=pt,
completion_tokens=ct,
input_cny_per_mtoken=self.caps.input_cny_per_mtoken,
output_cny_per_mtoken=self.caps.output_cny_per_mtoken,
cache_hit_tokens=usage_details["cache_hit_tokens"],
cache_hit_cny_per_mtoken=self.caps.cache_hit_cny_per_mtoken,
extra_units={
k: v for k, v in usage_details.items()
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
)
except Exception as e:
self._emit({"type": "warn", "msg": f"record_usage failed: {type(e).__name__}: {e}"})
self._emit({
"type": "llm_end",
"prompt_tokens": pt,
"completion_tokens": ct,
"cache_hit_tokens": usage_details["cache_hit_tokens"],
"cache_miss_tokens": usage_details["cache_miss_tokens"],
"elapsed": elapsed,
})
# content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。
if not tool_calls:
content = getattr(msg, "content", None) or ""
# 空-空且重试后仍空(_stream_llm 已耗尽非流式重试):不能当正常收尾静默 done,
# 否则表现为「无报错自己停」(task 2a1bc25d 案)。发可见 warn + 留「继续」入口,
# 复用 stall 熔断那套自停话术(warn + done,run_status 落 idle,用户可续)。
if not content.strip():
self._emit({
"type": "warn",
"msg": "模型返回空响应(已自动重试仍为空),已停止,回复「继续」可重试。",
})
self._emit({"type": "done"})
return "[stopped: empty response]"
self._emit({"type": "done"})
return content
step_productive = False
for i, tc in enumerate(tool_calls):
if self._is_cancelled():
self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "cancelled"})
return "[cancelled]"
result, productive, artifacts = self._execute_tool_call(tc)
self._remember_artifacts(artifacts)
step_productive = step_productive or productive
self.session.append(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.function.name,
"content": result,
}
)
# ask_user:本步调用了人工选择工具 → 提前结束本轮,等用户点选项 / 文字讨论,
# 不回灌 LLM。选项已随该 tool_call 的 arguments 流给前端渲染成选项卡;tool 结果
# 只是占位,下轮用户回复(点选项 = 发选项 label 文本)后模型自然接上。
if any(getattr(tc.function, "name", "") == "ask_user" for tc in tool_calls):
self._emit({"type": "done"})
return getattr(msg, "content", None) or ""
# 全局「无进展」熔断:整步所有 tool 都无净产出(全是 [Error]/重复/被拦)→ 累计;
# 连续 _STALL_LIMIT 步空转就主动停,别烧到 max_iterations。一旦某步有净产出立即清零。
if step_productive:
self._stall = 0
else:
self._stall += 1
if self._stall >= self._STALL_LIMIT:
self._emit({
"type": "warn",
"msg": (
f"连续 {self._stall} 步无净产出(全是报错/重复/被拦),已自动停止以免空烧。"
"换思路或补充信息后回复「继续」可重试。"
),
})
self._emit({"type": "done"})
return "[stopped: no progress]"
# 跑满 backstop:不是出错,是单轮自主步数到顶。明确提示可续跑,别静默停。
self._emit({
"type": "warn",
"msg": (
f"已达单轮步数上限({self.max_iterations} 步),任务可能尚未完成。"
"回复「继续」可接着跑。"
),
})
self._emit({"type": "done"})
return "[reached max iterations]"
# 工具参数畸形时的总尝试次数(首次流式 + 之后全部非流式)。流式只试一次:实测
# (2026-07,task 716ed3be,deepseek-v4-pro)3~4k 字符中文长文 write 的流式重 roll
# 同轮连挂 3 次——同轮失败强相关而非独立随机,多试流式纯烧 token 拖时间(每次都是
# 一整段 2k+ token 生成),首败即降级非流式(provider 服务端拼 tool_calls,绕开
# 流式 delta 错位),历史数据里非流式兜底从未再畸形。
_MAX_MALFORMED_ATTEMPTS = 3
# 连续多少步「整步无净产出」(全是 [Error]/重复结果/被拦)就判定空转、主动停。
# 比 max_iterations 早得多掐死死循环(第 8 步 vs 第 120 步),同时放正经长任务自由跑。
# 保守取 8:几乎不误伤"连踩几个错再纠正"的正常波动,配 _RepeatGuard 逐指纹 HARD=4 双保险。
_STALL_LIMIT = 8
# 上下文压缩门槛:历史体量未到 reliable_context 的此比例前不压缩 —— 短任务不丢旧工具细节,
# 且 prompt 前缀逐轮字节一致、DeepSeek 前缀缓存全程命中。50% 留足上下文安全垫。
# 常量本体在 core/context.py(与 context_fold 的 85% 折叠门槛共用折算,单一事实源)。
_COMPACT_CONTEXT_RATIO = COMPACT_CONTEXT_RATIO
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token);压缩是成本/安全优化、
# 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。
_CHARS_PER_TOKEN = CHARS_PER_TOKEN
# chars/token 校准比值(run 内状态,类属性兜默认):None = 尚未校准,首次取用时
# 从窗口内最后一次 provider 实报 usage 推算(_context_ratio),之后每轮成功调用用
# (sent_chars / prompt_tokens) 实测刷新。静态 2.5 对中文密集窗口低估近一倍,只作
# 无实测时的回退(详 context.py 常数注释)。
_ctx_chars_per_token: Optional[float] = None
_last_sent_chars: int = 0
def _context_ratio(self) -> float:
"""当前窗口的 chars/token 换算比值(校准态)。
首次调用(run 首轮)从窗口内最后一次实报 usage 推算(与 maybe_fold 的估算
同源);之后由 run() 在每轮成功调用后用真实 (sent_chars / prompt_tokens)
刷新。校准是信号不是正确性数据 —— 任何异常都回退 CHARS_PER_TOKEN(旧口径,
行为不变),绝不弄崩 run。
"""
if self._ctx_chars_per_token is None:
try:
provider_messages, _ = filter_reasoning_for_replay(
self.session.messages,
getattr(self.caps, "reasoning_replay", "none"),
)
self._ctx_chars_per_token = calibrated_chars_per_token(
provider_messages, self.session.last_measured_usage()
)
except Exception:
self._ctx_chars_per_token = CHARS_PER_TOKEN
return self._ctx_chars_per_token
def _maybe_fold_context(self) -> None:
"""§8.8 Phase 2:run 起点检查窗口体量,达 reliable_context×85% 则把窗口中段
折叠成结构化摘要(core/context_fold.py,SSE 事件 context_fold)。
放 run 起点而非轮间:轮间折叠要处理 tool 配对切割与已加载窗口的一致性,复杂
一档;对话是回合制,run 起点是自然缝隙,代价只是命中阈值那一回合首 token 慢
几秒(每分段一两次)。失败只 warn + 跳过,绝不阻塞本次 run(85% 距硬上限有垫)。
"""
try:
maybe_fold(
self.session, self.llm, self.caps,
user_id=self.user_id, emit=self._emit,
)
except Exception as e:
self._emit({
"type": "warn",
"msg": f"context fold failed: {type(e).__name__}: {e};本轮跳过折叠",
})
def _stream_llm(self) -> Tuple[Optional[Any], bool]:
"""拉一轮 LLM:上下文压缩准备(context 关注点)在此,wire 层健壮性(畸形/空响应
检测、非流式降级重试、salvage)委托 llm_transport.robust_stream —— 取流两条路径
以 bound method 传入,单测在实例上打桩 _collect_stream_once/_nonstream_once 即可。
返回 (response, cancelled_mid_stream);语义见 robust_stream docstring。
"""
# 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。
# 换算比值走校准态(实报 usage 优先,回退 2.5)—— 门槛语义是 token 口径,chars 只是载体。
ratio = self._context_ratio()
compact_threshold = int(
self.caps.reliable_context * self._COMPACT_CONTEXT_RATIO * ratio
)
llm_messages, context_stats = prepare_messages_with_stats(
self.session.messages,
compact_threshold_chars=compact_threshold,
reasoning_replay=getattr(self.caps, "reasoning_replay", "none"),
)
self._last_sent_chars = context_stats.get("sent_chars", 0)
llm_start_event = {
"type": "llm_start",
**{f"context_{k}": v for k, v in context_stats.items()},
# 窗口总容量(chars 口径,= reliable_context×校准比值):前端压缩指示环算占用比;
# 50% 进压缩区、85% 触发折叠(context_fold)。比值随实测刷新,环读数逐轮趋真。
"context_limit_chars": int(self.caps.reliable_context * ratio),
}
# 各工具必填参数(单一事实源:executor schema),供「必填 key 被吞」畸形检测。
required_by_tool = {
sc["function"]["name"]: (sc["function"].get("parameters") or {}).get("required") or []
for sc in self.executor.schemas()
}
return robust_stream(
collect_stream=self._collect_stream_once,
nonstream=self._nonstream_once,
try_salvage=self._try_salvage_response,
llm_messages=llm_messages,
required_by_tool=required_by_tool,
emit=self._emit,
llm_start_event=llm_start_event,
task_id=self.session.task_id,
user_id=self.user_id,
model_profile=f"{self.caps.family}.{self.caps.variant}",
max_attempts=self._MAX_MALFORMED_ATTEMPTS,
)
def _try_salvage_response(self, response: Any) -> bool:
"""尝试就地抢救本轮所有畸形 tool_call 的 arguments;成功才改写并返回 True。
全有或全无:任一畸形 tool_call 抠不出干净 JSON 就一个都不改、返回 False让调用方
原样走丢弃 + 非流式重试(半抢救 = 一部分执行一部分丢弃,状态不一致,不如整轮重来)。
allowed_keys 从各工具 schema 的 properties 现取(单一事实源,新增工具自动生效)。
改写用 ensure_ascii=False 保留中文原字节_execute_tool_call 会正常 json.loads 回来。
每条成功抢救记一行 kind=tool_salvaged 留痕 + 发 level=info 的 warn:抢救成功当轮
照常继续、对后续对话零影响,前端按 info 渲成灰色 muted 行;黄色显著标注只留给
真被打断的丢弃+重试路径。
"""
try:
msg = response.choices[0].message
tool_calls = list(getattr(msg, "tool_calls", None) or [])
except Exception:
return False
allowed_by_tool = {
sc["function"]["name"]: set(
(sc["function"].get("parameters") or {}).get("properties", {}).keys()
)
for sc in self.executor.schemas()
}
# 第一遍:只算,不改。任一失败即整轮放弃。
# pending 项:(tc, new_json, orig_len, salvaged_len, head) —— head=原始损坏前 300 字
# 过 ascii 转义(消费端编码不可控),留痕用于事后核验前缀形态是否仍是 char-0 型。
pending: List[Tuple[Any, str, int, int, str]] = []
for tc in tool_calls:
raw = (getattr(tc.function, "arguments", None) or "").strip()
if not raw:
continue
try:
json.loads(raw)
continue # 本就合法,不动
except (json.JSONDecodeError, ValueError):
pass
allowed = allowed_by_tool.get(tc.function.name)
if not allowed:
return False # 未知工具无白名单可校验,不冒险
obj = salvage_tool_arguments(raw, allowed)
if obj is None:
return False # 抠不出 → 整轮放弃,回落重试
new_json = json.dumps(obj, ensure_ascii=False)
pending.append((tc, new_json, len(raw), len(new_json), ascii(raw[:300])))
if not pending:
return False # 无可抢救的畸形(理论上不会到这:调用前已确认 bad 非空)
# 第二遍:全部确认可抢救,统一改写 + 留痕。
for tc, new_json, orig_len, salvaged_len, head in pending:
tc.function.arguments = new_json
try:
record_salvaged_tool_call(
task_id=self.session.task_id,
user_id=self.user_id,
model_profile=f"{self.caps.family}.{self.caps.variant}",
tool=tc.function.name,
arg_len=orig_len,
salvaged_len=salvaged_len,
head=head,
)
except Exception:
pass # 留痕失败绝不能打断:arguments 已改好,当轮照常执行
self._emit({
"type": "warn",
"level": "info",
"msg": (
f"已自动修复工具调用参数 "
f"{[f'{tc.function.name}({o}->{s})' for tc, _, o, s, _h in pending]},继续执行"
),
})
return True
def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]:
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
返回 (response, cancelled_mid_stream)。"""
chunks: List[Any] = []
stream = self.llm.chat_stream(
messages=llm_messages,
tools=self.executor.schemas(),
reasoning_effort=self.caps.default_reasoning_effort or None,
)
cancelled = False
try:
for chunk in stream:
if self._is_cancelled():
cancelled = True
break
chunks.append(chunk)
# delta.content 即时 emit 给前端打字机渲染;tool_call delta 不实时发
# (拼接散在多 chunk 跨 frame 难看,等拼回后整条 tool_call 事件由
# _execute_tool_call 时机发更直观)。
delta_text = extract_delta_content(chunk)
if delta_text:
self._emit({"type": "text", "delta": delta_text})
# thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达
# 分钟级,不发的话前端全程静止"思考中",用户以为卡死。
delta_reasoning = extract_delta_reasoning(chunk)
if delta_reasoning:
self._emit({"type": "reasoning", "delta": delta_reasoning})
finally:
# generator 提前 break 时 GeneratorExit 触发 chat_stream finally → close 底层连接
close = getattr(stream, "close", None)
if callable(close):
close()
if cancelled:
return None, True
# 用 litellm 官方 helper 拼回完整 response(包括 tool_calls 拼接 + usage)。
# messages 参数仅用于失败时回填 prompt token 估算,正常路径 stream_options.include_usage
# 已让最后一个 chunk 带准确 usage。
response = litellm.stream_chunk_builder(chunks, messages=llm_messages)
return response, False
_NONSTREAM_CANCEL_POLL_SECS = 0.5
def _nonstream_once(self, llm_messages: List[dict]) -> Optional[Any]:
"""非流式兜底:provider 服务端一次性拼好 tool_calls,绕开流式 delta 错位。
没有 chunk 级 cancel,content 也拿不到 delta —— 整段 text 一次性补 emit。
cancel 响应:非流式一次生成可达分钟级(重试轮常是大参数 write),同步阻塞会让
「停止」按钮等到调用整个返回才生效。故调用放后台 daemon 线程,主线程按拍 poll
cancel:命中即返回 None(线程弃养自行跑完,响应到达后被丢弃,不入库不记账 ——
与流式 cancel 丢弃已收 chunk 的语义一致)。"""
box: Dict[str, Any] = {}
done = threading.Event()
def _call() -> None:
try:
box["resp"] = self.llm.chat(
messages=llm_messages,
tools=self.executor.schemas(),
reasoning_effort=self.caps.default_reasoning_effort or None,
)
except BaseException as e: # noqa: BLE001 — 原样转抛回主线程
box["exc"] = e
finally:
done.set()
worker = threading.Thread(target=_call, daemon=True, name="llm-nonstream-retry")
worker.start()
while not done.wait(self._NONSTREAM_CANCEL_POLL_SECS):
if self._is_cancelled():
return None
if "exc" in box:
raise box["exc"]
response = box["resp"]
try:
msg = response.choices[0].message
rc = getattr(msg, "reasoning_content", None)
if not rc:
psf = getattr(msg, "provider_specific_fields", None) or {}
rc = psf.get("reasoning_content") if isinstance(psf, dict) else None
if rc:
self._emit({"type": "reasoning", "delta": rc})
text = getattr(msg, "content", None)
if text:
self._emit({"type": "text", "delta": text})
except Exception:
pass
return response
def _execute_tool_call(self, tc: Any) -> Tuple[str, bool, tuple[dict, ...]]:
"""执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局「无进展」熔断判定。
编排四个正交环节(各自独立方法):重复拦截(执行前)→ 真正执行 + 截断 →
skill 定向模型热切(load_skill 后)→ 重复登记/软提示 + pptx 产物机检(执行后)。
"""
name = tc.function.name
raw_args = tc.function.arguments or "{}"
try:
args = json.loads(raw_args)
except json.JSONDecodeError as e:
return f"[Error] invalid JSON arguments for {name}: {e}", False, ()
args_preview = json.dumps(args, ensure_ascii=False)
if len(args_preview) > 200:
args_preview = args_preview[:200] + "..."
self._emit({
"type": "tool_call",
"name": name,
"args": args,
"args_preview": args_preview,
})
blocked = self._check_repeat_block(name, args)
if blocked is not None:
return blocked, False, ()
ctx = ExecCtx(
user_id=self.user_id,
task_id=self.session.task_id,
working_dir=self.working_dir,
cancel_check=self.cancel_check,
)
tool_started_at = time.time()
tool_result = self.executor.call_tool(name, args, ctx)
result = tool_result.content
# 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000
truncated = False
if len(result) > MAX_LEN:
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
truncated = True
result = self._maybe_skill_model_switch(name, args, result)
result, productive = self._repeat_feedback(name, args, result)
result = self._maybe_pptx_guard(name, tool_started_at, result)
preview = result if len(result) < 400 else result[:400] + "..."
self._emit({
"type": "tool_result",
"name": name,
"result": result,
"preview": preview,
"truncated": truncated,
"artifacts": list(getattr(tool_result, "artifacts", ()) or ()),
})
return result, productive, tuple(getattr(tool_result, "artifacts", ()) or ())
def _remember_artifacts(self, refs: tuple[dict, ...]) -> None:
"""Accumulate a bounded, ordered set for the final assistant message."""
seen = {
(str(ref.get("scope") or ""), str(ref.get("path") or ""))
for ref in self._pending_artifact_refs
}
for ref in refs:
key = (str(ref.get("scope") or ""), str(ref.get("path") or ""))
if not key[1] or key in seen:
continue
self._pending_artifact_refs.append(dict(ref))
seen.add(key)
if len(self._pending_artifact_refs) >= MAX_ARTIFACTS_PER_MESSAGE:
break
def _check_repeat_block(self, name: str, args: Any) -> Optional[str]:
"""执行前的两道拦截(命中返回拦截话术,未命中返 None):
① 同参硬拦:同名同参已累计 HARD 次无产出重复 → 不执行,逼模型换路;
② err-streak 拦:换着参数连撞同一类错(如 edit 反复 old_str not found)→
拦一次,拦后 streak 重置到 SOFT(非永久封死,给换路后的重试留活口)。
"""
if self._repeat_guard.should_block(name, args):
n, _blocked = self._repeat_guard.register_block(name, args)
result = (
f"[已拦截重复调用] {name} 用完全相同的参数已调用 {n} 次且结果始终未变,本次未执行。"
"这通常意味着思路卡死:① 换不同的参数或方法;② 读一下相关文件/报错重新定位;"
"③ 若确实推进不了,停下来如实告诉用户卡在哪、缺什么。不要再用相同参数重试。"
)
self._emit({"type": "warn", "msg": f"拦截重复调用 {name}(同参第 {n} 次、结果未变)"})
self._emit_blocked_result(name, result)
return result
if self._repeat_guard.should_block_err(name):
cnt, esig = self._repeat_guard.register_err_block(name)
result = (
f"[已拦截重复调用] {name} 已连续 {cnt} 次撞同一个错误「{esig}」(每次只微调了参数)。"
"再这么试下去不会有新结果。换个做法:① 先 read 目标文件/用 grep 看确切内容"
"(old_str 必须逐字匹配,含空白与缩进);② 或换工具/换思路;③ 实在推进不了就停下来"
"如实告诉用户卡在哪。"
)
self._emit({"type": "warn", "msg": f"拦截撞墙调用 {name}(连续同错第 {cnt} 次)"})
self._emit_blocked_result(name, result)
return result
return None
def _emit_blocked_result(self, name: str, result: str) -> None:
self._emit({
"type": "tool_result",
"name": name,
"result": result,
"preview": result,
"truncated": False,
})
def _maybe_skill_model_switch(self, name: str, args: Any, result: str) -> str:
"""skill 定向模型:load_skill 成功且该 skill frontmatter 指定了模型 → 热切,
本 run 内下一轮 LLM 即用新模型(记账/压缩阈值/reasoning 都读 self.caps,自动跟上)。
切换说明追加在截断之后,不会被 16k 截掉。切失败(配错/缺 key)→ warn 后原模型继续。
"""
if (
name != "load_skill"
or self.skill_model_switch is None
or result.startswith("[Error]")
):
return result
cur_profile = f"{self.caps.family}.{self.caps.variant}"
try:
switched = self.skill_model_switch(str(args.get("name", "")), cur_profile)
except Exception as e:
switched = None
self._emit({
"type": "warn",
"msg": f"skill 定向模型切换失败,继续用 {cur_profile}: {type(e).__name__}: {e}",
})
if switched:
new_profile, new_caps, new_llm = switched
self.caps, self.llm = new_caps, new_llm
result += (
f"\n\n[模型切换] 该 skill 指定模型 {new_profile},"
f"已从 {cur_profile} 自动切换,本 task 后续消息也沿用 {new_profile}"
)
self._emit({
"type": "model_switch",
"model_profile": new_profile,
"from": cur_profile,
})
return result
def _repeat_feedback(self, name: str, args: Any, result: str) -> Tuple[str, bool]:
"""执行后登记结果做重复检测,并按累计情况在结果尾部注入软提示。
指纹用截断后、未加提示的原始结果算(保证同输出哈希一致);返回
(可能追加了提示的结果, 本次是否有净产出)。
"""
unproductive, productive = self._repeat_guard.record(name, args, result)
if unproductive >= _RepeatGuard.SOFT:
if unproductive == _RepeatGuard.SOFT:
self._emit({"type": "warn", "msg": f"{name} 同参重复且结果未变({unproductive} 次),已提示模型换路"})
result += (
f"\n\n[重复调用警告] 你已用完全相同的参数调用 {name} {unproductive + 1} 次、结果没有变化。"
"再原样重调不会有新结果——换参数/换工具/换思路,或停下来向用户说明卡在哪。"
)
else:
# err-streak SOFT 提示:参数每次不同但连撞同一个错(arg 判据不累计,单独提示)。
cnt, n_args, esig = self._repeat_guard.err_streak(name)
if cnt == _RepeatGuard.SOFT and n_args >= 2:
self._emit({"type": "warn", "msg": f"{name} 连续 {cnt} 次同错「{esig[:40]}」,已提示模型换路"})
result += (
f"\n\n[撞墙警告] 你换着参数调用 {name} 已连续 {cnt} 次撞同一个错误「{esig}」。"
"光微调参数没用——先 read/grep 看清目标的确切内容再动手,或换工具/换思路。"
)
return result, productive
def _maybe_pptx_guard(self, name: str, tool_started_at: float, result: str) -> str:
"""平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx
若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。
官方管线内的门只在模型用了官方脚本时生效,绕开管线的产物只能在这拦。
注入在 repeat_guard.record 之后 —— 不进指纹,免得干扰重复检测。
"""
if name not in _PPTX_GUARD_TOOLS:
return result
try:
guard_msg = pptx_guard.scan_and_report(self.working_dir, tool_started_at)
except Exception:
guard_msg = None # 机检自身故障绝不拖垮工具链路
if guard_msg:
result += "\n\n" + guard_msg
self._emit({
"type": "warn",
"msg": "产物机检:检测到「整页贴图」式 .pptx,已注入 ERROR 要求返工",
})
return result