v0.1.18
一次底座迁移 + 反向评审补强的发布:① MCP 宿主迁到官方 SDK v2 / 协议 2026-07-28(P4.18,
等价迁移——命令契约与工具集逐字保留,代价是 [mcp] extra 的安装面破坏性变更);② 三条源自兄弟项目
反向评审的确定性补强/修复——ingest 撞名守卫(llm_wiki)、wiki/·.trash/ 写走原子覆盖(OpenKB)、
交给 agentao 前剔除毒空 *_API_KEY(gbrain);③ 检索收敛提示与一批文档校正。不新增退出码、不动门禁、
raw/ 只读不破。
新增
- ingest 摄入前挡「同一 source 页 slug 撞名」(源自 nashsu/llm_wiki v0.6 反向评审 §2.2,见
docs/backlog/notes/llm_wiki-反向评审-v0.6.md)
—— source 摘要页由rawio.find_source_page按raw_slug(stem)(=页身份归口)定位,故两篇 raw
.md只要raw_slug(stem)相同(a/report.md与b/report.md、annual report.md与
annual-report.md、.report.md与report.md)就会误关联到同一张wiki/sources/<slug>.md——
一张压另一张、raw_digest只认得一个版本(此前源命名仅约定 kebab、无子目录消歧防线)。ingest.py
新增确定性_reject_source_slug_collision预检:摄入前扫raw/下其余.md,凡raw_slug(stem)
与目标相同即EXIT_USAGE拒绝、列出冲突路径、要求改名。零-LLM、只堵不重构——复用既有raw_slug
(不新增 slug 方案/哈希/迁移),只比.md(唯一会被 ingest 建 source 页者)故不误伤 convert 的
report.pdf+report.md同源对。经 xhigh code-review 三轮收敛:① 从初版「按 basename 判」收正为「按
raw_slug判」(basename 太窄,漏annual report↔annual-report等异名同 slug 的真撞页);② 合法重摄
豁免——目标页已存在且其raw_digest确证归属本文件时放行(_target_page_owned_by,复用
provenance.parse_digest_value归口),使「属主页长期维护、同 slug 旁支只是未摄草稿」时重摄不再假阳被挡;
真撞(拿非属主旁支覆盖属主页)改在摄入那个旁支时当场拒,安全性不减;③ 每次 ingest 全量rglob
一遍raw/是已接受代价(run_guarded_write的gate.snapshot_raw本就同量级遍历raw/、还带哈希,
故此遍历同阶更轻)。残留:find_source_page的.→-回退(1.报告↔1-报告)键不同、不在此拦(窄边角,
不复刻 rawio 折叠逻辑以免漂移)。测试见tests/test_ingest.py(撞页拒绝 / slug-fold 折叠拒绝 / pdf+md 同源
对放行 / 属主重摄放行 / 非属主覆盖拒绝)。
优化
- query / Web 续跑提示补「检索收敛红线」(源自 v0.6 反向评审 §2.1) —— CLI
query.QUERY_PROMPT与
Web 目标续跑web/conversation._continuation_prompt(P4.16 循环,正是易空转处)各补一句「不要重复等价
检索;证据足够即回答」,压重复召回 / 空转。纯提示词、零代码逻辑改动——刻意不写进AGENTAO.md
(会波及 ingest 等所有工作流,太宽)、不引 agentao 侧预算参数(保薄壳 + 循环真相源在 agentao)。测试见
tests/test_query.py(CLI 提示含红线)/tests/test_web.py(Web 续跑提示含红线)。
变更
⚠️ 破坏性(可选 extra):MCP 宿主底座迁到官方 SDK v2 + 协议2026-07-28(P4.18,见
docs/P4.18-MCP2.0迁移.md) ——guanlan-wiki[mcp]的 SDK 依赖从
mcp>=1.27,<2硬切到mcp>=2,<3:官方 SDK2.0.0(2026-07-28,与协议修订同日)转 stable、v1.x 转入
只收安全修复的维护态,且 v2 删掉了整个mcp.server.fastmcp包(无 alias),故不做双大版本兼容层而是
硬切(决策P4.18-2;前置依据:guanlan 的核心依赖agentao已自带探针式 1.x/2.x 兼容,Tool 注入反方向不受影响,
决策P4.18-10)。装了mcp1.x 的环境须一并升级——guanlan mcp会以EXIT_USAGE明示需要mcp>=2
(决策P4.18-9)。核心依赖agentao[cli]下限一并从>=0.4.13抬到>=0.4.17:agentao 的 1.x/2.x 探针兼容层
是 0.4.17 才有的,不抬下限就留下一个"依赖解析完全允许、但一定坏"的组合——已装 0.4.13–0.4.16 的用户装本 extra 时
pip 只把mcp抬到 2.x,于是guanlan mcp照常工作,而 ingest/query/Web 问答每次都死在 agentao 侧的 Tool schema
上,故障点看起来与真凶毫不相干(决策P4.18-10)。- 定性:等价迁移(决策P4.18-1)——命令契约(
--transport/--host/--port/--auth-token-env/
--allowed-host/--allow-ask与全部默认值)、七工具集与信封形状、page/path口径、只读 + KB 零写契约、
退出码逐字保留;已注册在~/.claude.json/mcp.json里的 stdio & http 条目零改——v2 仍服务握手
时代旧修订,stdio / http 双传输均实测initialize报2025-06-18时正常协商并可继续tools/list、
tools/call(客户端首帧决定本连接 era,同一连接不得混 era,决策P4.18-7)。 - 唯一主动引入的 wire 变化:
initialize.serverInfo.version现报 guanlan 自身版本(单一来源
guanlan/__init__.py)。此前 SDK v1 在此回的是所装 mcp SDK 的版本(实测1.29.0)、v2 默认回空串
——两者都不是 guanlan 的版本,故一次钉正(决策P4.18-12)。 - 代码面:
mcp.server.fastmcp.FastMCP→mcp.server.mcpserver.MCPServer(含三处类型注解)、
ToolError换mcp.server.mcpserver.exceptions;http 姿态从mcp.settings后置赋值改走
streamable_http_app(stateless_http=…, transport_security=…, host=…)kwargs——v2 的Settings已无这些
字段(后置赋值直接ValueError,决策P4.18-4)。tools.py七个工具逻辑、_http_security/
_BearerTokenMiddleware/_serve_http、to_thread卸载姿态(决策P4.18-5)全部逐字节不动。 - 协议红利与刻意不做:无状态核心 /
server/discover/ 标准错误码白拿(我们本就stateless_http+
零服务端会话);Mcp-Method/Mcp-Name路由头服务端不强制、仅在 P4.17 §5 补注为反代限流手段;
cache hints 与 tasks 扩展显式不做——前者的CacheableMethod只覆盖 list/read/discover 类方法
(tools/call不在其列,救不了list_pages/graph的大 payload),后者在 SDK 2.0.0 里无现成实现且与无状态
姿态冲突(决策P4.18-8);OAuth 面(RFC 9207iss、DCR→CIMD)仍属 E2。 - 测试:in-memory 会话改
Client(mcp, mode="legacy")——v1 的
create_connected_server_and_client_session已删除,而 v2 默认mode="auto"走DirectDispatcher直调
(无流、无 JSON-RPC 帧、无握手),无脑替换会静默丢掉全套用例的序列化覆盖(决策P4.18-11);另加
test_in_memory_modern_mode_parity(默认 mode 覆盖 2026 现代路径、与 legacy 结果一致)、
test_stdio_subprocess_emits_only_jsonrpc_frames(真 stdio 子进程逐帧解析,决策P4.18-6)、
test_http_serves_legacy_protocol_client(http 上钉2025-06-18的现役客户端不掉线)。 - 回归网按 xhigh code-review 补齐(这些位置此前"改对了但没上网",变异实测能被静默丢掉):
test_http_rejects_forged_host_and_origin(伪造Host→ 421 /Origin→ 403,含非环回host一档
——丢掉transport_security=kwarg 时,绑环回仍被 SDK 兜底白名单救回 421、只有非环回档会漏成 200)、
test_http_is_stateless(响应头无Mcp-Session-Id;丢掉stateless_http=True时 SDK 客户端往返用例照旧
全绿,因为它会透明回传 session id)、test_http_serves_modern_protocol_client(现代 era 的真帧覆盖:
对 in-process server,Client除mode="legacy"外任何 mode 都走DirectDispatcher、不产生帧,故手写
2026 per-request 信封直接 POST)、越界用例加断消息文案(证守卫仍在我们手里、未被 v2 的ResourceSecurity
接管)、era 断言改按 SDK 的{MODERN,HANDSHAKE}_PROTOCOL_VERSIONS集合(不钉2026-日期前缀,避免上游
改版把未动的代码搞红)、importorskip钉到mcp.server.mcpserver(只探顶层mcp时 1.x 环境会 collection
ImportError 中断整场 pytest 而非整体 skip)、stdio helper 加-u+encoding="utf-8"+ stderr 独立抽干
并不再过滤空白行(三者分别修:不 flush 的泄漏被 fd-1 改指吞掉、非 UTF-8 locale 下伪装成"一帧没吐"、
stderr 写满约 64 KB 管道后卡满 timeout、空行污染恒真)、降级用例补"装着 1.x"与"内部 ImportError 不得被
冒充成缺 extra"两档。 - stdout 洁净的机制说明校正(实测):v2 的
stdio_server()在服务期把 fd 1 改指 stderr,故服务期的
print/os.write(1,…)(含 OTel 若真吐字节)都进不了帧——这层是 SDK 的结构性保证;子进程用例真正守的是
SDK 接管 fd 1 之前那段窗口(require_kb_root/P5.4 预热/build_mcp/argparse),也正是我们自己的代码
可能泄漏处。原先"在真传输上实证 OTel 不污染 stdout"的说法说过头了,已按实测改写(决策P4.18-6)。 tests/test_mcp.py63 例、全套 1141 通过 / 1 skip。
- 定性:等价迁移(决策P4.18-1)——命令契约(
文档
CLAUDE.md优化:status 段瘦身、过时事实校正、命令清单补全。- 发版前收口:
docs/发布到-PyPI.md改按「版本单一源在guanlan/__init__.py」重写实操步骤(并补两-PR
仪式、CHANGELOG 段头格式、tag 落点、发布后验证的缓存坑);归档 gbrain / llm_wiki / swarmvault 三份
反向评审笔记;校正README.en.md与DESIGN.md§7 中与 P4.18 不一致的口径。
修复
-
在 Claude Code 会话里跑
guanlan时「.env有真 key 却报无 key」(源自 gbrain v0.42.58 反向评审 §2,
探针 gbrain #1249,见docs/backlog/notes/gbrain-v0.42.58-反向评审.md)
—— Claude Code 会给子进程注入ANTHROPIC_API_KEY=''以掐断子进程的 LLM 调用;而 agentao 的
safe_load_dotenv用os.environ.setdefault(no-override),空串也算「已设置」,于是.env里的真 key
永远 setdefault 不进来,os.getenv恒返空串。触发面:provider = Anthropic 且从 Claude Code 会话里跑
guanlan ingest/query/web(OpenAI provider 不受影响——Claude Code 不注入OPENAI_API_KEY='')。
现在两条 LLM 路径在交给 agentao 之前都剔除毒空值:CLI 子进程路径runtime._subprocess_runner显式传
env=scrubbed_environ()(不再裸继承父环境);Web 进程内嵌入路径在chat.build_from_environment前调
drop_poisoned_api_keys()就地摘除(时机关键——build_from_environment在调用期才safe_load_dotenv)。
只删空/纯空白的*_API_KEY,绝不注入或读取任何真 key——守「脚本零 LLM、wrapper 不持 API key」不变量,
与本接缝已有的stdin=DEVNULL同类(喂给子进程前的环境清洗)。已实测:摘掉毒值后 agentao 自己的
dotenv 加载即恢复正常(safe_load_dotenv→ 真 key,discover_llm_kwargs→ 真 key)。测试见
tests/test_runtime.py(只删空值不删真值 / 子进程 env 实际内容 / 就地摘除幂等 / API 只回变量名不泄值)与
tests/test_web.py(断言摘除早于build_from_environment)。注:根因在 agentao,已另提 agentao#157 修
safe_load_dotenv;
但观澜这层清洗不依赖那个修复——它对任何 agentao 版本都生效,故不锁 agentao 下限。
另更正 backlog note §2 的处方:只改 agentaodiscover_llm_kwargs跳过空值不足以修复(那只是把
api_key=''变成缺省,真 key 仍因掩蔽而永不加载),必须在加载器或调用方摘掉毒值。 -
wiki/与.trash/的确定性写全部走原子覆盖,消除半写坏页(源自 OpenKB 反向评审 §2,见
docs/backlog/notes/openkb-2026-07-反向评审.md) ——
此前wiki/的零 LLM 写用裸Path.write_text/write_bytes:进程中断 / 磁盘满卡在写一半,会把
权威 markdown(内容页、index.md、撤回恢复配方、frontmatter 修复页)截成半截;而rawio早有的
atomic_write_raw(tmp +os.replace)此前只用在raw/。现抽两支公共原语——atomic_write_bytes
逐字节底座 +atomic_write_textUTF-8 文本外壳(atomic_write_raw重构为复用文本壳,字节级行为不变),
单一实现杜绝多处落盘规则漂移;7 处确定性写改用原语:remove的_drop_slug_from_page(内容页)/
_prune_index_line(index.md)/.trashmanifest、reindex的index.md回填、
fmrepair.repair_page_frontmatter(CRLF 保真故走字节底座)、gate门禁回滚原字节、
provenance.stamp_raw_digest的 stamp 与回滚。覆盖既有文件时_preserve_metadata保留原权限位 +
属主 uid/gid(best-effort)——否则os.replace换新 inode 会把 0644 页窄化成 0600 并改掉属主
(经两轮 codex 评审的 P2 项)。有意保留的固有取舍(同既有atomic_write_raw,均记在原语 docstring):
符号链接不写穿——对本模块反而更安全(fmrepair/provenance本就先拒链接,remove/reindex不跟随
即杜绝写逃逸出 KB,旧的就地写反而会写穿);不保 ACL/xattr——os.*xattr在 macOS 不可用、POSIX ACL
无标准库,对纯 markdown 无实义。测试见tests/test_atomic_write.py(14 例:三类失败模式 × 文本/字节两路- CRLF 逐字 + 权限/属主保留 + 新建跳过 + 经
_drop_slug_from_page/fmrepair/provenance的集成证明)。
- CRLF 逐字 + 权限/属主保留 + 新建跳过 + 经
Full Changelog: v0.1.17...v0.1.18