# AGENTS.md — deep_agent 2.0 工程纪律 本文件是给**在本仓库里干活的人和 AI 助手**看的硬约束。改代码前先读这里; 与这里冲突的改动,要么别做,要么先改这里并说明理由。 --- ## 〇、项目定位 2.0 是通风智能体系统的**新一代骨架**,参照 1.5 的对外契约重建,但内部实现 不做移植。定位是「可运行的骨架」,不是「功能完备的产品」: - **对外契约完全兼容 1.5**(`POST /api/chat` 的 multipart 入参与 SSE 帧格式), 这样 1.5 的前端不改一行就能连上。 - **对内一切重写**。1.5 积累的 40 个业务工具、22 个业务技能、MCP 工具池、 计费/调度/子智能体管理页都**不在**本仓库范围内。 - 骨架的价值在于**结构与纪律**,不在于功能数量。 --- ## 一、五条不变式(改动前必读) ### 1. `thread_id ≡ session_id` 由 `api/chat_routes._init_session` 强制。客户端传别的值一律 404。 理由:checkpointer 是进程级共享的,允许客户端指定任意 thread_id 就等于 允许读写别人的图状态。这条不变式在 2.0 是**全局**的——包括定时任务 (它为自己的每次执行建独立会话,而不是另起一个 thread_id 命名法)。 ### 2. 单进程(`workers=1`) 三处进程内状态:SQLite checkpointer、并发闸计数、APScheduler。 多 worker 下它们各自独立,症状是「同一会话续跑读不到上文的图状态」、 「并发闸形同虚设」、「定时任务被每个 worker 各触发一次」。 `main._check_single_process()` 会检测并告警。要扩多进程,先把这三处 换成 Redis/MySQL,别只改启动参数。 ### 3. 计费口径以 provider 的真实 usage 为准 `api/context_meter.extract_usage` 优先读 `usage_metadata`,读不到才回落本地 估算,并把 `usage_source` 标成 `estimate`。 **不要改成本地估算优先**。1.5 就是估算优先,为此专门开了 `fix/context-usage-accounting` 分支修口径;估算值参与决策的代价是 「限额拦不住、账单对不上」,而且没人能一眼看出哪些数字是估的。 ### 4. 来源标注由工具侧产出,不靠模型自觉 工具用 `tools.registry.sourced(data=..., sources=[...])` 返回结构化的来源; `api/source_tracker` 从 ToolMessage 里抽取并去重。 **不要在提示词里要求模型「记得写引用」**。模型漏写和编造这两种失败模式 无法在事后可靠区分;读工具返回值就不存在这两个问题。 ### 5. 摘要压缩用框架的中间件,不自造 deepagents 的 SummarizationMiddleware 会把被淘汰的历史**追加落盘**到 `/conversation_history/{thread_id}.md`,并把路径嵌进摘要正文,agent 之后 能自己 `read_file` 找回来。 两个后果要记住: - **不要再传一个 SummarizationMiddleware 给 `create_deep_agent`**。 `create_agent` 会校验中间件 `name` 唯一,重复直接抛 "Please remove duplicate middleware instances"(1.5 时代靠同名替换的 做法在 deepagents 0.6.x 已失效)。 - 要关掉压缩,用 `CTX_COMPACT_ENABLED=0`(内部走 `HarnessProfile.excluded_middleware`)。关掉会**同时失去历史落盘能力**, 长会话会硬撑到超窗报错。 --- ## 二、踩过的坑(别重犯) 这些都在开发过程中真实触发过,症状与原因距离很远,所以单独记下来。 ### 坑 1:环境变量与 `.env` 的优先级 机器上存在全局 `OPENAI_API_KEY`(实测遇到过一把无效的)时,进程环境会盖掉 项目 `.env`。症状是「密钥明明配对了却报 401,且报错里显示的密钥后几位和 `.env` 里的不一样」。 `config.py` 把它做成**显式可配**的 `DEEP_AGENT_ENV_PRIORITY`: - `dotenv`(默认):`.env` 优先。防「被污染的 shell 静默改掉项目配置」 - `process`:进程环境优先。**跑变体实例必须用这档**,例如 `DEEP_AGENT_ENV_PRIORITY=process DB_DIALECT=mysql PORT=8081 python main.py` - `none`:完全忽略 `.env`(测试/CI 用) 为什么默认不是 `process`:两种失败模式代价不对等。`dotenv` 的代价是「有意的 环境变量覆盖不生效」——加个前缀就绕过,且启动日志会告诉你;`process` 的代价是 「本机某个无关的全局变量静默改掉项目配置」——表现为身份诡异的 401 或连错库。 **无论哪一档,冲突都会打 WARNING 并把键名暴露给 `/health` 的 `config_conflicts`**,包括「生效值长什么样」(脱敏)。这条可见性是本机制的核心 价值:它把「配置没生效」从玄学变成一行日志。 注意 `process` 档会放行**所有**环境变量,包括那把无效的全局密钥——所以跑变体 实例时要连密钥一起显式传入,不要只传你要改的那几项。 ### 坑 2:`MemorySaver` 之外,checkpointer 必须显式声明上下文窗口 摘要中间件的 `trigger=("fraction", x)` 依赖模型 `profile["max_input_tokens"]`。 不声明的话,框架在首次触发压缩时报错而不是降级。 `agents/model_factory.build_model` 无条件设置 `profile={"max_input_tokens": window}`。 新增任何模型构造路径都要照做。 ### 坑 3:子智能体的 `tools` 必须是 `BaseTool`,主 agent 的 `tools` 可以是裸函数 两者要求不同:主 agent 的 `tools=` 由框架包装,子智能体的 `tools` 会被直接 塞进 `ToolNode`,那里要读 `tool_.name`。传裸函数会炸在 `tool_.name` 上, 而堆栈只指向 `SubAgentMiddleware`,看不出是参数形态问题。 用 `tools.registry.get_tools_by_name([...])`,它返回 `BaseTool` 列表。 ### 坑 4:自定义中间件的同步/异步钩子都要实现 只实现 `wrap_model_call` 而服务全程走 `astream` 时,运行期抛 `NotImplementedError`,报错位置在模型节点内部。 `agents/middleware.DynamicTimeMiddleware` 同时实现了 `wrap_model_call` 与 `awrap_model_call`。 ### 坑 5:ToolMessage 也走 `messages` 通道 `stream_mode=["updates","messages"]` 的 messages 通道里不只有 `AIMessageChunk`,`ToolMessage` 也在推。不过滤的话,工具返回的原始 JSON 会混进给用户看的正文。 对策:只在 `isinstance(chunk, AIMessageChunk)` 时发 `token` 事件。 同理,摘要中间件的模型调用也在同一通道,靠 `metadata["lc_source"] == "summarization"` 过滤。 ### 坑 6:图输入必须是 dict `astream({"messages": [...]})`。传裸 list 会被当成 `__start__` 节点的返回值, 报 `InvalidUpdateError: Expected dict, got [...]`。 ### 坑 7:`load_builtin_tools` 对已导入模块是空操作 工具的注册靠**模块体里的装饰器**,而 `import_module` 对 `sys.modules` 里 已有的模块不重放模块体。所以「清空注册表后重新装载」必须显式 `reload`, 否则注册表一直是空的,表现为「工具莫名其妙全没了」。 ### 坑 8:时间戳精度决定消息顺序 `created_at` 只到秒时,同秒写入的 user/assistant 两条消息只能靠随机 UUID 做次级排序,顺序会随机颠倒——冷启动补种图状态时会把「谁说了什么」喂反。 `db.db_conn.fmt_dt` 输出到微秒,MySQL 侧对应 `DATETIME(6)`。 ### 坑 9:两个计量器之间没有自动连接 `ContextMeter`(算上下文占用与用量)与 `billing.meter.UsageMeter`(入账) 是分开的。收尾时必须显式把前者的数字 `add` 给后者——漏了这一步, 交互式对话全程**不会计费**,而 `/usage/today` 显示 0,表面看「一切正常」。 ### 坑 10:权限规则**首条命中即返回** `FilesystemPermission` 是逐条匹配、命中即返回。所以具体的 `allow` 必须排在 宽泛的 `deny` 之前,否则 `deny /**` 会把后面的 allow 全部短路,agent 连技能文件都读不到。 ### 坑 11:`python main.py` 会让模块被导入两次 `uvicorn.run("main:app")` 会再导入一次 `main`,于是模块级副作用执行两遍 (一次作为 `__main__`,一次作为 `main`)。日志会重复,配置会被构造两次。 **副作用一律放进 lifespan**,模块级只留纯定义。 ### 坑 12:工具卡必须按 `tool_call_id` 索引,不能按工具名 同一轮里同一个工具会被调多次(实测 agent 会自己换关键词连查 4 次)。 只按 `tool` 名索引卡片 → 后一次的结果挂到前一张卡上,界面看起来「结果和 参数对不上」。 1.5 的契约里没有 `tool_call_id`,2.0 补了它作为**附加字段**(`sse_events` 的 tool_call / tool_result)。附加字段安全、改名不安全——见 §四的契约纪律。 ### 坑 13:`element.textContent += x` 会摧毁子元素 `textContent` 的读是「所有后代文本的拼接」、写是「替换全部子节点」。所以 `bubble.textContent += token` 这类写法会:① 把子元素(工具卡片)的文本一起 读进来,② 赋值时把那些子元素整个删掉。表现是工具卡片被抹成一坨纯文本, 而且每次追加都重新拼接全文(O(N²))。 正确做法是给正文一个独立的文本节点,并在插入卡片时「断开」当前文本段, 以保留时间顺序(见 `static/index.html` 的 `appendToken` / `addCardFor` / `breakSegment`)。 --- ## 三、分层与依赖方向 ``` main.py ──> api/ ──> agents/ ──> tools/ └──> billing/ ──> db/ └──> scheduler/ 所有层 ──> obs/ 与 config ``` 规则: - **`db/` 不反向依赖上层**。业务逻辑不进 store,store 只做存取。 - **`billing/` 不依赖 `api/`**。计费只接受标量参数,不认识 `StreamRequest`。 - **`tools/` 不依赖 `agents/`**。工具是纯函数,拿不到 agent。 - **`obs/` 与 `config` 可以被任何层依赖**,它们不依赖任何业务层。 - 跨层取配置一律走 `config`,不要在业务代码里 `os.getenv`。 --- ## 四、编码约定 - **每个配置项必须有默认值**,且默认值 = 「不配也能跑起来」的那套。 新增开关的默认值不得改变既有行为。 - **对外契约的字段名只在 `api/sse_events.py` 声明**。其他任何地方都不得 手拼 `data: ` 字符串——字段改名漏掉一处,前端就静默渲染失败。 - **契约只做加法**:新增字段安全(旧前端忽略未知字段),改名或删除不安全 (静默破坏渲染)。要改已有字段名,先确认 1.5 前端也一起改。 **唯一的例外是删掉 `POST /api/chat/only`**(见 §六),那是一次经过评估的、 有记录的收缩——不是「删了也无所谓」的先例。 - **函数式注释写「为什么」不写「是什么」**。行内注释只在代码本身无法表达 约束时写(顺序要求、副作用、反直觉的取舍)。 - **同步驱动(pymysql / sqlite3)在 async 路由里必须走 `asyncio.to_thread`**, 否则单进程下一个人查历史会卡住所有人的流。 - **工具失败返回可读原因,不抛裸异常**。抛异常会打断整轮工具调用, 返回错误说明能让模型自己纠正。 - 文件规模控制在 500 行以内(1.5 的约定)。超了就拆。 --- ## 五、验证方式 ```bash # 1) 离线单元 + 契约测试(不需要模型密钥,不需要数据库服务) python -m pytest # 2) HTTP 层端到端(需要服务已启动 + 可用的模型密钥) python main.py & # 或 bash scripts/start_server.sh start python scripts/http_e2e.py # 3) 只做导入与语法自检 python scripts/selfcheck.py ``` 三者的分工: | 检查 | 覆盖 | 何时跑 | |---|---|---| | `pytest` | 单元逻辑 + SSE 契约字段 + 内核通道分工(用假 agent) | 每次改动 | | `scripts/http_e2e.py` | 真实 HTTP + 真实模型 + 落库 + 中断闭环 + 调度 | 提交前、发布前 | | `scripts/selfcheck.py` | 导入、建表、agent 编译、工具注册 | 改结构后快速自检 | **改 SSE 事件字段必须同步改 `tests/test_sse_contract.py`**——那里的断言就是 兼容性契约本身。 --- ## 六、已知缺口(有意为之,不是遗漏) 列在这里是为了让读代码的人知道「这里没写不是因为忘了」。 ### 为什么没有 `/chat/only` 1.5 有 `POST /api/chat/only`,2.0 **删掉了**。如果你正想「为了兼容 1.5 把它加 回来」,先看这段。 它和 `/chat` 的差别只有计费流水上那个 `kind` 标签(`chat` vs `chat_only`), **行为完全相同**。而那个标签在 1.5 和 2.0 里都没有任何消费方:没有按渠道聚合 的接口、没有按渠道拆的额度、没有渠道维度的报表。也就是说它看起来像渠道区分, 实际不承载信息。 这份重复在 1.5 有可查的代价: - **出过计费 bug**:`docs/实施记录-B3-P3.md` 的 R7-#24 / P1-11 —— 「/chat vs /chat/only 标题计费口径不一致」。两个本该一致的入口漂移了。 - **多付一份安全验证**:`tests/l4_auth_runtime.py` 里的 `L4-1b-c2`(thread_id 越权)与 `L4-8c`(413 体积闸)存在的唯一理由,是在第二个入口上再证一遍同样 的阀门。 - **多养一个专用探针**:`tests/probe_b345_dedup_diff.py` 的唯一职责是证明两者 行为相同。 - **最终被收敛**:B3-45 提交说明写着「/chat vs /chat/only 95% 重复,抽 `_run_chat_pipeline`」——1.5 自己也认定它是重复,只是出于兼容留了端点、只删 了重复实现。 - 而 1.5 前端**从头到尾 0 次调用** `/chat/only`。 要重新引入这类入口,正确的顺序是**先把渠道差异做实**(按渠道聚合用量、按渠道 拆额度、渠道维度报表),再引入入口;不要先立一个行为相同的端点,指望以后有人 去消费那个标签。 保留的另两个入口 —— `/chat/resume`、`/chat/interrupt` —— 不属于这类:它们承载 的是**不同能力**(中断恢复、挂起查询),不是同一能力的别名。 1. **没有鉴权**。`/api/chat` 直接取 multipart 的 `user_name`(缺省 `default`), 客户端可自报身份。计费、会话归属、并发配额全建立在这个值上,所以 **当前只适合内网或开发环境**。接真实鉴权时替换 `api/chat_routes._resolve_user` 为 `Depends(...)` 即可,其余代码不用动。 2. **没有业务工具与业务技能**。只有 4 个演示工具(时间/检索/计算/计划审批) 与 1 个示例技能。业务工具照 `tools/demo_tools.py` 的模子加。 3. **没有 MCP 接入**。1.5 的 `tools/mcp_client_pool.py` 等未移植。 4. **会话标题用截断而非 LLM 生成**(`_ensure_session_title`)。省一次模型 调用的钱和延迟;想换只改那一个函数体。 5. **并发计数是进程内的**。见不变式 2。 6. **`remember` 长期审批规则未实现**。`ResumeRequest.remember` 收到即忽略。 --- ## 七、改动清单模板 动到以下任一文件时,想一想上面哪条纪律相关: | 文件 | 相关纪律 | |---|---| | `api/sse_events.py` | 契约唯一出口(约定 §4)+ 契约测试必须同步 | | `api/sse_core.py` | 坑 5 / 坑 6 / 坑 9、不变式 1、3、4 | | `agents/agent_factory.py` | 不变式 5、坑 2、坑 3、坑 10 | | `config.py` | 坑 1、每项必须有默认值 | | `agents/agent_memory.py` | 不变式 2、坑 2 | | `tools/registry.py` | 不变式 4、坑 7 | | `billing/*` | 不变式 3、坑 9 | | `db/*` | 坑 8 | | `main.py` | 坑 11、不变式 2 |