# Agent 模块架构指南

ZenStory 写作 agent 的服务端：路由 → 多角色工作流（planner / hook_designer / writer / quality_reviewer）→
openai-agents-python 运行时（OpenAI 兼容 Chat Completions，主备两个供应商，见 `core/llm_providers.py` / `core/llm_failover.py`）→ SSE。
工作流是 `graph/` 里的自研编排，**不使用 LangGraph**；`StreamAdapter.process_langgraph_events` 只是
`process_workflow_events` 的兼容别名。

路径约定：不带前缀的路径相对 `apps/server/agent/`；模块外的文件写全路径（例如 `apps/server/utils/logger.py`，
不要和 `agent/utils/` 混淆）。行为细节与取舍以 `.agents/notes/` 里链接的决策记录为准，改行为前先读对应记录。

## 目录结构

```
agent/
├── service.py              # AgentService.process_stream：一次 /agent/stream 的主入口
├── stream_adapter.py       # StreamAdapter：workflow 事件 → SSE 帧，<file> 正文捕获与落库，CHAT_HOLD
├── suggest_service.py      # /agent/suggest 的下一步建议
├── constants.py            # 子系统共享常量
├── context/                # 项目上下文组装
│   ├── assembler.py        # ContextAssembler
│   ├── budget.py           # token 预算与请求级 prompt 台账
│   └── prioritizer.py      # 优先级分档
│                           # 注：没有会话压缩/总结模块，历史只按 token 预算取最近窗口
├── core/                   # 基础设施
│   ├── events.py           # SSE 事件（EventType）与帧构造
│   ├── workflow_events.py  # 图内部事件（StreamEventType / StreamEvent）
│   ├── sse_pump.py         # SSEStreamPump：心跳、请求墙钟时限、作者停止
│   ├── stream_billing.py   # StreamBillingTracker：按发出的帧判定计费 / 退还
│   ├── stream_errors.py    # 错误码、固定文案、retryable / refundable
│   ├── round_outcome.py    # 停止 / 断线的一轮怎样收尾（终态落库、空白占位文件）
│   ├── run_meter.py        # 请求级模型调用计数与耗时
│   ├── steering.py         # 运行中插话（steering）队列
│   ├── progress_channel.py # 长工具（parallel_execute）的进度事件通道
│   ├── output_truncation.py# 输出截断后的续写提示
│   ├── author_facing_text.py # 作者可见文字整理（AgentTextShaper、交接原因、角色中文名）
│   ├── session_loader.py   # 历史窗口 + 面包屑 + 跨轮工作集
│   ├── message_manager.py  # 系统提示与本轮上下文构建、历史保存、作者语言判断
│   ├── stream_processor.py # <file>…</file> 捕获状态机
│   ├── llm_providers.py    # 主 / 备供应商配置与 OpenAI 兼容客户端
│   ├── llm_failover.py     # 主备切换：会话固定、熔断、只在没有输出时换供应商
│   ├── llm_client.py       # 输入建议 / 润色的单次调用
│   └── metrics.py
├── graph/                  # 多 agent 工作流（自研编排，非 LangGraph）
│   ├── writing_graph.py    # run_writing_workflow_streaming：路由、agent 循环、交接、自动质检
│   ├── router.py           # 意图路由（RouterDecision、跨轮路由延续）
│   ├── nodes.py            # run_streaming_agent：组装本次运行的系统指令并调用 runner
│   ├── state.py            # WritingState
│   ├── author_scope.py     # 按作者本轮要求收窄工作流
│   ├── body_writes.py      # writer 本轮写进正文类文件的字数
│   ├── completion_evidence.py # run 收尾的文字信号
│   └── review_evidence.py  # 送审前的程序检测证据
├── openai_agents/          # openai-agents-python 适配层
│   ├── runner.py           # run_openai_agents_streaming_agent：Runner.run_streamed + 事件映射
│   ├── model.py            # FailoverChatModel：每次模型调用按主备选择供应商
│   ├── tools_adapter.py    # 项目工具 → SDK FunctionTool（只读 / 先问清楚时拒绝写工具）
│   ├── tool_failure_breaker.py # 请求级工具失败熔断
│   ├── repeat_read_guard.py    # 重复读取无进展守卫
│   ├── usage_hooks.py      # 每次模型调用的用量记账
│   ├── intra_run_trimmer.py# run 内上下文安全阀
│   └── events.py
├── prompts/                # 共享协议与角色提示（项目写作配置只存数据库）
│   ├── base.py             # get_base_prompt、UNIFIED_EXECUTION_PROTOCOL
│   ├── subagents.py        # ROUTER / PLANNER / HOOK_DESIGNER / WRITER / QUALITY_REVIEWER 提示
│   └── suggestions.py      # 建议生成提示
├── schemas/context.py      # 上下文数据模型
├── skills/                 # 技能系统（标准 SKILL.md，渐进式加载，永不执行脚本）
│   ├── active_skills.py    # 当前用户启用中的技能视图
│   ├── context_injector.py # L1 技能目录（名称 + 用途）
│   ├── content_budget.py   # 技能正文 token 预算
│   └── package.py          # SKILL.md 解析、zip 导入安全检查、导出
├── tools/                  # 工具
│   ├── tool_schemas.py     # provider-neutral 工具 schema（TOOL_SCHEMAS）
│   ├── mcp_tools.py        # 工具实现（MCP_TOOL_HANDLERS）与 ToolContext
│   ├── registry.py         # schema / handler / agent 工具集的唯一接线处
│   ├── parallel_executor.py# parallel_execute
│   ├── file_executor.py / file_ops/ # 文件 CRUD、编辑、项目操作
│   ├── author_changes.py   # 作者在 AI 写过之后的净改动
│   ├── author_edit_guard.py# 作者手改文件的删除 / 整份覆盖保护
│   ├── task_progress.py    # 任务板编号范围与实际文件对照
│   └── permissions.py
├── utils/                  # aho_corasick、token_utils、world_model_visibility
└── llm/                    # 空的遗留占位包，不要往里加代码
```

