Skip to content

v0.1.22

Choose a tag to compare

@github-actions github-actions released this 16 Aug 16:08
· 20 commits to main since this release
a60aa4e

主线是把 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_chats AND (allow_all OR user ∈ allow_users)
      AND mentioned_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 告诉你还在等谁。
    • 并如实声明:优雅停机没有绝对时间上界。 模型侧 openairead=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-宿主.mddocs/guide/en/08-im-host.md
      (标题、表格、代码块逐节镜像),入口 README 两侧目录同步。飞书那节把发布/审核未通过
      单列为「最大的坑」——它的表现是「连得上、日志正常、就是收不到消息」,不写出来必然重复踩。

变更

  • 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_envelopestdout/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_typequery 参数、不是请求体字段(放 body 里服务端读不到,照样报 missing),
      合法值只有 3
    • get_qrcode_status30 秒长轮询,而客户端读超时恰好也是 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 == 1split_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.pyguanlan/web/defaults.py
    IM 与 Web 一并收齐)。此前每个值要写三遍——常量、cli.pydefault=、help 文案里的
    中文「默认 N」——而 CLI 永远用它自己那份:改常量对命令行用户毫无效果,--help
    docstring 跟着说假话,且没有任何测试会红confirm_timeout 最夸张,120.0
    web/server.pyapp.pyconversation_store.pyconversation.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 / --confirmchoices:原先 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