Releases: jin-bo/guanlan
Release list
v0.1.23
主线是把答案里的引用从死字变成入口。v0.1.22 把 wiki 接进了聊天窗口,但 [[甲实体]] 还只是
四个字:想看那一页,得回头去 Web 或命令行。本版补这最后一公里——新增零 LLM 的 /page(所有平台
都能手打),飞书上再把这些引用做成按钮。
形态上的那一句是:点击 = 一条合成的入站消息。适配器只把核心生成的命令文本塞进按钮、回调时原样
取回,它不知道 /page 是什么;于是点击原路走回同一条入站流,五道闸全部复用、问答那条路零改、核心
零平台分支。不是新宿主、不开监听端口、不引入任何写路径——决策P4.21-21(无监听面)与决策P4.21-8
(KB 零字节写)都继续成立。无接口破坏、无新退出码、依赖零变更。
主链路已过真机(2026-08-17),但几条仍未核对,逐条见 docs/P4.22-IM可点引用.md §9——那一节记的是
"观测到了什么",不是"通过了"。
新增
- IM 可点引用:
/page+ 飞书卡片回传交互(P4.22,见
docs/P4.22-IM可点引用.md) —— 答案里的[[某页]]在飞书里可以点,
点完机器人把那一页发回来。不是新宿主、不开监听端口、不引入任何写路径:回调走 P4.21 已经建好的那条
WS 长连(决策P4.21-21 无监听面继续成立),/page只读盘(决策P4.21-8 KB 零字节写继续成立)。/page <页面名>(所有平台,零 LLM) —— 按名开页,复用 P3.8 的单一 owner 归口
(link_resolution_index+resolve_owner),故精确 stem / 别名(P3.1)/ 安全 fold variant(P3.8)
三种写法都认,与check/graph/heal/Web 是同一张解析表——IM 侧另写一套按名查找就是给这个系统开
第五个口径。未命中降级为一次 top-3 检索(手打错别字才是未命中的现实来源,而 BM25 + CJK 2-gram 正擅长救它)。
截断告示不附 Web 入口:内容在盘上,但 Web 没有按名开页的路由,给出去仍是死链(决策P4.22-12)。- 点击 = 一条合成的入站消息(枢纽,决策P4.22-2) ——
Action携带的是核心生成的命令文本
("/page 甲实体"),适配器只负责把这句话塞进按钮、回调时原样取回,它不知道/page是什么。
于是点击原路走回adapter.inbound(),五道闸全部复用、问答那条路零改、核心零平台分支;
新增动作类型时适配器同样零改。 - 红线的闸在接收端,不在构造端(决策P4.22-14) ——
InboundMessage新增事实字段origin,
核心据此把合成消息能跑的命令收窄到ACTION_COMMANDS(v1 只有page)。只校验"以/开头"守不住:
classify()把未知斜杠命令当普通提问送进 LLM,一个被改动过的回传串就是一次不该发生的 LLM 调用。
/help、/new都是零 LLM 却不在白名单里——前者是噪音、后者会清掉用户的上下文。 chat_type只认见过的会话(决策P4.22-5) —— 回调不带这个字段,而open_chat_id的前缀区分不了
单聊与群。适配器为每条真实入站消息记一条(tenant, chat_id) → chat_type的事实缓存,回调只查这张表、
未命中即整条丢弃。飞书卡片可被转发,"卡片出现在哪里"不构成授权依据;allow_chats一个字都不进适配器
(有一条静态断言守着)。键的两半都取map_event的同源字段(header.tenant_key,不是
event.operator.tenant_key——两者在跨租户/外部群下可能不同值,取错的后果不是报错、是间歇性"会话已过期")。/page的输出也出按钮(决策P4.22-25)—— 于是可以一跳接一跳地在库里走。引导语是「本页引用的
页面」;自引用不出按钮(判据是解析到的 owner 而非名字,否则[[别名]]绕回本页那一路会漏);
长页被截断时尾巴里的引用不上卡片(同下一条)。一次/page只建一次解析表——open_page
回的是PageView(text, links),出按钮时在已备好的「键 → 拥有页」表上零盘 IO 过滤,不再解析第二遍全库
frontmatter(有一条计数用例守着:双解析不会让任何功能用例变红)。- 卡片上只有页面名,且只来自已送达的正文 —— 动作从实际成功送达的片里抽(决策P4.22-13/20):
split_for超限时会丢后缀,从完整答案抽会把正文里根本没出现过的页面名印在群内公开可见的卡片上。
出按钮前还会先解析一次,断链的引用不出按钮(不承诺一个不存在的东西);超出max_actions=8时显式告示
"另有 N 个",正文里的[[原文]]始终保留,故手打/page永远是可用的退路。去重按拥有页、不按名字——
同一页有别名与 fold variant 两类合法异写,只按名字去重会给出两个点开同一页的按钮,还白占按钮位、
把"另有 N 个"也算歪。 - 回执帧头还原(决策P4.22-26) —— 上面那层兼容层有一处自伤:SDK 写回执时复用的就是入站那个帧对象,
于是被我们改写过的type=event会跟着应答回去;平台若按帧型路由应答,那颗 toast 就没了(页面照样会到,
症状恰好是"点了、页面来了、没有任何反馈")。故在写出那一刻还原帧头再重新序列化,传递用ContextVar
以免并发两帧串台;探不到写出接缝就整层不启用,不留收不了尾的改写。 Intake在adapter.start()之后重读一次能力位(决策P4.22-27) —— 适配器会在start()里就地降级
(探不到卡片注册面即如此),而Intake是装配期构造的。今天恰好无害,但下一个"在start()里降级且被
Intake读到"的能力位会静默用陈旧值且无用例会红。- CARD 帧兼容层(决策P4.22-21) ——
lark-oapi的 WS 客户端对MessageType.CARD帧直接 return,
而只有 EVENT 分支才会走到卡片回调的注册表;"卡片回调到底以哪种帧下发"这件事的证据是矛盾的
(Go SDK 的 EVENT 分支注释 vs 上游 issue #126 的真机 200340 报告)。实现选择不选边:子类化 WS 客户端,
把 CARD 帧头就地改写成 EVENT 再委回父类——纯粹的超集,走 EVENT 时它一次都不触发。探不到私有接缝即
降级告警(不拒启),并配真 SDK 形状探针;上游修好即可整段删除。 - 个人微信一个字不变:
supports_actions=False,send_actions永不被调(有反向用例守着)。 guanlan im-identify期间不注册卡片回调(决策P4.22-24)—— 那个模式的安全性整个建立在
「绝不回复任何消息」上(决策P4.21-28),而卡片回调必须同步回一个 toast:上一次guanlan im
发出的卡片在这 300 秒里被点一下,就等于告诉对方机器人此刻正活着。故新增一个与group_wanted同款的
平台无关部署意图actions_wanted,identify 传 False ⇒ 连注册都不做。- 飞书新增一步后台配置:「事件与回调 → 回调 → 订阅方式:长连接」+
card.action.trigger。
漏配是静默的(卡片发得出、点了没反应、无日志),故首次发卡片时记一条可执行的 INFO 提示,
中英双语指南也各加了一节。
真机验收(2026-08-17,首次)
- 主链路已跑通:飞书上点卡片按钮 → 弹「已收到」→ 那一页发回来。顺带确证三条,都是从行为反推、
不是看了一眼(推理链逐条写在 §9):toast 走普通dict返回值的赌注成立(决策P4.22-22);回调的
open_chat_id与消息事件的chat_id同值(缓存未命中会回「会话已过期」并整条丢弃,而实际弹的是
「已收到」⇒ 缓存命中);卡片 2.0 的组件键原样就对,build_actions_card()一个字不用改——但只覆盖到
回调链必需的那些键,tag: "markdown"的渲染档与config两项照不到,仍挂在 §9。 - 仍未确认:卡片回调走 EVENT 还是 CARD 帧(当次未看 DEBUG 日志)。兼容层照留——它是纯超集,
留着的代价是两个私有方法名,删错的代价是按钮整个失效。另:toast 弹出不能反推帧类型,两种假说
都与之一致(决策P4.22-26 把回执帧头修对之后,这个观测就失去了区分能力)。 - 仍未验:未授权者点击、卡片转发后点击、3 秒超时重推。逐条见
docs/P4.22-IM可点引用.md§9。
Full Changelog: v0.1.22...v0.1.23
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 共六处。- 之所以另开叶子模块而不是直接从 `...
v0.1.21
单一主题:让写进 wiki 的数字自己说话——观澜经 MCP 接入结构化数据库后,Agent 取到的结果行
此前只能以表格或裸 JSON 落盘。P4.20 把最后一公里补上:```flint 规格块在浏览器内编译成图表,
而同一段 markdown 在 CLI / 纯文本回退里仍是 honest 的规格源码(规格与数据都在块里,零信息损失)。
服务端零改、无接口破坏、无新退出码、无新依赖。全量 1231 passed / 1 skipped。
新增
- Web 里的
```flint围栏块被编译渲染成数据图表(P4.20,见
docs/P4.20-Web-flint图表渲染.md) —— wiki 页与对话答案里的
flint 图表规格 JSON 不再显示成一坨源码,而是在浏览器内编译成柱/线/散点/热力等图(37 种图型)。
承 P4.13(mermaid)/ P4.14(数学·化学·代码)同一骨架:服务端零改(render.py一个字节没动)、
vendored 运行时、懒加载、安全闸硬编码、失败保留源码。P4.14 的enhanceContent编排器让七个注入点
一个字不动,本相位只在编排器里加一行。存在的理由是观澜经 MCP 接入用户的结构化数据库后有了可视化需求——
Agent 取数 → 把结果行写成数据整体内联的 flint 块 → 浏览器成图。- 图是快照,浏览器永不出网(决策P4.20-5/-12):只认内联
data.values,拒data.url;
刷新数据 = 重新问答产出新块,不是让前端去查库。于是图 100% 可从那段 markdown 重建,
check/graph/search全部原样复用。 - 安全闸:入口白名单(只五个顶层键)→ flint 生成式编译(不 merge 用户私货)→ 键域出口断言 →
renderer:'svg'+tooltip.renderMode:'richText'(tooltip 不走 HTML 通道)。输入是纯 JSON、
没有函数,ECharts 的回调注入面天然不存在——这正是选 flint 而非让模型直接写 ECharts option 的理由。 - 资源闸三条(结构化库接入独有的问题):块源 64 K 字符、
data.values1000 行、画布尺寸有限数且
在范围内。超限不渲染、保留源码 + 「数据过大」徽标,不静默截断——截断出的图会说谎,比裸 JSON 更坏。 - 闸子不只要「拦得住」,还要「不乱拒」(决策P4.20-16):把 37 个图型 × 各自正确的数据形状逐个跑完整链,
一次揪出四例误杀——Waterfall Chart的renderItem是 flint 自己造的函数、Sankey/Network Graph
的links[].target是节点名、Sunburst Chart缺color时 flint 静默回吐非 ECharts 中间态
(会被尺寸闸误报成「数据过大」,把作者引向删数据这条错路)。⇒target移出黑名单、renderItem改判
「必须是函数」、加not-echarts-option形态守门。这条全集扫描已设为常驻冒烟场景 + 升级复核项:
打桩只能证「该拒的拒得住」,「不该拒的不乱拒」只能靠把全集跑一遍。 - 前端第一个有状态渲染器(决策P4.20-7):ECharts 实例挂在它自己的全局注册表上,切语言/切预览会让旧
<figure>脱树——每次增强前先清扫并dispose,否则每切一次泄漏一份。mermaid/KaTeX/hljs 都是一次性
DOM 变换、没有这个义务。 - 渲染是对 DOM 的事务(决策P4.20-15):整条链在
<pre>还挂在树上时跑完,replaceWith是最后一步。
反过来做会漏掉一整类失败——setOption抛错时源码已脱树,既贴不上徽标也回不去,页面留一个空白。 - 配套独立作者 skill
flint-chart-author(决策P4.20-11):mermaid/LaTeX 模型本就会写,flint 不会,
没有作者侧供给渲染器就是「通了电没有灯」。它不进guanlan-wiki——作图与 wiki 维护(页型/frontmatter/
wikilink/索引)是两条正交的轴,且本仓已有pdf-to-markdown这个同形态先例。图型×通道表由 vendored 资产
确定性生成,升级重跑即可、不与钉版漂移。 - vendored 两枚资产:
flint-chart@0.4.1(531,443 B,MIT)+echarts@6.1.0(1,121,883 B,Apache-2.0),
合计 1.65 MB、不到既有mermaid.min.js的一半,钉版 + SHA256 记vendor/README.md。flint dist 是
自包含单文件 ESM,经动态import()加载——本仓唯一走 ESM 的 vendored 资产,仍无 npm、无构建。 - 对编译产物的「事后改写」被收紧成一条原则(决策P4.20-19,实现后 xhigh 评审的主要收敛):
能让 flint 自己算的,绝不在事后改。按栏宽收窄改为拿chart_spec.canvasSize重编译——
此前是编译完再改画布尺寸,而 flint 把legend.left等按自己算出的宽度烤成绝对像素,
缩画布会把整个图例画到视口之外(图还在、没有徽标,读者却分不清哪条线是哪个模型)。
剩下的三类事后改写各自限定到能讲清楚:逐槽换色(映射表从产物自身的color现造,语义色图型
整张跳过——否则 Waterfall 的「跌」会被画成绿的)、tooltip 的 HTML 串压成纯文本(flint 的 formatter
返回<br/>,而richText不解析 HTML,不压就会画出字面标签)、补viewBox。 - 三档失败文案,各归各的:「数据过大」(行数/体积/画布)、「数据被截断」(flint 自己超预算整批
丢掉类别,color通道尤其无声)、「渲染失败」(其余)。修法完全不同——前者减行、中者减类别数、
后者改规格;合并任意两条都会把作者引向错路。画布闸同时去掉了下界:小画布不消耗资源,
而 flint 按语义定尺寸时小图很常见(两周的 Calendar Heatmap 只有 114px),拒它纯属误伤。 - 无接口破坏、无新退出码、无新依赖(两枚资产随既有
webextra 的静态资源打包)。
16 条服务端契约用例(tests/test_web.py)+ 真浏览器冒烟 18/18(scripts/smoke_p420.py,
新增的三条都做过反向对照:摘掉对应修复后用例确实变红)。
- 图是快照,浏览器永不出网(决策P4.20-5/-12):只认内联
修复
docs/发布到-PyPI.md的「验证」段改为 uv 口径——原来那条命令跑不通:uv venv不带 pip
(除非--seed),故<venv>/bin/pip install …必然no such file;且 uv 用--no-cache
而 pip 用--no-cache-dir、--python不显式给会装进开发树的.venv。v0.1.20 发版实操中撞上,
已按实际可跑的口径改写,并补上「装[web,mcp]extra 才覆盖到guanlan/web/、guanlan/mcp/
随包内容」这一条。
Full Changelog: v0.1.20...v0.1.21
v0.1.20
单一主题:把一件早就在发生、却没人看得见的事显示出来——外部 MCP server 早已被 agentao 默认注入
观澜的每一次 ingest / query / heal / audit / Web 问答,而观澜里此前没有任何痕迹。P4.19 只补这份可见性,
不改变注入行为:零 LLM、零策略、零管理、不写 KB、不执行任何外部工具。无接口破坏、无新退出码、
无新依赖(连接检查复用既有的可选 extra guanlan-wiki[mcp])。全量 1215 passed / 1 skipped。
新增
- Web 里看得见本库的外部 MCP 配置,并能显式做一次连接检查(P4.19,见
docs/P4.19-Web-MCP诊断.md) —— 顶栏新增「MCP」按钮,浮层里列出这个知识库
实际会被注入的外部 MCP server(名称 / 来源 / 传输 / 端点 / 是否 trust),可点一次「检查连接」看每个 server
连不连得上、报了哪些工具。本相位补的是可见性,不改变任何注入行为:agentao 的build_from_environment
在调用方不传mcp_registry=时默认注入FileBackedMCPRegistry,读<kb>/.agentao/mcp.json+
~/.agentao/mcp.json,而观澜的 CLI 子进程与 Web 嵌入都吃这个默认——也就是说外部 MCP 工具早已在注入
ingest/query/heal/audit/Web 问答,此前用户在观澜里看不到任何痕迹。零 LLM、零策略、零管理、不写 KB、
不执行任何外部工具(只initialize+tools/list)。- 时点语义钉死措辞(决策P4.19-8):面板显示的是「当前磁盘配置的解析结果;新建 Web 会话或下一次 CLI
作业将使用这些配置。已有会话需新建后生效」——MCP 是会话构造期一次性加载的,含糊成"当前生效"
会让用户以为改完mcp.json就对手上这个会话生效,事实相反。 - 两条轨:生效集合取上游
load_mcp_config的归一结果(就是 agent 实际会连的那一份,不另立口径);
诊断另读两份文件的原文——上游_load_json_file对坏 JSON 静默返回{},只信它就只会显示
"没有配置"而不是"配置写坏了"。静态检查只两项:json_unparsable/transport_unresolvable。 - 脱敏覆盖到错误文本与日志(决策P4.19-5/14/15):URL 只留
scheme://host[:port]/path(去 userinfo 与
整个 query 串,并遮掉路径里 ≥16 字符的段——托管 MCP 把 key 放路径里是标准形态,只去 query 会让活
密钥原样打在面板上)、stdio 只回command的 basename(不回args)、绝不回headers/env/token;
连接错误串走同一道脱敏——agentao 的NonMcpEndpointError会把完整 URL 写进消息,只脱敏端点而放过
error等于没脱敏;上游那条logger.error也过一道——响应体擦干净而跑guanlan web的终端里留着
?token=,不叫"不出进程"。做法是已知敏感值定向擦除(能保住可诊断性就保:command只去目录留命令名)- URL 正则兜底,且每个过滤分支各配一条可被变异测试杀的用例(端到端只覆盖上游今天恰好会拼进错误串
的形态,headers/env 分支曾被整段删掉而全套用例仍绿)。
- URL 正则兜底,且每个过滤分支各配一条可被变异测试杀的用例(端到端只覆盖上游今天恰好会拼进错误串
- 配置坏掉不再静默:坏 JSON、
mcpServers写成数组、条目不是对象、env值不是字符串、command拼错
——上游对这些一律静默丢弃(甚至一条坏条目让整份配置消失),此前面板把它们统统渲染成「未配置任何
外部 MCP server」,与真空配置一字不差。现在各自报json_unparsable/config_shape_invalid(点名到条目)
/transport_unresolvable;同一份坏配置在检查端回 422 + 可执行原因而不是 500。 - 姿态:检查端点不接受前端传入的 server 定义 / URL / header(否则面板就成了任意 MCP 客户端);
不在启动或页面加载时自动探测(连接会真起 stdio 子进程 / 发网络请求,必须是用户点的);同一进程内
单飞、并发请求返回 409 不排队;不设端点级墙钟,沿用上游 per-server startup timeout;reader
只读部署下两个端点不注册(理由不是"写 KB",而是有外部副作用);检查端点请求体必填
(最简{},与 ingest/heal 同姿态)——无体 /text/plain的浏览器简单请求不触发 CORS 预检,
放行无体等于让任意网页驱使本机观澜连一遍全部配置的 MCP server(响应读不到,副作用照发)。 - 依赖:连接检查需要
guanlan-wiki[mcp]带的 MCP 客户端栈,缺时回 501 + 安装指引(不是 500);
配置展示纯读盘、零 SDK 依赖,照常可用。 - 与
guanlan mcp(P4.10/P4.17/P4.18,观澜作 MCP 服务端)方向相反:这里诊断的是观澜作为
MCP 客户端被注入的外部 server。真 stdio 端到端测试正是拿guanlan mcp当被测上游(零新依赖)。
- 时点语义钉死措辞(决策P4.19-8):面板显示的是「当前磁盘配置的解析结果;新建 Web 会话或下一次 CLI
Full Changelog: v0.1.19...v0.1.20
v0.1.19
一次三层递进的修复,同一条因果链:起点是「HTML 注释里的链接被当成真链接」;修它引入的过滤
规则第二次错在相反方向(把真断链抹白,写门禁静默放行坏库);第三次收窄让原始 \r 第一次真的
流进扫描器,掀出四个确定性写点共有的「CRLF 静默转 LF」。三条按发生时序排,后两条里的「上一条」
即指前一条。无新功能、无接口变更;全量 1186 passed / 1 skipped。
修复
- HTML 注释里的链接不再被当成真链接(
check/lint/graph/health/reindex --prune/remove)
—— 各链接扫描器此前对<!-- … -->一视同仁,于是注释里的示例链接被当作生效引用。影响面比表象大,
按严重度排:①reindex --prune把 init 模板index.md里那四行<!-- ingest 自动追加:- [<名称>](entities/<Name>.md) — <一句话> -->整行删掉——被删的正是告诉 Agent 追加格式的说明;
②check对注释掉的[[X]]报wikilink.broken,退出码 3,即"随手注掉一段草稿"会让写门禁判库坏了;
③graph造幽灵边/幽灵断链,lint.broken_link与heal的missing_entity因派生自该图跟着误报;
④ 全新guanlan init的库跑health立刻报 4 条index_dangling(首次体检就是四条假告警);
⑤remove会连带删掉注释里提到该源的行。修法是pages.strip_html_comments一处归口,扫描前抹掉
闭合注释,四个扫描点(check断链、graph建边、index_md_links、reindex._scan_lines)共用。
两条刻意的设计:保留行数(注释替换为等量换行,按str.splitlines口径数行,reindex --prune靠
原行与判定行逐位对齐来剪枝,行数一变就错删);未闭合的<!--原样保留、不吃到文末(那会把后文
真断链静默吞掉,把漏报伪装成通过——门禁宁可多报不可少报)。回归网 14 例,其中 11 例对旧行为变异验证
为失败;另加一条test_freshly_initialized_kb_is_health_clean直接走run_init真模板体检——
这个 bug 一直没被测试看见,正是因为test_reindex的 index 夹具用的是不带链接的
<!-- ingest 自动追加 -->,与出厂模板漂移了。经 Codex 评审三项收敛:① 注释抹成等长空白
而非删空——删空会把两侧字符粘起来,凭空造出原文没有的链接([名]<!--注-->(entities/X.md)粘成
合法 markdown 链接、[[Mis<!--注-->sing]]粘成可能真解析到某页的[[Missing]]);② 行分隔符
逐字保留而非统一补\n(裸\r文本里补出的\n会与前一个\r合成一个 CRLF 边界、行数少一,
于是_prune_dangling的 strict zip 抛——这条是纵深防御,两条调用路径今天喂进来的文本已被
read_text通用换行归一过);③ Web 渲染同步对齐——render.py此前仍会把注释里的[[X]]渲成
可点链接,与 check/graph 打架;改为落在注释区间内即拒绝成链。Web 与扫描器的失活方式刻意不同:
扫描器抹成空白,Web 保留注释原文按转义文本显示(决策P4-4 关了原始 HTML 透传),视图不该让页面上
凭空少一段字。 - 注释剥离收窄为「只认独占整行的注释」,堵掉它自己引入的门禁漏报(xhigh 评审 8 条)
—— 上一条的实现用裸<!--.*?-->匹配,非贪婪只保证止于第一个-->、不保证那个-->属于
同一段注释。后果比原 bug 更重:① 正文或行内 code 里的字面<!--(讲注释写法时很常见)会与后文
任意一个-->配对,把中间的真链接整段抹白——check于是对真断链退 0,写门禁静默放行坏库,
graph/lint/heal一并失明;② init 模板自带四段注释,任何一个忘闭合的<!--都能找到配对,
于是health误报index_missing_page、reindex追加第二条重复登记行、remove留下悬空项;
③ 抹成空格并不能阻止粘连——下游link_stem/index_md_links自己会.strip(),[[Foo<!--旧名-->]]
被重新粘成有效引用[[Foo]],断链伪装成通过。修法是把匹配收窄为行锚定 + 回火
(^[ \t]*<!--(?:(?!<!--|-->).)*?-->[ \t]*$):行中间的标记永不构成注释开头,未闭合的<!--
也不与后一段注释的-->配对。代价是行尾的行内注释不再被识别、其中链接照常参与扫描——宁可
多报,不可少报。 reindex --prune不再铰碎注释块:strip_html_comments既然只认独占整行的注释,行尾开块的
写法(- [死页](…) 悬空 <!--+ 若干行 +-->)就不被它保护,照删会把块拆开——块内被注掉的
行「转正」,下一轮--prune再把它当真悬空删掉,两步内静默蒸发。新增_comment_touched_lines:
凡沾注释的行一律不删。它与strip_html_comments分工相反、各自保守——严格口径决定「忽略
什么」(放宽会漏报),宽松口径决定「保护什么不删」(收紧会毁数据),互不继承对方的失效模式。- Web 渲染改在解析前抹注释,真正与扫描器同口径:上一条只在行内
[[…]]处理器设了守卫,既漏
又散——_CodePathLinkTreeprocessor(反引号页面引用)与_RawPathTreeprocessor(裸raw/<slug>.md)
跑在元素树上、拿不到原文偏移,照常成链;而 inline 处理器只看得到当前块,含空行的注释被切成
普通段落后守卫直接失效(正是「注掉一段多段落草稿」这个动机场景)。改为_StripCommentsPreprocessor
在整篇行列表上抹一次,所有下游自然干净。行为变化:注释文本不再显示在 Web 页面上——与
Obsidian/GitHub 一致;此前能看见只是决策P4-4 关掉原始 HTML 透传后注释被转义成字面文本的副产物。
被抹的行置空串而非等长空格(4 个以上前导空格在 markdown 里是缩进代码块,会渲染出一个空的
<pre><code>)。 _prune_dangling(text, dangling)自己切行:原三参签名把「两列逐位等长」外包给三个调用方,
少传/传错就会让「模板提示行被整行剪掉」静默复发,而strict=True挡不住(两列等长、只是判定错)。
契约收回函数内部,_scan_lines这个跨模块私有符号一并消失。- CRLF 行尾不再被静默改成 LF(
reindex/remove/ingest+audit的指纹 stamp)
—— 四个确定性写点都是「Path.read_text读 → 改几行 →rawio.atomic_write_text写」。读侧走通用
换行(\r\n、裸\r一律归一成\n),写侧却是逐字节的,一读一写净效果就是把整份文件的
行尾改掉:Windows 用户跑一次guanlan reindex,index.md 27 行 CRLF 全变 LF,git 里炸出一屏与本次
操作无关的 diff;remove摘一个 slug、audit打一次指纹,同样把整张内容页/摘要页重写一遍。
reindex._split_lines里那句探测 CRLF 的代码因此从来没生效过(读侧早已归一,探到的永远是\n),
而atomic_write_text的文档还在承诺「自管 EOL 的调用方原样保真」——三处说法互相矛盾。修法是给
逐字节的写补上逐字节的读:rawio新增read_text_verbatim(atomic_write_text的读侧对偶)、
detect_eol/split_eol_lines(拆行改行再拼回时保原 EOL)、dump_frontmatter(重序列化
frontmatter 的单一归口,块按原 EOL 出、body 逐字不动),四个写点全部改用。fmrepair早就为同一个
理由走read_bytes+atomic_write_bytes,这次是把那条纪律推广到文本层。三处顺带收敛:
①detect_eol取首个出现的行尾而非「含 CRLF 即判 CRLF」——后者会让 LF 文件里混进的一个 CRLF
把整份重写成 CRLF,修 CRLF 丢失反而制造 LF 丢失;②split_eol_lines只认 CRLF/CR/LF,不像
str.splitlines连\v/\f/\x1c/\u2028也切(切开再按统一 EOL 拼回去 = 把行内字符静默换成
换行,同属"重写时悄悄改用户字节");③apply_origin改走dump_frontmatter,与另两处 frontmatter
重写共用「绝不裸拼」的同一实现(零行为变更,有用例逐字比对旧字面量)。 - 注释与链接正则改为行尾中立:上一条让原始
\r第一次真的流进strip_html_comments,而它的
行锚定用的是^/$+MULTILINE——只认\n,CRLF 文本里-->与\n之间隔着\r、$匹配不上,
整行注释当场不再被识别(前一条 CHANGELOG 里「这条是纵深防御、调用路径喂进来的文本已被read_text
归一过」的前提,至此失效)。改用「前后不是非换行符」的环视(?<![^\r\n])…(?![^\r\n]),对 LF /
CRLF / 裸 CR 一视同仁(LF 下与旧式逐字等价);WIKILINK_RE/_MD_LINK_RE一并把\r与\n同等
排除,杜绝引用跨行粘连。边界说清、不夸大:reindex --prune今天并不会因此删掉 CRLF 库的注释行
——那层由_comment_touched_lines的宽松口径兜着(实测:把正则退回只认\n,端到端用例仍绿)。
这里修的是严格口径自己不能因行尾失灵——strip_html_comments是check/graph/index_md_links/
Web 渲染共用的公共原语,让它对 CRLF 静默换一套含义,是给下一个消费方埋雷。回归网 16 例,逐条对旧
实现变异验证:读侧、frontmatter 重出、注释正则、链接正则各自的用例都能在退回旧写法时失败;其中
两条是反方向守卫——CRLF 下真断链仍被扫出、真悬空行仍被剪掉,免得「兼容行尾」写成「CRLF 库一律
不报不剪」。
Full Changelog: v0.1.18...v0.1.19
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
v0.1.17
新增
-
P4.17 MCP 远程传输
guanlan mcp --transport http(Streamable HTTP,见docs/P4.17-MCP远程传输.md)
—— 给 P4.10 stdio-only MCP 宿主补第二种传输:官方 Streamable HTTP(spec 2025-03-26 起、取代 legacy
HTTP+SSE)。同一套只读工具、只换传输:tools.py零改、build_mcp仅加一个allow_ask注册门(把
ask的注册包进if allow_ask:,六个零 LLM 工具无条件注册照旧),host/port/无状态/DNS-rebinding/token
校验全部接线收在serve_mcp的 http 分支(_http_security/_BearerTokenMiddleware/_build_http_app/
_serve_http)。安全默认:默认仍绑127.0.0.1(沿用决策P4-4)、非环回强制 bearer token(从
--auth-token-env指定的环境变量读、绝不命令行明文/落盘;最小 ASGI 中间件hmac.compare_digest常量
时间比对、失败 401、不引 OAuth)否则拒启EXIT_USAGE、askover http 默认关
(allow_ask=(transport=='stdio' or --allow-ask):stdio 永远七工具、http 默认六、--allow-ask才七,避
任意网络客户端触发付费 LLM 子进程的成本/DoS 放大 + 子进程写探问放大)、无状态stateless_http(无
Mcp-Session-Id、无事件重放)、DNS-rebinding 白名单派生自--host/--allowed-host(可重复,反代对外
域名须显式补入否则被拒)、TLS 外置。完整 OAuth(MCP 2025-06-18)/ 多租户 source 级 scoping 显式留给
E2。零依赖变更——已实测装的 mcp SDK(mcp>=1.27,<2)原生支持transport="streamable-http"且自带
starlette/uvicorn硬依赖,[mcp]extra 与 SDK 下界均不改。新增 CLI 旗标
--transport {stdio,http}/--host/--port/--auth-token-env/--allowed-host/--allow-ask(默认全部
向后兼容 P4.10:不带--transport即 stdio 字节等价)。测试:tests/test_mcp.pyP4.17 套件(ask 门控、
绑定红线拒启、token 闸 401/放行、rebinding 白名单派生、真 uvicorn + streamable-http 客户端端到端往返)+
tests/test_cli.py旗标解析。编号辨析:属 P4 宿主家族兄弟半阶段(非 P4.10.1 补漏、非新里程碑、非直接
E2)。连带更新 DESIGN §7(P4 行 + E2 前哨行改「已实现」)、CLAUDE.md status/命令注释、docs/P4.17-MCP远程 传输.md(标题去「· 草案」、状态改「已实现」)。 -
指令层:告知 Agent 可直接写
```mermaid围栏图 ——AGENTAO.md新增「图表(流程图等直接写
mermaid)」节、conventions.md新增「§图表(mermaid 直绘)」(带最小示例)、SKILL.mdingest 工作流补
指针:表达流程/时序/状态/类/架构/关系等结构时,Agent 直接在正文写 mermaid 围栏块即可,无需调用任何
外部画图/生图工具——围栏块本身就是产物,Web 宿主(P4.13)浏览器内strict渲染成图、CLI 回退字面源码
(markdown 唯一事实不破)。明确边界:新综合的关系图用 mermaid 直绘,raw/源里已有的插图仍按图片
引用、不重画;图里实体/概念正文仍须[[wikilink]]互链(mermaid 节点文本不进 wikilink/召回解析);
与guanlan graph的graph.json/html(确定性 wikilink 关系图)无关。纯指令/文档,零代码改动,解锁的
是 P4.13 已落地的既有渲染能力。同步补examples/AGENTAO.md(init 模板单源)与本地库kbs/{jindu,jingzhen}
的AGENTAO.md(init不覆盖既有文件,存量库需就地补)。 -
Web 正文/对话里的
raw/<slug>.md引用联成只读 raw 源链 —— wiki 单页(/api/page)与对话答案
渲染时,把指向现存raw/<slug>.md的引用联成可点的a.rawlink[data-raw]:点击在右栏内联
调既有/api/raw/file只读渲染那篇原始素材(复用单页历史栈,一键「←」回引用它的原页;左栏对话气泡
里点亦切右栏,与既有[[wikilink]]行为一致)。识别四种写法(口径由用户敲定):① 裸路径串
raw/x.md(plain text 行内);② 整段恰好是raw/x.md的行内 code;③[[raw/x]](wikilink,带/不带
.md、支持|别名);④ markdown 链接[文字](raw/x.md)(改写<a href>、保留链接文字/内联格式、容
./前缀)。与[[wikilink]]同纪律——仅真实存在的 raw 文件联链,缺失标灰span.rawlink.broken
(dashed 下划线区别于 wikilink 的 dotted)。- 纯前端复用既有只读端点:
render.py三处处理器共用raw/*.md真实文件名集(wiki.parent/raw
推出、每渲染重扫,同_stem_to_path纪律)——_RawPathTreeprocessor(裸路径串 + markdown 链接 href,
树上处理故按祖先跳过a/code/pre)、_CodePathLinkTreeprocessor内 raw 分支、_resolve_wikilink
内raw/前缀拦截;右栏新增{kind:"raw"}视图(banner 标明「原始素材·只读」+ raw/ 路径 +
「在新标签打开」逃生口经/?raw=弹出 SPA,供并排对照源)。后端/api/raw/file·/api/raw零改动
(reader 下仍注册)。 - 关键正确性:
raw/x.md即便存在同名 wiki 页也联到 raw 源、绝不误链 wiki(raw 分支先于link_stem
页解析);裸路径串改用 treeprocessor(而非 inline)按祖先跳过<a>——根除[raw/x.md](url)在链接
文字里被回灌再联、套出非法嵌套<a>的隐患;[**强调**](raw/x.md)内联格式不丢;外链/绝对路径
(https://…/raw/y.md、/raw/x.md)不动;cat raw/x.md命令 code 与围栏块字面保留;不给wiki时
不联链(保 P4 既有姿态)。新增 4 条 i18n 键raw.{banner,openInTab,loading,openFail}(中英平价);
测试见tests/test_web.py。 - 多智能体评审修复(同一变更内):
span纳入_RAW_SKIP_SUBTREE——杜绝[raw/exists.md](raw/missing.md)
在断链灰 span 内再联出可点且指向另一源的 rawlink;basename 解析改为「按存在性试<名>与<名>.md」
(_lookup_raw)——含内部点的 stem(raw/1.示例报告)正确命中,且 markdown 链接指向非.md资产
(raw/report.pdf、raw/images/x.png)时原样保留 href、不再毁成断链 span;去掉 wikilink display 的
AtomicString包裹——恢复[[页|**别名**]]别名内联格式渲染(旧实现的既有 wikilink 回归);裸路径尾界
(?![\w\-])放行句末.(raw/x.md.仍联链);_raw_name_index精确文件名键 + 无冲突小写兜底(镜像
link_resolution_indexfold 纪律)——大小写敏感盘上raw/Foo.md/raw/foo.md各按真实名解析、零串台;
markdown 链接改写保留作者title;_linkify尾段插入改 O(n) 偏移跟踪(去list.index的 O(n²))。
- 纯前端复用既有只读端点:
变更
- Web 翻 auto 后的确认 UI 收进单框 —— 用户点「本会话起自动放行」(confirm_mode 翻 auto)后,原先
「恢复逐次确认」按钮与「本会话已自动放行 ASK 操作(仍只写 wiki/workspace,raw/ 与 AGENTAO.md 仍只读)」
安全提示是确认框下方另起的一条独立黄条;现把两者都并进「Agent 请求执行操作(请审阅)」确认框内:
恢复钮就近放在置灰的「拒绝」钮前,提示文案作为框内一行(.interaction-automode-inline)。点「恢复逐次
确认」后框内如实回滚——note.remove()撤掉已失真的提示行、去掉「本会话起自动放行」前的✓(移除
.chosen)、钮后内联「已恢复逐次确认。」。冷启刷新时页面已是 auto(无对应确认框可挂钮)仍走原来的独立
黄条带钮路径不变。实现上抽出共享的makeRestoreButton()工厂(确认框内一处、独立黄条一处共用),
scripts/smoke_p415.py同步把定位从独立.interaction-automode改为框内.interaction-automode-inline。
纯前端、零服务端改动;写边界与/confirm-mode {ask}可逆语义不变。
修复
-
毒会话状态文件(goal sidecar / 会话快照)不再把会话或整个侧栏打成永久 500(反向评审 gbrain v0.42.53
探针 + 后续 code-review 加固) —— 一份手改/半写成非对象 JSON(null/[]/标量)或坏 UTF-8 字节
的状态文件会让上层data.items()/data.get(...)抛AttributeError/UnicodeDecodeError,逃逸出只接
(OSError, JSONDecodeError)的容错网,把读它的端点打成 500。统一补齐三条读路径的毒值容错:read_goal(web/goal_io.py)加isinstance(data, dict)守卫 → 非对象 goal sidecar 退化为「无目标」,
不再经ConversationStore.restore()/cold_info/GET /api/info持续 500(违其 docstring 承诺)。_prune_old_snapshots(web/chat_support.py)加isinstance守卫 + 把UnicodeDecodeError并入 catch →
非对象/坏字节的兄弟快照在每轮 save 前的卫生步被跳过,不再崩 save(形同 gbrain「整循环崩在 checkpoint
写」)、不再令_save静默不落盘丢会话。ConversationStore(web/conversation_store.py)新增_safe_list_sessions包装 agentaolist_sessions
的 per-file.get毒值抛错(其except (IOError, JSONDecodeError)不接AttributeError/UnicodeDecodeError),
list/_disk_session(喂restore/messages_for)改走它 → 一份坏快照不再 500 整个会话侧栏
GET /api/conversations与冷会话 restore/messages(降级为「该坏文件不计入盘 catalog」),并给两处
load_sessioncatch 补 race 兜底。- 回归测试:
test_read_goal_tolerates_non_object_json/test_poison_goal_sidecar_degrades_cold_info_not_500
/test_prune_old_snapshots_tolerates_non_dict_session/test_prune_old_snapshots_tolerates_bad_utf8_bytes
/test_poison_session_snapshot_degrades_list_not_500。
(本轮另一处「convert 子进程强制 UTF-8 解码」的改动经 code-review 判定方向错误并已回退:skill 子进程裸
print发 locale 编码字节,父端强解 UTF-8 反而打断 matched-locale 下的中文路径往返、且 stderr surrogateescape
会漏孤 surrogate 把 Web 解析端点 500 ——_run_converter维持text=Truelocale-faithful 行为。convert 编码
的真正修法(子进程两端协同强制 UTF-8 + stderrerrors=replace+ pypdf/LANG parity 测试验证)连同续跑循环无
进展探测 / convert 无超时等结论,见docs/backlog/notes/gbrain-v0.42.53-反向审计-guanlan缺陷.md。)
Full Changelog: v0.1.16...v0.1.17
v0.1.16
新增
-
Web 自省斜杠命令无会话时自动开空活会话(P4.4 UX) ——
/tools·/skills·/context·/goal 设目标·
/mode <值>在无活动会话时不再回「需要活动会话:先提一个问题以开启会话」的死提示,而是先经新端点
POST /api/conversations开一个 warm 空会话并采纳为当前会话(写?c=、刷新可经内存命中续),
再正常渲染。建会话零 LLM、零盘写(agent 构造期注册工具 / 激活 skill,_save仅成功轮后落盘)——
故空会话只在内存、未进盘上枚举,用户随后第一条真问题进同一会话、不二次新建;满则同 chat 转 503。这是
早期「纯自省不值当建 agent」(决策P4.4-7)的 UX 反转,仅作用于「完全无会话」一路;冷会话(盘上有
历史未载入)维持「续聊一轮以恢复」、不自动重恢复。新端点不取 write_lock / 不跑 turn,故无须层③
writable-active 拒。- reader 部署不暴露此端点 / 不自动开会话(决策P4.9-2):匿名多用户下一个零-LLM、可循环调用的建会话
端点会让攻击者廉价占满MAX_CONVERSATIONS顶掉真实读者,且 reader 开 idle 回收、未落盘空会话被回收后
?c=续聊会 404——故POST /api/conversations套_writer_only(reader 不注册)、前端
ensureActiveConversation亦有 reader 闸;reader 读者仍可经POST /api/chat(首条消息)隐式建会话,
各斜杠命令在 reader 下回退到原goal.needSession/slash.modeNoSession/slash.needSession提示(这三键
由此复用、不成死键)。/goal仅「设新目标」开会话;show/pause/resume/clear/edit/budget 是对既有目标的
操作、无会话即无目标 → 直接提示、不空建 agent。ensureActiveConversation并发合一(杜绝双建孤儿)、
res.json()纳入 try(损坏响应体不抛未捕获 reject)。 - 新增三条 i18n 键
slash.openFail{,Http}/slash.openFull(中英平价);测试见tests/test_web.py。
- reader 部署不暴露此端点 / 不自动开会话(决策P4.9-2):匿名多用户下一个零-LLM、可循环调用的建会话
-
Web 长任务目标与自动续跑
/goal(P4.16) —— 在浏览器对话框设一个长任务目标,由宿主拥有的
while goal.is_active外层循环反复驱动Conversation.turn(),每轮注入续跑 prompt、host 侧记时间/轮数
预算、撞预算跑恰好一次收尾轮;agent 仅经注入的update_goal工具自报complete/blocked,用户经
/goal [<目标>|show|pause|resume|edit|budget|clear]控制(见docs/P4.16-Web目标续跑.md)。
最大化复用 agentao 已落地原语——cli.goal_state.GoalState/cli.duration.parse_duration/
tools.goal.UpdateGoalTool(故依赖上提至agentao[cli]>=0.4.13),guanlan 只建 Web 编排层:
per-conversation.agentao/goals/<id>.jsonsidecar(os.replace原子写)、asyncrun_goal续跑循环
(每内层轮ensure_future + asyncio.shield、_goal_lock+_GoalProxy串行 GoalState 读写、_goal_io_lock
串行落盘)、POST /api/chat/{id}/goal{,/budget,/pause,/resume,/edit,/clear,/run}端点(/run走 SSE 续跑流:
goal_start/turn_start/turn_done/goal_done轮边界帧 + 每内层轮独立气泡 + goal 横幅)、前端
/goal斜杠支线、guanlan web --goal {on,off}开关(reader 部署强制关)。- 不放宽任何边界:goal 每内层可写轮照走 P4.5 层①②③ + 写后 check + 撤销 + 单写者写锁(逐轮取放、
绝不跨整个 goal 持锁);与 P4.15 confirm/ask 人在环完全复用、自动组合;进程内至多一个活跃可写
goal(set/budget/resume/run 一致互斥);停止断整个 goal、断线收为paused、重启盘上 active 显示
为「可恢复」不自动续。--turns(外层续跑轮数)与max_iterations(单轮内层工具循环)正交、不混淆;
不走force_continue、零 agentao 内核改动。 - 并发/取消硬化(设计三轮评审 + 实现后 xhigh 工作流 + 多轮 codex 评审收敛至零 finding):
_in_goal/
预订经try/finally+ task done-callback 零泄漏(杜绝「进程级写端点永久 423」)、被中断轮墙钟计入
(--for不被反复 stop/resume 架空)、删会话与 goal 循环经_goal_deleted+_goal_io_lock串行(不
resurrect 已删 sidecar)+ 删会话连带删 sidecar、冷会话 goal 端点懒恢复、孤儿 sidecar 可清。测试在
tests/test_web.py(goal 套件,注入消息驱动 fake agent、不打真实 LLM)。 - 流式中拦下撕毁在飞流的生命周期斜杠命令(评审 #1):为让
/goal pause·show·budget在 goal 续跑
(chatStreaming=true)期可输入,submitChat把斜杠分发提到chatStreaming守卫之前——但这也放行了
/new·/clear·/mode <值>,它们会清屏 + 置conversationId=null(/clear还 DELETE 这一在飞会话)、
或在 turn 中途翻姿态,把正在流式的这轮/这个 goal 撕成「半清屏半流式」错乱态。改为在handleSlash
按chatStreaming单独拦下这三条生命周期命令(落slash.busyLifecycle提示、要求先停止),只读
自省(/help·/status·/context·/skills·/tools·/mode 无参)与 goal 控制仍放行——危险副作用兜在命令层、
不粗暴全拦(那会误杀 /goal pause)。新增 i18n 键slash.busyLifecycle(中英平价)。 - SSE 帧解析归口单一实现(评审 #11):P4.16 的 goal 续跑流另抄了一份与
handleSSE字节相同的帧解析
循环(event:/data:前缀剥离 + 多行 data 拼接 +JSON.parse容错),两份须人手保持同步、改一处忘
另一处会让 goal 流静默错帧。改为handleSSE也走唯一的parseSSE、随后只管分发渲染——解析规则只动
一处、两条流自动一致;纯重构、行为零变化。 /goal budget拒绝冲突的清/设上限组合(codex 评审 P2):--clear/--unbounded(清上限)与显式
--for/--turns(给上限)同用时,旧实现径取(None, None)静默丢弃后者,留下「以为设了上限其实
无上限」的假象(前端/goal budget --unbounded --turns 5即可触发)。改为先校验冲突 → 400,对齐设目标
_resolve_goal_budget既有的 #14 同款校验,让用户二选一。- 真浏览器端到端冒烟
scripts/smoke_p416.py(承 P4.13/P4.14/P4.15 同骨架):test_web.py的 ASGI
TestClient验后端 + SSE 帧,但验不了 goal 横幅/每内层轮独立气泡的真渲染、/goal斜杠支线、以及
「自省/设目标命令无会话时自动开空活会话」这条纯前端路径。脚本起真 socket Web 宿主 + 目标驱动的
fake agent(objective 嵌[complete@N]/[slow]标记位驱动提前 complete / 慢流),Playwright headless
Chromium 端到端验五景:设目标自动开会话 + 续跑 + 提前 complete、撞轮数预算 + wrap-up 收尾轮、续跑中途
/goal pause打断在飞轮、/goal show无会话不自动开会话、/tools无会话自动开会话。不入 pytest(需
浏览器 + 真 socket);uv run --extra web python scripts/smoke_p416.py,5/5 通过。
- 不放宽任何边界:goal 每内层可写轮照走 P4.5 层①②③ + 写后 check + 撤销 + 单写者写锁(逐轮取放、
Full Changelog: v0.1.15...v0.1.16
v0.1.15
优化
- Web 确认气泡观感打磨(P4.15) —— 纯前端微调,服务端 / i18n 契约零改动:① 审阅卡字号整体小两号、
更紧凑;② 命令 / 参数块渲染超过三行时默认折叠到前三行,框内右下角浮一枚「展开全文 / 收起」钮(纯视觉
max-height+overflow:hidden收起,量高在钮入框前完成;全文始终留在 DOM——复制 / 字面显示 /
断线重渲 / smokeinner_text均不受影响);③ 已决态不再另起一行状态——人点 允许 / 本会话起自动放行 /
拒绝后在对应按钮前打勾(✓)并取消置灰、标出最终选择,系统自决的 超时 / 中止 则在「拒绝」钮后
内联图标+文字。ask 提问气泡仍走原状态行。smokescripts/smoke_p415.py三处确认断言随之更新
(查.chosen取代查状态行)。新增两条 i18n 键interaction.cmdExpand/cmdCollapse(中英平价)。
内部
- CI 卫生:job 超时上限 + actionlint 校验 workflow —— 纯仓库基建、与里程碑无关、零功能改动:
① 给ci.yml的 test job 与release.yml的 pypi-publish / github-release 各加timeout-minutes
(15 / 15 / 10),卡死的跑(死锁 / 等网络)十几分钟内被杀、不白烧 GitHub 默认 6h job 超时的额度;
②ci.yml新增独立actionlintjob(单跑、不进 3×Python 矩阵),用钉到 v1.7.12 镜像 digest
(docker://rhysd/actionlint@sha256:b1934ee5…、SHA 钉版可复现、非浮动:latest)校验 workflow
YAML 语法 / action input /${{ }}表达式,并经镜像自带 shellcheck 检run:内联 shell
(release.yml抽 CHANGELOG 的 awk/bash 由此受检)。concurrencyci.yml 早已有、不动;release.yml
故意不加(不在发布到一半被取消)。落地前以钉版 actionlint v1.7.12 + shellcheck 对两 workflow
跑过、零 finding。
Full Changelog: v0.1.14...v0.1.15
v0.1.14
新增
- Web 工具确认 /
ask_user人在环(P4.15) —— 把 P4.5 可写 Web 会话里「ASK 决策被嵌入 transport
静默自动放行」的那一刻补成「人在浏览器里确认」,并接上模型主动提问(ask_user)。
workspace-write 下凡 agentao 判为ASK的调用(带操作符/管道的 shell、带requires_confirmation
的工具)不再默默跑,而是经 SSE 推一帧confirm_request、阻塞 turn 等用户点 允许/拒绝、再把布尔
回传运行时;模型ask_user同机制弹ask_request(可带选项/自由文本)、收回答案串。三个硬点都
钉死:① 写锁——确认/提问阻塞期全程持进程级write_lock,故 ② 超时(--confirm-timeout,默认
120s)是硬需求、无人应答默认拒绝(绝不默认放行),③ 取消——停止按钮 / 客户端断开都 trip 同一
取消令牌(断线时若有未决 pending 则request_stop(),内层立即收尾释写锁、不空等满超时)。线程桥:
确认/提问回调在arun的 executor 线程同步阻塞,经call_soon_threadsafe推帧、queue.Queue
收应答,端点(POST /api/chat/{id}/confirm·/answer·/confirm-mode)在事件循环线程put_nowait
瞬返、绝不取 write_lock/conv.lock(防与持锁等待的 turn 死锁)。confirm 气泡三选项:允许 /
本会话起自动放行(对标 CLI 第 2 项但取安全版——只把本会话confirm_mode切auto,姿态仍
workspace-write、层①②③ 一个不少、raw//AGENTAO.md仍硬只读,非 full-access,且可逆「恢复逐次
确认」)/ 拒绝。安全闸:pending 槽记kind,/confirm只消费 confirm、/answer只消费 ask、
跨类一律 409(否则/answer的非空串被confirm_tool当 truthyTrue绕过「点允许」放行
shell);info().pending回完整请求 envelope 供断线重连重渲染。关键不变量:「人点允许 ≠ 绕过
immutable」——确认只决定 ASK 工具跑不跑,不开新写路径,raw//AGENTAO.md只读永由确定性层①②扛。
新进程开关guanlan web --confirm {ask,auto}(默认 ask)+--confirm-timeout;子进程ingest/
query路径不在本期(无人值守、靠permissions.json)。ask_user跨姿态触发(read-only 也问、只占
conv.lock不持写锁)。守于tests/test_web.py(确认放行/拒绝/超时/停止/断线释锁/跨类拒/陈旧 409/
写工具不弹/immutable 不放宽/auto 模式/气泡②≠full-access/可逆/info envelope/层③ 423/ask 往返/只读
ask 不持写锁,20 例)+test_web_i18n.py平价;前端确认·提问气泡(命令/问题字面显示、倒计时、断线
重渲)。无新退出码 / 无新 Python 依赖;设计见docs/P4.15-Web工具确认.md。
Full Changelog: v0.1.13...v0.1.14