## 请求与流

1. `apps/server/api/agent.py` 的 `POST /api/v1/agent/stream`：鉴权、项目权限（`verify_project_access`）、按用户限流、
   预扣一次 AI 对话额度；用 `SSEStreamPump` 包住 `AgentService.process_stream`，`StreamBillingTracker` 记账每一帧。
   同文件还有 `/stop`（作者停止）、`/steer`（运行中插话）、`/suggest`、`/health`。新增会触发 LLM 的端点必须同时有
   鉴权 + 项目权限 + 成本上限 + 按用户限流（见该文件模块 docstring）。
2. `AgentService.process_stream`（`service.py`）：`SessionLoader` 取历史与跨轮工作集，`ContextAssembler` 组装上下文，
   `MessageManager.build_system_prompt` 建只含跨轮、跨角色不变内容的系统提示，`build_turn_context` 建本轮上下文
   （拼进本轮用户消息，见「上下文与跨轮记忆」），`ToolContext.set_context` 设置工具上下文，然后
   `StreamAdapter.process_workflow_events(run_writing_workflow_streaming(...))`，结束时保存历史。
3. `run_writing_workflow_streaming`（`graph/writing_graph.py`）选初始 agent，顺序是：`resume_route_after_exhaustion`
   （上一轮截停后只回「继续」）→ 笼统要求先问清楚 → `inherit_routing_after_clarification`（仅 llm 策略）→
   `router_node`（llm）或固定从 writer 开始（off）。策略来自 `AGENT_ROUTER_STRATEGY`；前端 `generation_mode=fast`
   （前端默认）照常走 llm 路由、只关自动质检，`quality` 强制 llm 并开自动质检。[fast-mode-default][n-fast-default]之后循环跑 agent，直到完成或达到协作轮数上限。
4. `nodes.run_streaming_agent`：共用系统提示 + `nodes.build_agent_directive`（角色提示 + `scope_directive` 等，作为这次输入的最后一条消息）
   → `openai_agents/runner.run_openai_agents_streaming_agent`。[cache-stable-prompt-layout][n-cache-layout]
   runner 用 `Runner.run_streamed` 跑 SDK，事件映射：`response.output_text.delta` → TEXT，reasoning delta → THINKING，
   `tool_called` → TOOL_USE，`tool_output` → TOOL_RESULT / HANDOFF / WORKFLOW_STOPPED（熔断后为
   MESSAGE_END(stop_reason=tool_failure_circuit_open) + ERROR）。
5. `StreamAdapter.process_workflow_events`：`core/workflow_events.StreamEvent` → SSE 帧（`core/events.EventType`），
   用 `StreamProcessor` 捕获 `<file>` 正文并落库。

预算（`apps/server/config/agent_runtime.py`，均可用同名环境变量覆盖）：`AGENT_COLLABORATION_MAX_ITERATIONS`（12，agent 轮数）、
`AGENT_TOOL_CALL_MAX_ITERATIONS`（60，单次 run 的 SDK max_turns，最后 `runner.SOFT_LANDING_TURNS` 轮提示收尾）、
`AGENT_RUN_MAX_MODEL_CALLS`（120，请求级）、`AGENT_RUN_WALL_CLOCK_TIMEOUT_S`（780，须早于 Railway 900 秒断连）、
`AGENT_SSE_HEARTBEAT_INTERVAL_S`（15）、`AGENT_AUTO_REVIEW_THRESHOLD_CHARS`（500）。见 [runtime-budget-and-heartbeat][n-budget]、
[no-progress-guard-and-soft-cap][n-noprogress]、[platform-deadline-and-terminal-guard][n-deadline]。

