Commit Graph

56 Commits

Author SHA1 Message Date
ThearchyHelios 26ff8e1e06 只读数据源真的只读了:WITH … DELETE 不再从首关键字下面钻过去,连接本身也写不进去
守卫只看首关键字,而 with / pragma 都在只读白名单里。在只读的 SQLite 源上
实测:`WITH k AS (SELECT 1) DELETE FROM t` 被放行,5 行删得只剩 2 行;
`PRAGMA user_version = 7` 也写进去了。调用方只看到一个 ResourceClosedError——
pysqlite 只在 INSERT/UPDATE/DELETE 开头的语句前隐式开事务,WITH 开头的直接
自动提交,连接关闭时的回滚兜不住。Excel/CSV 上传生成的正是这种 SQLite 源。

两层一起收:

守卫:以查询开头的语句(SELECT / WITH / EXPLAIN)要把正文扫一遍,先抹掉注释
和引号里的内容,再找写关键字(DELETE、UPDATE、INTO、FOR UPDATE、EXPLAIN
ANALYZE 里的语句……),关键字后紧跟 ( 的是同名函数不算(MySQL 的 INSERT()、
TRUNCATE())。PRAGMA 改成只读白名单,`= 值` 和 `(值)` 两种赋值写法都拦。
另收一小撮有副作用的函数,其中 set_config 必须拦:PG 真库实测,它能把连接层
的只读关掉,同一连接的下一个事务就能写。函数名加引号、dblink 另开连接这两种
绕法也补上了。is_write 与只读判定共用一份判定,免得审批那道门另有一套口径。

连接层:只读源连接时就只读(engine_args)。SQLite 以 mode=ro 打开(file: URI,
路径带空格、中文、# 都测过);PostgreSQL 设会话默认事务只读;MySQL / MariaDB
设会话事务只读。Oracle 没有会话级开关,只剩守卫——README 写明请配只读账号。

真库验证(临时实例,已清理):PostgreSQL 18 与 MySQL 8.0 上,DELETE、WITH … DELETE、
PG 的数据修改 CTE、nextval、EXPLAIN ANALYZE 绕过守卫直连执行,都被数据库以
只读事务拒绝;可写源不受影响。

测试:守卫新增 33 例(藏在查询里的写、会写的 PRAGMA、同名函数与引号标识符不误杀),
去掉修复重跑是 17 例失败;test_readonly_source.py 6 例(当初那条绕过、绕过守卫
直连写、中文路径、可写源不受波及、各库的会话参数)。297 → 334 passed。

顺带查到但没在这里修的:可写源上的写操作既不审批也不生效——is_dangerous_call
全仓库没人调用,而 run_query 从不 commit,写完就被回滚。两件事必须一起修
(先接审批再让写入生效,反过来就是一个无审批写库的窗口),放在审批统一那一步。
2026-09-25 14:44:56 +08:00
ThearchyHelios 66cc96b463 汇合节点只跑一次:长短支路不再把它触发两遍,循环的 done 出口也不再被提前放行
多条边汇入同一个节点时,编译器对每条入边各 add_edge 一次。LangGraph 对这种
接法的语义是"任一上游跑完就触发一次":两条支路一样长时,两次触发落在同一步
被合并,看不出问题;一长一短时,汇合节点先拿短支路的结果跑一次,长支路到了
再跑一次——模型调用、工具副作用、下游全部翻倍。循环体里更糟:每轮汇合两次,
循环节点就多推进一格,done 出口在循环跑到一半时就放行了,成果节点跑了 3 次。
README 写的"自动等待汇聚"只在支路等长时成立。

修法是给汇合节点加 defer(等图里其余待执行的任务都跑完再跑一次),而不是
屏障 add_edge([a, b], j):分支之后的二选一汇合,没走的那条永远不会到,屏障
会一直等,而 LangGraph 没有可执行任务时会安静结束——汇合节点和下游就被无声
跳过。代价写在 _join_nodes 的注释和 README 里:它也会等图里无关、恰好在跑的支路。

汇合 = 两个以上前向上游,循环回边不算。回边识别因此从 layout.py 挪到
schema.back_edges,加了 roots 参数:执行语义要从入口起 DFS,否则循环体里的
前向边可能被误判成回边;排版照旧按节点顺序起算,行为不变(check-canvas-layout
全过)。

新增 test_fan_in.py 5 例:长短支路、循环体内汇合(修前分别跑了 2 次和 4 次);
二选一汇合、被分支剪掉一路的汇合(守着别把修法做成屏障);回边不算汇合。
292 → 297 passed。内置模板里 ③④ 的成果节点是二选一汇合,行为不变。
2026-09-25 14:33:56 +08:00
ThearchyHelios 8905fe5e60 多 agent 节点在画布上展开协作矩阵,运行时的动效统一成一套语汇
多 agent 节点原本是个黑盒:卡片转三十秒圈,然后吐一段文本。"它同时派了三个
人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道,而右栏是线性的
时间线,看不出并发的形状。

现在它跑起来会在卡片里展开一块协作矩阵。三条设计上的选择:

- 花名册固定在原地,不按派单顺序重排——几轮下来眼睛知道谁在哪一行
- 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条,
  墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了;页脚给出
  并行一共省下多少
- 本轮没被派到的人不显示成"待命",而是留着上一轮的成绩(压暗)——它已经
  交回来了,说它待命是错的,看的人会以为它还没干活

矩阵和右栏泳道读的是同一份翻译:把 decodeRun 里那段攒泳道的逻辑抽成
reduceTeam(不可变,每次返回新对象,画布那份挂在 zustand 上需要新引用)。
两边各写一遍的话,同一次运行在右栏和画布上会给出不同的并行度——而"几个人
同时在跑、省了多少"正是这个节点唯一值得看的东西。抽完之后 check-decode
一条没红。

其余节点按同一套语汇动:边框上转一圈光弧 + 扫描线 = 正在跑,绿色扫一遍 =
刚跑完(只扫一次——常驻动画会让整张画布一直在动),短促抖动 = 刚失败,
琥珀呼吸 = 在等人。循环卡片显示"第 1/5 轮";分支跑完点亮命中的出口、压暗
其余,实际走过的边留在画布上,那就是这次的执行路径。连线上的虚线换成顺着
路径跑的光点:虚线只让人觉得"在动",光点能看出往哪走,速度按路径长度归一化
(长边上的光点走得慢会像卡住了)。

原来那个往外扩的声纳圈(pulse-ring)删掉了。同一个意思说两遍只会更吵,而且
声纳扩出去会和邻居卡片的边框糊在一起。

一个前提先验证过:offset-path 在 SVG 子元素上能不能对齐路径。测下来和
getPointAtLength 的位置差 0px,才敢用它。

排版要按**展开后**的高度给多 agent 节点留位置(layout.py 的 _height 按名册
人数算)。不留的话它一跑起来就盖住下面那张卡,而且运行结束后矩阵不会收起来
(那正是要回看的东西),重叠是永久的。

前庭敏感的人由 prefers-reduced-motion 兜住:光弧、扫描线、流光条、光点整体
不绘制,而不是把时长压到 0.01ms——那样它们会停在动画的最后一帧,光弧卡在
角落、光点全堆在起点,看着像渲染坏了。静态下"谁在跑"由实心圆点和"进行中"
三个字承担。

新增 scripts/check-canvas-fx.mjs:把脚本化的事件灌进 store(走的是页面上那一份
applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵真的长出来、
并行度算对、条形长度和耗时成正比、分支命中的出口被点亮、关掉动效时光效确实不
绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上"三个人同时还在跑"
那一瞬间——那样的检查会变成偶发失败。另外用仓库里 fixtures.json 那份真实导出
的事件验过一遍,含老运行那种只有中文消息串的 log(走正则兜底)。

顺手修掉检查脚本互相下绊子:check-canvas-fx 原来会留下一张没坐标的工作流
(节点全叠在原点),而 check-ui 打开 /studio 会落到"最近更新"的那张上,于是
点节点被挡住。现在落库前先让后端排好版。

292 passed(新增 2 条:展开的多 agent 卡片必须在排版里留出位置)。
2026-09-25 12:47:07 +08:00
ThearchyHelios 7fb2976f29 代码节点的变量不再带着 print 的换行:一个变量不该有两种判定
运行 d47bfcb03dc747658c2d63f8755992b9 交出来的是自相矛盾的结果——
"已重试 2 次仍未成功,流程终止",而最后输出是 {"ok": true, "result":
"1+1 = 2"}。看着像运行失败了,其实 status=succeeded。"已重试 2 次"是图里
写死的文案,跟实际跑了几轮、跟成败都无关。

真正的毛病在事件流里:judge 节点(code)的 vars 是 {"ok": "ok\n"}。
assign_to 拿的是**原始 stdout**,而 print() 一定补一个换行,于是
`print('ok')` 写进变量的是 "ok\n"。后果是同一个变量、同一个表达式被两个
控制节点判出相反结论:

    loop  retry          vars.ok == 'fail'  → False  循环正常退出(看着像成功)
    branch check_outcome vars.ok == 'ok'    → False  所有条件都不满足 → default → 失败

这不是个例,是代码节点的默认用法:print 是它唯一的产出通道,写完再比字符串
是标准套路,所以只要这么写就必然踩上,而且**静默**——没有一个地方会说
"这个值多了个换行"。

同一个函数里本来就为了"stdout 是不是 JSON"算过一次 strip(),只是没拿它当值
用。现在统一成一份:

- payload["text"] 和 assign_to 都用去掉尾部空白的文本。只削尾不削头:首行的
  缩进是内容(逐行打印一段缩进文本时削掉就毁了),而尾部换行是 print 的产物
- payload["stdout"] 保留原始输出——审计"到底跑了什么"只能看它
- stdout 是 JSON 时仍解析成对象,既有约定不变
- {{ vars.x }} 和 {{ nodes.x.text }} 必须是同一个值,两种写法给不同的值比
  原来的毛病更难查,所以一起改

顺带在 Copilot 的 NODE_REFERENCE 里写明代码节点的输出契约(尾换行已去掉;
stdout 是 JSON 就变对象,那时要用字段引用而不是比字符串),否则模型会继续
生成下一个变种。

新增 tests/test_code_output.py:五条,覆盖尾换行、多行内容不被削坏、JSON
仍解析成对象、text 与 vars 一致、纯空白输出归一成空串。修之前红四条。

验证做过变异:把改动还原,同一张图回到"走了失败分支",而 print('ok',
end='') 仍是成功分支——换行就是原因。把用户那张图原样重跑,judge 的 vars
是 {"ok": "ok"},check_outcome 命中 "vars.ok == 'ok'" 走成功出口。

290 passed。
2026-09-24 23:15:19 +08:00
ThearchyHelios 75be5f485e 画布上的线不再叠在一起:排版按车道留走廊,走线不再交给 smoothstep
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。

后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。

前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。

顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。

新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。

285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
2026-09-24 22:58:54 +08:00
ThearchyHelios 4057cdc35f 步数用尽时先收个尾,别把半截过程当答案交出去
走到步数上限一定意味着最后那步**要求了工具**(不要工具的话循环早 break 了),
也就是说模型从来没拿到过"说结论"的那一轮——它只是被掐断在半路。交出去的
是它上一句中间过程的话,末尾补一句"步数不够,请调大"。

实测这条线是绷紧的:库里 116 个 agent 节点,87% 在 10 步内收口,11 步只有
1 个,**正好 12 步的有 11 个**。1 跳到 11 是被截断的特征,不是自然分布。

所以补上那一轮:不给工具,让它基于已经查到的收口,并说清哪些没查到、
结论因此有什么局限。用 base_model 而不是绑过工具的 model——收尾轮必须在
结构上发不出 tool_call,靠提示词请它别调是约束不住的。

两种收场发不同的 code:收上来了是 step_limit_settled(DEGRADED),
收不上来仍是 step_limit(BROKEN)。不复用同一个 code:库里那 8 条老事件
的含义确实是硬截断,改语义等于把历史运行重新解释成另一回事。

顺带修掉一个一直在白烧钱的 bug:chat.ts:440 的注释说自动重试能"调大最大
步数",而 copilot.py 明确禁止模型写 max_steps。两边对不上,于是 step_limit
的重试是用同一个默认 12 再跑一遍,必然撞同一堵墙。现在那条禁令改成有条件
的——只有上一次死于步数不够时才准显式写大值。改完两条路各自成立:收尾
成功走 DEGRADED,闸门不开、不重试(对的,同配置再跑还是撞墙);收尾失败
仍是 BROKEN,而这次 Copilot 真能把步数调大。

硬顶 25 → 100,画布滑块跟着放开,**节点默认仍是 12**:要更多的人调得动,
不知情的人不会突然多花十倍。

新增 tests/test_agent_steps.py,顺带补回 agent 循环的集成测试(原来那个
文件在脱敏那轮被整个删掉了)。两条反向断言是重点:正常收口的 agent 一次
调用都不该多花,以及收尾轮发不出工具调用。两者都做过变异验证——把收尾
结果丢弃、把收尾轮换成绑工具的 model,各自都能让对应的断言变红。

273 passed(去掉一个会真连 api.openai.com 的既有慢测试是 238 passed / 9.4s)。
2026-09-24 16:38:26 +08:00
ThearchyHelios c8393c3c6b 移除仓库中的商业数据:删掉 21 个相关用例,其余标识符脱敏
这个仓库是公开的,而历史里带着一份客户 BI 仓的逐字转储:ANALYTICS(原
schema 名)下 53 张视图名、实测行数、字段结构表,外加一句写明取数方是谁
的注脚。还有一处把真人姓名和工作单位写进了检查脚本的断言里。这些都不是
化名,是能直接拿去对库的东西。

按"含敏感词的测试直接删"处理,共 21 个用例:

  test_agent_tool_loop.py   整个文件(2 个,agent 工具循环的真图验收)
  test_conversations.py     9 个(会话 CRUD、历史文本、上下文进 prompt)
  test_datasource.py        5 个(探查失败的实情、候选 schema 提示)
  test_copilot_context.py   3 个(数据源摘要的限定名)
  test_review.py            1 个(干净运行无信号)
  test_tool_args.py         1 个(call_tool 端到端)

辅助函数和产品源码不在"测试"之列,改为脱敏——否则删一个 _seeded 会连带
带走 8 个没被点名的用例。标识符一律换成中性名:shop→shop、
ANALYTICS→ANALYTICS、prod_shop→prod_shop、真人姓名→张三。

markdown 样本里那两条不是改标识符能解决的:行数、字段结构、业务域描述
整段都是客户的。换成同构的合成内容——领域换了,markdown 构造逐项对齐
(标题层级、有序/无序列表、三列表、带 inline code 的单元格、引用块),
渲染器的覆盖面没有变窄。check-stream 里那句"用的是真实输出"跟着改成实话。

286 → 265 passed,差值正好是删掉的 21 个。tsc 干净,decode / stream / ui
三个 check 不回归。

历史里的同一批内容另行用 git filter-repo 处理。
2026-09-20 22:52:28 +08:00
ThearchyHelios 5641d0631f 循环变量不再被报成"未定义":错的是分析器,不是模板
模板⑦「批量处理(循环)」是唯一一个装好就红着的内置模板——新用户点开第一眼
看到「1 个错误」,运行按钮还是灰的:

    {{ vars.item }} 没有任何节点产出,是不是想写 {{ vars.items }}?

**但模板是对的。** 循环节点配的 item_var 就叫 item,control.py:142 每轮往
vars 里注入 vars.item 和 vars.item_index。错的是静态分析器:产出侧只登记了
input.*、vars.<assign_to>、nodes.<id>,从来没登记过循环注入的那两个。
kind="loop" 在 _KIND_ORDER 里一直占着位置,只是没人产出过——漏实现,
不是设计如此。

顺着修下来一共三处误报,都是同一类:

**一、循环变量没登记。** 补上 vars.<item_var> 和 vars.<item_var>_index,
只在 foreach 模式(while 不遍历列表,自然没有"当前项")。

**二、顺序判定被回边搞乱。** 补上之后错误降成警告,但换了一句同样不对的话:
"{{ vars.item }} 由「逐条循环」产出,但那一步在这之后才跑"。循环体末尾有一条
回到循环节点的边,于是循环节点的拓扑深度被推高、反而排在自己的体后面。
改成先算出每个循环的"体"(从 body 出口走可达闭包,遇到循环节点自己就停),
体内引用不套先后判定。体**外面**引用它确实取不到值,那条留着,
并换成说得清的措辞:"只在循环体内部有值"。

**三、表达式字段里的裸引用没扫。** transform 的 expression、branch 的
cases[].condition、skip_if 写的是裸 vars.x,没有 {{ }},而扫描只认模板语法。
于是模板⑦里 `(vars.collected or '') + str(vars.one)` 一个引用都认不出来,
vars.one 被报成"产出了但没有任何地方引用"——它明明在用。

顺带把"没人用"这条收窄到**作者声明过的**变量:循环的 item_index 和入口字段的
vars.<name> 别名都是系统自动提供的,报它们等于噪音(内置变量没有 produced_by,
本来就不在这条路上)。而真正未引用的 assign_to 照报不误——⑤⑥⑧ 还各有一两条。

误报是 linter 最坏的失败模式:它会让人把整个校验一起无视掉。这条理由本来就
写在这个文件的注释里,只是自己没做到。

现在 8 个内置模板全部零错误。验证:后端 284 passed(test_variables.py 新增
8 条,含"体外引用仍然要警告"和"while 模式没有当前项"两条反向断言);
三个前端 check 不回归。
2026-09-20 21:30:42 +08:00
ThearchyHelios c3f7d6ffcd 协作团队真的能并行了,界面上也看得出来
先说一个和名字不符的事实:这个节点叫「多 Agent 协作」,但在此之前它是一条
严格的串行流水线。调度协议写死成 `next: string`——"一次只能派一个"被编进了
协议本身,模型再想并行也表达不出来。三个专家各花 10 秒,总是 30 秒。

(真并行一直在图级 fan-out:实测 3 个各 sleep 2 秒的节点连在同一个上游,
全部 +0.00s 开始、+2.37s 结束,墙钟 2.6 秒。只是协作团队走不到那条路。)

**协议改成 assignments 数组**,一轮可以派多个互不依赖的专家,asyncio.gather
并发执行,总耗时按最慢的那个算。max_parallel 可配,设成 1 即退回严格串行。

**并发只在任务真的互不依赖时才成立**,这件事的成本必须说清楚:同一轮发出去的
专家拿到的是**同一份进展快照**,谁也看不见谁这一轮干了什么。派错了不报错,
只会让两个人基于一样的旧信息重复劳动,而这在结果里很难看出来。所以判断责任
压在调度者身上,提示词里把反例写出来:

    可以同时派:「查 A 的资料」和「查 B 的资料」
    不能同时派:「查资料」和「根据资料写报告」
    不能同时派:「写初稿」和「校对初稿」

并明确告诉它拿不准就分两轮——代价只是慢一点。

**每个人单独计时**,而不是事后拿整轮墙钟去摊。界面要显示"并行省下多少",
那个数必须是量出来的:按 N×墙钟 推算等于假设每个人都跑满了最慢那条,
而实际上快的那个可能只花了三分之一。测试里专门有个快慢不对称的用例守这条。

**调度日志改成结构化字段**(agents / done / reason / parallel)。以前只有一句
中文消息串,解码层得拿正则去拆「调度 → X(理由)」——后端文案一改就散架,
而这次正是改了文案。老运行的事件没有这些字段,正则那条路留着兜底。

**界面加泳道。** 每个成员一行、每轮一格,条形宽度按耗时归一化,所以一眼看得出
谁和谁是同时干的、谁是这一轮的瓶颈。顶上一行汇总"3 名成员 · 3 轮 · 2 轮并行,
省下 11.8s";点任意一格展开那个人这一轮的任务和回复。全程串行时不装作有并行,
而是说"本次任务互相依赖,逐轮进行"。

泳道排在子步骤**前面**:先给一眼看清的形状,再往下是每一步的内容。反过来的话,
得把十几行步骤读完才知道这个团队是怎么分工的。窄栏(画布右栏 360px)下隐去
"N 人同时"的文字——并行与否看条形本来就一目了然,而缩写成"并2"没人看得懂。

验证:后端 276 passed(新增 test_supervisor.py 7 条,核心那条用**墙钟**判定
并发而不是看事件顺序——顺序只能说明"发出去了",说明不了"同时跑");
check-stream 新增 10 条泳道断言,含"同一轮里慢的那个条更长"和两种栏宽不溢出;
tsc 干净,三个 check 不回归。
2026-09-20 20:57:03 +08:00
ThearchyHelios 71561a0133 探查失败不再伪装成"还没探查":把实情和下一步交给 agent
从 run 554a0f92 查出来的。那次问「userID=2 是谁」,9 次工具调用花掉 6 次、
前 29 秒没碰到一点真数据:

    + 2.1s  db_schema            → "还没有探查过结构,请在设置页里执行一次"
    + 4.9s  db_schema ×7 个猜的表名 → "没有 user / users / member / …"
    + 7.7s  SELECT 1             → 不信任前面的结果,先探一下连通性
    +12.8s  information_schema.tables   ┐
    +18.3s  information_schema.tables   ├ 自己把 schema 重建一遍
    +21.5s  information_schema.columns  ┘
    +29.2s  终于开始查真数据

根因:shop 的探查**失败过**(Lost connection to MySQL server during query),
但失败被存成了一个"成功但没表"的缓存,schema_synced_at 照常盖戳:

    {"tables": {}, "total": 0,
     "error": "OperationalError: (2013, 'Lost connection...')",
     "available_schemas": ["prod_shop", "shop_dev", "toolsdb", …10 个]}

于是三处同时说假话:界面显示"结构未探查"、工具对 agent 说"还没有探查过"、
而那 10 个候选 schema 就躺在缓存里没人交出去。**用户明明点过探查,是超时了。**

**分清"还没探查"和"探查失败了"。** introspect 的结果加 failed 标记;
失败照样把缓存存下来(候选是下一步的线索),但不盖"已同步"的戳;
界面把红字和候选列出来;探查按钮的提示也分清是超时还是 schema 填错。

**给 agent 一个它做得到的下一步。** 以前那句"请在设置页里执行一次结构探查"
——agent 进不了设置页。现在换成实情 + 候选 schema + "不要猜表名,直接查
information_schema / sqlite_master"。那条自救路线 agent 最后也走通了,
只是自己摸索了三轮才找到,直接告诉它就行。

describe_table 同样:缓存空的时候说"数据源里没有 user"是在误导,真相是我们
什么都不知道。那次一口气问了 7 个表名,收到 7 条一模一样的
"没有 X。现有的对象:(还没探查过结构)",白花一整步。

顺带把逐表反射换成 SQLAlchemy 2.0 的 get_multi_*,但**别指望它治超时**:
实测 60 张表逐表 121 次往返、批量 122 次——反射缓存本来就把 get_columns 和
get_pk_constraint 合并了,而 MySQL 方言压根没实现 get_multi_*,落到基类还是
每表一次 SHOW CREATE TABLE。换它是因为这是 2.0 的正经接口、少三层
try/except,不是因为它快。shop 那次超时多半是偶发的,所以真正的对策
不在那儿,在"失败要如实记成失败"。

重新探查了一次 shop:153 张表、38 秒、成功。之前 agent 要花 6 次调用、
29 秒拼出来的东西,现在工具描述第一眼就给了。

验证:后端 269 passed(test_datasource.py 新增 4 条,守"失败不能被说成没探查过"
和"给 agent 的下一步它做得到");tsc 干净;三个 check 不回归。
2026-09-20 18:05:11 +08:00
ThearchyHelios 7b04162540 Excel/CSV 变成能查的表、PPT 读得进来、跑挂了能接着跑、文档和工具有了地址
四件事,其中 Excel 那条不是"多支持一种格式",是补一个和地基冲突的缺口。

**Excel / CSV → 可以用 SQL 查的表。**

这个项目有条不可回退的决策:叙述层无算术权限,所有算术下沉到 SQL 或口径卡,
叙述只能引用(engine/issuance.py)。表格传进知识库只能被切块检索,数字就成了
模型从片段里"读"出来的——复核层拦得住凭空多出的数字,拦不住"从一堆片段里
读错了一格"。所以表格必须变成表。

不新增数据源类型:kind='sqlite' 早就支持,build_url 拿 database 当文件路径。
落成 .db 文件再建一条 DataSource 指过去,下游一行都不用改——SQL 守卫、结构
探查、db_query__<name> 工具、查询快照进工件库、Copilot 的数据源清单,全都白拿。
建表走 sqlite3 直连而不是 run_query:guard 的 _ALWAYS_DENIED 里有 create,
那是对的(模型不该能建表),而导入是我们自己发起的可信操作。

四个语义是这块的全部分量:

- **类型要推,不能全存 TEXT。** SQLite 是动态类型,但列亲和性决定排序和比较:
  整数列落成 TEXT 的话 '10' < '9',SUM() 在混入空串时静默出错,而没有任何
  地方会报。逐列扫描推断 INTEGER/REAL/TEXT,空单元格存 NULL 不存 "",
  日期存 ISO(字典序恰好等于时间序)。测试里那条 ORDER BY 要是 9<10 而不是
  '10'<'9',守的就是整块的意义。
- **表头取第 1 行,但不猜。** 不做"自动找表头"的启发式:猜错的表现是列名变成
  一行数据,没有任何征兆,直到某天有人发现汇总一直少一行。改成 header_row
  可填,并把推断出的列名和类型当场回显——错了一眼看见。
- **同名重传原地替换,id 和 name 都不变。** 名字会成为工具名(db_query__sales)
  而工具名写进了保存过的图,另起一个 sales_2 等于悄悄让那些图失效。
- **中文原样保留。** 一开始把 sheet 名清洗成了 ASCII,结果「明细」「汇总」都变成
  sheet、sheet_2,模型和用户都认不出哪张是哪张——而 SQLite 的标识符本来就允许
  非 ASCII,那道清洗纯属自找麻烦。现在只替换会断句的空白和标点。

实测:两 sheet 的中文 xlsx → 两张表,金额列 INTEGER,跨表 JOIN + SUM 跑通,
Copilot 的工具清单里带着表名。CSV 按 utf-8-sig / utf-8 / gb18030 依次试——
GBK 的 CSV 是 Excel 另存为的默认行为,按 UTF-8 读会得到乱码列名,而乱码不报错。

知识库那边顺手改了指路:传 .xlsx/.csv 进去不再说"不是 UTF-8 文本",
而是告诉你该传到数据源那边、以及为什么。

**PPT。** 文本框和表格分开取(表格的 has_text_frame 是 False),幻灯片之间
留空行当段落边界。.ppt/.doc/.xls 这些老二进制格式认出来再拒,说清楚"另存为
新格式"——以前它们会掉进"不是 UTF-8 文本"那个兜底,对着一个 .ppt 说这话,
用户根本不知道该怎么办。

**问数据页的「接着跑」。** 挂了那一轮不该只剩一行红字:断点一直在 checkpoint
里躺着。按钮挂在 approvalsFor 那个每轮插槽里(本来就托着「看执行过程」和
「跑一下」)。不带图——问数据页改不了图,这里就是"用原来的配置重试失败的
那一步",配置本身错了要到画布上改。实测:续的是同一次运行(8→13 条事件),
只跑了 lp 往后,轨迹里有 run.resumed。

**文档 / 工具的地址。** /knowledge/kb/:docId 是新页面——文档原来连详情视图都
没有,而切块这层做了不少事(按行边界留重叠、跨块续表头),结果一处都看不见。
现在逐段列出来带字数和 token 数,还标出"这份文档里混着两个模型建的向量"
(换模型只重建了一部分时真会发生,而那会让这部分内容悄悄退回关键词检索)。
新增 GET /kb/documents/{id}。工具那边 tab key 从 tools 改成 library,
否则地址是 /tools/tools/<id>。

验证:后端 265 passed(新增 test_tabular.py 16 条);tsc 干净;三个 check 全过,
check-ui 补了文档/工具两节;四块都在真浏览器/真后端上端到端跑过。
开发库里造的演示数据源、会话和运行已清干净。
2026-09-20 16:19:07 +08:00
ThearchyHelios c980da65d0 失败的运行能从断点接着跑了,前面跑过的节点不重来
库里 28 条 failed 的构成说明了为什么值得单独开这条路:

    模型 id 写错(404)        7
    循环条件语法错             3
    401                        3
    工具参数名不对             3
    缺必填输入                 2

大半是**配置错了**,改一下就能跑。而"改一下再跑"原来意味着整张图从头来
一遍——前面花两分钟查完的表结构、跑完的 SQL 全部重跑,尽管那些结果一直
躺在 checkpoint 里。checkpointer 本来就一直开着(thread_id = run_id),
挡住这条路的只是 resume 里的一行状态判定:它只认 interrupted,failed 进不去。

**带着改过的图续。** 不让改配置的话这功能基本没用:上面那几类续下去会
立刻再撞同一个错。所以 /runs/{id}/continue 接受一张改过的图,画布上点
「接着跑」带的就是当前编辑中的那张——用户正是改完它才点的。

**但只准改配置。** checkpoint 是按节点名存的(编译时 add_node(node.id)),
而节点配置是运行时从 spec 现读的(NodeContext.cfg)。所以只改配置对得上,
增删节点或改连线就对不上——续下去会拿着错位的通道状态跑出一份似是而非的
结果,比直接报错糟得多。加了 schema.topology_of() 做这道判据:节点 id +
类型 + 边,不含任何配置;对不上就 409,让用户重新发起。

这正是 resume 那边踩过的坑的同一类:不看有没有 checkpoint 就发 Command,
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。所以这里也先问
一次 aget_state,没有待跑节点就拒绝,而不是硬发一个 None 下去。

时间线上要说清楚是接着跑、保了几步("从「lp」接着跑,前面 2 个节点的结果
保留")——否则用户看到时间线又动起来,分不清这次是续的还是整张图又跑了一遍。

验证:后端 249 passed(新增 test_continue_run.py 7 条,核心那条断言的是
node.started 事件——前面的节点确实没有再出现)。端到端真跑了一遍:
第一遍 start → prep → lp 挂在 lp,改好条件后第二遍只跑 lp → out,
成果里带着 prep 那步的结果;改了结构的图被 409 拒绝;没有失败的运行时
画布上不显示这个按钮。

顺带:_drive 里 resumed 的判定漏了 payload is None 这种(从 checkpoint
直接往下走),run.started 事件会说成不是续跑。
2026-09-20 11:54:16 +08:00
ThearchyHelios cc608f56ec 跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
2026-09-20 11:18:53 +08:00
ThearchyHelios 6a46e4b55c 大文件传得进来了:分批算向量、上传转后台、边读边数大小
压测量出来的三个问题,10MB 文本(切 7914 段):

    上传耗时  150s(本地哈希)   换成 Qwen3-Embedding-4B 要 40 分钟以上
    而这 150 秒是压在一个 HTTP 请求里同步跑完的

浏览器和反向代理的默认超时都在 60 秒上下,也就是 5MB 以上的文件用户必然
看到超时——而后端其实还在跑,跑完文档也确实入了库。界面显示失败、数据其实
成功,比直接拒绝还糟。

**分批算向量。** 原来 ingest 是 embed_texts(pieces) 一次全发。本地哈希只是慢,
换成远端模型就是必挂:近八千段一次请求塞不下(OpenAI 单次上限 2048)。
reindex 那边早就分批了,ingest 这条一直漏着——而我上一轮刚把"换真模型"做成
设置页里点两下的事。

**上传转后台。** create_document / process_document 拆开,上传立刻返回
status=processing,切块和算向量在后台跑,进度写回文档行,前端轮询显示
"正在切块并算向量… 4288/7914 段"。实测上传返回 0.07 秒。

进度这件事返工过一次:先用另一个 session 单独写,结果整个过程界面上都是空的、
只在结束时闪一下——SQLite 只允许一个写者,主事务开着的时候那次写被堵住了。
改成主事务自己按批提交,顺手把进度写进去。

失败要写回 failed,否则永远停在"处理中"转圈。服务重启时也把残留的
processing 收尾成 failed——和 runner 对 running 的 run 是同一个道理。

**边读边数大小。** 原来是 raw = await file.read() 读完再判断,传个 1GB 的
文件内存在报 413 之前就吃掉了——那个检查拦的是"入库",不是"占内存"。改成
按块读、超了立刻停。上限从写死的 10MB 变成配置项,默认 50MB。

端到端验过:10MB 上传返回 0.07s,后台进度 768→1472→…→7914 实时可见,
最终 ready / 7914 段。
2026-09-20 09:47:02 +08:00
ThearchyHelios 11780ed0b4 设置页能接本机的向量模型了,不用改代码
上一版只有三个写死的选项:本地哈希 / OpenAI 3-small / 3-large。想用本机起的
LM Studio、Ollama、vLLM,只能去代码里调 configure——等于没有。

现在可以填任意 base_url。配套加了一个探测接口(GET /kb/embedding/probe),
按地址列出那台服务上有哪些模型,从列表里选。模型名手填太容易错——LM Studio
里叫 text-embedding-qwen3-embedding-4b,不是 Qwen3-Embedding-4B,而填错的
表现是切换时 404,人还以为是服务没起。

base_url 跟着落库。不存的话重启后会悄悄退回本地哈希,而库里的向量是上一个
模型建的,检索整体退回关键词——用户只看到"重启之后搜得不准了"。

几处说给用户听的:
- 当前用的是什么、几维,直接显示在最上面
- 本地哈希旁边标"没有语义能力"——它是默认值,而这个缺陷不说没人会发现
- 切换成功的提示里带上新维度和"多少条存量向量需要重建"。换模型后维度多半
  变了,存量全部作废,这件事要当场说,不能等人发现搜不准了再去猜

check-ui 加了一节,真浏览器里验过:展开、填地址、探测到本机的
text-embedding-qwen3-embedding-4b、出现「用它」。
2026-09-20 09:34:55 +08:00
ThearchyHelios 1e9f32b869 embedding 能接本地端点了:维度改成探测,不再按模型名猜
在本机 LM Studio 上接 Qwen3-Embedding-4B 时撞出来的两个兼容问题。

**维度不能猜。** 原来是"名字里有 3-large 就 3072,否则一律 1536"。本地部署的
模型五花八门——bge-m3 是 1024、Qwen3-Embedding-4B 是 2560——猜错的后果不是
差一点,是所有存量向量都被判成"和当前 embedder 对不上",检索整体退回关键词。
改成建 embedder 时实际探一次。

**langchain 默认发的是 token 数组。** 它先把文本分词,再把 token id 数组发过去,
OpenAI 官方认,LM Studio 这类本地服务只收字符串,直接 400:

    'input' field must be a string or an array of strings

自定义 base_url 时关掉这个行为发原文。我们的片段都在 800 字上下,本来也用不着
那套长度保护。顺带设了 chunk_size=512——远端接口单次批量有上限,不分块的话
大文档必挂。

报错也改了:原来只说"检查 OPENAI_API_KEY",现在带上试的是哪个地址、哪个模型
——本地服务没起的话,照着旧文案去翻 key 是白费功夫。

实测 Qwen3-Embedding-4B(2560 维)对比本地哈希向量:

            hit@1  hit@5   MRR    字面    语义    入库    每查
    哈希      88%   100%  0.911   100%    75%    0.2s   0.00s
    Qwen3    94%   100%  0.958   100%    88%    3.4s   0.09s

涨的全在语义类,字面类没被搞坏。has_semantics() 转真后 alpha 默认从 0 自动
回到 0.5,不用手调。

一个没料到的结果:纯向量(alpha=1)只有 81%,比纯关键词(alpha=0)的 88% 还低,
混合(0.5)才是 94%。专有名词靠 BM25、换了说法靠向量,两边各管一半——混合
检索在这份语料上是真有必要,不是摆设。

样本只有 16 题,差一题就是 6 个百分点,别把 94% 当结论;方向是明确的。
2026-09-20 09:31:00 +08:00
ThearchyHelios 901d840b86 Word / PDF 真的传得进来了,表格切开也还读得懂
上一轮做了解析,但那条路在产品上够不着:

- environment.yml 装的是 [microvm,dev,db],没有 docs——新机器起来,传 PDF
  只会得到一句"要先装解析库"
- 文件选择框的 accept 里压根没有 .pdf / .docx,点上传根本选不中这些文件

两处都补上。表格类(xlsx)故意不放进 accept:它进知识库会被切成文本,
聚合算不了、数字也无法回指,那条路该走数据源。

装上依赖实测各格式:Word(含表格)、PDF(含中文,扫描件会明说要先 OCR)、
HTML、txt/md/csv/json 都通;Excel 和 PPT 不认得,报错说得清楚。

**切块对表格的处理是这次的主要工作。** 一份 60 行的 Word 表格切出来长这样:

    块1 首行  | 上海 | 172151 | 13%

门店名被切没了,也没有表头——召回到这块,模型看到的是一列没有主语的数字。
两个毛病:

1. 重叠按字符切(buf[-120:]),会从行中间断开。改成只在行边界取,
   整段没有换行就宁可不要重叠
2. 表格横跨多块时,除第一块外都没有列名。改成开新块时把表头补在最前面

第二条我第一版写成了 tail+header+block,表头被夹在重叠的那几行后面成了中间
一行——"表头在不在"查出来是 True,看着修好了,块的开头其实仍然是一串没有
主语的数。测试里专门有一条盯着表头必须领头,就是为这个。

端到端验过:60 行表格的 Word 传上去切成 3 段,搜"门店销售额"召回的片段
第一行就是"门店 | 城市 | 销售额 | 同比"。
2026-09-19 22:58:01 +08:00
ThearchyHelios a027261701 Copilot 会自己记该记的了;顺带让写记忆这件事看得见
以前只有用户明说「记住」才会写:17 轮有图的对话里只有 4 轮带记忆节点,全在
那个用户主动谈记忆的会话里。于是他说过一次「我叫张三」,换个话题再问就
谁也不知道了。

现在 answer 路径多一步判断:这句话里有没有值得长期记住的东西,有就在主链路
前面加一个 memory(write) 节点。边界比指令本身要紧,所以写死了四类不该记的:
这一轮的问题本身、查出来的数(会过期且能重查)、一次性指令、以及**用户没说过
而你推测出来的**。另外两条是从召回的实际情况倒推的——content 要脱离上下文也
成立(召回时没有这轮对话,「他说他叫张三」读起来不知道「他」是谁),以及
拿不准就不记:记错了污染以后每一次对话,漏记一条用户再说一遍就是了。

**动手前先修了一个前提**:写记忆在界面上是隐形的。memory 节点发的是 info 级
日志,而解码层明确丢弃所有 info(decode.ts),也就是说自动记忆 = 系统背着
用户往一个跨会话、长期有效的地方存关于他的信息,而他看不见。

新增 memory.end 事件,解码层按动作分开渲染:写入把**记了什么**原样显示
(只报「写入 1 条」等于没说,用户没法判断它记的是不是他想让它记的)、召回说
「想起 N 条」、清空标成警告。

真模型验过(deepseek-v4-pro):

「我平时叫小蒋,以后报表金额用万元」
  → content='用户平时叫小蒋,他要求以后报表里的金额统一用万元作单位'
    scope 未写、跑完落库、事件里带着内容
  → 换个说法召回得到:「该怎么称呼我」「金额用什么单位」都命中

三条对照确认不滥记:
  shop 里有几家店        → 没记
  重来                    → 走 reply,连图都没建
  把上一轮的结果按月份排一下  → 没记
2026-09-19 22:41:27 +08:00
ThearchyHelios 33c7c41336 记忆写进去读不回来:Copilot 不许自己起 scope,重建也管记忆了
查会话 cbe09f4b(「我是谁」)时发现的。用户说了「我叫张三」,下一轮问
「我是谁」答不上来——记忆在库里好好的,只是读错了地方:

    写入 run 6cd33864  memory 节点 {"action":"write","scope":"user"}  → write @ user
    读取 run c71fa1bc  memory 节点 {"action":"recall"}  没写 scope    → recall @ default

同一个会话里 Copilot 生成的两张图,写的时候自己发明了一个 user 域,读的时候
忘了写、落回 default。根子在节点参考把 scope 列成了可填字段却没说该填什么:

    - memory:… config: {action, query, content, scope, assign_to}

对比一下,memory_recall / memory_write 这两个**工具**压根没有 scope 参数,
一律用 ctx.memory_scope,所以走 agent 那条路从来不会错位。只有节点会。

现在从可填字段里去掉,并写明后果。节点的 scope 配置本身保留——以后接 project
时从 run.memory_scope 传进去即可,不用改节点。

顺带补上两个上一轮埋的口子。给 Chunk 和 MemoryItem 都加了 embed_model/embed_dim,
对不上就退回关键词,但**只给知识库做了 reindex**:

- store.reindex / stale_count 补齐,记忆终于有了恢复手段
- store.recall 加 on_degrade。以前退得悄无声息——知识库那边至少发一条 warn,
  记忆这边只是"最近想不起事",人根本不会想到要去重建索引
- 重建一次管两边。它们共用一个 embedder,换模型时一起失效;分成两个按钮的
  结果是点了一个、以为好了,另一个还在悄悄退回关键词
- 没建倒排的片段也归这个按钮管。它们走全表扫,结果对但慢,而重建正好把倒排
  一起建了;按钮只认"向量对不上"的话,这些片段永远等不到人来点

实机验证:一次重建收拾了 2 段知识 + 1 条记忆 + 2 个倒排缺口,三个计数归零。

数据侧另行清理(不在本提交里):身份记忆从 scope=user 移到 default,删掉
zz_fix_* 等测试残留,删除前整表导出到 data/backups/。
2026-09-19 16:59:51 +08:00
ThearchyHelios 2a1040bbfc 重排给足输出预算;失败提示分清是哪种失败
拿真 provider(deepseek-v4-pro,千帆网关)跑了一遍评测集,重排确实有用:

              hit@1   hit@5
    初筛        88%    100%
    重排后     100%    100%

变动的两题都是语义类的,正好是本地哈希向量的短板——BM25 把「权限」的字面
命中排给了「数据导出」(那篇里有「export 权限」),模型读懂了问的是「谁有权
改」。字面类原本就 100%,没有被重排搞坏。

但 16 次里有 2 次退回了初筛顺序。查下来模型回的是**空字符串**,而失败那次的
out_tokens 正好等于 max_tokens——重排的输出只有一行几十字的 JSON,但模型为了
读完 20 条候选要花 800~1600 个输出 token,顶到上限后正文什么都不剩。llm.py
里对同一个现象早有记录:「思考型模型把额度全花在推理上了,正文是空的」。

所以重排改成有自己的预算(至少 4096),不跟节点上那个走。补跑 16/16 全部成功。

顺带纠正一处判断:thinking=off 救不了这个 provider。实测关思考那组照样在
out_tokens=2048 时空手——factory.py 只在 anthropic 分支里读 thinking 字段,
openai_compatible 整条路径不碰它。仍然传 off,因为对 Anthropic 有效、而且打分
任务本来不需要思考模式,但它不是这次的解药,注释里写明了。

失败提示原来四种坏法统一报「解析不出来」,会把人引去查 JSON 格式,而该调的是
token。现在分开说:空回复 → 「把最大输出 token 调大」;找不到 scores → 带上
原文前 80 字;个数不符 → 说明给了几个、候选几个;混了非数字 → 指出是哪个值。
测试里有一条专门断言空回复的提示中不出现「解析」二字。
2026-09-19 11:09:04 +08:00
ThearchyHelios 913380f738 倒排索引取代全表重算;PDF/Word 终于传得进来
**倒排**:以前每次检索都 select(Chunk) 全量载入,再在 Python 里重建一遍 BM25
——不只是扫表,是对每一段重新分词、重新统计词频,每次查询都来一遍。几百段
无感,上万段就是秒级,而那正是知识库开始有用的规模。

没用 SQLite 的 FTS5。方案里写了实施前先验证,验了:trigram 分词器要求查询
至少三个字符,中文的「商家」「订单」「权限」这些两字词一个都召不回。照搬会
让召回大幅倒退。现有分词器本来就做中文单字 + 二元组,自己存一张 chunk_terms
既保住召回,也不把知识库锁死在 SQLite 上。

快是次要的,一致才是前提——所以先写了"倒排必须和全表扫给出同一个名次"的
测试。它当场抓到两个真问题:

1. BM25 被算了两次:倒排按全集统计(N、avg_len、df)算一次,hybrid_rank
   又在候选子集上重建一次,而子集的 df 和平均长度是另一套数。改成把倒排
   算好的分传下去,不再重算。
2. 原来的 BM25.score 遍历的是**带重复**的查询词表:「导出订单最多多少行」
   里「多」出现两次,贡献就加两次。倒排用 set() 去重,顶部那条差了 2.32。
   改成按查询词频加权。

还有一处是排序确定性:分数并列时稳定排序保留输入顺序,而两条路径喂进去的
顺序天生不同——同一个查询会因为走了哪条路给出不同名次。两边都按 id 定序。

倒排跟着 ingest 建、跟着删文档清、跟着 reindex 重建;没建倒排的片段(存量
数据升级上来就是这样)退回全表扫——宁可慢,也不要因为索引没建好就说"没搜到"。

**解析**:upload 以前只收 UTF-8,PDF 传上去直接 415,而 mime 字段记了却从不
参与判断。现在认 PDF / DOCX / HTML。解析库走可选依赖 [docs](几十兆,只用
纯文本的人不该为此付出安装时间),HTML 用已在依赖里的 bs4。

几处是实际验出来的,不是想出来的:DOCX 要连表格一起取(合同和口径说明的关键
信息常常整个在表里);PDF 每行拖着排版填充空格,不清掉会混进切块和词频统计
(实测 416 字里只有 68 字是正文);扫描件提不出文字要明说,否则悄悄存一份
空文档,用户以为传成功了却永远搜不到。

PDF/DOCX 两条路径在临时 venv 里用真文件验过(含中文、表格、扫描件、坏文件),
没有动开发环境。
2026-09-19 09:44:09 +08:00
ThearchyHelios e8ef6ff9a8 检索质量有数了:评测集 + 重排,alpha 不再写死
先做评测再做重排——否则重排的收益只能靠"搜两下感觉准了"。

**评测集**(test_retrieval_quality.py):24 篇语料 + 16 个问题,问题分成字面
重叠和纯语义两类。第一版是 8 篇、主指标 hit@3,结果各档 alpha 全是 100%,
等于什么都没量——8 选 3 瞎猜都有 37%。扩语料、加干扰、主指标换成 hit@1
之后才开始能分辨。

量出来的第一个结论就推翻了我原来的猜测:

    alpha   hit@1   hit@5   MRR     字面    语义
    0.0     88%     100%    0.911   100%    75%
    0.2     81%     100%    0.880   88%     75%
    0.5     81%     100%    0.880   88%     75%
    1.0     81%     100%    0.880   88%     75%

本地哈希向量那一路不是"弱一点",是**纯噪音**:给它任何权重,hit@1 都从 88%
掉到 81%。而 alpha 的默认值一直写死 0.5,等于主动把检索做差。改成跟着
embedder 能力走(default_alpha:有语义泛化才给 0.5,否则 0),节点上的
alpha 也不再写死默认值。

**重排**(memory/rerank.py):初筛多捞一些(20 条),让模型按"对回答这个问题
有多大帮助"重排,再取 top_n。只捞 5 条的话重排最多把这 5 条换个顺序,第 6
条是对的也救不回来。

用已配置的对话模型做,不引入本地 cross-encoder——后者要下模型权重,和"零配置
能跑起来"的基线冲突。代价是每次检索多一次调用,所以默认关闭。

失败一律退回初筛顺序并说一声:解析不出分数、个数对不上、模型抛异常、没配
provider,四条路径都有测试。重排是增强,不该因为模型抽风就让整个检索挂掉。
2026-09-19 09:33:09 +08:00
ThearchyHelios df935599f2 知识库:换 embedding 模型不再 500,检索能回指了
三件事,都是"这三个模块一条测试都没有"的后果。

**换 embedder 会让检索整体 500。** 库里是 512 维的本地向量、查询是 1536 维的
远端向量时,cosine 直接 `shapes (1536,) and (512,) not aligned`。from_blob 有
dim 参数,但 kb.py 和 memory/store.py 调用时都没传;而且存量向量属于哪个模型
代码里没有任何地方记着,所以也无从降级。

Chunk / MemoryItem 各加 embed_model + embed_dim(走现成的 _COLUMN_MIGRATIONS,
存量回填空值,正好表示"不知道是谁建的")。检索遇到对不上的向量整体退回纯
关键词并说破,不是把它们当 0 分静默沉底——那样用户看到的是结果莫名其妙变差,
而不是"这里有一批索引该重建了"。配套 kb.reindex + POST /api/kb/reindex。

长期记忆那份一起修了。判重那条更危险:维度碰巧相同但模型不同时 cosine 算出来
是随机数,会把不相关的旧记忆认成"重复",然后把新内容丢掉。

**默认 embedder 没有语义能力,而开关藏在一个环境变量里。** LocalEmbedder 是
特征哈希 + TF,和 BM25 吃同一批词;alpha 默认 0.5 等于给了它一半权重,同义
改写一条都召不回。开启真模型的唯一开关是 AGENTLAB_USE_OPENAI_EMBEDDINGS——
没界面、没人会发现。改成设置项(照 COPILOT_SETTING_KEY 的路子),知识库页顶部
能选能看能重建。

配置失败必须响:第一版写成静默退回本地,但选了 OpenAI 却拿到哈希向量的人,
在搜不出同义表达时完全无从怀疑到这里。现在抛 EmbedderUnavailable,API 400
原样交回"检查 OPENAI_API_KEY 是否配了",且不会把配置改掉。

**检索结果不进工件库。** db_query、工具节点、agent 工具调用都落 put_json 快照,
run_retrieve 什么都不落——于是结论只要来自知识库,"这个数是哪来的"就断在这儿,
而数字回指正是这个项目的签名能力。现在落 kind="retrieval_snapshot",并发一条
retrieve.end 带 artifact id。解码层补了正式映射(原来会掉进"未知事件"兜底,
画成一坨原始 JSON)。

新增 test_kb.py 11 条,补上这三个模块此前的零覆盖,包括 embeddings.py 里
注释记着、但一直没有测试守的那个归一化边界(单条候选被 min_score 误杀)。

端到端在真实服务上验过:撞上存量向量返回 200 + 降级说明(以前 500)、
重建后 stale 归零、配不起来的远端模型 400 且配置不变。
2026-09-19 09:27:07 +08:00
ThearchyHelios cd7ced7673 不是每句话都值得跑一遍库:模型自己选要不要执行
以前必执行——final 一到就无条件 launch。库里那条会话 7 轮全建了图、全跑了
一遍 153 张表的库,其中:

- 「重试」「继续找找」这种对过程说的话,各自重建了一整张图去查
- 一句明确要「设计一个工作流,使得每次执行的时候都给出管理员账户信息」的,
  建完也直接跑了——用户要的是那张图,不是这一次的结果

流协议给模型两个新出口,三条路径二选一:

- {"op":"reply","text":"…"}  不建图不跑。问的是前几轮已经查出来的东西
  (口径、怎么算的),或者是对过程说的话,或者需要先反问清楚
- done 里 run:false          建图但停住,界面上给「跑一下」
- done 里 run:true(默认)   不变

reply 一到服务端就收流:让它走到后面那套排版校验上,空图会撞出"图校验未
通过:图是空的,先拖一个节点进来"——上一个提交刚治过同类的坑。

放开「直接回答」等于绕开了这条路径上最硬的一条约定(涉及数据必须真查),
所以两头都上:prompt 里写死 reply 只能复述或解释前面几轮已有的结果、不得
新断言任何数据事实,凡是要动到库才知道的哪怕觉得上一轮见过也重新查;界面
上那一轮的结论**前面**挂一条声明,说清楚这一条没有查库。check-stream 里有
一条断言专门盯它排在结论前面——排在后面等于先让人读完再说来源不可靠。

需要留意:这依赖模型照约定表态,不是硬规则。判断跑偏的话收紧那几句约定。
2026-09-18 22:30:45 +08:00
ThearchyHelios dcadc0c48b 对话有上下文了,左边也能选会话
三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话,系统里却
毫无关联。查下来不是哪里传漏了,是整层缺失:

- 每轮 base_graph=None 从零建图,Copilot 不知道上一轮问过什么
- 每轮 thread_id = run.id,三条独立的 checkpoint 线程
- 前端 turns 只活在 zustand 里,刷新页面就全没了——那三轮当时在界面上
  已经找不回来,只剩 runs 表里三条互不相干的记录
- 后端 13 张表里没有会话这个概念

加两张表(conversations / conversation_turns)。失败的轮次也记:只记成功的,
历史就成了一份美化过的记录,而人回来最想搞清楚的恰恰是上次卡在哪。

Copilot 的上下文带三样:前几轮问答、上一轮那张图(走 _slim 去掉坐标噪音)。
只给问答文字不够——"再按月份拆一下"会让模型重新设计一张图,实测经常接到另
一张表上,答案对不上前一轮。运行时另外注入 {{ input.history }},管的是指代:
用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮那 12 家店。

左侧会话栏只在问数据页出现,可折叠、可改名删除,当前会话存 localStorage。
轮次按会话分桶缓存——切走再切回来要看得到原来的步骤,回源只拿得到问答。
历史轮次的执行过程不预加载,点「看执行过程」才取事件、走现成的 decodeRun。
画布右栏的 Copilot 依附工作流各有一条会话,连说两次"再加一个节点"能接上了
(base_graph 带的是图的结果,带不出"你刚才要我干什么"),但不进左侧列表。

过程中修掉的两个自己引入的坑:

- 第一版在列表为空时自动建一条会话。create 是异步的、StrictMode 把 effect
  跑两遍,于是每次进页面都留下两三条"新对话"。改成用户开口那一刻按需建
- 加了"在上一轮的图上改"这条指令之后,模型照改图协议只发 update_node,
  而问数据页这条路径的累加器从空图起步,操作全部落空,用户收到的是
  "图校验未通过:图是空的,先拖一个节点进来"(会话 f5db9809 的第二轮,
  而它的 explanation 证明上下文其实完全读懂了)。指令改成沿用 id 和 config
  但完整重发,另加一道兜底:空图且上一轮有图时把操作重放到那张图上

check-ui 多一节「会话」:切换、刷新还在、逛一圈不多出空会话。
2026-09-18 17:58:04 +08:00
ThearchyHelios c02ef189bb 工具调用失败能自救;agent 改成一步一个工具
起因是一条没人看得懂的报错:

    工具 db_query__shop 执行失败:
    _make_query_tool.<locals>._run() got an unexpected keyword argument 'query'

参数名写成了 query(它叫 sql),而 call_tool 对动态工具是裸 **args 展开的,
跳过了 args_schema —— pydantic 那句"缺少 sql 字段"漏成了只剩内部闭包名的
TypeError,既指不到是哪个工具,也说不出该填什么。同一个函数里内置工具那条
分支本来是校验的,两条路径不一致。

registry 里补上 prepare_args / describe_args,两条路径共用:

- 候选唯一时(多一个不认识的、少一个必填的)改名重试,并发一条 warn 说破
  —— 配置里那个错名还在,替它跑通一次却不吭声,下次照样踩
- 有歧义就不猜,报错改成"参数不对:query 不是它的参数;缺少必填参数 sql。
  它接受:sql(必填,字符串)— 要执行的 SQL…",模型照着这句就能改对。
  这才是失败之后真正能自愈的重试
- 顺带堵了一类静默失败:pydantic 默认把认不出的键悄悄丢掉,全可选 schema 上
  {"tabel": "orders"} 会"成功"执行一次什么都没查的调用

agent 循环默认改成串行:一轮只调一个工具,看到结果再想下一步。并行更快,但
一批调用中间没有任何新的思考——第二个是照着"还没看到第一个结果"时的判断发出
去的。节点上留了「并行执行工具」开关。两个必须守住的点:没执行的调用绝不发
tool.start(前端靠 call_id 配对,发了没结尾的 start 会永远转圈),但必须各有
一条配对的 tool_result(少一条下次调用直接 400)。

交错思考跟着补上。4.6 起 adaptive thinking 自带交错,不需要也不该挂 beta
header(Opus 4.6 上手动模式压根没有交错);4/4.5 那一代要手动开 thinking +
interleaved-thinking beta,光挂 header 是一条什么都不做的空设置。开了 thinking
就不能再传 temperature,这一代本来是收的,漏了就是 400。

还有两处是串行带出来的,一并治了:

- 步数用完时收尾取的是 messages[-1],而那往往是给模型看的 ToolMessage。
  真漏到用户面前当过答案(run dd9927e6):一句"本轮只执行了第一个工具"冒充
  结论。改成只取模型自己说过的话,并把"步数用完了、去调大最大步数"附在后面
- db_schema 一次能问多张表。一次调用就是一步,逐张问 6 张表就是 6 步——
  实测 8 步的运行里 7 步花在这上面,最后只剩一步真查数据
2026-09-18 17:57:20 +08:00
ThearchyHelios fa6a8a2ffe 问数据页:问题终于传进图里了,而且不再被误报卡住
两条日志(e3c2f743 / 61655cfe,相隔 30 秒)是同一件事的两半,指向三个问题。

**一、用户的问题根本没进图。** store/chat.ts 的 launch 里写死 `input: {}`。
Copilot 只要给入口声明了一个必填字段(它有时候会,比如 topic),这次运行就
在第一个节点上立刻挂掉:"缺少必填输入:topic"——用户刚问完就看到报错,而且
错误里那个字段名他从来没见过,那是生成的图里的东西。e3c2f743 就是这么死的。
之前"能跑"纯属运气:那几次 Copilot 恰好没声明字段。

**二、把提问当成了流程描述。** 两个入口共用一句 "请设计一个工作流:<原话>"。
画布上这是对的(用户在描述流程),但在问数据页用户是在**提问**。于是
"shop 里都有哪些数据?"被设计成了一个"给这段文字生成摘要"的流程,跑完把
用户的问题原样复述一遍还回去(61655cfe 的成果就是这个)。加 intent 区分:
answer 意图下明说"流程是手段,用户要的是答案",并把注入契约写死成
{{ input.question }}——不说死键名,模型会自己编一个(见过 input.text),
下游取到空字符串,而模板取不到值不报错。

**三、我上一个功能把整条对话路径卡住了。** 变量分析器把 `{{ input }}` 报成
"没有任何节点产出"——那是完全合法的写法(template_context 里 input 就是顶层
键),而 runs 启动前要过校验,一条假错误意味着什么都跑不了。这正是我自己在
上个 commit 里写的"误报是 linter 最坏的失败模式",隔天就踩了。

修的时候连着踩了三步,每步都是测试当场抓的:
- 注册裸根之后,input.quesiton 回退到根被当成合法,拼写检查整个失效
- 收紧后 {{ vars.rows[0].name }} 被误报——那是在往值内部钻,结构枚举不了
- 再收紧后 {{ input.question }} 被误报——问数据页的入口**故意不声明字段**

最终规则:裸根合法;input/vars/nodes 只校验到第二段(再往下是值内部);
**入口声明了字段才是闭集,没声明就是开放的**(运行时传什么键都行);
根本不认识的根降成 warning——能穷举才配报 error,假错误比漏报贵得多。

端到端验证:问 "shop 里都有哪些数据?挑一个最大的表说说",现在生成
input→tool→tool→llm→output(带 db_query__shop),input 带着问题,
真查出 backend_log 481,242 行 / 528.64 MB 并列出全部字段。
2026-09-18 13:52:43 +08:00
ThearchyHelios 0895a01b41 变量表抽屉;顺便治了一个每敲一键发一次全图请求的老毛病
先说顺带修的那个,因为它是前提:validate() **零防抖**,从七个 mutation 点
直接调,其中包括 updateNode——也就是 Inspector 里每敲一个字都发一次带全图
的 POST。实测敲 15 个字 = 15 次请求。再挂一个变量分析就是翻倍,所以必须先
治:一次防抖(300ms)、两个请求并发、epoch 丢弃过期响应(图在飞行期间又改
了的话,旧答案不能覆盖新答案——校验结果闪回是最难查的那种 UI bug)。
现在敲 15 个字 = 1 次请求。

变量表另有一层跳过:**"有哪些变量"只取决于结构,不取决于文案**。节点 id、
类型、assign_to、入口字段名组成签名,改 prompt 里一个字不会触发变量分析。

抽屉本身做成挤压式而不是浮层——React Flow 的 Controls 和 MiniMap 是绝对
定位在画布容器里的,浮层会把它俩埋掉,挤压会把它俩顶上去。限高并给画布
留底(React Flow 容器高度归零会直接报 error 004),可拖拽改高,两端都夹住。

表里四列:变量 / 来自哪个节点 / 被引用几处 / 最近一次运行的值。点开右侧
详情能看到谁产出、每一处引用在哪个节点的哪个字段,点一下定位到画布。
实跑验证:

  {{ input.question }}  输入   —     变量表要显示这个值
  {{ vars.question }}   输入   —     变量表要显示这个值
  {{ nodes.start }}     输入   —     { "question": "变量表要显示这个值" }
  {{ last_message }}    内置   1 处  —

另外让用户看得见**真正跑的那份代码**:代码节点送进沙箱的是 {{ }} 渲染后的
字符串,和编辑器里那份可能差很远(一个带引号或换行的值插进字符串字面量里
就把程序写坏了),而报错指的是渲染后的行号,对着自己那份怎么也数不对。
sandbox.start 现在带上渲染结果,只在真插过值时才显示(没插值的两份一模一样,
显示等于噪音)。沙箱步骤的 detail/result 也分开了:detail 是跑的什么,
result 是跑出了什么——塞一个字段里的话,报错时最需要的两样东西只能看见一样。
2026-09-18 10:48:22 +08:00
ThearchyHelios 4cc19730cb 变量的运行时取值如实落进事件,并修掉一处误报
两件事,都是实际跑了一遍才发现的。

**vars 的值原先不在任何落库事件里。** node.finished 的 preview 只取
updates["nodes"][node_id],不含 updates["vars"]。界面上要做"变量当前值"
这一列,就只能从 preview 反推(data ?? text ?? 整个 output)——那个推断
大多数时候对,而一个"大多数时候对"的调试工具比没有更糟:它会在你最需要它
的那一次骗你。改成如实带上,只多一个已截断的值,且只有真赋了值的节点才有
(截断比 preview 更狠,要全文有工件库;事件是审计凭证,能省则省)。

**输入节点还会把每个字段同时写进 vars**(run_input 的返回里 input 和 vars
是同一份)。上一版的分析器不知道,于是 {{ vars.topic }} 这种完全合法的写法
被报成"未定义"。误报是 linter 最坏的失败模式——它会让人把整个校验一起
无视掉,那就比没有校验还糟。

现在实跑验证:
  start | vars={"topic": "变量表"}
  t     | vars={"note": "主题是 变量表"}
  out   | vars=null            (没赋值就不带这个字段)
校验:{{ vars.topic }} 干净,{{ vars.tpoic }} 报错并建议 {{ vars.topic }}。
2026-09-18 10:32:09 +08:00
ThearchyHelios 66b8969bc5 变量静态分析:把拼错一个变量名的静默失败说破
这套编排靠 {{ }} 串数据流,而模板取不到值时**渲染成空字符串**
(resolve_path 返回 None → _stringify → "")。那是为了让半成品的图也能跑,
代价是:拼错一个变量名不报错、不警告、不留任何痕迹——下游拿到一段空文本,
然后模型基于空内容一本正经地编一段回答出来。这是这套东西最大的调试黑洞,
而界面上此前没有任何地方提过它。

app/engine/variables.py 做纯静态分析(不用跑图、不用有运行记录——用户在
编排到一半时最需要它,而那时候还没有任何一次运行可看):

- 产出侧:input 字段、各节点的 assign_to、每个节点的 nodes.<id>
- 引用侧:递归扫所有配置字符串(output.fields[].value、code 的 files 都是
  嵌套的,只扫顶层等于没扫),认得过滤器和下标
- 三类问题:
  · 引用了没人产出的 —— 十有八九是拼错,用 difflib 给出"是不是想写…"
  · 引用早于产出 —— 变量确实存在、名字也对,但那一步在这之后才跑,这里
    取到的是空。这类最难查,因为表里查得到
  · 产出了没人用 —— 只报 info,不拦着跑

前两类接进 validate_graph,画布上直接标红/标黄。另外开了
POST /api/workflows/variables 返回完整的变量表(谁产出、谁在哪个字段引用),
给接下来的变量表界面和输入补全用。

tests/test_variables.py 8 条,包括循环图不死循环(loop 的 body 连回自身很
常见)、节点读自己上一轮写的变量不该误报。
2026-09-18 10:08:45 +08:00
ThearchyHelios f43f56abb7 把 Copilot 的思考显示出来:模型一直在想,只是被丢掉了
用户问能不能看到"正在理解需求、规划步骤"里面发生了什么。查下来是这样:
模型确实在想,reasoning 花了实打实的 token,但一路上被丢了三次。

1. langchain 的 ChatOpenAI **故意**不保留第三方的 reasoning_content——它的
   文档原话是 "Non-standard response fields added by third-party providers
   are not extracted or preserved",建议换 ChatDeepSeek 之类的专用包。可
   "用哪个供应商"在这个产品里是用户在设置页配的(DeepSeek / Qianfan /
   vLLM / OpenRouter 都可能),为每种装一个专用包不现实。在 factory 里
   子类化捞回 additional_kwargs,只加字段,不动 langchain 对正文的处理。

2. thinking_text 只认 Anthropic 的 thinking 块。补上兼容端点那一种。

3. 前端把一段思考的标题固定成开头 60 个字。思考是流式的,开头往往是
   "用户想要一个……"这类复述,之后几十秒里它一直在推进——固定显示开头
   等于把一段活的叙述冻在起点上,看着就像卡住了。改成:还在想的时候显示
   最新一句,想完了换回开头那句(那时它是一条历史记录)。

实测一次真实生成,60 秒里侧栏逐句变化:

    3s  可用数据源 bi
    9s  人工节点 approve 模式,审核
   18s  我们可以用 db_query__sales 执行 SELECT COUNT(*) FROM orders 等
   36s  更合理:rejected -> query_agent(重新
   45s  对于 bi 有 53 个对象,靠
   57s  (plan 到达,思考收尾,换回开头那句)

顺带运行期的 LLM 节点也跟着有了思考——走的是同一个 thinking_text,之前
同样只有 Anthropic 系才有。已验证流式合并后思考连成完整一段,不是只剩
最后一片。

tests/test_thinking.py 守两种形态、合并、以及"不产思考的模型不硬凑"。
check-decode 补 5 条守"还在想显示最新一句、想完了换开头"。
2026-09-18 09:55:42 +08:00
ThearchyHelios aa18904af0 运行和 Copilot 分成两层;顺带修了一个会丢事件的时序缺陷
用户:「点击右上角运行后,会跳到流程;然后下方就又有 Copilot 对话框,把
结果和 Copilot 又放在一块儿了」。说得对,而且我上一版的理由是错的。

我当时把建图和跑图排进同一条时间轴,说它们是"一件事的两半"。它们在时间上
先后发生,但不是同一种东西——最直接的证据在我自己的代码里:运行那一轮的
question 只能填 undefined。一个没有提问的"对话轮次",本来就不是对话。

更实际的问题是那个输入框:它贴在运行结果下面,暗示你可以回复这次运行。
但你不能——在那儿打字改的是画布上的图。这是个假的可供性。两者的生命周期
也对不上:Copilot 的轮次会累积,运行永远只有最新一次。

现在右栏是一个常驻的家(Copilot 对话)加两层临时盖上来的东西(节点属性、
运行过程)。运行那一层没有输入框,有自己的头部和停止按钮;对话那一层底部
留一条运行进度,点一下过去。

---

顺着这条线查下去,发现一个更严重的问题:**后端在丢事件**。

浏览器侧连跑 5 次,2 次丢了中间一条。抓 WebSocket 原始帧才看清:所有帧的
replay 都是 false——历史读回来是空的,全部走实时;丢的是 run.started,
它在客户端订阅之前就广播了,而那时还没落库,所以历史里也没有。

根因是 _emit 的顺序:先广播、后落库。订阅者的完整性靠"先订阅 → 读历史 →
接实时"拼起来,它需要这条保证——提交早于我这次 SELECT 的,在历史里;提交
晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。后半句只有在"提交先于
广播"时才成立。交换顺序后,6 次 WebSocket 探测 + 5 次浏览器运行全部无缺口。

(上一轮我修过这个文件的另一个窗口——先读历史后订阅。那个修复是对的,但
只堵住了两步之间的缝,堵不住订阅建立之前的那一段。)

tests/test_emit_order.py 守这条不变式:广播的那一刻,这条事件必须已经能从
另一个 session 查到。去掉修复后它确实失败。

---

还修了三处:

- 停在人工介入时,"在不在等人"原先取自审批列表,而那是 4 秒轮询一次的;
  中断后会有几秒钟界面说"这次运行已结束",而它正等着你点通过。改成从事件
  推导(isAwaitingHuman),即时且和显示的内容一致。
- 新增 tests/conftest.py 把整个测试会话关进临时目录。test_emit_order 第一版
  直接用 SessionLocal 往开发库里写,几行假运行排到了运行列表最前面。已清掉
  那 12 行。这该是全局保障,不是只修一个文件。
- 开发期把 store 挂到 window.__studio,好让 playwright 直接读状态——"事件
  收全了没有"在界面上看不出来,只能问 store。已确认不进生产包。
2026-09-18 09:39:24 +08:00
ThearchyHelios 3a1e4aa816 模型报错说人话,并堵住 Copilot 猜 model id
用户拿到的是这么一段:

  OpenAIAuthenticationError: Error code: 401 - {'error': {'code':
  'invalid_model', 'message': 'The model does not exist or you do not have
  access to it.'}}
  流式失败,回退非流式:(同一段,再来一遍)

401 + Authentication 看着像 key 过期,实际上 key 好好的——是 model id 在这个
端点上不存在。照着原文去换 key 是白费功夫。而且整条轨迹里**没有任何地方**
记着试的是哪个模型:llm.start 只带了 message_count。

三处:

1. llm.start 带上 model。调用失败时至少知道试的是什么。
2. explain_model_error():invalid_model 译成"模型「x」在这个供应商上不存在…
   去设置→供应商改模型列表";额度耗尽单独认;不认识的错误照抄原文(猜错了
   比不猜更糟)。流式回退再失败时不再把同一段原文吐两遍,而是抛一条
   NodeError。结构化输出那条路径也走同一个翻译——模型不存在不叫"结构化
   输出失败"。
3. Copilot 提示里补上"model 字段不要填"。它原先只被告知 llm 节点有个 model
   字段,却从不知道有哪些 model id,于是按供应商名字猜(看到 DeepSeek 就写
   deepseek-chat)——猜出来的那个正好不存在。这是这次报错的真正来源,不修
   的话每次生成都会再踩一遍。

顺带把那个供应商的模型列表改对了:列着的 deepseek-chat / deepseek-reasoner
两个都返回 invalid_model,而真正能用的 deepseek-v4-pro 反倒不在列表里(它是
default_model,所以"跟随默认"的节点一直是好的,手动选模型的节点必挂)。
逐个实测后换成 deepseek-v4-pro / deepseek-v3.2 / ernie-4.5-turbo-128k。
已确认没有已保存的工作流节点写死了那两个失效 id。
2026-09-17 18:41:14 +08:00
ThearchyHelios f0fd63f0b9 UI 重构:Copilot 进侧栏,三个页面共用一条助手流
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点,
使得用户知道他大致在想什么,以及用了哪些工具」。

画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成
「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。
Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享
seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step
之后才能排进同一条时间轴。

Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后
需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast
(z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。
两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行
和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程
让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来,
输入框草稿和展开状态全丢。

问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里
那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面
会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数
(画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表
加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样,
要分清哪条是哪条只能挨个点开。

跑真实运行时发现两个缺陷:

1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图
   正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是
   异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图
   开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、
   它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。
   tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。

2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样,
   和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而
   三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是
   新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回
   → 改写 → 放行)已收进 fixtures。

验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批
和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真
浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件
切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s,
deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
2026-09-17 18:29:16 +08:00
ThearchyHelios a6ee525148 Copilot 编排期看得见数据库了,附带修一个让非流式生成整体失效的 bug
阶段②。之前 agent 是运行时才摸索表结构,每跑一次重探一遍——慢,而且同一张图
两次运行走的 SQL 可能不一样,没法钉口径。现在 Copilot 建图时就知道有哪些库、
哪些表,生成的 SQL 直接能跑。

## 先修一个更要紧的:非流式生成的 config 全是空的

GRAPH_SCHEMA 里 config 写的是 {"type": "object"},没有 properties 也没有
additionalProperties。不少 provider 的结构化输出实现遇到这种 object 会按
"不允许额外字段"处理,于是模型填好的 config 被整个过滤成 {}——生成的图每个
节点都是空壳,一提交就挂在"工具节点还没选工具"。

config 的键随节点类型千变万化(tool 有 tool/args,code 有 language/code…),
没法枚举成 properties,只能显式声明 additionalProperties=true。流式那条路径
走操作流协议、不经过 structured output,所以这个 bug 一直藏着没露头。

## 注入什么

- 工具清单补上动态的 db_query__<源> / db_schema__<源>。all_specs() 只有静态
  注册的内置工具,不补进来 Copilot 根本不知道这些工具存在,只会退回让用户自己
  写 SQL 的代码节点——接了数据库等于白接。
- 数据源摘要(含 schema 全名),外加四条写 SQL 的硬性要求。这几条都是真实
  踩过的:漏 schema 前缀在 Oracle 上直接 ORA-00942,写惯 MySQL 的模型会顺手
  写 LIMIT,分号拼接会被守卫拒掉。

## token 预算:按对象数自适应,不是按字符数

实测一个 53 对象的库,带字段 2715 token、只给对象名 956 token(35%)。
但真正的决策点不是字符数——detail 模式每库只列 40 个对象,多出来的 Copilot
根本看不见。对 53 个对象的库,"40 张表带字段"不如"53 张表只给名字":
找不到那张表,字段写得再全也没用。截断丢信息比省 token 更糟。

所以先按对象数判断(超过 40 就整体切紧凑),再用字符预算兜多库叠加的情况。
真实库实测:紧凑模式 1219 token,53 个对象全部可见。

## 实测

完整模式:一句话需求 → Copilot 出图 → 不改一个字直接跑通真实 Oracle。
生成的 SQL 三处细节全对——ANALYTICS. 前缀、FETCH FIRST(不是 LIMIT)、单条语句。

紧凑模式(看不到字段):Copilot 没有硬猜字段,而是生成了一个带
db_schema + db_query 两个工具的 agent 节点,让运行时先查结构再写 SQL——
正是 prompt 里"字段拿不准就先放一个 db_schema 节点"那条。跑通,结论正确。

测试 72 项。
2026-09-17 12:16:54 +08:00
ThearchyHelios 72de33c03d schema 发现改成看事实,不再靠黑名单猜
起因是一个合理的质疑:这套 schema 处理是不是只对那一个 Oracle 有效。查证下来
"schema" 在四家里根本不是同一个东西——MySQL 的 schema 就是 database
(SHOW schemas),Oracle 的 schema 就是 user(all_users),PG 是库下面的
命名空间(pg_namespace),SQLite 只有 main。SQLAlchemy 抹平了调用方式,
没抹平语义。

## 黑名单方案实测不可用

原来靠一张硬编码的系统 schema 名单过滤。在真实 Oracle 上:94 个用户过滤完还剩
75 个(anonymous、C##OGGADMIN、infa96_domain、xs$null…),人在里面根本找不到
那两个业务 schema。名单再怎么补也补不完——这是在猜,而不是在看。

改成看事实:统计每个 schema 下有多少表/视图,有东西的才留下并按数量排序。
两条路径覆盖全部支持的库——information_schema 是 SQL 标准(MySQL/PG/SQL Server
都有),Oracle 不遵循这条标准,走它自己的 all_tables/all_views。不用"逐个
schema 调 get_table_names"的通用写法:94 个 schema 就是 94 次往返。

黑名单保留但降级为辅助:系统 schema 的对象数往往很大(SYS 有 1871 个),
光靠排序压不下去。

实测:94 个用户 → 2.3 秒收敛到 ANALYTICS、STAGING 两个业务 schema。

## schema 填错时给可选项,而不是抛驱动异常

大小写、拼写、或者根本不知道该填什么,都是高频事故。原来直接把
OperationalError 抛出去,等于让人自己猜。现在先看连接本身好不好:能列出候选
就说明只是 schema 不对,这种情况返回空结构 + 候选列表,摘要里直接写
"该账号名下没有对象,数据可能在这些 schema:ANALYTICS、STAGING"。

## 覆盖

SQLite 没有 information_schema,统计路径安静降级(实测);没有 schema 概念时
qualified 不硬拼前缀,有 schema 时必须带(Oracle 上少了就是 ORA-00942)。
新增一条用例守着"加新数据库类型时别忘了补统计路径",漏了就会退化回黑名单猜测。

MySQL / PostgreSQL 手上没有实例,走的是 information_schema 标准路径,
代码路径与 Oracle 共用,但未实测——这点在 README 里会标明。
2026-09-17 11:50:16 +08:00
ThearchyHelios a127f7049c 真实 Oracle 生产库跑通,修掉三处只有连真库才暴露的问题
在一个企业 BI 库(SAP 物料/采购/凭证 + MES 生产 + IOT 设备)上端到端验证。
连接 3 秒握手,thin 模式,确实不需要 Instant Client。三处问题只有连真库才看得见:

## options.schema 被当成驱动参数拼进了连接串

我自己引入的:build_url 把 options 里所有剩余的 key 都拼成 query 参数,
而 schema 是探查配置不是连接参数,于是 ?service_name=BI&schema=ANALYTICS 直接
被驱动拒掉。options 里混着两类东西,现在显式区分。

## 只读账号名下一张表都没有

这是企业环境的常态而非例外:TAIKE 名下只有 2 个 SYNONYM,数据在 ANALYTICS
(51 个视图) 和 STAGING(9 张表)。默认探查当前用户 schema 得到 0 张表,而原来
只会安静地返回一个空壳——用户完全不知道下一步该干什么。

现在:options.schema 可指定探查目标;探不到任何对象时自动列出可访问的非系统
schema(排掉 MDSYS/CTXSYS/XDB 那一屏 Oracle 内部对象),摘要里直接告诉
Copilot"该账号名下没有对象,数据可能在这些 schema"。

## 表名不带 schema 前缀直接 ORA-00942

只读账号访问的是别人 schema 下的对象,SELECT * FROM V_TRIP_FACT 必然失败。
schema_cache 里为每个对象记 qualified 全名,summary / table_names /
db_query 的工具描述统一用全名——模型照着写就不会漏前缀。describe_table 则
反过来放宽:全名、裸表名、大写形态都认(Oracle 惯用大写,SQLAlchemy 返回小写)。

## 实测

探查 53 个对象 7.8 秒,摘要约 2000 token(含一个 273 字段的宽视图),在预算内。
Oracle 的中文表/列注释正确读出("不合格数据明细表"、"工厂代码,复合主键之一")。

agent 自主查询 8 轮:列对象 → 挑 3 个 MES 视图看结构 → 逐个 COUNT + MAX(load_time)
→ 补查业务时间字段,最后报出 v_demo_table 有 365,633 行、最新数据
2026-09-17 06:53。全程自己带对了 schema 前缀。

安全边界在生产库上复验:
- UPDATE / DELETE / DROP 全被拒,数据未变
- 19.87 亿行的 TG_ECC_MSEG 裸查 SELECT *:0.7 秒返回 200 行即断开,
  流式读取没有把表拉回来
- 203 列宽表的字节上限在第 37 行触发

凭据只进加密库(Fernet 密文,已核验库中无明文),不在仓库、提交历史或任何输出里。
2026-09-17 11:44:22 +08:00
ThearchyHelios 39e7a5b014 数据源接入:MySQL / Oracle / PostgreSQL / SQLite
阶段①。产品原先能取数的只有 http_request 和沙箱里自己写代码——面对"分析上季度
华东区销量下滑"这类需求,用户得自己知道数据在哪张表、字段叫什么,再手写 SQL 塞进
代码节点。最难的部分留给了用户。

## 连接层(app/data/)

方言差异全收敛在 engine.py 一处:Oracle 要 service_name/sid、MySQL 要 charset、
各家默认端口不同。三个驱动都是 asyncio 原生且免装客户端——oracledb 走 thin 模式,
不需要 Instant Client。密码复用 app/core/crypto 的 Fernet,和 Provider 同一套;
build_url 默认把密码替换成星号,日志和错误信息里用这个版本。

introspect.py 产出两种粒度:summary()(表名+代表字段,几百 token,给 Copilot 的
system prompt 用)和 describe_table()(单表完整字段,按需取)。分两种是因为一个
中等业务库全量塞进 prompt 轻松几万 token,既贵又会把真正的需求淹掉。

## 守卫(app/data/guard.py)

这是唯一不能出错的地方:SQL 由模型生成,模型会写出什么事前无法穷举,防线不能
建立在"它应该不会这么干"上。三层:

- 只读判定用**白名单**而非黑名单——黑名单永远漏,MERGE/CALL/GRANT/各家私货写不完
- 多语句在到达驱动前就断掉,不赌驱动默认只跑第一条(MySQL 开了
  CLIENT_MULTI_STATEMENTS 就会全跑)
- 行数/字节/超时三重上限,一条 SELECT * 不该把内存和模型上下文一起打爆

刻意不引 sqlparse:完整解析 SQL 方言是无底洞,而这里的判断很窄——"首关键字是
什么"和"有没有第二条语句"。窄问题用窄解法。难点是不能误杀:`SELECT ';'` 只是
一条语句,简单 split(";") 会把它切两半然后拒掉,那比漏杀更让人无从下手。

## 工具(app/tools/datasource.py)

每个数据源动态生成一对 db_query__<源> / db_schema__<源>,而不是一个通用工具带
datasource 参数:工具名自带语义,模型不会把查销售库的 SQL 发给财务库;description
里直接塞表清单,省掉"先问有哪些表"那一轮。接进 registry.build_tools 作为第四来源
(前三个是内置/MCP/自定义)。

查询结果落 put_json(kind="query_snapshot"),把 SQL 原文 + 结果集 + 数据源一起存,
出具体系的数字回指能下钻到"这个数是哪条 SQL 查出来的"。

## 实测

真实 SQLite 业务库(600 行订单,埋了华东 6 月断崖的信号)+ 真实模型:
agent 8 轮自主查询——列表 → 查三张表结构 → 探数据时间范围 → 查地区维度 →
按月汇总 → 按地区+月份下钻,准确定位到华东 6 月从 1187 件跌到 354 件。
只读守卫在 agent 路径上同样生效(UPDATE/DROP/多语句全被拒且数据未变)。

测试 55 项:守卫 30 项(含"字符串里的分号不能误杀")、连接与探查 15 项、
URL 构造 6 项、Copilot 流式 4 项。
2026-09-17 10:39:23 +08:00
ThearchyHelios 1756476a30 Copilot 等待期不再是黑箱,外加项目第一组测试
从点"生成"到第一个节点落到画布上,本机实测 31.7 秒(DeepSeek V4 pro,中等
复杂度需求)。这段时间界面上只有"正在起草…"五个字一动不动——用户没法判断
是在想还是已经卡死。生成质量本身没问题,瓶颈全在这段等待的体验。

三处改动:

## 思考转发

_iter_ops 原先只取正文、丢掉其余一切,模型的 thinking 块也一并丢了。现在用
thinking_text() 抽出来单独转成 thinking 事件,前端可折叠地实时显示。

刻意不用"把非 JSON 行当成思考"这种取巧写法:那会把模型的 markdown 注释、
协议跑偏时的乱输出一并当作思考展示出来,反而误导。不支持 thinking 的模型
就只有心跳,不硬凑假的思考。

注意这条只对支持 adaptive thinking 的模型(Claude 4.6+)生效,当前默认的
DeepSeek V4 pro 不会产出——代码路径用构造的 chunk 验证过,但端到端触发需要
启用 Anthropic provider。

## 心跳与阶段

模型沉默时每 3 秒补一拍,带阶段和已用时长。必须在 _with_heartbeat 这一层做:
模型不吐 chunk 时 _iter_ops 里那个 async for 的循环体根本不执行,在里面判断
时间差轮不到。阶段跟着实际操作走(planning → building → wiring → finalizing),
阶段会变本身就是"它还活着"的信号。实测那 31.7 秒里补了 10 拍。

## 失败后不用重敲需求

生成失败只有一个 ✕,重试得重新打开 Modal 把需求再敲一遍——而失败多半跟需求
本身无关(模型抽风、协议跑偏、网断了)。store 里留住 lastInstruction,
错误浮条上直接给"用同一需求重试"。

## 顺带:项目第一组测试

这组守的是体验契约不是实现细节——thinking 和 heartbeat 两路反馈只要断了就是
回归,而这种回归在界面上表现为"又变回干等",很难被注意到。4 个用例覆盖:
thinking 块转发、纯文本模型不伪造思考、沉默期补心跳、阶段随进度推进。

pytest 走 [dev] extra,environment.yml 一并带上。
2026-09-14 16:01:06 +08:00
ThearchyHelios b634c89be4 人工介入的驳回按钮不再是摆设,画布脏边与待办入口
## 点"驳回"实际等于放行

ApprovalCard 无条件渲染"通过/驳回"两个按钮,而后端 human 节点只在 approve
模式下读 approved:edit 和 input 分支直接取 value,__decision__ 硬编码成
"approved",approved 字段看都不看。按钮在、文案在、toast 说"已驳回",运行却
照常往下跑——比没有这个按钮更糟。

这两种模式在图上只有一个 out 出口(没有 rejected 那条边),所以驳回的正确
语义是终止运行。缺省仍是通过,只传 value 不带 approved 的老调用方行为不变。
前端按钮文案改成"驳回并终止",toast 也说清楚。

顺带:run 级的错误消息不再给 NodeError 套类型名前缀,界面上从
"NodeError: 人工驳回:内容不合格" 变成 "人工驳回:内容不合格"。

## 改分支 case key 会留下看不见、删不掉的脏边

出口 handle 是从 config 算出来的(分支的 case key、human 的 mode),而
Inspector 的输入框逐字符 setConfig。key 从 yes 改成 yesno 的过程中 handle id
会依次变成 y/ye/yes…,已有边的 sourceHandle 仍是 yes。React Flow 对这种边只在
console 打一条 008 警告然后不渲染——看不见、点不到、删不掉,却照样被 toGraph
序列化存盘,后端校验也不管。

updateNode 现在同步处理:出口数量没变就按位置重映射(改名时连好的线跟着走),
出口真的没了就把边一并清掉。

## 未保存的编辑会被静默丢弃

切换工作流直接 load() 覆盖画布,没有 dirty 检查;全局也没有撤销和
beforeunload。加了切换确认与关页面拦截。撤销栈是更该补的,但那是另一件事。

## 刷新后"等待人工"的运行没有任何入口

审批卡片挂在 studio store 的当前 run 上,而那是纯内存的,刷新后为 null,卡片
就不渲染了。更糟的是导航把待办徽标挂在"运行"页上,用户点进去——而 RunsPage
从头到尾没有审批 UI,只有只读事件列表。现在 RunsPage 为选中的运行渲染
ApprovalCard,徽标指向的地方终于能处理待办。

回归:input/edit 两种模式的通过与驳回、approve 模式、代码节点 strict 档、
分支、口径卡出具 formal、工具节点、SSRF 拦截、记忆工具,全部通过。
2026-09-14 15:46:45 +08:00
ThearchyHelios 761117344b 沙箱会话生命周期与记忆检索
## 每跑一次图就多一台常驻 VM,没有任何自动回收路径

_reap_idle 只在 run() 里调,也就是说没人再跑代码时闲置 VM 永远收不掉,
microvm_idle_seconds 形同虚设;而 thread_id 每个 run 都是新的,runner 的
finally 又从不调 cleanup,于是每跑一次带代码节点的图就多一台几百 MB 的常驻
microVM(seatbelt 那边同样积了几十个历史会话目录)。

两处一起补:run 到终态就 cleanup(run_id)(interrupted 除外,人回来还要接着跑),
外加 lifespan 里起一个后台回收任务定期 _reap_idle——回收本来也不该占着请求
路径的时间。实测连跑三次 live_vms 稳定回到 0。

## 超时会连人带文件销毁整台 VM

会话契约是"同 session_id 共享文件系统,可以先写文件下一步再读回来",而
microVM 超时直接 _destroy,工作区连同用户数据一起没了,返回结果里没有任何
提示。另外三个后端超时只 killpg、工作目录照留——换到 microVM 之后同一张图的
行为就变了。现在超时先在 VM 内 pkill 掉跑飞的进程(入口名带唯一后缀,不会
误伤同会话的并发执行),确认 VM 还活着就保住工作区;收拾不动才销毁。

限额变更那条没法避免重建(VM 内存是启动时定死的),但不能不吭声:同一个 run
里两个代码节点填了不同的 memory_mb 就会触发。ExecResult 新增 session_reset,
代码节点在时间线上明确告警,否则下游只会看到一个无从追查的 FileNotFoundError。

## 新建 scope 写入第一条后永远搜不到

hybrid_rank 的 _norm 在 hi==lo 时把所有分数抹成 0,而候选只有一条时 hi 恒等于
lo,合并分 0.0 会被 min_score 过滤掉。"先写一条、下一轮再 recall"恰恰是 agent
最常见的用法。全都一样分是"都一样好",不是"都一样差",改成返回 1.0。

## 记忆去重误判,写入被静默丢弃且返回不相关的旧记忆

remember 拿 recall 的分数判重,但那是 min-max 排名归一化的结果——排第一的那条
在两路信号上都会被归成 1.0,与它和查询像不像毫无关系。于是 scope 里只要已有
两条记忆,新内容随便撞一个字排到第一就被判成重复丢掉,接口还返回 201 并把那条
不相关的旧记忆当成"刚保存的"交回去。改成直接比未归一化的余弦相似度。

验证:三条互不相关的记忆都能写进去、完全相同的仍被去重、单条 scope 可检索。
2026-09-14 15:41:05 +08:00
ThearchyHelios b0575a7d1b 引擎四处:错误处理、恢复语义、幂运算炸弹
## 一条分支条件能把后端冻死

求值器放行 ast.Pow,唯一护栏是 AST 节点数 <= 200。`9**9**9**9` 只有 11 个节点,
却让 CPython 无限期算大整数。而求值是在事件循环里同步执行的(分支条件、
transform、口径卡、每个节点的 skip_if),没有线程池隔离也没有超时,asyncio 的
cancel 打断不了同步 CPU。给底数和指数加上界,超界报 ExpressionError。
2**10、2**0.5、10**-2 这些正常用法不受影响。

## NodeError 绕过 retries / on_error / node.failed

`except NodeError: raise` 排在重试与兜底之前,于是节点自己抛的错既不重试、
也不发 node.failed、更不被 on_error=continue 接住——而 NodeError 恰恰是所有
执行器表达失败的标准方式(缺必填输入、模型没配、口径卡算不出…)。同一张图
配着同样的 retries=1 + on_error=continue,1/0 会重试并继续,而 JSON 模板非法
直接整个 run 失败。两条路径现在行为一致,node.failed 也带上 node_id
(前端靠它把出错节点从"转圈"收敛成"失败")。

## 没有 checkpoint 的运行被"恢复"成一份假结果

重启扫描把 running 和 queued 一律标成 interrupted,而 resume 不检查有没有
checkpoint 就发 Command。queued 的运行从没写过 checkpoint,LangGraph 会拿空
state 从 START 重跑整张图——run.input 完全没注入,用字段默认值算完还落成
succeeded。现在 queued 直接标 failed(没有进度可恢复,请重新发起),resume
先 aget_state 确认有断点,没有就 409 并说清为什么。

## 并行人工节点:一次 resume 打爆整个 run

同一 superstep 有多个 interrupt 时,resume 仍只发标量 Command,LangGraph 直接
抛 RuntimeError;而所有 pending 审批已经被写成同一个回复。Approval.interrupt_id
和 ResumeIn.approval_id 这两个字段一直存在却从没被用过。现在按 interrupt_id
逐条收集:多条待办时必须指定 approval_id,回复的那条转 answered 继续等,
凑齐引擎在等的全部 interrupt 才发 Command(resume={iid: value})。

验证:两个并行 human 节点——不指定 approval_id 被 409 拒、回复第一条后仍
interrupted 且剩 1 条 pending、凑齐后 succeeded。单人审批与 decide 通道回归通过。
2026-09-14 15:35:47 +08:00
ThearchyHelios 0afffd4b9a 补上四道形同虚设的门禁
## 沙箱关不掉

get() 先看 prefer,而 _resolve_named 完全不看 settings.sandbox_backend。
配置里 off 只挡得住默认路径,图里任何 isolation=strict 的代码节点照样能跑。
另外 alias.get(prefer, prefer) 对未知档位原样透传,而 isolation 是图 JSON 里的
自由字符串,写 isolation="local" 就能点名那个明说了"没有任何访问控制"的兜底
后端——档位是用来提高隔离的,不该能降级。现在 off 优先判定,档位限白名单。

## formal 可以跑从未过闸的版本

"不可变"和"过过闸"是两件事:PATCH 存出来的草稿同样有不可变快照。原来只查
version 存不存在,于是工作流只要发布过任意一版,之后所有草稿都能以 formal
身份启动,发布期的 validate_graph + lint_for_publish 全部绕过。这和代码里
"正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本"直接矛盾。
现在要求 version == published_version,否则 409。

放大这个问题的是 update_workflow 改图不重置 status:发布过 governed 之后把
画布改成含 supervisor 的图,界面上仍显示 governed。status 说的是当前画布的
状态,改图就退回 draft;已发布的那版由 published_version 指着,formal 不受影响
(所以 formal 的前置检查也从看 status 改成只看 published_version)。

## governed lint 只查 contract 这个 key 在不在

契约内容一律不校验,于是 contract={"metrics_from": "随便一个字符串"} 就满足了
"受管模板必须声明出具契约"这条硬门禁——一个结构上保证每次都出 formal 的空壳
模板能合法发布成受管模板。门禁写成这样还不如没有:它给的是"这张图过过闸"的
错觉。现在实质校验:metrics_from 必须指向图里真实存在的口径卡节点、narrative
必须非空、governed 下 required 必须非空、非 strict 给警告。

端到端验证:空壳契约发布被拒(4 条 issue)、改图后 status 退回 draft 且
published_version 保留、formal 跑 v2 被 409 拒、跑已发布的 v1 正常。
2026-09-14 15:30:57 +08:00
ThearchyHelios 2207269e07 修 critical:中文叙述里的数字校验一直是空转的
_NUM_RE 的后瞻写的是 (?<![\w.]),而 Python 的 \w 按 Unicode 判定,汉字也算
词字符。中文写作数字紧贴汉字不加空格是常态,于是整个 token 抽不出来:

  extract_numbers('本周营收9999元,订单7777单。')  → []          全部漏检
  extract_numbers('本周营收 9999 元,订单 7777 单。') → ['9999','7777']

等于说"叙述层无算术权限 + 数字回指校验"这套机制只对西式排版生效,中文叙述
完全不设防,而出具物照样盖 formal 章、印 matched_numbers: 0——读的人无法
分辨是"查过都对"还是"根本没查"。改成只排 ASCII 词字符,版本号 v1.2.3、
标识符 sha1 这些仍然豁免。

顺带修同一文件里的两条 high:

## 跳过规则只看形态不看语境

年份规则的"年"字是可选的,等于把 1900–2099 的任何独立整数都免检了——而
客户数、门店数、万元金额大量落在这个区间。日期规则不限年份范围,"9600/12"
这种比值写法会被整段当日期吞掉。引用标记 \[\d+\] 不限位数,[98765] 也放行。

  '本周新增客户 2050 家,流失 1987 家。' → []      两个数字都免检
  '全年目标 9600/12 个月'                → []      斜杠两边都免检

年份改成必须带"年"字,日期的年份限 19xx/20xx、月份限 1-12,引用标记限三位。
宁可误杀(要求它回指指标集)也不放过——这是校验不是排版。

## 校验没发生被当成校验通过

decide_tier 只看 missing/unmatched,而这些列表全空既可能是"查过了都对",
也可能是"根本没查":叙述模板里一个笔误让 narrative 渲染成空串、metrics_from
指向不存在的节点,都会得到一模一样的空列表,然后盖 formal 章。

  contract.narrative = "{{ vars.does_not_exist }}"  → tier=formal, matched=0
  contract.metrics_from = "压根没这个节点"           → tier=formal, metrics=0

新增 gaps 承载"校验为什么不完整"(叙述渲染为空、指标源未解析、指标集为空),
非空就不允许 formal,并把原因印进 issuance。校验没发生却盖 formal,比漏检
某个数字更糟:它让整套机制看起来在工作。

端到端四例验证:编造数字→withheld、真实数字→formal、模板写错→degraded
(带 gaps)、指标源不存在→withheld(带 gaps)。
2026-09-14 15:27:33 +08:00
ThearchyHelios a16cdf3ab1 修两条 critical:文件工具可读任意路径、并发代码节点互相覆盖
## 文件工具可读任意文件(含加密主密钥)

sandbox_session 从请求体直接拼进工作区路径,没有任何净化。传 ".." 就能把
根目录挪出工作区,之后 _resolve 的越界检查是在这个被挪走的根下做的,等于失效:

  curl -X POST /api/tools/file_read/run -d '{"sandbox_session":"..","args":{"path":".secret_key"}}'
  → {"ok":true,"content":"8UygDbx9..."}    # 加密所有 provider Key 的 Fernet 主密钥

根因是同一段净化逻辑被抄了五遍:四个沙箱后端各一份,文件工具那份漏抄了。
所以提到 config.sanitize_session 统一一份,五处共用;RunToolIn.sandbox_session
再加 pattern 做纵深防御——畸形 session 直接 422,而不是悄悄净化成 default。

## 并发代码节点互相覆盖入口脚本

同一 run 的节点共用一个沙箱会话,而入口脚本写死 main.py。README 主打的
"连线即并行"会让并排的代码节点在同一 superstep 并发执行,都写 main.py 就
互相覆盖——后写的赢,每个节点却都拿到 ok=True 和"自己的"返回值。

  8 路并发同 session:7/8 执行了别人的代码,error 全为 None

改成每次执行生成唯一入口脚本(__entry_<uuid>.py),跑完即删。共享工作区语义
保留,跨节点传文件照常。四个后端同构改造,实测 8/16 路并发均 0 串味。

## 顺带修掉两个自己埋的坑

- conda 迁移把沙箱解释器隔离弄丢了:conda 环境 sys.prefix == sys.base_prefix,
  于是"干净解释器"挑出来的就是项目环境自己,沙箱代码能 import cryptography
  直接解密落库的 Key。改成实际探测候选解释器是否看得见敏感包,都不干净就
  退回 -S 关掉 site-packages。README 那句承诺现在重新成立了。
- -I 在 3.11+ 隐含 -P,脚本目录不进 sys.path,代码节点的 files 参数(多文件)
  在 seatbelt/bubblewrap 下一直是坏的,import helper 直接 ModuleNotFoundError。
  换成 -s -E,隔离意图不变,同目录 import 恢复。
- microvm 的 files_written 取 FsEntry.name(不存在),塞进去的是对象 repr。
  改取 path 的尾段。

沙箱回归 16 项通过(两档各 8 项)。
2026-09-14 15:22:58 +08:00
ThearchyHelios 7d00c24ab1 microVM 沙箱后端:内存限额终于真限得住,但网络关不干净
Seatbelt 在 macOS 上最难受的缺口是 RLIMIT_AS 不被强制——设 256MB 仍能分配
900MB。microVM 不是"更严格的访问控制",是换了一类边界:宿主 Darwin 27.0.0,
VM 里 Linux 6.12.99(libkrunfw),PID 1 是 init.krun,/Users 根本不存在,
free 只看得见 512MB。申请 900MB 会被 OOM killer 杀掉而 VM 照常存活。

实现按实测契约写,不按别的 SDK 的惯例推断:create() 直接返回已启动的
Sandbox,没有 handle/connect 两层;cwd/env/timeout 都是 exec 的原生参数;
超时抛 ExecTimeoutError 而不是返回非零码。

必须写明的缺口:network=false 时 HTTP/HTTPS 与域名解析都断得掉,但 UDP/53
拦不住,手写 DNS 包仍能拿到真实响应。default_egress=DENY、Rule.deny_dns()、
显式 deny UDP、max_connections=0 全试过都堵不上——deny_dns 生成的规则只针对
宿主,DNS 出口在 microsandbox 网络栈里是硬编码放行的。所以 health 里 network
标成 partial 而不是 true,设置页对应显示黄色「部分生效」:拦了一半要是显示成
全绿,比显示成全红更危险。

成本也照实说:首次拉 OCI 镜像实测 54.5s,之后热启动 0.19s、执行 7~30ms。
贵的只是第一次,所以 auto 在镜像没缓存时先用 Seatbelt,不让人干等一分钟;
显式选 strict 档不受此限——那是使用者自己要的硬隔离。

端到端 17 项验证通过:隔离、内存拦截、超时 124、退出码透传、多文件与 env、
会话间互不可见、VM 回收。Seatbelt 那档回归未受影响。
2026-09-14 14:10:10 +08:00
ThearchyHelios 4e901be0ec 改用 conda 管理后端环境,移除 uv
environment.yml 只负责把解释器和依赖装进具名环境 agentlab,依赖清单仍以
backend/pyproject.toml 为准,不在两处各写一遍。dev.sh 通过 conda run 解析出
真实解释器路径再启动 uvicorn,环境不存在时自动按 environment.yml 创建。

uv.lock 一并删掉:留着会和 conda 装出来的环境慢慢漂移,两套锁反而更难查问题。
2026-09-14 14:09:51 +08:00
ThearchyHelios 5ac681c481 Copilot 模型可独立配置
此前 Copilot 的模型是兜底猜的:取第一个非 mock 且启用的 provider 的
默认模型。后端 GenerateIn 一直有 provider/model 字段,但前端从没传过,
于是禁用某个 provider 或调整创建顺序就会让 Copilot 静默换模型。

现在是独立设置(Setting 表 copilot 键),优先级:单次请求显式指定 >
保存的 Copilot 专用模型 > provider 兜底。做成独立设置是因为 Copilot
写的是编排本身,对指令遵循的要求和工作流节点不是一回事。

新增 GET/PUT /api/copilot/model 读写设置,并回报 effective_model
(区分显式配置与兜底);generate / generate-stream / explain 三处
统一走 copilot_model_spec;流首帧发 {op:'model'} 告知实际所用模型,
画布浮条上直接显示,不用猜。

前端 Copilot 弹窗加模型选择器,选择即持久化,下次打开记住;说明里
点明它只影响 Copilot 自己、且指令遵循弱的模型(如 Mock)跑不出结果。

验证:兜底→claude-opus-5;设为 deepseek-chat 后流首帧回报一致;
单次请求传 claude-opus-5 可覆盖设置;清空恢复兜底。浏览器实测选择
持久化跨弹窗、浮条显示模型名。
2026-09-14 11:01:47 +08:00
ThearchyHelios 1b595f699c Copilot 流式生成:图在画布上实时长出来
此前点击生成后是 20-30 秒黑盒等待,模态框关着,用户看不到任何过程。

改成操作流协议:模型按 NDJSON 逐行输出 add_node / add_edge /
update_node / remove_node / done,后端边流边经 SSE 转发,前端每收
一个操作就应用到画布——节点带紫色描边逐个出现(临时摆位跟着入边源),
左下角浮条显示当前动作并可随时取消;done 后后端统一排版+校验,
以 final 事件整体落位,高亮几秒后淡出。修改现有图的场景只输出改动
操作,用户能看到 Copilot 具体动了哪里。

行解析对模型犯规免疫:markdown 围栏、夹杂的解释文字、done 之后的
多余输出都被跳过或截断(单测覆盖跨块行缓冲)。

顺手修结构化输出必失败的 bug:langchain 靠 title 字段识别裸 JSON
Schema dict,四处 with_structured_output 的 schema 都缺它,抛
ValueError: Unsupported function 后靠 fallback 文本解析兜底——
每次都白调一次模型。补上 title(llm 节点 output_schema、branch
语义分类、supervisor 路由、旧版 copilot generate)。

实测:首节点 7.2s 上画布(原先干等 27s),浏览器采样节点数
0→2→3→4→5→6 逐步生长,6 个新节点全部高亮,零页面错误。
2026-09-14 10:54:31 +08:00
ThearchyHelios dcba9a63e0 一次思考在轨迹里只落一条事件
思考流上线时漏了把它登记进 ephemeral 集合,导致每个流式增量都
落库——一条思考链在执行轨迹里被拆成几十条 llm.thinking 事件。

拆成两种事件:llm.thinking.delta 是流式增量,只推给画布做实时
滚动、不落库(与 llm.token 同类);llm.thinking 是调用收尾时落的
单条汇总,携带完整思考内容(截断 6000 字符,完整版在节点输出
工件里)。顺带修好刷新回放:此前回放重现不了思考(增量被隐藏、
汇总不存在),现在一条汇总事件直接恢复全文,时间线上也可展开查看。

验证:确定性假思考模型过完整管道(3 条增量实时推、汇总恰一条且
内容完整);真模型跑模板⑤,轨迹 29 条降到 18 条,llm.thinking
恰好 1 条。adaptive thinking 对简单任务自主跳过思考时两类事件
都不产生,已从工件核实是模型行为而非管道丢失。
2026-09-14 10:40:20 +08:00