Commit Graph

21 Commits

Author SHA1 Message Date
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 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
ThearchyHelios e905426dfe 发布门禁、操作者归属、治理遥测与前端支撑
审批记录批准人(X-Actor 先落库再恢复执行)。设置页可配操作者署名,
随请求头发送——这是归属记录不是身份认证,界面上写明了区别。

治理遥测三件套,全部从既有事件流取材:
- 工具白名单衰减报表:授了权但从未用过的工具摆上台面,放宽可被回收
- 探索问题聚类:同一问题反复出现(≥3 次)标记为晋升候选——模板生长
  的触发器
- 轨迹→模板提取器:把探索性运行实际走过的路径确定性提取成草稿工作流
  (剪掉未走的分支、输入参数化),人审发布后才成为模板——探索层的
  价值在路径沉淀,不在单次答案

前端:口径卡节点与指标编辑器、出具契约配置、发布对话框(两级门禁、
问题列表)、试运行/正式运行双按钮、出具档位横幅(含缺数据声明与
未回指数字点名)、运行列表正式/探索徽标、运行详情版本与清单哈希、
提取模板按钮。

真模型验证闭环:周报模板 governed 发布 → formal 运行,Opus 5 正文
数字全部回指,但结尾'(约 100 字)'被校验器抓住降档——修 prompt
禁元话语而非加白名单,重发 v2 后正式档。8/8 模板回归通过。
2026-09-14 10:27:13 +08:00
ThearchyHelios 46eb674bc2 指标集与出具契约:口径卡节点、叙述数字回指、三档出具、周报模板
派生边界收死在固定层:新增 metrics 节点(口径卡),指标由 AST 白名单
求值器确定性计算,产出受控指标集(含口径名与版本)。叙述层没有算术
权限——不是求模型别编数,而是它编了也过不了校验。

output 节点配 contract 即升级为出具契约:核对必需/期望指标是否齐、
从叙述中抽取所有数字逐个回指指标集(支持千分位、百分数↔比率、
万/亿后缀、按书写精度给舍入容差;日期、ISO 周、枚举序号、引用标记
不参与回指),三档判定 formal / degraded / withheld,缺口清单和
口径版本一起印在成果上,另发 issuance 事件入流水。白名单(allow_numbers)
是模板配置的一部分,进版本、被审查。

新增模板⑧周报:取数固定 → 口径卡算数 → LLM 只许引用清单 → 出具
逐数回指,是'模板定骨架、agent 填关节'的完整示范。
回指校验器 10 用例 + 三档判定 + 端到端五场景全部通过。
2026-09-14 10:17:45 +08:00
ThearchyHelios 4fdd58e3be 运行分级与版本钉死:formal/exploratory、方法卡钉版、口径升版被迫处置
formal 运行的全部语义就一条规则:必须引用一个不可变的已发布
WorkflowVersion(按 graph_hash 钉住);传裸 graph 的运行自动是
exploratory。发布后改图不影响已发布版本——formal 永远执行快照。

子图节点支持 workflow_version 钉版,这就是方法卡的机制核心:
钉住后上游改方法卡不会让口径悄悄漂移。钉住的方法卡出现新版本时,
formal 启动被拒,除非节点已声明 upgrade_policy(recompute 回算 /
dual 双印 / incomparable 标注不可比,无默认值);已声明的升版
以 caliber.upgrade 事件记入流水。

发布端点带两级门禁:published 只拦基础错误,governed 额外过治理
lint(禁 supervisor、方法卡必须钉版、agent 不得关闭全部审批、
output 必须声明出具契约)。X-Actor 记录发布人与运行发起人。
2026-09-14 10:14:37 +08:00
ThearchyHelios 2df591d05f 工件库:内容寻址存储、节点输出与工具快照落盘、运行清单哈希
事件流里的输出是给画布看的截断预览,出具溯源需要完整证据。
新增 data/artifacts/ 内容寻址工件库(sha256 即地址,取回时复验哈希,
篡改即报错);节点完整输出与工具调用快照(query+结果集)自动落盘,
事件只携带工件 id。运行终态对全部事件计算清单哈希存入 Run,
事后对事件流的增删改会与它对不上。

graph_hash 剔除 viewport 后计算——挪画布不改变图的身份。
Run/Workflow/WorkflowVersion/Approval 预置分级与治理列,
init_db 附带 SQLite 轻量列迁移。
2026-09-14 10:10:20 +08:00
ThearchyHelios dd57c12016 沙箱改用系统自带的隔离原语,移除 Docker 依赖
跑一段不可信代码不需要一整套容器运行时。macOS 用 Seatbelt
(sandbox-exec,系统自带),Linux 用 bubblewrap;冷启动从 ~250ms
降到 ~25ms,且不再需要装任何东西。

守住三件事:默认断网、家目录不可读、只有工作区可写。策略由内核
强制且对子进程继承——沙箱内 subprocess 出来的 cat 读家目录同样是
Operation not permitted。解释器改用 sys.base_prefix 下那个干净的
Python,沙箱代码因此 import 不到后端的 FastAPI、SQLAlchemy 和凭据
处理代码。

如实标注能力边界,不给虚假的安全感:
- macOS 不强制 RLIMIT_AS,内存限不住(实测设 256MB 仍可分配 900MB),
  health 接口申报 enforced 字段,界面把限不住的项划掉
- Seatbelt 是访问控制而非虚拟化:进程表共享,以当前用户身份运行
- bubblewrap 侧多隔离 PID/IPC/挂载视图,内存限额真实生效

顺带修两个 bug:
- LocalSandbox 设 RLIMIT_NPROC=64 会误伤——macOS 上它按 uid 全局
  计数,用户已有上千进程时沙箱内任何 fork 都失败,连 bash 都起不来
- 模型所属 provider 被停用时,原先静默换一家继续用原模型名,必然
  404 且报错指不到原因;改为直接说清是哪个 provider 被停用了

写 SBPL 的坑记在注释里:(deny default) 会让 Python 直接 SIGABRT,
改用「默认允许 + 精确拒绝」。

7 个模板全部回归通过。
2026-09-13 18:04:36 +08:00
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00