## 多 Agent 协作

`graph/router.py` 的 `WORKFLOW_AGENTS` 是初始 agent 之后的计划序列上限：

| workflow_plan | 序列 |
|---|---|
| quick | writer（必要时再 review） |
| standard | planner → writer |
| full | planner → hook_designer → writer |
| hook_focus | hook_designer → writer |
| review_only | quality_reviewer |

每条序列之后都可能经自动质检门或显式交接进入 quality_reviewer。`plan_workflow_agents` 按用户范围裁剪：`write_content=false` 剔除 writer，`read_only=true` 整条清空。交接有三种：
显式 `handoff_to_agent`；按 `workflow_agents` 的计划交接；自动质检门（writer 本轮写进 draft / script 的字数达到阈值后交给
quality_reviewer；同一交付在 writer 及其之前的计划内可写角色中成功写入的正文累计，中间经过只读角色不清零，计划外跳转不累计）。审查最多 `MAX_REVIEW_ROUNDS` 轮，返修稿不再自动送审。

### Router

- 输入：用户本轮原话；有上一条 assistant 消息时，user 消息为 `[上一轮助手结尾]`（最后 600 字 + 状态卡摘要）+
  `[上一轮路由]`（落库 routing 的一行 JSON）+ `[用户本轮原话]`（`build_router_user_message`，系统提示不变）。确定性的延续（见上文第 3 步）先于 LLM 路由，命中时不调路由模型。
  ROUTER_PROMPT 区分补问作答（沿用上一轮全部路由字段）和确认提议的下一步（按那一步路由，不沿用
  read_only=true / write_content=false / 已交付的 scope）。
  作者在这部作品里的第一条消息（`service.FIRST_MESSAGE_ENTRIES`，会话里还没有 AI 回复）时 user 消息为
  `[新作品的第一条消息]`（作品类型）+ `[用户本轮原话]`，ROUTER_PROMPT 按「先写再给方向」判断交付范围；模型输入另附
  `service.first_message_hint`（不落库）。[first-turn-writes][n-first-turn]
- 输出：`current_agent`、`workflow_plan`、`workflow_agents`（已裁剪）、`routing_metadata`（`RouterDecision` 全量，含
  `write_content`（None = 旧格式未给出）、`read_only`、`scope`、`requested_file_types`；ROUTER_PROMPT 要求 scope <= 40 字，`MAX_SCOPE_CHARS`=80 硬截断）。
- 决策记录：[router-previous-turn-context][n-router-ctx]、[routing-continuity][n-routing]、[routing-inherits-the-task-owner][n-owner]。

### 用户范围与交接规则

- **等作者回答使用结构化事件**：需要回答才能继续时调用 `request_clarification`，普通问号和条件句不控制计划交接；跨轮延续只认落库的 `clarification_pending`，不从回复标点猜等待状态。参见 [typed-boundaries][n-typed-boundaries]。
- **计划交接不扩大交付**：交接说明固定追加 `author_scope.PLANNED_HANDOFF_SCOPE_NOTE`（已完成的不重做、不改刚交付的文件、
  不追加作者没要的产出）。[vague-and-plan-files][n-vague]
- **笼统要求先问清楚**：`author_scope.is_vague_edit_request` 命中时不走路由，writer 单独一轮先问（`state["clarify_first"]`，写文件
  工具以 `error_type="clarify_first"` 拒绝，显式交接丢弃）；上一轮只提了一处修改或已经问过一次时不问。[vague-and-plan-files][n-vague]
- **规划补写只认结构化请求类型**：Router 明确给出 `requested_file_types` 且包含 outline 时，规划师在聊天里交付多章规划、未写入大纲文件才有一次纠偏；缺少/非法类型或未请求大纲不从作者措辞补写。参见 [typed-boundaries][n-typed-boundaries]。
- **作者改了什么给模型看，不猜授权**：`tools/author_changes.py` 按版本历史算净差异放进 `<author_edits>`（`query_files` 按 id 读全文时也带）；
  `edit_file` 在锁内比对本请求模型看过的正文（`mcp_tools._ReadSnapshots`，读到的内容由 `usage_hooks.ReadSnapshotPromotionMixin`
  在下一次模型调用起生效），作者在生成期间又改了就以 `stale_content` 拒绝，改到作者写的段落时结果带 `author_edits_touched`。
  [author-edits-shown-not-guessed][n-author-edits]
- **受保护稿件不由聊天删除或整份覆盖**：作者保存过正文的文件，在应用内对话中的 delete_file（含递归删除）和 create_file 同名剧集复用会拒绝；附加、焦点、点名、否定词、上一轮提议不授予权限，核验失败也不放行。作者使用现有文件菜单/编辑器；普通 edit_file、AI 自己的产物和直接人工 API 保持既有路径。参见 [typed-boundaries][n-typed-boundaries]。
- **作者可见文字**：每个 agent run 的 TEXT 经 `core/author_facing_text.AgentTextShaper` 补段落空行；作者写中文时，紧接工具调用的
  英文过程句不发给作者。[no-english-narration][n-narration]
