AGENTS.md 15 KB

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 的约定)。超了就拆。

五、验证方式

# 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