# Spec-1：Agent 执行环境修复（解析容错 + 步级重试 + 摘要附实体）

- **日期**: 2026-07-11
- **状态**: 已批准（用户确认，步级重试退避改为 20s/40s）
- **依据**: `research-wiki/findings/2026-07-11-benchmark-failure-taxonomy.md` §四（T8，5 题）与 §二 M1（view_node 摘要吞 entities）
- **系列**: Spec-1/2/3 三件套之一，见 [2026-07-11-batch-tree-build-design.md]、[2026-07-11-question-gen-v2-design.md]

## 1. 问题

| # | 缺陷 | 证据 | 影响 |
|---|------|------|------|
| A1 | `_parse_response`（`core/agent/loop.py:265-300`）只接受 `action.args` 嵌套结构；deepseek 稳定输出变体（args 平铺 + ```json 围栏）解析三连拒 → 0 步阵亡 | 637-3、615-3 | 整题报废，且 json_repair 修不了结构错位 |
| A2 | LLM 调用异常（`loop.py:118-129`）直接整题终止，无步级重试 | 796-3（SSL BAD_RECORD_MAC 废掉 13 步上下文） | 一次网络抖动损失全部已积累推理 |
| B | `summarize_node`（`app/search/summarizer.py`）两轮按题摘要后 entities/visible_text 不保证幸存 | 786-2、872-3、750-1（Agent 站在证据节点上漏读实体） | M1 负证据幻觉的恶化因素 |

## 2. 设计

### A1 解析容错（结构归一化层）

在现有 `repair_json → json.loads → 校验` 之后、返回 None 之前，增加确定性归一化：

1. **围栏剥除**：repair_json 前先剥 ```json / ``` 围栏（正则，幂等）。
2. **args 收拢**：若 `action` 为 dict 且含 `tool` 但缺 `args`，把 action 下除 `tool` 外的所有平铺键收拢为 `args` 嵌套。
3. 归一化成功 → 照常执行；失败 → 走现有 retry 追问路径（行为不变）。

纯函数实现，用 637-3/615-3 的真实坏输出作单测样本。

### A2 步级重试

`_call_llm` 异常处理改为步级重试循环：

| 参数 | 值 | 说明 |
|------|-----|------|
| 重试次数 | 2 | 第 3 次失败才整题终止（stop_reason=error 不变） |
| 退避 | 20s / 40s | 用户指定 |
| 可重试异常 | 显式类型元组，默认 `(TimeoutError, OSError)`（`ssl.SSLError`/`ConnectionError` 均为 OSError 子类，覆盖实测穿透案例）；作为构造参数可注入扩展 | 遵循 CLAUDE.md P5（不做全 Exception 兜底）；core/ 不依赖 openai SDK——API 类异常由 GovernedLLMClient 内部重试栈负责；`asyncio.CancelledError` 绝不吞；未知异常 fail-fast 整题终止（现状行为） |
| 上下文 | 原样保留 | messages 不回滚，重试即重发 |
| 遥测 | 失败尝试的 error 记录由 `GovernedLLMClient` 内部负责（已有）；AgentLoop 侧只以 loguru 记录步级重试事件（不注入 TelemetryRecorder） | AgentLoop 无遥测端口，不越层补写 |

### B 摘要附带实体原文

在 **dispatcher 侧**（`SearchToolDispatcher._handle_view_node`）由确定性代码（非 LLM）在摘要结果末尾追加节点 card 的字段原文：

```
[实体] <entities + visible_entities 原文>
[画面文字] <visible_text 原文>
```

- **调用链改动**（Codex 审查修正）：`summarize_node` 只收 `raw_text: str`、无结构化 card 访问，且 `_node_full_text` 递归收值不保留字段名——因此在 `TreeEnvironment` 新增结构化字段提取方法（如 `node_entity_fields(node_id) -> dict[str, str]`），dispatcher 调用它并把区块拼接到 summarize_node 返回值之后；summarize_node 本体不改
- 摘要后追加 → LLM 无法吞掉；字段为空则不加对应区块
- 对 anchor / 非 anchor 两种模式一致生效

## 3. 不做什么（YAGNI）

- 不改 GovernedLLMClient 的内部重试栈（已有四层治理）
- 不改判分协议、不动 prompt 版本化内容
- 不做异常分型重试策略（统一兜底已覆盖已知案例）

## 4. 验证

1. 单测：坏输出样本（围栏/平铺/两者叠加）归一化正确；空 content、缺 tool 仍拒
2. 单测：步级重试计数与退避（mock LLM 抛错）
3. 单测：summarize_node 追加区块（有/无实体字段两种节点）
4. 集成：抽 10 道 T2/T8 错题重跑（637-3、615-3、786-2、872-3、750-1 必含），对比修复前后
5. `make test` 全绿 + 覆盖率不降

## 5. 被否方案

- **prompt 层要求 LLM 修正输出格式**：治标，deepseek 变体是稳定行为，代码归一化是确定性修复
- **重试时区分异常类型**（仅网络类重试）：已知案例全是穿透型异常，分型收益低且易漏