- **只给作者一份总结**：writer（非快速模式、非返修、未到审查上限，且开着自动质检门或计划里下一个是 quality_reviewer）写正文类文件、
  可能送审时（create_file 的正文还没写出来；edit_file / write_chapter 累计字数到了送审阈值；或计划送审）图发 `CHAT_HOLD(begin)`，StreamAdapter 扣住之后的对话文字（正文不扣）；真的交给
  quality_reviewer 时在 HANDOFF 前发 `CHAT_HOLD(discard)`，由审稿人（或返修后的 writer）写唯一总结，writer 的问题以
  `[内容创作者要问作者的问题]` 交给它们；不送审时扣着的文字在下一个 agent / 交接 / 收尾 / 出错事件前或流结束时原样放出
  （run 中途的 STEERING_RECEIVED 不放出）。CHAT_HOLD 不发给前端。[single-summary][n-summary]
  质量门可用且作者要正文时，writer 前的原计划可写角色也按成功正文写入扣留；同一交付的累计正文达到门槛、实际沿计划前缀交接且没有收尾问题才丢弃，
  正文记录接续给 writer。只有规划、失败写入、未执行交接和提问原样释放。[planned-body-review][n-planned-body]
- **送审证据用落库后的事实**：送审交接附 `[本轮正文字数]`（落库后的 `count_words`）和 `[作者的篇幅要求]`
  （`review_evidence.find_length_requirements`：原话 + `length_requirement_bounds` 换算的达标区间，单个目标数 ±15%，
  范围和上下限是硬要求；区间内不算缺陷），读取有时间上限、超时用已读到的；`[待审查内容]` 取本请求写过的正文落库后的样子，
  放不进预算的长文件只附本请求改动的那段（写入前正文由 `ToolContext.remember_pre_write_content` 记下）。
  多文件在既有总预算内逐文件分配，每份保留来源文件 ID 与 `updated_at`；附了全文的来源行写「以下是全文，不用再读取」，未附全文的部分明确标注，按 ID 精确 `query_files` 按章节读取，不把省略部分当作已经核对。返修交接的 `[返修对象当前正文]` 只附审稿意见点到的文件。
  [single-summary][n-summary]、[writer-delivery-requirements][n-length]、[thinking-loop-inputs][n-thinking]、[quality-mode-lean][n-lean]
- **自动质检门只认成功的正文写入**：按 `graph/body_writes.py` 计 draft / script 里实际写进的字数；改大纲、设定、角色和聊天文字不算；
  写入全部失败时不送审。[single-summary][n-summary]、[tool-failure-breaker][n-breaker]、[review-gating][n-review]
- **作品第一条消息不送审**：`state["first_message_project_label"]` 非空的一轮关掉自动质检门，writer 自己 `handoff_to_agent(quality_reviewer)` 也丢弃；交接前没给作者写话时，同一个 writer 再跑一轮只写总结（`FIRST_ROUND_SUMMARY_CONTEXT`，一次）。作者读到的是 writer 自己的交付说明，不是返修记录；路由显式安排的审稿照常。[first-turn-skips-review][n-first-review]
- **StreamProcessor 丢掉 IDLE 下独占一行的标记**：没有进行中的捕获时，独占一行（不在 ``` 围栏里）的 `<file>` / `</file>` 不进对话，
  行内提到、反引号包着的照常保留。[single-summary][n-summary]
- **只读请求不改文件**：router `read_only=true` 时不交接给有写权限的 agent（显式 handoff 也拦），提示卡
  `WORKFLOW_STOPPED(reason="read_only_handoff_blocked")` 推迟到终止事件之前或作为图的最后一个事件发出，`apps/server/api/agent.py` 按
  `core/events.NON_TERMINAL_WORKFLOW_STOPPED_REASONS` 不把它当终止帧（之后若异常仍补发兜底 error 帧并退款）；交接给 quality_reviewer
  这类只读 agent 照常；`tools_adapter` 对 `registry.FILE_WRITE_TOOL_NAMES` 一律以 `error_type="read_only_request"` 拒绝（工具仍在清单里，
  描述标明本轮不可用）并经熔断器记账，`parallel_execute` 整次拒绝，
  `update_project` 照常；只读请求留下的空文件直接回滚、不补写。[scope][n-scope]
- **调了不存在的工具不致命**：runner 的 `RunConfig` 设 `tool_not_found_behavior="return_error_to_model"`，返回
  `error_type="tool_not_found"` 并经熔断器记账（同名连续 3 次的熔断原因为 `repeated_unavailable_tool_call`）。[scope][n-scope]、[tool-failure-breaker][n-breaker]
- **范围指令进每个 agent 运行的系统指令**：路由的范围判断转成 `state["scope_directive"]`，由 `nodes.build_agent_directive` 以
  `## 本轮用户范围约束 [最高优先级]` 放进这次运行的系统指令（`[本次运行的系统指令]`，runner 把它作为输入最后一条 user 消息，不写回
  `state["messages"]`；系统提示里固定写明它与系统提示同等效力），每次跑 agent 前按当前 agent 重新生成（「规划完直接交接 writer」只给规划类角色）；
  计划交接的 `handoff_packet.todo` 带用户 `scope`。角色提示、截断续写、篇幅要求、快速模式同样放在这里，不进系统提示。
  [scope][n-scope]、[cache-stable-prompt-layout][n-cache-layout]
