"""主 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, prepare_messages_with_stats
from .context_fold import maybe_fold
from .executor import ExecCtx, Executor
from .llm import LLM
from .salvage import salvage_tool_arguments
from .session import Session
from .storage import (
    record_chat_usage,
    record_empty_response,
    record_malformed_tool_call,
    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+")


def _norm_err(result: str) -> str:
    """取 [Error] 结果首行、抹平路径/数字,得到稳定的错误签名(截断 120)。"""
    line = (result.lstrip().splitlines() or [""])[0]
    line = _ERR_PATH_RE.sub("<path>", line)
    line = _ERR_NUM_RE.sub("N", line)
    return line.strip()[:120]


class _RepeatGuard:
    """检测「同名同参 + 无产出」的病理性重复调用,断掉死循环。

    背景(2026-06-08 DB 实测):高轮数烧 token 的 task 里,单个工具被用**完全相同的
    参数**重复调用几十上百次(`document_search` 122 次、空参数 `shell{}` 51 次、反复
    `glob` 同一个不存在的路径)。loop 原本对此零防护,照单全收直到撞 max_iterations。

    命门是只惩罚「无产出」重复,绝不误伤正常迭代:
    - 同参但**每次结果不同**(改了脚本后重跑 run_python、修 bug 后重跑构建)→ 有产出,
      计数清零,永不拦。
    - 同参且**结果是 `[Error]` 或与之前某次一字不差**(空 `{}` 缺参、反复撞同一个错)
      → 无产出,累计。
    累计 >= SOFT 注入软提示(模型当轮就看到);>= HARD 直接拦截不执行,逼它换路。

    顺带堵掉 `_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")).hexdigest()
        is_err = result.lstrip().startswith("[Error")
        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:
            esig = _norm_err(result)
            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


def _extract_delta_content(chunk: Any) -> Optional[str]:
    """从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream:
    choices[0].delta.content。usage-only 收尾 chunk(没 choices / delta)返 None。
    """
    try:
        choices = getattr(chunk, "choices", None)
        if not choices:
            return None
        delta = getattr(choices[0], "delta", None)
        if delta is None:
            return None
        content = getattr(delta, "content", None)
        return content if content else None
    except Exception:
        return None


def _extract_delta_reasoning(chunk: Any) -> Optional[str]:
    """从 stream chunk 提 delta.reasoning_content(thinking 模型的推理片段)。
    litellm 对多数 provider 归一到 delta.reasoning_content,个别只放
    provider_specific_fields —— 两处都查。没有则返 None(非 thinking 模型零开销)。
    """
    try:
        choices = getattr(chunk, "choices", None)
        if not choices:
            return None
        delta = getattr(choices[0], "delta", None)
        if delta is None:
            return None
        rc = getattr(delta, "reasoning_content", None)
        if not rc:
            psf = getattr(delta, "provider_specific_fields", None) or {}
            rc = psf.get("reasoning_content") if isinstance(psf, dict) else None
        return rc if rc else None
    except Exception:
        return None


def _malformed_tool_calls(response: Any) -> List[str]:
    """检出 arguments 损坏(JSON 解析不了)的 tool_call,返回 [name(len=N), ...]。

    背景:deepseek-v4-flash 大参数工具调用偶发畸形 —— 流式 delta 错位把别处的内容
    碎片粘到 arguments 开头(如 `].cells[1].merge(...{"path":...}`),拼回来后 JSON
    解析直接失败。这种是上游瞬时抖动,不该入库污染上下文,调用方据此丢弃整轮重 roll。

    只看「解析失败」;空字符串 / 合法空对象不算畸形(交给 executor 按缺参数处理)。
    """
    try:
        msg = response.choices[0].message
    except Exception:
        return []
    bad: List[str] = []
    for tc in (getattr(msg, "tool_calls", None) or []):
        raw = (getattr(tc.function, "arguments", None) or "").strip()
        if not raw:
            continue
        try:
            json.loads(raw)
        except (json.JSONDecodeError, ValueError):
            bad.append(f"{tc.function.name}(len={len(raw)})")
    return bad


def _toolcalls_partial_args(
    response: Any, required_by_tool: Dict[str, List[str]]
) -> List[Tuple[Any, str, List[str]]]:
    """检出「JSON 能解析、但必填 key 被吞掉」的畸形 tool_call。

    背景(2026-07,失败面板 #3:edit `缺少必填参数 ['path']` 跨 13 task/9 用户):流式
    arguments delta 乱序把某个键的值碎片瞬移拼进相邻字符串(实证 [1]:path 的
    `.../gen_final_report_v2.py` 被吞进 old_str 尾部),独立的 `"path"` 键随之消失。这类
    与 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls`
    (只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路
    漏到 executor 才在语义层报「缺必填参数」,回 [Error] 喂回模型 → 再拼再乱序,反复烧。

    判据(窄,避免误伤模型真漏参):解析为**非空 dict** 且 **至少一个必填 key 在场**同时
    **至少一个必填 key 缺失**(即 0 < len(missing) < len(required))。空 `{}` / 必填全缺
    (纯垃圾/无关键)不算 —— 交给 executor + _RepeatGuard 现状处理。

    返回 [(tc, name, missing_keys), ...]。
    """
    try:
        msg = response.choices[0].message
    except Exception:
        return []
    out: List[Tuple[Any, str, List[str]]] = []
    for tc in (getattr(msg, "tool_calls", None) or []):
        try:
            name = tc.function.name
            raw = (getattr(tc.function, "arguments", None) or "").strip()
        except Exception:
            continue
        if not raw:
            continue
        try:
            obj = json.loads(raw)
        except (json.JSONDecodeError, ValueError):
            continue  # parse 失败归 _malformed_tool_calls,不重复处理
        if not isinstance(obj, dict) or not obj:
            continue
        required = required_by_tool.get(name) or []
        if not required:
            continue
        missing = [k for k in required if k not in obj]
        if 0 < len(missing) < len(required):
            out.append((tc, name, missing))
    return out


def _log_partial_args(
    task_id: Any, user_id: Any, model_profile: str,
    partial: List[Tuple[Any, str, List[str]]], response: Any,
) -> None:
    """必填 key 被吞的畸形留痕:与 _log_malformed_args 对称,进 usage_events(kind=
    tool_malformed),error 签名固定为 `missing required keys [...]` —— 在失败面板里和
    char-0 型(`Expecting value`)、executor 的「缺必填参数」区分开,便于统计这条新裂缝。
    任何一路失败都静默,绝不打断重试主路径。"""
    try:
        usage = _extract_usage_details(getattr(response, "usage", None))
        for tc, name, missing in partial:
            raw = (getattr(tc.function, "arguments", None) or "")
            err = f"missing required keys {missing}"
            print(
                f"[malformed:partial] task={task_id} tool={name} len={len(raw)} "
                f"{err} head={ascii(raw[:300])} tail={ascii(raw[-300:])}",
                flush=True,
            )
            try:
                record_malformed_tool_call(
                    task_id=task_id,
                    user_id=user_id,
                    model_profile=model_profile,
                    tool=name,
                    arg_len=len(raw),
                    error=err,
                    head=raw[:300],
                    tail=raw[-300:],
                    tokens_in=usage["tokens_in"],
                    tokens_out=usage["tokens_out"],
                )
            except Exception:
                pass
    except Exception:
        pass


def _is_empty_response(response: Any) -> bool:
    """检出「空响应」:assistant 轮既无 tool_calls 又无正文(去空白后为空)。

    背景(task 2a1bc25d 案):provider wire 偶发吐空 —— 截断流 / finish_reason 无内容 /
    网关把 tool_use 漏成正文后又丢空,回来的这一轮 tool_calls 空且 content 空。run loop
    见 tool_calls 空即当「模型答完」静默 done、返回空串,无报错、run_status=idle,与卡死
    无法区分。故和畸形同类对待:丢弃本轮走非流式重试。
    注意:纯 tool_call 轮(tc 非空、content 空)不算空响应 —— 那是正常的工具调用轮。
    """
    try:
        msg = response.choices[0].message
    except (AttributeError, IndexError, TypeError):
        return False
    if getattr(msg, "tool_calls", None):
        return False
    content = getattr(msg, "content", None) or ""
    return not content.strip()


def _log_empty_response(
    task_id: Any, user_id: Any, model_profile: str, response: Any, attempt: int,
) -> None:
    """空响应留痕:stdout + usage_events(kind=empty_response)双写,任何一路失败都静默。

    与 _log_malformed_args 对称:空响应轮同样整轮丢弃、messages 无痕,这里是唯一留痕 ——
    供事后定性(哪个模型档在吐空)与「工具失败聚集」面板第四段聚合。留痕绝不能反过来打断
    重试主路径(单测无 DB 时 record_* 落库失败也吞掉)。
    """
    try:
        usage = _extract_usage_details(getattr(response, "usage", None))
        print(
            f"[empty_response] task={task_id} mp={model_profile} attempt={attempt} "
            f"tok={usage['tokens_in']}/{usage['tokens_out']}",
            flush=True,
        )
        try:
            record_empty_response(
                task_id=task_id,
                user_id=user_id,
                model_profile=model_profile,
                attempt=attempt,
                tokens_in=usage["tokens_in"],
                tokens_out=usage["tokens_out"],
            )
        except Exception:
            pass  # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试
    except Exception:
        pass


def _log_malformed_args(
    task_id: Any, user_id: Any, model_profile: str, response: Any,
) -> None:
    """畸形 arguments 的首尾片段 + JSON 报错位置留痕:stdout + usage_events 双写。

    畸形轮不 append/不记账,这里是唯一留痕 —— 用于事后定性损坏形态(provider 流式
    delta 错位 vs 本地 stream_chunk_builder 拼接 bug),以及向 provider 报 case 取证。
    stdout 片段过 ascii() 转义:日志消费端编码不可控(Windows dev 控制台 GBK 遇 emoji
    会崩),转义后 grep '\\[malformed\\]' 拿到的内容可无损还原。DB 行(kind=tool_malformed,
    cost=0)喂 admin「工具失败聚集」面板 + 巡检邮件(core/toolfail.py),units 里
    快照该轮真实 token 供估算浪费。任何一路失败都静默 —— 留痕绝不能反过来打断重试主路径。
    """
    try:
        msg = response.choices[0].message
        usage = _extract_usage_details(getattr(response, "usage", None))
        for tc in (getattr(msg, "tool_calls", None) or []):
            raw = (getattr(tc.function, "arguments", None) or "").strip()
            if not raw:
                continue
            try:
                json.loads(raw)
            except (json.JSONDecodeError, ValueError) as e:
                print(
                    f"[malformed] task={task_id} tool={tc.function.name} "
                    f"len={len(raw)} err={e} "
                    f"head={ascii(raw[:300])} tail={ascii(raw[-300:])}",
                    flush=True,
                )
                try:
                    record_malformed_tool_call(
                        task_id=task_id,
                        user_id=user_id,
                        model_profile=model_profile,
                        tool=tc.function.name,
                        arg_len=len(raw),
                        error=str(e),
                        head=raw[:300],
                        tail=raw[-300:],
                        tokens_in=usage["tokens_in"],
                        tokens_out=usage["tokens_out"],
                    )
                except Exception:
                    pass  # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试
    except Exception:
        pass


def _usage_to_dict(usage: Any) -> dict:
    if not usage:
        return {}
    if hasattr(usage, "model_dump"):
        usage = usage.model_dump()
    elif hasattr(usage, "dict"):
        usage = usage.dict()
    if isinstance(usage, dict):
        return usage
    return {}


def _extract_usage_details(usage: Any) -> dict:
    """从 provider usage 提取统一 token 明细。

    DeepSeek 直接给 prompt_cache_hit_tokens / prompt_cache_miss_tokens;
    OpenAI 风格把 cached tokens 放在 prompt_tokens_details.cached_tokens。
    """
    data = _usage_to_dict(usage)
    prompt_details = data.get("prompt_tokens_details") or {}
    completion_details = data.get("completion_tokens_details") or {}
    if not isinstance(prompt_details, dict):
        prompt_details = {}
    if not isinstance(completion_details, dict):
        completion_details = {}

    cache_hit = (
        data.get("prompt_cache_hit_tokens")
        or prompt_details.get("cached_tokens")
        or 0
    )
    cache_miss = data.get("prompt_cache_miss_tokens") or 0
    return {
        "tokens_in": int(data.get("prompt_tokens") or 0),
        "tokens_out": int(data.get("completion_tokens") or 0),
        "cache_hit_tokens": int(cache_hit or 0),
        "cache_miss_tokens": int(cache_miss or 0),
        "reasoning_tokens": int(completion_details.get("reasoning_tokens") or 0),
    }


def _extract_usage(usage: Any) -> Tuple[int, int]:
    """从 litellm response.usage 提 (prompt_tokens, completion_tokens)。"""
    details = _extract_usage_details(usage)
    return details["tokens_in"], details["tokens_out"]


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,
    ) -> 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
        # 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
        self._repeat_guard = _RepeatGuard()
        # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
        self._stall = 0

    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:
        self._maybe_fold_context()
        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]"

            msg = response.choices[0].message
            asst_msg_id = self.session.append(msg)

            usage_details = _extract_usage_details(getattr(response, "usage", None))
            pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
            # 记账(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,
            })

            tool_calls = getattr(msg, "tool_calls", None) or []
            # 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 = self._execute_tool_call(tc)
                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

    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 并保证返回的 tool_call arguments 可解析。

        返回 (response, cancelled_mid_stream):
        - 正常完结 → (response, False);response shape 与非流式 completion() 等价
          (choices[0].message + usage)
        - 中途 cancel → (None, True);已收 chunk 丢弃,内层 generator 在 finally 关闭底层连接。
          非流式重试期间 cancel 同样 (None, True)(线程级 poll,见 _nonstream_once)

        畸形重试:deepseek v4 系(flash/pro 均实测踩过)大参数工具调用偶发把内容碎片
        错位粘进 arguments,拼回后 JSON 解析失败。这种损坏一旦入库会被每轮重发、诱导
        模型继续学坏(投毒级联)。故拼回后先校验 tool_call arguments 能否解析:不能 →
        丢弃整轮(不 append/不记账,原始损坏片段打服务端日志留痕)并立刻降级非流式重试
        (同轮流式失败强相关,重试不再走流式);全部尝试耗尽仍畸形则交给 executor 的
        invalid-JSON 分支返错给模型。重试消耗的 token 不单独记账。
        """
        # 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。
        compact_threshold = int(
            self.caps.reliable_context * self._COMPACT_CONTEXT_RATIO * self._CHARS_PER_TOKEN
        )
        llm_messages, context_stats = prepare_messages_with_stats(
            self.session.messages,
            compact_threshold_chars=compact_threshold,
        )
        llm_start_event = {
            "type": "llm_start",
            **{f"context_{k}": v for k, v in context_stats.items()},
            # 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比;
            # 50% 进压缩区、85% 触发折叠(context_fold)。
            "context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
        }
        # 各工具必填参数(单一事实源:executor schema),供「必填 key 被吞」畸形检测。
        required_by_tool = {
            sc["function"]["name"]: (sc["function"].get("parameters") or {}).get("required") or []
            for sc in self.executor.schemas()
        }
        for attempt in range(self._MAX_MALFORMED_ATTEMPTS):
            use_nonstream = attempt > 0
            # 每个 attempt 重发 llm_start(stats 同一份):非流式重试完成前零 delta 事件,
            # 而 warn 事件会让前端把当前文字段定稿关闭 —— 不重发的话「思考中 · Ns」占位段
            # 没人重建,页面静止到重试完成,与卡死无法区分。
            self._emit(dict(llm_start_event))
            if use_nonstream:
                response = self._nonstream_once(llm_messages)
                if response is None:
                    # 非流式重试期间用户点了停止(线程级 poll,见 _nonstream_once)
                    return None, True
            else:
                response, cancelled = self._collect_stream_once(llm_messages)
                if cancelled:
                    return None, True

            bad = _malformed_tool_calls(response)
            if not bad:
                # 空响应(tc 空且正文空):provider wire 吐空,和畸形同类瞬态故障 ——
                # 丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),留痕供面板可见。
                if _is_empty_response(response):
                    _log_empty_response(
                        self.session.task_id, self.user_id,
                        f"{self.caps.family}.{self.caps.variant}", response, attempt + 1,
                    )
                    self._emit({
                        "type": "warn",
                        "msg": (
                            "模型返回空响应,丢弃本轮"
                            f"{'重试' if use_nonstream else ',改非流式重试'}"
                            f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
                        ),
                    })
                    continue
                # 必填 key 被吞的畸形(parse 成功、salvage 无能):非流式重试(服务端一次拼好,
                # 绕开流式 delta 乱序)。耗尽尝试仍缺 → 落下面 return,交 executor 返「缺必填参数」
                # 给模型(多为模型真漏参,不再空转)。
                partial = _toolcalls_partial_args(response, required_by_tool)
                if partial:
                    _log_partial_args(
                        self.session.task_id, self.user_id,
                        f"{self.caps.family}.{self.caps.variant}", partial, response,
                    )
                    names = ", ".join(f"{n}(missing={m})" for _, n, m in partial)
                    self._emit({
                        "type": "warn",
                        "msg": (
                            f"工具调用必填参数被吞 {names},丢弃本轮"
                            f"{'重试' if use_nonstream else ',改非流式重试'}"
                            f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
                        ),
                    })
                    continue
                return response, False
            # 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
            # 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
            # 任一抠不出则一个都不动,原样走下面的丢弃 + 重试(零回退风险)。
            if self._try_salvage_response(response):
                return response, False
            _log_malformed_args(
                self.session.task_id, self.user_id,
                f"{self.caps.family}.{self.caps.variant}", response,
            )
            self._emit({
                "type": "warn",
                "msg": (
                    f"工具调用参数损坏 {bad},丢弃本轮"
                    f"{'重试' if use_nonstream else ',改非流式重试'}"
                    f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
                ),
            })
        # 非流式重试仍畸形(理论极罕见):交还给 _execute_tool_call 的 invalid-JSON 分支
        # 优雅返错给模型,而非在此死循环。
        return response, False

    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 留痕 + 发 warn 让前端重建占位段。
        """
        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",
            "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 底层连接
            stream.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]:
        """执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
        净产出供 run loop 的全局「无进展」熔断判定。"""
        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,
        })

        # 病理性重复拦截:同参已累计 HARD 次无产出重复 → 不执行,回硬停消息逼模型换路。
        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({
                "type": "tool_result",
                "name": name,
                "result": result,
                "preview": result,
                "truncated": False,
            })
            return result, False

        # err-streak 拦截:换着参数撞同一堵墙(如 edit 反复 old_str not found)。拦一次逼换路,
        # 拦后 streak 重置到 SOFT(非永久封死)。
        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({
                "type": "tool_result",
                "name": name,
                "result": result,
                "preview": result,
                "truncated": False,
            })
            return result, 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()
        result = self.executor.call_tool(name, args, ctx).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

        # skill 定向模型:load_skill 成功且该 skill frontmatter 指定了模型 → 热切,
        # 本 run 内下一轮 LLM 即用新模型(记账/压缩阈值/reasoning 都读 self.caps,自动跟上)。
        # 切换说明追加在截断之后,不会被 16k 截掉。切失败(配错/缺 key)→ warn 后原模型继续。
        if (
            name == "load_skill"
            and self.skill_model_switch is not None
            and not result.startswith("[Error]")
        ):
            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,
                })

        # 登记结果做重复检测(用截断后、未加提示的原始结果算指纹,保证同输出哈希一致)。
        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 看清目标的确切内容再动手,或换工具/换思路。"
                )

        # 平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx
        # 若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。
        # 官方管线内的门只在模型用了官方脚本时生效,绕开管线的产物只能在这拦。
        # 注入在 repeat_guard.record 之后 —— 不进指纹,免得干扰重复检测。
        if name in _PPTX_GUARD_TOOLS:
            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 要求返工",
                })

        preview = result if len(result) < 400 else result[:400] + "..."
        self._emit({
            "type": "tool_result",
            "name": name,
            "result": result,
            "preview": preview,
            "truncated": truncated,
        })
        return result, productive
