扩展与治理
Trove 的扩展面很多:数据源适配器、组织技能、判定规则、行动模板、语义模型、 MCP 工具面。它们有三个共同的工程问题——扩展点真的通电了吗 (函数存在但零调用方,是历史上真实发生过的)、 组织资产谁说了算(自动生成的草稿绝不自己生效),以及 这个资产到底是什么(读什么、作用在哪个域、装上去会改变什么)。 这页讲一条注册路径、一套统一的治理形状、信封与 guard 断言档、 一个能进 CI 的干跑校验(装前试跑与影响面回放共用同一条退出码契约)、 两个粒度的停用(整层总开关与单资产停用)、能带着来源链搬走的资产包, 以及每次运行留下的那两份证据。
扩展点的两种死法
一个扩展点可以死于两件事:没通电——接口做好了、文档写了, 但没有任何一处代码真的走它(注册表空着,扩展者照着文档接上却发现不生效); 两条路——内置走一条内部捷径、外部走公开入口,两条路迟早行为分叉, 而分叉只会在扩展者那边暴露。Trove 的适配器注册表两种情况都经历过, 所以现在的形态被刻意收窄成一句话:
register_adapter(dialect, cls)
registry.py:45 register_adapter
是唯一入口:内置的 8 个方言在模块末尾走同一函数自我注册
registry.py:516 内置自我注册——
没有任何「内部第二条注册路径」。新增数据源 = 实现 DatabaseAdapter + 调一次这个函数,
文件末尾那 8 行 import + 注册本身就是落地样板,照抄一行即可——早先这条路径上用注释掉的一行
# register_adapter("snowflake", …) 充当「未实现示例」,A7 落地 Snowflake 时它被真实注册取代。
统一治理形状:草稿不自证
组织资产(技能、判定规则、行动模板、语义模型的草稿)全部走同一个生命周期: 自动生成的内容落 pending,管理员确认后才进入消费面, 拒绝是终态;每次变更进 git,可查历史、可回滚。这不是四个系统的四套规矩, 是同一条纪律在四个面上的实例:
flowchart LR
DRAFT["自动草稿(LLM 起草 / 学习回路 / 候选收件箱)"]:::gen --> PENDING["pending
不进消费面 · 不进提示词"]:::gate
HUMAN["管理员手写"]:::entry --> PENDING
PENDING -->|"confirm"| LIVE["confirmed
下一问即生效(消费点每问现读,无缓存)"]:::entry
PENDING -->|"reject"| DEAD["rejected(终态)"]:::entry
LIVE --> GIT["GitVersioning 提交
scoped: git add -- 仅具名路径"]:::entry
GIT --> HIST["历史 / 回滚"]:::entry
HIST -->|"rollback sha"| LIVE
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
linkStyle 6 stroke:#8b5cf6,stroke-width:1.5px
三个实现细节让这条链是真的:
- 字段闭集校验——技能草稿的输入字段是闭集
(
_CREATE_INPUT_FIELDSservice.py:142 字段闭集), 未知键响亮报错。此前 frontmatter 字段是「死常量」:未知键经 YAML 往返 静默消失——作者以为写了,系统当没看见。 - 版本上界守卫——
decisions.yml声明版本,读到 比本代码更新的版本直接拒绝 (rules.py:484 version 守卫): 旧代码读新文件不会报错,它只会静默丢掉不认识的字段, 再保存一次就把它们抹掉了。宁可不读,不悄悄损坏。 - scoped 提交——版本化只 stage 具名路径
(
git add -- <path>,绝不用裸git add) git_versioning.py:86 scoped add—— 一次资产变更的提交里不会混进工作区里恰好存在的别的改动。 回滚也走同一条路:git checkout --source <sha> -- <files>恢复 工作区后再提交一条 rollback——历史是 append-only 的, 回滚不改写历史,只记录「回到了哪」。
同样的形状在三个管理面重复出现:技能(draft / llm-draft / confirm / reject / tier / body / history / rollback / disable / enable, skills.py:266 history)、 语义模型草稿(drafts / confirm / reject / history / rollback, semantic.py:221 history)、 行动模板(confirm / reject, 行动安全架构)。 学习回路(闭环与契约)的产物也全部落在这一形状里—— 自动内容从不为自己背书。
信封:能力是推导的,不是声明的
治理要回答的第一个问题不是「谁批准了」,而是「这个东西到底是什么」:
它读哪些输入、产生什么效果、作用在哪个域(提示词、结果,还是 SQL)。
每个组织资产都能编译出一份这样信封(envelope)——
它不是新的文件格式,而是从资产本体推导出的编译产物:
trove extensions list / show 读的就是它。
关键在「推导」二字:能力清单不是作者声明的,是从引用闭包里算出来的。
断言表达式实际读到的标识符闭包,就是它需要的变量域;效果取自闭集;
作用域跟着表达式使用的域走——一个 guard 断言的信封上,targets
自然是 sql。推导不出引用的(表达式里出现了域外的东西),
信封不猜,而是把它列进 unresolved 响亮报出来。
「作者声明了一套、表达式读的是另一套」这种漂移从结构上就没有发生的余地:
声明与校验读的是同一份事实。
信封还出现在另外两处:--run 试跑的语料按信封枚举资产
(测什么由信封决定,不由测试脚本手抄清单);extensions plan
把信封与运行时装配对账(见下文)。
envelope.py:122 Capabilities envelope.py:257 derive_check_capabilities sources.py:113 collect_assets
guard 档:把组织底线放上 SQL 闸
治理形状之外,扩展面还有一道运行时的闸:技能的第四档
guard——正文从不进模型的声明清单,由引擎在 execute_sql 节点
「授权门之后、执行之前」逐条判定。判定机制(九个 SQL 结构变量、
三值落点、两档 severity)在 Skills 页,
这里讲它为什么长这样:
- 闸序:安全先于质量。guard 排在授权门之后——没通过授权判定的 SQL, 轮不到守卫发表意见;同时排在执行之前——拦的价值随「钱已经花了多少」递减, 执行前是拦截成本为零的唯一位置。
- 回滚目标收窄成闭集。blocking 命中时回滚到哪,不是自由文本,而是
gen_retrieve/gen_sql/query_sketch/schema_linking四选一(默认回生成)——守卫说得出「错在 SQL」还是 「错在计划」,回滚就不该一律从头再来。 - 判不了绝不拦。SQL 解析失败时全部变量为 UNKNOWN,守卫放过并在
guard_hits记一笔。哨兵型断言的职责是拦明确违反, 不是替解析器补漏——误杀一条正确 SQL 的代价高于放过一条可疑的。
guards.py:71 GUARD_TARGETS execute_sql.py:62 ORG_GUARD_TAG
干跑校验:一条能进 CI 的退出码契约
治理不能只靠事后审计。所有扩展面有一场零 LLM 的干跑校验:
trove validate
(validate_cmds.py:92 main_validate)
把 KB、规则、技能全部试编译一遍,退出码就是判定:
0 clean · 1 有硬错误(--strict 下警告也算)· 2 用法/环境错误
validate_cmds.py:9 退出码契约。
这让「资产是否健康」成了一个可以挂在提交前/CI 里的布尔量,
而不是管理员肉眼巡检。
同一条命令还带两条语料回放——--run 装前试跑与
--impact 影响面回放(见下两节)。它们零 LLM、与静态检查共用同一条退出码,
并把它扩写了一格:在试跑与回放里,2 意为「这次没验成」——
语料缺失、判定执行出错、首选方案读不出、一条判定都没跑过,都走 2。
静默的 0 会把「没验成」卖成「验过了」,所以这里宁可响亮。
| 开关 | 作用 |
|---|---|
--datasource demo | 只体检一个数据源 |
--json | 机器可读整份报告(CI 消费) |
--strict | 警告也视为失败(退出码 1) |
--live | 额外连数据源:枚举缺口探测 + 真方言编译 + 声明验证(自然键唯一 / 关系基数 / fan_out: dedup 前提,对库只读探测,四态裁决——违例即硬错误)——静态检查证明不了的,交给真实连接证明(service.py:1431 run_validate) |
--run | 装前试跑:用本机语料对已确认资产做「装 / 不装」双态消融(零 LLM;语料见 --fixtures / --episodes) |
--impact <包|目录> | 影响面回放:候选资产集(待导入的包,或直接的资产目录)装上前后,同一份语料的判定差分;与 --run 互斥 |
--packs | 反作弊检查:KB 示例 SQL 与本地 gold 集(gold.sql)的相似度——命中只出警告,绝不自动拒载或改写 KB;没有 gold 集时如实报 skipped(未检查 ≠ 检查过,service.py:1359 _check_packs) |
装前试跑:装上会拦什么
--run 拿本机语料(默认读 .trove/kb/<ds>/fixtures.yml;
--episodes 追加本机历史问答)把已确认资产「装 / 不装」两种装配各判一遍,
差分出这批资产的净效果(service.py:1573 run_validate_with_dryrun)。
两个细节:
- 测什么由信封决定。语料按信封枚举资产(见上文), 不由测试脚本手抄一份清单——资产与测试对不上这种事从结构上排除。 显式给的语料路径不存在 = 无法试跑(退出码 2),不悄悄退回默认语料。
- 只有 SQL 的语料如实计 skipped。历史问答(
--episodes)只带 SQL 没有结果集,validator 型(判结果的)资产对它判不了——计skipped而不是计通过:三值纪律在语料这一侧也成立。
dryrun.py:957 run_dryrun dryrun.py:636 Assembly
影响面回放:换一批,判定差在哪
--impact 面向「换资产」这件事:候选包(或资产目录)装上前后
各判一遍同一份语料,差分落进四个桶——新增拦截 / 新放行 / 无变化 / 无法判定
(impact.py:313 classify),
每一行都带证据:哪条断言触发、表达式是什么、变量当时的取值。
--json 的顶层 metrics 就是回归门 scripts/eval_gate.py
认的 scorecard 形状——回放报告不需要转换脚本就能挂进离线评测回归门
(impact.py:881 run_impact)。
- 可复现写进输出。同输入两次,报告逐字节一致(无时间戳、无集合迭代序)—— 影响面是给别人签字用的,它自己得先站得住 (impact.py:598 diff_assembly)。
- 隐私默认收紧。问题文本默认只出哈希短码,
--include-questions才保留原文——报告多半会进 CI 日志,日志不是放业务问题原话的地方。 - 与
--run互斥。两条路问的是不同的问题(装/不装 vs 现状/现状+候选), 同时给两个是用法错误(stderr + 退出码 2),不是「两个都跑一遍」。 两条路各自的结论与静态检查取更严的一支 (service.py:1650 combined_exit_code)—— 静态干净但试跑没跑成,「这次没验成」不许被静态的 0 盖过去。
同一套校验在 REPL 里是
/validate [--live] [--run [--episodes]] [--impact <包|目录>]
(validate_cmds.py:157 /validate,不出退出码——结论落在输出末尾的「退出码 N」一行)。
技能面单独有一层检查(service.py:554 _check_skills)——
包括 validator 与 guard 两型技能的断言规格(结果域 / SQL 域两套闭集校验,
见判定内核)。
停用开关:停消费面,不停写入面
扩展面治理有一个总开关 agent.extensions.org_extensions_enabled
(config.py:361 ExtensionsConfig,
默认全开)。它停用的是组织扩展这一层的消费面:组织技能的注入与
load_skill 广告、validator 断言(结果域)与 guard 断言(SQL 域)、
决策规则的执行。
两条边界写死在文档字符串里:
flowchart TB
SW["org_extensions_enabled = false"]:::gate --> STOP1["停:组织技能注入与 load_skill 广告"]:::entry
SW --> STOP2["停:validator 断言(结果域)与 guard 断言(SQL 域)"]:::entry
SW --> STOP3["停:决策规则的执行(DecisionService 评估入口)"]:::entry
SW -.-> KEEP1["不动:code skills(trove/prompts/skills/)"]:::entry
SW -.-> KEEP2["不动:KB / few-shots / 语义模型"]:::entry
SW -.-> KEEP3["不动:写入面——照常起草 / 确认 / 编辑"]:::entry
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
配套的一条响度纪律值得单独说 (service.py:364 _org_enabled): 停用期间,定时决策任务报错(「已停用」),绝不静默按零规则通过—— 一个安静跑着的健康态和一个被关掉的判定,从运行记录上看必须不一样。 这是全仓同一哲学的另一处实例:失败要响亮, 沉默是最难排查的状态。
颗粒停用:停一个资产
总开关停的是一层;颗粒停用停的是技能面上的一条:org 技能的第四个状态
disabled(状态机与消费面细节见
Skills · 颗粒停用)。
两条停用语义一致——停消费面、不碰写入面:停用期间资产照常被编辑、
确认、回滚,重开后即刻生效。状态机严格非幂等(disable 仅
confirmed→disabled,enable 仅 disabled→confirmed),每次变更一条 git 提交;
自动路径绝不写入 disabled——停用永远是管理员的显式动作。
与总开关的第二处一致:停就要停得干净。四个消费面(节点提示注入、
load_skill 广告与按名直取、validator / guard 执行、信封枚举与试跑语料)
共用同一个过滤点,停一个资产等于四处同时熄灭;load_skill
即使被点名直取也拒绝——点名不是提权。
service.py:731 disable skills.py:173 disable
装配清单:plan 与信封对账
trove extensions plan 回答「现在到底哪些资产在动」:
它是信封在运行时装配视角的投影——已停用的资产整封摘除。每个条目给出两侧的存活状态:
预期存活(静态信封的 state)与运行时存活(消费面真读一遍),
再逐条对账(plan.py:170 reconcile_plan)。
对账漂移即退出码 1——静默的成功码会让 CI 把「两份副本已经对不上」当成「一致」。
两侧刻意走不同路径:静态侧读信封的 state 列,运行时侧走消费面真读函数
(不过信封)——如果两边都读信封,对账就成了自己跟自己比。
它是影响面回放的运行时镜像:--impact 回答「装上会改变什么」,
plan 回答「现在有哪些在动」。
plan.py:106 expected_liveness plan.py:121 runtime_liveness
包与来源:搬走也带着出处
组织资产可以打成一个包搬走:
trove extensions export <name> <dir> 把技能、生效的决策规则、
预设原样落盘(导出物 = 文件逐字节原样,往返保真因此是可断言的性质),
加一份 manifest 与逐文件 sha256(service.py:583 export_pack)。
只收组织资产:内置 / 代码资产不进包——它们随代码分发,打包会制造第二份会漂移的事实源;
会话数据(episodes、用户事实)永不进包。空包直接拒绝生成。
导入是包级校验 + 逐条落 pending:
- 包级失败什么都不落。不是包 / 缺文件 / 被篡改(sha256 对不上,点名报哪个文件)/ pack_schema 比本代码新——一条资产都不写。半导入的包在接收方看起来和完整导入一样, 那正是要避免的结局。
- 落点全部 pending。技能 = 文件原样 +
status行归一为 pending (唯一字节改动;源文件本已 pending 时逐字节相同);决策 = 落decision_drafts.yml草稿(执行面结构上读不到);预设 = 文件原样落盘, 但预设本身不被消费——apply时仍只落 pending 草稿。导入永远等价于 「多了一批待审草稿」,外来资产最需要过确认门,这条路径不绕过它 (service.py:622 import_pack)。 --force也有一条天花板。同名技能默认拒载,--force以 pending 形态覆盖;但已生效的决策规则永不被包覆盖,--force也不行——要改生效规则请走管理端。- 来源链。本地导出记
origin: local;从包导入后再导出, 可用--origin export:<包名>把上一跳写进 provenance—— 「这个东西从哪来」是审计链的一环,不是注释。
pack.py:208 PackManifest pack.py:364 read_pack
运行证据:一次 run 留下什么
治理的另一半是可回溯。每次运行收尾在唯一一个写入点落盘
(RunTracer.finish
runlog.py:278 finish),
产出与 <run_id>.log 成对的结构化清单
<run_id>.assembly.json
(assembly.py:39 ASSEMBLY_SUFFIX):
| 节 | 回答 |
|---|---|
blocks | 进了生成提示词的上下文块逐块逐项的成本与截断(few_shots / rules / … / plan / skill_injections)——「这一问为什么带上了/没带上这条规则」 |
tools | 生成阶段工具注册表的每个工具(name / level / roles / lazy / activated / calls)——工具级治理的观测面 |
verdicts | 确定性判定(validator_hits / rule_hits / fast_path_hit)——这一步回答过哪些确定性检查 |
两个设计约束让它可用而不添乱:数据全部来自既有产物
(装配时已算过的同一份 token 计数、state 上的既有字段),不做二次计算;
字段名一次定死、缺失一律降级为空值——观测物绝不允许因为自身形状问题
打断或影响一次查询。工具这一侧还有配套的治理维度:
ToolSpec
agent_loop.py:199 ToolSpec
上 roles 是可见性门(谁能看到这个工具),
level 是纯分类(core / catalog,它从不决定可见性,
只被运行记录消费)——门与分类分开,工具面才有可审计的授权模型。
怎么用
# 提交前 / CI:全量干跑(零 LLM 零网络)
trove validate # 退出码 0/1/2 即判定
trove validate --json | jq . # 机器可读
trove validate --live --datasource demo # 连真源:枚举缺口 + 真方言编译
# 语料回放(同样零 LLM;--json 顶层 metrics 可直喂 eval_gate)
trove validate --run --datasource demo # 装前试跑:装/不装 双态消融
trove validate --impact ./my-pack --datasource demo # 影响面回放:四桶差分
trove validate --packs # 反作弊:KB 示例 vs 本地 gold 集
# REPL 内
/validate [--live] [--run [--episodes]] [--impact <包|目录>]
# 信封与包(零 LLM 零网络)
trove extensions list --kind skill # 资产信封:能力从引用推导
trove extensions show <名称> # 单个信封:挂点 / 能力 / 来源 / unresolved
trove extensions plan # 运行时装配清单 + 与信封对账(漂移 → 退出码 1)
trove extensions export my-pack ./my-pack-dir
trove extensions import ./my-pack-dir # 全落 pending;--force 也不覆盖生效规则
# conf/agent.yml —— 扩展面总开关(管理端可配)
agent:
extensions:
org_extensions_enabled: true # false = 停组织扩展消费面(写入面照常)
管理端对应面:技能治理(草稿/确认/档位/正文/历史/回滚)在 技能, 语义模型草稿与回滚在 语义层, 判定规则在 判定规则, 行动模板在 行动安全架构。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 单一注册路径(内置也走 register_adapter) | 内部捷径的启动便利 | 两条路迟早分叉,而分叉只会在扩展者那边暴露;函数存在但零调用方的历史教训就来自捷径 |
| 草稿一律 pending,自动内容不自证 | 「AI 起草大多对,自动生效」的吞吐 | 组织方法论是人工资产;一次错误自动注入污染的是所有后续回答。免确认的资格只发给能确定性验证的档(见语义编译边界的 A/B 门) |
| 版本上界守卫(新版本文件直接拒读) | 「尽量读,认得的字段先用」的宽容 | 旧代码读新文件不报错,只会静默丢字段、再保存时抹掉——这种损坏无声且不可逆 |
| scoped 提交 + 回滚也留提交 | 裸 git add 的顺手 |
提交必须对应「这一次资产变更」,历史才可审计;回滚不改写历史,审计线永远 append-only |
| 干跑校验做进 CLI 退出码 | 纯 UI 的巡检体验 | 退出码让资产健康成为 CI 可挂的布尔量;--live 补上静态检查证明不了的真方言编译 |
| 总开关只停组织层,且停消费不停写入 | 「一键全停」的简洁语义 | 产品默认不是组织资产;停用期照常起草、重开后即刻生效,停用是治理动作而不是破坏动作 |
| 观测 dump 单点、纯既有产物、失败降级为空值 | 更丰富的运行中实时观测 | 观测物不得影响被观测对象;一次 run 一份清单,写入点唯一才不会有半个状态 |
| 能力从引用推导(信封),不采信作者声明 | 「capabilities: 字段想写什么写什么」的自由 |
声明与表达式会漂:推导让信封、校验、试跑语料读同一份事实;推导不出的引用响亮进 unresolved,不猜 |
| 试跑与回放共用退出码,2 = 「这次没验成」 | 「没跑成 = 当没事发生」的宽松 | 零 LLM 让语料回放便宜到可以进 CI;静默的 0 会把「没验成」卖成「验过了」——同一个数字不许兼职两种结论 |
导入全落 pending(--force 也不覆盖生效规则) |
「导包即生效」的迁移效率 | 包是外来资产,最需要过确认门;半导入的包在接收方看起来和完整导入一样,那是最贵的失败 |
| 颗粒停用走文件状态 + git 提交 | 内存里的热停用 | 治理动作不落盘等于两处事实源;「谁关的、谁开的」必须留在审计史里,所以状态机严格非幂等 |
优势在哪
- 扩展者与内置走同一条路。适配器、技能、规则的扩展入口就是内置实现用的入口——文档与代码之间没有第三条路可以分叉。
- 治理形状统一,学习成本一次。四个资产面同一套 pending→确认→历史→回滚;管理端的操作肌肉记忆可以跨面迁移。
- 健康量可进 CI。
trove validate的 0/1/2 退出码 +--json+--live,让「资产没坏」在合并前就被证明。 - 停用语义诚实。开关停的是消费面、写入面照常、产品默认不动;被停用的判定任务报错而不是静默通过。
- 资产「是什么」可对账。信封从引用闭包推导能力,与授权面、试跑语料、
extensions plan共用同一份事实;推导不出的引用响亮列出。 - 装得上装不上,CI 里先跑。
--run双态消融与--impact四桶差分零 LLM、可复现、报告直喂回归门;「没验成」有自己的退出码。 - 停用有两个粒度,一套语义。整层总开关与单资产停用都停消费面、不碰写入面;
plan让「现在有哪些在动」随时可对账。 - 包带着出处走。逐文件 sha256 + provenance 来源链 + 导入全落 pending(
--force也不覆盖生效规则)。 - 每次运行都可回答「用了什么」。
.assembly.json把上下文块成本、工具激活、确定性判定三个治理面落成同一份 run 级证据。