- **空文件纠偏轮由建空文件的 agent 自己补写**；被纠偏打断的一轮若以提问收尾，「等用户回答」带过纠偏轮。[scope][n-scope]
- **写完 `</file>` 就结束 run 时同一 agent 续写**：有写权限的 agent 这次 run 里 create_file 成功、最后的文字以写完的 `<file>` 正文
  结束（`completion_evidence.ends_on_file_block`），且没有显式交接、澄清、轮数耗尽、错误时，同一 agent 再跑一轮（交接说明以
  `FILE_STREAM_CONTINUATION_MARKER` 开头）；放在计划交接 / 自动质检之前，正文与正文写入记录接续给续写轮；受 `max_iterations` 约束，
  每轮都要真的新建并写完文件才再续。
  [file-stream-continuation][n-continue]
- **「已保存 / 已还原 / 已修改」不做文字检测**：靠 `prompts/base.py` 的 `UNIFIED_EXECUTION_PROTOCOL` 约束（只有本轮对应工具成功后才这样说，
  没改就直说没改）。[file-stream-continuation][n-continue]
- **任务板声明产物类型和来源**：文件任务声明 artifact_file_type、artifact_refs/file_id 或 unit_spec；收尾只用本请求写过、仍存在非空且类型匹配的项目文件。正文范围每个编号须有实际正文产物，大纲标题不能证明正文；新 done 声明缺证据时不保存任务板。缺类型的旧任务可读但不自动收尾。参见 [typed-boundaries][n-typed-boundaries]。
- **重复建挡路的空文件**：空文件守卫（一次只流式写一个文件）挡住的 create_file，若要建的正是挡路的那个空文件，返回那个文件
  （`content=""` + `next_step`）；挡路的是别的文件时照旧 `pending_empty_file_unwritten`（`mcp_tools._repeat_of_pending_empty_file`）。
  [repeat-create][n-repeat]
- **项目备注由作者维护确认来源**：Agent 的新/改写条目仅为 AI 提议，引文、编号或答应文字不能升格；旧的作者确认和未标来源条目即使漏传或 notes="" 也保留。proposals 只替换提议区，三块共用4000字；作者直接保存 AI 记忆文本框仍可编辑整个字段。作者自己的项目名也不能由模型布尔值或原话放行改名。参见 [typed-boundaries][n-typed-boundaries]。
- **回复语言跟随作者最近一条消息**（`message_manager.detect_author_language`），本轮上下文 `<turn_context>` 末尾写明本轮语言，所有角色共用；界面语言只在
  看不出时兜底。[reply-language][n-lang]

## 流的终止、停止与计费

- 每个 `/agent/stream` 恰好以一个终止帧结束（`done` / `workflow_complete` / 终止性 `workflow_stopped` / `error`）；逃逸异常先补发 error 帧。
  错误码与固定文案在 `core/stream_errors.py`。[terminal-frame][n-terminal]、[error-and-refund][n-refund]
- 计费规则以 `core/stream_billing.py` 模块 docstring 为准：作者停止 / 断线 / 墙钟超时时，本轮没有实质产出（墙钟超时还要求结束时不在写
  回复）则退还；失控停止（no_progress / 模型调用上限 / 工具轮数耗尽）且没有任何写入成功则退还；正常结束计费；出错时 error 帧
  `refundable: false`（如工具失败熔断）、已有产出或正在写回复则计费，其余退还。「实质产出」只有写入留下正文，或一段回复正文达到
  `PROSE_MIN_CHARS`；思考、读文件、只建空文件、过渡旁白都不算。退还落库后补发 `quota_refunded`，结算只做一次。
  [error-and-refund][n-refund]、[stop-output-definition][n-output]
- 作者停止走 `POST /agent/stop` → `SSEStreamPump.request_stop()`，连接上补发 `workflow_stopped(user_stopped)` 与 `done`；停止 / 断线 / 超时
  的取消路径会把进行中的 `<file>` 捕获补存，并由 `core/round_outcome.py` 写终态、退还时移除本轮空白占位文件。
  [graceful-stop][n-stop]、[abort-flushes-capture][n-flush]、[stop-output-definition][n-output]
