910 lines
44 KiB
Python
910 lines
44 KiB
Python
"""主 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,
|
||
user_root: Optional[Path] = None,
|
||
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.user_root = Path(user_root).resolve() if user_root else None
|
||
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."""
|
||
if refs and self.user_root is not None:
|
||
from .artifact_lifecycle import register_published_artifacts
|
||
|
||
refs = register_published_artifacts(
|
||
user_id=self.user_id,
|
||
task_id=self.session.task_id,
|
||
user_root=self.user_root,
|
||
working_dir=self.working_dir,
|
||
refs=refs,
|
||
)
|
||
seen = {
|
||
(
|
||
str(ref.get("artifact_id") or ""),
|
||
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("artifact_id") or ""),
|
||
str(ref.get("scope") or ""),
|
||
str(ref.get("path") or ""),
|
||
)
|
||
if not key[2] 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
|