v0.1.22
主线是把 wiki 接进人已经在的聊天窗口。P4 此前有两种传输——Web(浏览器里的人)与 MCP
(Agent 客户端);P4.21 补第三种:IM 里的人。同一套只读会话逻辑、同一零写契约、同一零业务智能。
无接口破坏、无新退出码;核心依赖零变更(两个新 extra 均可选)。
另带一条与 IM 无关、但影响面更大的修复:agentao JSON 信封的编码契约(issue #50)——它是
ingest/query/heal/audit/Web 作业/MCP query 所有 LLM 命令的唯一入口,在中文 locale 的
Windows 上约三分之二的表现是"退出码 0 交付一段乱码",从来不会被谁注意到。
新增
guanlan im/im-login/im-identify——IM 宿主(P4.21,见
docs/P4.21-IM宿主.md) —— 让人在微信 / 飞书里直接问知识库。
形态是「一个平台无关的核心 + 若干薄适配器」,不是「某一个平台的宿主」:核心永不写
if platform ==,一切分派只读AdapterCaps(有一条测试断言守着)。v1 的两个适配器恰好互补
——个人微信不能编辑、只能整条发;飞书能原地改写——核心一行未改即驱动两者,这就是分界的验收标准。- 新的信任线是身份与受众面(§0.3):P4.11 管提示词注入、P4.17 管网络传输,两条都不覆盖它。
一个零监听端口的进程,照样能经出站长连把整库答案送进一个 200 人的群。 故默认 deny、
空白名单拒启;群聊放行 =chat ∈ allow_chatsAND (allow_allORuser ∈ allow_users)
ANDmentioned_me——--allow-all-users只旁路「用户」名单,永不旁路「群」名单,
没有任何旗标能一次开放所有群,这是有意的。ID 精确匹配、大小写敏感(平台 ID 是不透明标识,
.lower()归一可能把两个不同主体合并)。 guanlan im-identify解开一个真实的死锁:白名单要 ID、ID 只能从日志读、日志又脱敏。
它是一个独立、限时、绝不回复任何消息、不读知识库、不调 LLM 的模式,把完整 ID 打到终端。
「绝不回复」正是它的安全性来源——对方只看到「发了没人理」,不泄露「这里有个知识库机器人」。- 身份可信度分档(§0.2):企业系(飞书)强档,个人微信弱档——扫码得到的 iLink 标识
稳定但不可枚举、不可反查、与真实身份无绑定。结论不是「弱档不能用」,是**「弱档只适合小圈子」**:
几个人的手工授权是一次性成本,200 人的组织不可行。帮助文案不得让弱档冒充强档。 - 会话 TTL 归宿主自持的
SessionRegistry独占(store.idle_ttl=None)。三条已核实的源码事实
否掉了「两套 TTL 分工」:ConversationStore.get()命中即刷新last_active(源码注释写明是有意的)、
回收只在create/restore触发、顶满是抛错而非逐出。于是「registry 已过期、未到 store 的
2×ttl」这段窗口里没有任何人腾容量——多设一层兜底反而制造了一个谁都不负责的空档。
过期语义靠墓碑((None, last_seen),有界淘汰)保住:彻底删记录与「日后仍知道它过期过」
不可兼得,显式选后者。 - 停机纪律:两类取消必须分清。 收流 task 只阻塞在 I/O 上,
cancel()正当且干净;问答 turn
绝不可以——arun收到CancelledError是置令牌后立刻 re-raise、不等线程,
取消它不是「没效果」,是把「还没停」伪装成「已经停了」。故只request_stop()并无限期
等真实收尾,30 秒只打一条 WARNING 告诉你还在等谁。 - 并如实声明:优雅停机没有绝对时间上界。 模型侧
openai的read=600是每次网络读取的
空闲上限、不是整轮墙钟;外部 MCP 那条链上出站写入、legacy SSE 传输进入、stdio 传输进入、
断连清理都没有人管(--mcp-request-timeout只收住「请求写完之后、等tools/call回话」那一段)。
唯一的绝对保证是第二次中断信号的os._exit(5)——那条路上连logging都不许碰
(logging.shutdown()会去拿 handler 锁,后台线程正持着它时主线程就到不了os._exit),
提示也降为非阻塞 best-effort。凡与「保证退出」冲突的东西一律让位,包括我们自己想留下的那句话。
总 deadline 记为 agentao 上游诉求。 - 长答案契约是「不静默截断」,不是「绝不截断」:≤N 片 / 每片限长 / 绝不截断三者数学上不可同时
满足,必须选边。超限时末片是显式截断告示(含剩余字数),故用户永远看得到被截断了;
且不得称「完整版」——IM 会话persist=False,被舍弃的内容没存在任何地方,
承诺一个不存在的东西比不给链接更糟。 --mcp-request-timeout(默认 120s)取min(显式值, T):v6 的「显式值优先」意味着
mcp.json里写 3600 时命令行传 60 什么也没收紧——一个任何配置文件都能绕过的上界不是上界。
判据是**「agentao 解析之后是不是有限正数」而不是配置形态:0/-1/"bad"/NaN/Infinity都会被
上游 WARNING 后退回None= 又变回无限等待。另有--no-mcp彻底关掉发现。
不默认关掉 MCP:P4.19 那半阶段正是围绕注入到本库的外部 server 做的,一刀关掉会让同一个库、
同一个问题在手机上和浏览器里答得不一样**,而用户无从知道差别从哪来——给上界比砍能力诚实。[[wikilink]]无条件保留原文(决策P4.21-76,实现期核实后撤回了"给了--web-base-url
就转链接"这条设计支线):Web 宿主根本没有"按页面名开某一页"的路由(SPA 只认?raw=与
?c=,/api/page?path=要相对路径且返回 JSON),故照页面名拼出来的地址一律是死链。
一个 404 比一段可读的[[甲实体]]更误导——与"截断告示不得称完整版"同一条判据。
--web-base-url仍是站点入口,附在截断告示里。- 飞书凭据与
--allow-user-env指向的变量都可以写在.env里(决策P4.21-78),
与模型 API key 同一个文件、同一套规则:从 cwd 逐级上溯查找、真环境变量压过.env
(no-override)。刻意复用 agentao 的safe_load_dotenv而非自己调dotenv——
要的就是逐字节同一套语义,自己写一遍必然漂移,而漂移的表现是"某个 key 在这条路上读得到、
在那条路上读不到"。连带把.env/.env.*加进.gitignore(本仓此前没有忽略它):
既然把.env写进文档当作存 App Secret 的推荐位置,就得同时承担它的泄露面。 - KB 零字节写为必测契约(无
agentao.log、无.agentao/sessions/);适配器状态只落
~/.guanlan/im/<platform>/(原子替换),但**「零写 ≠ 进程无状态」**要说清楚。 - 起服后打一条横幅(平台 / 库路径 / 白名单人数与群数 / 外部 MCP 姿态),否则「连上了在等
消息」与「卡在某处」在终端上长得一模一样。三条判据各有反向用例:打在adapter.start()
成功之后(连不上就不打——「已连上」不许出现在一个没连上的进程里,那正是首连看门狗刚
拆掉的假象);走 stdout 而非_logger.info(宿主不配置 logging,INFO 没有 handler 接,
用一条默认看不见的通道发它等于没写);只打数量、绝不打 ID(宿主长驻、stdout 常被重定向
进日志文件,完整 ID 只该出现在im-identify的终端里)。不加--verbose/--log-level。 - 三个子命令共用一把平台目录级的本机凭据锁(微信一 token 一长轮询实例、飞书一 app_id 一 WS,
是平台语义不是可绕的实现细节),抢不到即拒启并同时给出 owner pid 与占用者子命令名。
连带确立:加人到白名单 = 停服 → identify → 改配置 → 重启,这是「绝不回复未授权者」的必然代价。 [im-weixin](httpx)与[im-feishu](lark-oapi>=1.6.8)按平台分,不给一个大[im]
拖来两家 SDK。飞书下限钉死在 1.6.8:那是 WS 客户端接受extra_ua_tags的首个版本,
不传这个 tag 服务端就不推群 @ 事件——适配器启动时用inspect.signature探针,
缺则拒启并明示升级,这比静默收不到群消息好。CI 必装两个 extra,正是为这条回归。- 用户指南补第 8 篇,中英双语:
docs/guide/zh/08-im-宿主.md与docs/guide/en/08-im-host.md
(标题、表格、代码块逐节镜像),入口 README 两侧目录同步。飞书那节把发布/审核未通过
单列为「最大的坑」——它的表现是「连得上、日志正常、就是收不到消息」,不写出来必然重复踩。
- 新的信任线是身份与受众面(§0.3):P4.11 管提示词注入、P4.17 管网络传输,两条都不覆盖它。
变更
guanlan/web/__init__.py改为 PEP 562 惰性取serve:只装 im extra 的环境里
from guanlan.web.chat import Conversation不再顺带拉起 fastapi/uvicorn。
from guanlan.web import serve在缺 web extra 时仍抛ImportError,CLI 降级路径逐字不变。Conversation/ConversationStore各加一个可选的mcp_registry透传参数(用哨兵区分
「没传」与「传了None」——后者是关掉 MCP 文件发现的文档化写法)。不传则行为与此前逐字节相同,
Web / reader 两路零影响。这是本半阶段唯一一处改动既有会话层的地方。
修复
- Windows 中文 locale(CP936)下解不动 agentao 的 JSON 信封(issue #50,与本次 IM 主题无关的
独立修复)——agentao run --format json的 stdout 此前两端都没约定编码、各自跟 locale 走,
只在「父子 locale 恰好一致」时侥幸成立。Windows 上 agentao 自己强制 UTF-8 输出、观澜却按 CP936
解码,于是失配。现在两端一起钉死 UTF-8:父端encoding="utf-8",子端经PYTHONIOENCODING
(不是PYTHONUTF8——后者连 fsencoding 一起改,血溅面远大于这条协议缝)。- 崩溃只是少数派:报告里那条
UnicodeDecodeError约占三分之一,另外约三分之二是中文信封
被静默解成乱码、json.loads照样成功——JSON 骨架全是 ASCII、撑得住,死的只有中文正文。
也就是说query会以退出码 0 交付一段乱码答案,而这一半从来不会被谁注意到。 - 只钉一端会换个 locale 继续错:单钉父端修好 Windows、却打断 POSIX 的 matched-locale
(LANG=zh_CN.GBK下子端仍按 GBK 发)。convert.py已经踩过这个坑并回退过一次
(backlog §1.③/§2.4b:「须两端协同」),这次不重蹈。convert.py本次不动——那条缝的对端
是裸print的 skill 脚本,与本接缝不同构。 errors="replace"不是懒惰档、是 Windows 上唯一可控的档:capture_output在 Windows 走
reader 线程,strict 解码抛的异常死在 subprocess 内部线程里,父进程try/except根本够不着
——「保持 strict、捕获UnicodeDecodeError」这条路走不通,只会再次拿到proc.stdout is None。- 连带把
_parse_envelope的stdout/stderr归一为str | None:读不到 stdout 时给一句人话
诊断,而不是json.loads(None)抛个TypeError把真因盖掉。这只是错误呈现层的兜底,
真修是上面的编码契约——留着它是因为「读不到 stdout」不止编码一种成因。 - 顺带说明这条缝的影响面:它是所有 LLM 命令的唯一入口(
ingest/query/heal/audit、
Web 作业、MCP 的query工具)。且异常路径会绕过写门禁——run_agent_task抛异常时
enforce_write_result根本不执行,那次运行的raw/只读快照比对一条都没跑。 - 回归测试三层:契约测试(父端
encoding/errors+ 子端PYTHONIOENCODING成对断言,
跨平台常绿)、_parse_envelope(None, None)单测、以及一条真管道用例——在
LC_ALL=C+-X utf8=0的真子解释器里跑(父端 locale 编码 = ASCII,与 Windows CP936 同构),
PATH 上摆一个只吐 UTF-8 字节的假 agentao。进程内 monkeypatch 造不出这个缺陷(locale 解码档在
解释器启动时就定了),故必须真起进程;造不出非 UTF-8 父端的环境诚实跳过、不静默空跑。
- 崩溃只是少数派:报告里那条
guanlan im-login --platform weixin拿不到二维码——真机首跑即挂,服务端回
{"err_msg":"missing bot_type","ret":1}。三处修正(详见docs/P4.21-IM宿主.md§7.6 实测表):bot_type是 query 参数、不是请求体字段(放 body 里服务端读不到,照样报 missing),
且合法值只有3;get_qrcode_status是 30 秒长轮询,而客户端读超时恰好也是 30s——每一轮都在服务端
正要回话时被自己掐断。提到30+15s,并把ReadTimeout当作预期内的重来而非崩溃;- 「已过期」的响应形态从未被观测到,故不再只靠
status == "expired"收场:加 180 秒墙钟
兜底,否则用户扫码前走开就是无限静默等待(正是决策P4.21-57 要杜绝的活死人)。
- 扫码后一直没反应、
account.json不生成——确认响应的字段名全是猜的:真实是
bot_token/ilink_bot_id(形如xxx@im.bot),不是token/account_id;另有服务端
自报的baseurl,现在以它为准(迁域名或分片时这是唯一的通知渠道)。ilink_bot_id而非同响应里的ilink_user_id——后者是扫码那个人,取错会让自消息过滤失效、
机器人把自己的话当成新消息,回声循环。有用例并排断言两种取法的后果。- 连带补一条硬错误:
status == "confirmed"却取不到 token 时直接失败并列出本次响应的键名。
原先这种情况的表现是继续wait——扫完码一切"正常"、只是永远等下去,日志一个字都没有。
静默失败比崩溃难查一个数量级,这种地方宁可崩。
- 取二维码失败时把服务端原话打出来(
err_msg/ret)。原先只说「服务端未返回 qrcode 字段」,
等于把唯一的线索丢掉——操作者除了重试无事可做,而重试一万次还是这个结果。 - 补上扫码登录流的测试:
run_qrcode_login早就为可测性留了transport形参,却从没有测试用过它,
整条流零覆盖。新增四条(含一条反向用例守着上面那个"吞掉服务端原话")。 - 二维码现在直接画在终端里,用手机对着屏幕扫即可,不必打开链接。
qrcode因此从"可选增强"
提为[im-weixin]的正式依赖:退回打印的那个 URL 就是登录凭据本身,用户要用它只能贴进
第三方生成器(把登录链接交给外人)或想办法挪到手机上——不是稍差一点的等价路径,是把安全成本
转嫁给用户。画法上用tty=True写死前景白/背景黑,深浅色主题的终端都扫得出(只用invert
的话浅色主题下黑白反过来,多数扫码器认不出);终端宽度不足则不画并说明原因,因为折行后
是一团扫不出来的乱码,而用户只会以为"码坏了"。 - 一轮代码评审的 11 处修复,其中三处会造成真实损害:
- 微信凭据缺
account_id时拒启。原先只校验token,而自消息过滤全靠
from_user == account_id,空串永不命中 ⇒ 过滤整条失效 ⇒ 机器人把自己的回复当成新提问
⇒ 回声循环 + 无上限 LLM 花费。登录侧同步补了写入前的守卫(同bot_token那条判据),
绝不落一份"过滤失效"的凭据。 --allow-all-users的告警说反了。它旁路的是「用户」名单,故不给--allow-chat时
实际含义是「任何能私聊到机器人的人都可读整库」;而原文案只讲"群",在没有--allow-chat
时恰好读成"没有人"。一个说反了的告警比没有告警更危险——运维照字面判断"没暴露",
然后把库开给了所有陌生人。现在按有无--allow-chat分两种措辞,都点名单聊。- edit 档 writer 丢唤醒(check-then-clear)。
clear()排在读状态之后,主协程恰在
edit()飞行途中置的final/abort会被下一轮抹掉:终稿白等满一个EDIT_INTERVAL_S,
停机时每个 writer 也多拖 2 秒。改为 clear-then-check。 - 其余:
max_parts == 1时split_for把正文整段吞掉("不静默截断"退化成"静默丢光");
硬切时可能产出空片、进而向平台发一条空消息;cli.py里--platform抄了一份平台字面量,
绕开「新增平台 = 注册一行」的对外承诺(现从ADAPTERS/LOGIN_FLOWS取);飞书 SDK 版本探针
(一次纯inspect.signature)排在网络调用之后,害得 SDK 过旧的用户先撞上凭据类报错、
排查方向被带偏;凭据锁的 pid 解析遇坏值会抛 traceback,且会把"读不出 pid"判成陈锁
并删掉别人刚抢到的锁(正是这把锁要防的双持);去重表剪到恰好等于上界,导致此后每条消息
都对 4096 项全表排序;以及一处只是 re-raise 的空except CancelledError。 - 补 12 条回归用例,并逐条做了变异验证:把每处修复改回原样,确认对应用例真的变红。
第一轮有一条没守住——它测的"删掉clear()"并不是原来的写法(那样 wake 恒为真、反而更快),
改成还原真实的 check-then-clear 顺序后才红。正例全绿不说明任何事:一条守不住 bug 的
回归用例比没有更糟,它让人以为这里被盯着。
- 微信凭据缺
- 宿主默认值收归单一来源(新增
guanlan/im/defaults.py与guanlan/web/defaults.py,
IM 与 Web 一并收齐)。此前每个值要写三遍——常量、cli.py的default=、help 文案里的
中文「默认 N」——而 CLI 永远用它自己那份:改常量对命令行用户毫无效果,--help与
docstring 跟着说假话,且没有任何测试会红。confirm_timeout最夸张,120.0在
web/server.py、app.py、conversation_store.py、conversation.py四层签名加 cli 共六处。- 之所以另开叶子模块而不是直接从
server.py取:cli 要在建 parser 时读到这些值,而
import guanlan.im.server会拉起 agentao + anyio(实测模块数 177 → 242),guanlan.web.server
更会拉 fastapi——缺[web]extra 时guanlan web --help都会崩,那句"请先 pip install"
的优雅降级发生在解析之后,根本来不及。两个 defaults 模块因此保持零 import。 - 顺带收了
--mode/--confirm的 choices:原先 cli 列一份、chat_support._WEB_MODES
与app.py/conversation.py的运行期校验各列一份。两头漂开的后果是命令行收下一个运行期
不认的值,用户拿到 422 / ValueError 而不是「invalid choice」。 - 守闸用例的形式是改常量、看 CLI 跟不跟着动(不是"断言默认值等于常量"——两边各写一份
100时那种断言照样绿)。同样做了变异验证:把default=、help 文案、choices 各抄回一份
字面量,四种改法全部变红。用例里另有一条not in baseline_help的自检——起初拿auto
当探针,而它本就出现在邻近文案里,那条断言是假绿的。 - 所有默认值一字未变(8765 / 100 / 120 / 1800 / 300 / read-only / ask),
--help输出逐字节同前。 - MCP 宿主一并收齐(
guanlan/mcp/defaults.py):"stdio"/"127.0.0.1"/8766此前在
serve_mcp签名与 cli 各写一份,--transport的 choices 亦然;--port的 help 里那句
「与 web 8765 错开」更是 web 端口的第四份拷贝,改 web 默认端口它立刻开始说假话。
为此guanlan/mcp/__init__.py也改成 PEP 562 惰性壳(同 web/im)——否则读一个常量都会
连带拉起官方 mcp SDK,缺[mcp]extra 时连guanlan mcp --help都崩。
- 之所以另开叶子模块而不是直接从
- 飞书 WS 首连看门狗(决策P4.21-79):长连没建起来时拒绝装作启动成功。
- 此前
_WS_DEAD哨兵只覆盖「WS 线程会返回」的一半。读 SDK 源码确认了另一半:start()把
首连异常吞进_reconnect(),而首连成功之前_reconnect_count还是构造时的默认 -1
⇒ 走while True分支、每 120 秒重试一次、永不放弃 ⇒ 线程永不结束 ⇒ 哨兵永远投不
出去。于是宿主一路"启动成功"、日志安静、消息一条收不到——正是决策P4.21-57 要消灭的活死人,
只不过是哨兵结构上够不到的那一种。真机上的CERTIFICATE_VERIFY_FAILED走的就是这条。 - 判据刻意非对称:有失败的正面证据(
on_reconnecting已触发/线程已死/到点_conn仍空)
→ 拒启并点名 SSL 根证书与凭据两个常见原因;有成功的正面证据 → 放行;两种证据都取不到
(SDK 换了形状)→ 告警放行,绝不拒启——观测不到 ≠ 坏了,这里误判一次就是让所有升级 SDK
的人起不了服,看门狗自己成了故障源。 - 判据是「失败了且没恢复」而非「失败过」:写成后者的话一次抖动就让宿主起不来,而它明明
已经连上了。有一条反向用例守着。另有一条真 SDK 探针盯着看门狗依赖的三个形状
(_conn存在、_reconnect_count默认为负、_reconnect会调on_reconnecting),
上游一改就红——比运行期悄悄降级成"看不见"早得多。 - 首连失败不等它重试:间隔 120 秒,任何合理的启动窗口都接不住第二次尝试。与其让用户对着
两分钟静默发呆,不如立刻报错。中英指南的故障排查表各补一行。
- 此前
Full Changelog: v0.1.21...v0.1.22