- 墙钟时限由 `SSEStreamPump` 执行：到点先发真实超时终态再等生产者收尾。[platform-deadline][n-deadline]、[deadline-terminal][n-deadline2]
- 运行中插话：`/agent/steer` 写入 `core/steering.py` 的队列，runner 在边界处消费。[steering-busy-409][n-steer]
- SSE 事件类型见 `core/events.py` 的 `EventType`。

## 上下文与跨轮记忆

- `ContextAssembler`（`context/assembler.py`）收集焦点文件、附加文件、用户引用、相关大纲 / 角色 / 设定。分档（`context/prioritizer.py`）：
  CRITICAL（焦点文件、用户引用）→ CONSTRAINT（角色设定、高重要度设定、前一章，角色卡与设定排在章节之前）→ RELEVANT（兄弟章节、
  中重要度设定、检索片段）→ INSPIRATION。非 CRITICAL 档截断后不足 500 字且不到原文一半的残片丢弃（仍在文件清单里）；检索只排除最终
  入选的条目；角色 / 设定名作为子串出现在查询里时加分。
- 预算：上下文 `AGENT_CONTEXT_TOKEN_BUDGET`（32000）、历史窗口 `AGENT_CHAT_HISTORY_TOKEN_BUDGET`（32000），与系统提示、工作集共用
  `context/budget.DEFAULT_PROMPT_TOKEN_LEDGER_CEILING`（160000）台账，台账只收缩历史。
- 渲染：条目标题行为 `标题 (id=…) [全文]` 或 `[已截断：显示 x/y 字；全文用 query_files(id="…")]`（检索片段标 `[检索片段，非全文…]`）；
  标注 [全文] 的条目就是该文件截至本请求开始的完整内容（本请求内被修改过的文件除外）。`MessageManager` 只对原始上下文 strip + 兜底封顶；已在原始上下文完整出现的条目，
  world_model 里只列标题 + id。
- 跨轮（`core/session_loader.py`）：runner 回放历史时丢弃工具结果，新一轮靠面包屑（assistant 历史末尾的「[此前的工具操作]」，列创建 /
  编辑 / 删除和按 id 精确读取的文件）和工作集（最近 2 条 assistant 回复读写过的文件，按当前库内容整份注入，最多
  `AGENT_WORKING_SET_MAX_FILES`=8 份、`AGENT_WORKING_SET_MAX_CHARS`=60000 字）。`service.py` 用
  `SessionLoader.attach_working_set_to_user_content` 把它拼在本轮用户消息之前，不进系统提示、不改历史，前缀缓存可以一直命中。
- 本轮上下文（`MessageManager.build_turn_context`，`<turn_context>`）：叙事约束、作者本条消息选的技能、组装的项目上下文、当前文件、
  任务计划板快照、本轮回复语言。放在本轮用户消息最前面、工作集之前；系统提示只留基础提示、语言规则、布局说明和技能目录，
  所以 `[系统提示][历史]` 跨轮、跨 agent 逐字不变（`tests/test_agent/test_prompt_cache_prefix.py`）。历史超过预算后窗口每轮滑动，
  仍会在系统提示之后打断跨轮前缀。
- 决策记录：[full-text-and-working-set][n-context]、[characters-and-core-lore][n-lore]、[cache-stable-prompt-layout][n-cache-layout]。

## 工具

工具清单以 `tools/registry.py` 为准：`DEFAULT_TOOL_NAMES`（planner / hook_designer / writer）、`QUALITY_REVIEWER_TOOL_NAMES`（审稿人，没有写工具）。

| 工具 | 用途 |
|---|---|
| `create_file` | 新建文件（只建空文件，正文随后用 `<file>` 流式写入） |
| `edit_file` | 精确编辑（replace / insert / append / delete） |
| `delete_file` | 删除文件 |
| `rename_file` | 只改文件标题，id 与正文不变（同文件夹重名拒绝；[rename-file][n-rename]） |
| `query_files` | 查询和读取文件；按 id 可只读 `section` / `tail_chars` / `start_char`+`max_chars`，结果标明范围，范围读取只做陈旧检查快照、不算看过全文（[range-reads][n-range]） |
| `update_project` | 项目信息、备注、任务板 |
| `handoff_to_agent` | 交接给另一个 agent |
| `request_clarification` | 请作者澄清并暂停工作流 |
| `parallel_execute` | 并行执行多个子任务（schema 在 `tools/parallel_executor.py`） |
| `load_skill` / `read_skill_resource` | 技能 L2（方法与资源清单）/ L3（参考文件或 `path="SKILL.md"`，支持 `offset` 续读）；受每请求技能 token 预算限制，每请求最多读 `skills/content_budget.MAX_LOADED_SKILLS_PER_REQUEST`（3）个不同技能 |

