"""定层 glm.pro52 空响应(失败面板 kind=empty_response 主角,11/15/近7天)。

背景:assistant 轮既无 tool_calls 又无正文,被 run loop 当正常收尾静默 done。
与 opus48 的「tool_use 漏成正文」(narrated,见 diag_narrated_toolcall_2a1bc25d)
是不同现象——要判 glm 到底是①真吐空(tc=0 且 content=0)、②漏成正文(narrated)、
还是③正常(能回结构化 tool_calls,线上那次只是瞬态)。

做法:build_agent(resume=True) 复原真实 system prompt + 全套工具 schema + **完整多轮
上下文**(空响应发生在深层,单条 user 消息复现不了 —— 这是与 2a1bc25d 探针的关键差别),
直接打 glm.pro52,stream / non-stream 各打一发。只读 DB,不跑 loop 不写库。
输出 ASCII(Windows GBK 安全),完整正文写文件。

用法(生产机跑,本机 import litellm 卡 ~20min):
  .venv/Scripts/python.exe scripts/diag_glm_empty_probe.py [task_id] [user_id] [model_profile]
默认 = 35744bea(近7天 empty 5 次、最可复现)。model_profile 缺省用 task 自身档(glm.pro52),
传第三参可换档做对照(如 deepseek_v4.pro 看是否 glm 独有)。
"""
import os
import sys
from pathlib import Path
from uuid import UUID

ROOT = Path(__file__).resolve().parent.parent
env = ROOT / ".env"
if env.exists():
    for line in env.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line and not line.startswith("#") and "=" in line:
            k, v = line.split("=", 1)
            os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")

sys.path.insert(0, str(ROOT))
import litellm  # noqa: E402
import core.agent_builder as _ab  # noqa: E402
from core.agent_builder import build_agent  # noqa: E402

# 探针只读:不跑任何工具,只需 executor.schemas() —— docker 与 host 的 schema 完全一致
# (executor_docker.schemas() 直接 `return self.host.schemas()`)。独立脚本没跑 web
# lifespan 的 init_pool(),docker backend 下 _resolve_executor 会因 pool=None 抛
# RuntimeError。故 monkeypatch 它返回 host executor 绕开沙箱池;ZCBOT_SANDBOX_BACKEND
# 仍留 docker,让 _build_system_prompt 的 is_docker=True → 保留容器 env 块 + 容器路径,
# 复现保真(system prompt / tools 与线上逐字一致,只是不真起容器)。
_ab._resolve_executor = lambda host, *a, **k: host

# 默认锁定 35744bea(近7天 empty 5 次、最可复现);可 argv 覆盖。
TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "35744bea-49c0-41c8-86be-8d0c5c05bd32"
USER_ID = UUID(sys.argv[2]) if len(sys.argv) > 2 else UUID("3cc07a22-f667-47f3-9617-e7d8a1a60cf0")
MODEL_OVERRIDE = sys.argv[3] if len(sys.argv) > 3 else None

_tag = (MODEL_OVERRIDE or "self").replace(".", "_").replace("/", "_")
OUT = open(ROOT / "scripts" / f"_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt", "w", encoding="utf-8")


def w(*a):
    print(*a, file=OUT)


agent, session, sid, tstate, wd = build_agent(
    user_id=USER_ID, session_id=TASK_ID, resume=True,
    model_name=MODEL_OVERRIDE,
)
schemas = agent.executor.schemas()
caps = agent.caps
llm = agent.llm

# 复原 loop 下一轮真会送的完整消息(system + 全部历史)—— 空响应就发生在这个上下文上。
messages = list(session.messages)
sys_txt = next((m["content"] for m in messages if m.get("role") == "system"), "")
roles = [m.get("role") for m in messages]

w("[SETUP]")
w(f"  task={TASK_ID} model_id={caps.model_id} profile={caps.family}.{caps.variant}")
w(f"  api_base={llm.api_base} temp={caps.optimal_temperature} "
  f"parallel_tools={caps.parallel_tools}")
w(f"  system_prompt chars={len(sys_txt)}  tools={len(schemas)}")
w(f"  messages count={len(messages)}  last_role={roles[-1] if roles else None}")
w(f"  role tail={roles[-6:]}")
w("")

kwargs_base = dict(
    model=caps.model_id,
    api_base=llm.api_base,
    api_key=llm.api_key,
    messages=messages,
    tools=schemas,
    temperature=caps.optimal_temperature,
    timeout=300,
)
if caps.parallel_tools:
    kwargs_base["parallel_tool_calls"] = True
# A/B 开关:PROBE_THINKING_OFF=1 透传 GLM 的 thinking 禁用(验证「禁 thinking 能否让
# reasoning_content 归零、避免输出预算被推理烧穿」这条修法)。默认不带 = 复现线上现状。
if os.getenv("PROBE_THINKING_OFF") == "1":
    kwargs_base["extra_body"] = {"thinking": {"type": "disabled"}}
    print("[probe] thinking DISABLED via extra_body", file=sys.stderr)

stdout_lines = []


def probe(tag, stream):
    w(f"========== {tag} ==========")
    try:
        if stream:
            chunks = list(litellm.completion(
                **kwargs_base, stream=True,
                stream_options={"include_usage": True}))
            resp = litellm.stream_chunk_builder(chunks)
        else:
            resp = litellm.completion(**kwargs_base)
    except Exception as e:
        w(f"[FAIL] {type(e).__name__}: {str(e)[:300]}")
        stdout_lines.append(f"{tag}: FAIL {type(e).__name__}")
        return
    msg = resp.choices[0].message
    tc = msg.tool_calls or []
    content = msg.content or ""
    fr = resp.choices[0].finish_reason
    u = resp.usage
    rc = getattr(msg, "reasoning_content", None) or ""
    w(f"  finish_reason={fr}")
    w(f"  reasoning_content chars={len(rc)}  (>0 = 该档 thinking 是开的)")
    w(f"  usage prompt={u.prompt_tokens} completion={u.completion_tokens}")
    w(f"  tool_calls count={len(tc)}")
    for i, t in enumerate(tc):
        w(f"    [{i}] {t.function.name}({(t.function.arguments or '')[:200]})")
    w(f"  content chars={len(content)}")
    w("  content:")
    w(content[:2000])
    w("")
    # 三态判定:EMPTY(真吐空)/ NARRATED(工具意图漏成正文)/ STRUCTURED / plain-text
    narrated = (not tc) and ("`" in content) and ("\n- " in content or "No result received" in content)
    if tc:
        verdict = "STRUCTURED tool_calls"
    elif not content.strip():
        verdict = "EMPTY (tc=0 content=0)  <-- 复现空响应"
    elif narrated:
        verdict = "NARRATED-as-text"
    else:
        verdict = "plain-text-no-tools"
    stdout_lines.append(f"{tag}: {verdict} (finish={fr}, tc={len(tc)}, content={len(content)}c)")


probe("NON-STREAM", stream=False)
probe("STREAM", stream=True)

OUT.close()
for ln in stdout_lines:
    print(ln)
print(f"full -> scripts/_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt")