文件流式写入分两次回复：这次回复以 `create_file` 工具调用结束；下一次回复以 `<file>` 开头写正文、`</file>` 结束，StreamAdapter 捕获后落库。
一次只流式写一个文件。[two-replies][n-two-replies]

## 提示词

- 真实失败稿用于回归和正确反例，不把样例人物、账目、物件或情节关键词写成提示词/Agent 的特判。先定位通用的范围、输入、执行证据或权限缺口；提示策略变更须验证跨题材效果与误改，不能只证明已知样例修好了。
- `prompts/base.py`：`get_base_prompt` 与 `UNIFIED_EXECUTION_PROTOCOL` 等共享协议。
- `prompts/subagents.py`：`ROUTER_PROMPT`、`PLANNER_PROMPT`、`HOOK_DESIGNER_PROMPT`、`WRITER_PROMPT`、`QUALITY_REVIEWER_PROMPT`。
- `prompts/suggestions.py`：建议生成。
- 项目写作配置只存数据库（`prompts/__init__.py`）；项目类型的默认模板在 `apps/server/config/project_templates.py`。

## 开发清单

### 添加工具

1. schema 加进 `tools/tool_schemas.py` 的 `TOOL_SCHEMAS`。
2. 实现加进 `tools/mcp_tools.py` 的 `MCP_TOOL_HANDLERS`（需要适配入参的可像 `parallel_execute` 一样在 `registry.TOOL_FUNCTIONS` 里接）。
3. 在 `tools/registry.py` 把它加进对应 agent 的工具集（`DEFAULT_TOOL_NAMES` / `QUALITY_REVIEWER_TOOL_NAMES`）。会改项目文件的工具还要加进
   `FILE_WRITE_TOOL_NAMES`（只读请求拒绝、`writing_graph._agent_can_write_files` 判断写权限）。
4. 会产生作者可见产出的工具：在 `core/stream_billing.py` 里定义它怎样算产出（`WRITE_TOOL_NAMES` 等），否则停止 / 出错时会被退还。
5. 前端：`apps/web/src/components/ToolResultCard.tsx` 的卡片、`apps/web/public/locales/{zh,en}/chat.json` 的 `chat:tool.<name>` 文案、
   `apps/web/src/lib/thinkingDisplay.ts` 的 `UNDERSCORE_IDENTIFIER_KEYS`。
6. 测试：`apps/server/tests/test_agent/test_tool_registry.py`（`validate_registry_alignment`）加上工具自己的用例。

### 添加 Agent 类型

1. 提示：在 `prompts/subagents.py` 写角色提示，注册到 `graph/nodes.py` 的 `specialized_prompts`；需要路由到它时更新 `ROUTER_PROMPT`。
2. 路由：`graph/router.py` 的 `AgentType`、`_AGENT_TYPES`、`get_next_node`、`_normalize_router_payload`、`_infer_workflow_from_agent`；
   新工作流还要改 `WorkflowType` 和 `WORKFLOW_AGENTS`。
3. 工具集：`tools/registry.py` 的 `AGENT_TOOL_NAME_MAP`（缺了会落到 writer 的工具集）。
4. 交接目标：`tools/tool_schemas.py` 里 `handoff_to_agent` 的 `target_agent` enum，和 `tools/mcp_tools.py` 的 `handoff_to_agent` 目标校验。
5. 作者可见名字：`core/author_facing_text.py` 的 `_AGENT_NAMES`、`graph/writing_graph.py` 的 `agent_names`；规划类角色加进
   `graph/author_scope.py` 的 `PLANNING_AGENTS`。
6. 前端：`apps/web/src/lib/agentDisplayName.ts` 的 `AGENT_DISPLAY_KEYS`、`apps/web/public/locales/{zh,en}/chat.json` 的 `chat:workflow.agents.<id>`、
   `apps/web/src/lib/thinkingDisplay.ts` 的 `UNDERSCORE_IDENTIFIER_KEYS`（带下划线的 id）或 `BACKTICK_ONLY_IDENTIFIER_KEYS`（普通英文词）。

## 调试

- 日志：`apps/server/utils/logger.py` 的 `log_with_context`（自动带 `apps/server/utils/request_context.py` 里的 `request_id` / `agent_run_id`）。每个请求结束时 `apps/server/api/agent.py`
  写一条 `Agent stream billing evaluated`，含 `billing_reason`、`produced_output` 和 `run_*` 运行摘要（模型调用数等，来自 `core/run_meter.py`）。
- 工具执行看 `tools/mcp_tools.py` 的返回结构（`status` / `error_type`）；事件转换看 `stream_adapter.py`；图的决策看 `graph/writing_graph.py` 的日志。
- 测试在 `apps/server/tests/test_agent/` 与 `apps/server/tests/test_api/`（如 `apps/server/tests/test_api/test_agent.py`、`apps/server/tests/test_api/test_agent_stream_hardening.py`）。

[n-budget]: ../../../.agents/notes/implemented/feature/2026-10-05-agent-stream-runtime-budget-and-heartbeat.md
[n-noprogress]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-no-progress-guard-and-soft-cap.md
[n-deadline]: ../../../.agents/notes/implemented/bug-fix/2026-10-13-agent-stream-platform-deadline-and-terminal-guard.md
[n-deadline2]: ../../../.agents/notes/implemented/bug-fix/2026-10-14-deadline-terminal-before-cancellation-cleanup.md
[n-router-ctx]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-router-previous-turn-context.md
[n-routing]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-routing-continuity.md
[n-owner]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-routing-inherits-the-task-owner.md
[n-scope]: ../../../.agents/notes/implemented/bug-fix/2026-10-03-agent-handoff-respects-user-scope.md
[n-planner-q]: ../../../.agents/notes/implemented/bug-fix/2026-10-09-planner-question-keeps-requested-prose.md
[n-vague]: ../../../.agents/notes/implemented/bug-fix/2026-10-09-agent-vague-requests-and-plan-files.md
[n-author-edits]: ../../../.agents/notes/implemented/architecture/2026-10-12-author-edits-shown-not-guessed.md
[n-manual]: ../../../.agents/notes/implemented/bug-fix/2026-10-09-agent-respects-author-manual-edits.md
[n-offer]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-author-edit-guard-reads-the-ais-offer.md
[n-narration]: ../../../.agents/notes/implemented/bug-fix/2026-10-09-agent-text-no-english-narration-or-glue.md
[n-summary]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-author-reply-single-summary-and-real-word-counts.md
[n-planned-body]: ../../../.agents/notes/implemented/bug-fix/2026-10-14-planned-planner-body-review-and-summary.md
[n-length]: ../../../.agents/notes/implemented/feature/2026-10-14-writer-delivery-author-requirements.md
[n-thinking]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-thinking-loop-contradictory-inputs.md
[n-breaker]: ../../../.agents/notes/implemented/bug-fix/2026-10-03-agent-tool-failure-circuit-breaker.md
[n-review]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-review-gating-and-handoff-details.md
[n-continue]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-file-stream-continuation-and-truthful-completion.md
[n-repeat]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-repeat-create-of-pending-empty-file.md
[n-notes]: ../../../.agents/notes/implemented/architecture/2026-10-11-project-notes-source-sections.md
[n-lang]: ../../../.agents/notes/implemented/bug-fix/2026-10-11-agent-identity-reply-language-rewrite-in-place.md
[n-terminal]: ../../../.agents/notes/implemented/architecture/2026-07-18-sse-stream-always-ends-with-terminal-frame.md
[n-refund]: ../../../.agents/notes/implemented/architecture/2026-10-05-agent-stream-error-and-refund-contract.md
[n-output]: ../../../.agents/notes/implemented/architecture/2026-10-09-stop-output-definition-and-round-outcome.md
[n-stop]: ../../../.agents/notes/implemented/architecture/2026-10-09-agent-graceful-stop-and-no-output-refund.md
[n-flush]: ../../../.agents/notes/implemented/bug-fix/2026-10-05-agent-stream-abort-flushes-file-capture.md
[n-steer]: ../../../.agents/notes/implemented/bug-fix/2026-10-05-steering-run-heartbeat-and-busy-409.md
[n-context]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-context-full-text-and-working-set.md
[n-lore]: ../../../.agents/notes/implemented/bug-fix/2026-10-08-agent-context-keeps-characters-and-core-lore.md
[n-two-replies]: ../../../.agents/notes/implemented/bug-fix/2026-10-10-file-streaming-protocol-two-replies.md

[n-typed-boundaries]: ../../../.agents/notes/implemented/architecture/2026-10-14-typed-authority-and-delivery-evidence.md
[n-rename]: ../../../.agents/notes/implemented/feature/2026-10-19-agent-rename-file-tool.md
[n-range]: ../../../.agents/notes/implemented/feature/2026-10-19-query-files-range-reads.md
[n-first-turn]: ../../../.agents/notes/implemented/feature/2026-10-19-first-turn-writes-before-asking.md
[n-first-review]: ../../../.agents/notes/implemented/feature/2026-10-20-first-turn-skips-auto-review.md
[n-fast-default]: ../../../.agents/notes/implemented/feature/2026-10-20-fast-mode-default-keeps-router.md
[n-cache-layout]: ../../../.agents/notes/implemented/bug-fix/2026-10-20-agent-cache-stable-prompt-layout.md
[n-lean]: ../../../.agents/notes/implemented/simplification/2026-10-20-quality-mode-length-tolerance-and-attached-text.md
