@sema-agent/server 7.14.0 → 7.16.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +16 -1
- package/USAGE.md +38 -4
- package/dist/approval-ask-machine.d.ts +10 -0
- package/dist/approval-ask-machine.js +10 -0
- package/dist/approval-card.d.ts +65 -0
- package/dist/approval-card.js +54 -6
- package/dist/approval-reconciler.d.ts +17 -1
- package/dist/boot/memory-boundary.d.ts +6 -0
- package/dist/boot/memory-boundary.js +10 -2
- package/dist/boot/resolve-spec.js +31 -4
- package/dist/boot/runner-deps.d.ts +41 -2
- package/dist/boot/runner-deps.js +43 -0
- package/dist/boot/session-faces.js +26 -4
- package/dist/boot/shutdown.js +17 -0
- package/dist/boot/stores.js +28 -7
- package/dist/capabilities/memory-notice.d.ts +13 -9
- package/dist/capabilities/memory-notice.js +35 -15
- package/dist/config-center/apply-effective.d.ts +14 -0
- package/dist/config-center/apply-effective.js +81 -1
- package/dist/config-types.d.ts +28 -2
- package/dist/config.js +38 -2
- package/dist/fleet/fleet-terminal-window.d.ts +12 -0
- package/dist/fleet/fleet-terminal-window.js +27 -4
- package/dist/http/active-run-conflict.d.ts +41 -1
- package/dist/http/active-run-conflict.js +24 -10
- package/dist/http/routes/approvals-assistant.d.ts +11 -3
- package/dist/http/routes/approvals-assistant.js +97 -20
- package/dist/http/routes/capabilities.js +20 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +86 -19
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- package/dist/http/routes/trace-usage.js +38 -37
- package/dist/http/server.js +67 -13
- package/dist/leader/fanout.d.ts +18 -0
- package/dist/leader/fanout.js +34 -1
- package/dist/leader/leader.js +9 -5
- package/dist/leader/wire.js +16 -5
- package/dist/main.js +1 -0
- package/dist/memory-scope.d.ts +20 -0
- package/dist/memory-scope.js +45 -0
- package/dist/model-select.d.ts +43 -1
- package/dist/model-select.js +70 -2
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +7 -1
- package/dist/parent-watch.d.ts +57 -0
- package/dist/parent-watch.js +108 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
- package/dist/plugins/checkpoint-store-sql.js +133 -7
- package/dist/plugins/local-checkpoint-store.js +11 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
- package/dist/plugins/memory-embedder-fingerprint.js +280 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
- package/dist/plugins/permission-rule-store-sql.js +1 -7
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/store-backend.d.ts +5 -6
- package/dist/plugins/store-backend.js +4 -1
- package/dist/plugins/store-contracts.d.ts +17 -0
- package/dist/plugins/store-contracts.js +33 -0
- package/dist/plugins/tidb-pool.js +7 -0
- package/dist/plugins/tool-result-store-sql.d.ts +18 -13
- package/dist/plugins/tool-result-store-sql.js +50 -29
- package/dist/run-local.js +1 -0
- package/dist/tool-approval.d.ts +10 -0
- package/dist/tool-approval.js +143 -9
- package/dist/trace/project.js +8 -0
- package/dist/trace/redact.d.ts +14 -1
- package/dist/trace/redact.js +14 -2
- package/package.json +3 -3
package/MIGRATION.md
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
> 版本策略:`@sema-agent/server` 1.x = 快速迭代期,行为破坏性变更可能落在 minor 版本
|
|
4
4
|
> (内部多 AI 协作节奏,当日黑板通告+实解)。**生产部署请锁精确版本**;GA 后 2.0 起严格 semver
|
|
5
|
-
> (BREAKING → major)
|
|
5
|
+
> (BREAKING → major)。本文件只记录会影响存量部署行为的变更。
|
|
6
|
+
>
|
|
7
|
+
> ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
|
|
8
|
+
> 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
|
|
9
|
+
|
|
10
|
+
## SQL 存储面 BREAKING(3.0.0 之后的三个窗)
|
|
11
|
+
|
|
12
|
+
**常设口径**:本仓**不出 `ALTER TABLE` 增量迁移**(成文裁定:预生产期零存量用户窗口,schema 变更
|
|
13
|
+
一律删表/删库重建);boot 对旧形 schema **拒启并带恢复动作文案**,不会静默跑在错形表上。只用
|
|
14
|
+
file/local 存储形(未配 SQL 后端)的部署不受本节任何条目影响。
|
|
15
|
+
|
|
16
|
+
| 窗 | 变更 | 升级动作 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| **7.6.0**(2026-08-08) | SQL 双端归一化第一刀(#192):隔离键排序规则钉死(MySQL 腿表级 `COLLATE utf8mb4_bin`/PG 腿逐列 `COLLATE "C"`)+索引名 36 条+列名/宽度收窄 | **删库重建**(两方言) |
|
|
19
|
+
| **7.8.0**(2026-08-09) | SQL 命名三轴归一化第二刀(#192):9 张表名单数化、epoch 毫秒列补 `_ms` 后缀 5 列、approval 两表 `version→rev`;wire 面零变化 | **删库重建**(两方言) |
|
|
20
|
+
| **7.14.0**(2026-08-12) | `tool_result` 换代(core 5.26.0 #119):ref 四段单射形、主键 `VARCHAR(190)→518`、新增出处两列 `owner_session_id`/`owner_task_id` | 升级前两方言 **`DROP TABLE tool_result`**(不删=boot 拒启;offload 产物是可恢复窗缓存,转录内联预览不受影响) |
|
|
6
21
|
|
|
7
22
|
## server 3.0.0 —— BREAKING 四条(2026-07-31)
|
|
8
23
|
|
package/USAGE.md
CHANGED
|
@@ -154,8 +154,8 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
|
|
|
154
154
|
🔴 **`false` 自 core 5.27.0(7.14.0 提货)起还多一层——但只在 file 记忆引擎上**(`MEMORY_ENGINE_BACKEND` 未设 = 默认单机形):该会话是**受限会话**——materialize/搜索/harvest 一律按**已提交账**供给,记忆根下无事务背书的磁盘分歧既不收编也不供给,而是留盘 + 响亮点名(`restricted_divergence`,本服务把它计进 `memory_harvest_rejections_total{code}` 并 warn 一条),等下一次**非受限**会话走正常门收编。合法流程不受影响:用户手改、`git pull` 落下的删除照旧(延后,不销毁),并发的可写会话提交的变更算有事务背书。
|
|
155
155
|
⚠️ **`MEMORY_ENGINE_BACKEND=pg|tidb` 上没有这一层**(引擎的受限视图是 file 后端的实装):那两条腿的读侧本来就走库、盘上目录只是每任务的投影工作区,不存在「读磁盘即收编」的通道,所以也无洞可堵——`false` 在它们上仍然照常买到披露、写门与零收编 harvest 三件(与后端无关)。
|
|
156
156
|
⚠️ **口径别混**:「受限」是**会话级的这一个声明**;请求键 `memoryWrite:false` 铸出的只读**平面**(以及 org 层默认只读、双根非写面)**保持原样的 adopt-on-read**——它只是不 harvest,照旧看得见盘上的新内容。
|
|
157
|
-
⚠️ **core 5.26.0 起,remote 执行车道上的 `# Memory` 写指令被撤**(沙箱手带够不着 host 记忆根;读与注入不变)
|
|
158
|
-
另:记忆**没有写工具**,模型是用普通文件工具往记忆根写的——所以记忆根必须落在任务的 fs 授权边界内。结构性够不着时启动会 warn 并给出三条改法(
|
|
157
|
+
⚠️ **core 5.26.0 起,remote 执行车道上的 `# Memory` 写指令被撤**(沙箱手带够不着 host 记忆根;读与注入不变)。**判据是「`REMOTE_EXEC` 有没有设」,不是那个旗**——凡设了 `REMOTE_EXEC`(含 `host`)且这次任务真有记忆面,写指令就被撤。因此被打到的**不止**显式 `MEMORY_ENGINE_REMOTE_LANE=allow` 的部署,还包括:① `MEMORY_ENGINE_BACKEND=pg|tidb` 的 DB 记忆面(它不受那个旗管辖——旗只管 file 引擎,库是持久真身,任何车道上都照常点亮)= 最常见的云形;② `REMOTE_EXEC=host` + file 引擎(文件平面就在本机,但 core 的执行环境判决仍是 remote)。若该车道确实够得着记忆根,`MEMORY_PERSISTENCE_CAPABLE=true` 是官方恢复路径(启动日志 `memory_remote_lane_write_instruction_dropped` 会点名这一条);够不着就表 `false`,让只读状态对模型说明白。
|
|
158
|
+
另:记忆**没有写工具**,模型是用普通文件工具往记忆根写的——所以记忆根必须落在任务的 fs 授权边界内。结构性够不着时启动会 warn 并给出三条改法(把根挪进 workspace / 经 `additionalDirectories` 授权 / 声明 `MEMORY_PERSISTENCE_CAPABLE=false` 让披露诚实)。⚠️ **「挪根」用哪个旋钮按记忆后端分腿**:file 引擎是 `MEMORY_ENGINE_DIR`;`MEMORY_ENGINE_BACKEND=pg|tidb` 的根是 `<数据根>/memory-work`(数据根 = `LOCAL_DATA_ROOT`/`AGENT_DATA_DIR` 族),那两条腿**不读** `MEMORY_ENGINE_DIR`——启动 warn 的文案自己会按腿指对旋钮。
|
|
159
159
|
- **记忆检索的向量面(embedder,7.14.0 起)**:记忆检索有三档——`lexical`(词面 jaccard)/ `portable`(库内存向量,进程内算余弦)/ `native`(库侧向量算子)。⚠️ **没有档位旋钮**:档位是「记忆后端上限 × embedder 在不在场」**推断**出来的结果(显式选档只会造出「选了 native 却没 embedder」这类矛盾态)。**配上 embedder = 唯一的开法**,而且只在 `MEMORY_ENGINE_BACKEND=pg` 上有意义(tidb 记忆后端 v1 是词面档,file 引擎没有 embedder 接缝——两者配了 `MEMORY_EMBEDDER_*` 一律**拒启**点名)。不配 = 保持 `lexical`(默认姿态,零 warn)。
|
|
160
160
|
|
|
161
161
|
| env | 缺省 | 说明 |
|
|
@@ -167,9 +167,17 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
|
|
|
167
167
|
| `MEMORY_EMBEDDER_API_KEY` | 缺省 | 可选 Bearer(自托管 TEI/vllm/ollama 通常不需要;公有云端点需要) |
|
|
168
168
|
|
|
169
169
|
⚠️ **半配 = 拒启**:上表前三键是一个整体,少任何一件都启动报错并**点名缺哪几键**。静默忽略的后果不是「功能缺席」而是「operator 以为向量面开着」——检索照常出结果(词面档),差异只在答案质量里,零日志。同理:只配 `TIMEOUT`/`API_KEY` 而三键不全、或 `MEMORY_ENGINE=off` 却配了 embedder,都是死旋钮,一律拒启。
|
|
170
|
-
⚠️ **维度守卫是拒启/抛错,不是 warn**:向量列写错维度是**静默毒库**——本仓 pg 记忆表的 embedding 列是 `jsonb`(无维度约束),错长度向量既不会让 DB 报错,也不会让检索报错(那些行只是悄悄退回词面档)。所以坏 `DIM`(非正整数)启动即拒;运行期端点返回的向量长度与 `DIM` 不符,embedder
|
|
170
|
+
⚠️ **维度守卫是拒启/抛错,不是 warn**:向量列写错维度是**静默毒库**——本仓 pg 记忆表的 embedding 列是 `jsonb`(无维度约束),错长度向量既不会让 DB 报错,也不会让检索报错(那些行只是悄悄退回词面档)。所以坏 `DIM`(非正整数)启动即拒;运行期端点返回的向量长度与 `DIM` 不符,embedder **直接抛错**,绝不截断/补零。换 `MODEL`/`DIM` 的**清空由指纹门自动做**(见下条),不必手动清。
|
|
171
171
|
⚠️ **embedder 出故障的方向**:端点 5xx/超时会让当次记忆**写入**报一条 `io error: …` 冲突(不写坏向量),检索腿则退回词面档继续服务——即「坏了退回 lexical」,不是「坏了写坏数据」。故障有专门信号:metric `memory_embed_failed_total{backend}` + 日志 `memory_embedder_failed`(只看 PatchReport 里的冲突会把供应商故障误读成并发改动)。
|
|
172
|
-
⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里按词面档参与排序(不会被丢掉,但也享受不到向量档)
|
|
172
|
+
⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里按词面档参与排序(不会被丢掉,但也享受不到向量档)。要立刻全量生效,得自己重写一遍语料。
|
|
173
|
+
🔴 **换 `MODEL`/`DIM` 的行为:下次 boot 自动清空向量列(embedder 指纹门,7.15.0 之后的下一个发布起)**。本服务在 `agent_memory_engine_meta` 单行元表里记住当前 embedder 的**指纹** `{model, dimensions}`(明文,便于排障;**endpoint 不进指纹**——换域名/代理不换语义空间)。每次启动比对一次:
|
|
174
|
+
- **相等** ⇒ 零动作、零写;
|
|
175
|
+
- **不等 / 元表还没有这一行而库里已有向量 / 那一行读不出来**(手改、回滚残留)⇒ **先把 `agent_memory_engine_entry.embedding` 整列清成 NULL,再写新指纹**(次序不可换:反过来若中途崩溃,新指纹已记而旧向量还在,此后每次 boot 都判「相等」,残留永不复检);
|
|
176
|
+
- 启动日志 `memory_embedder_identity_changed` 带**旧值/新值/清了几行/耗时 ms/原因**(`changed` | `unattributed` | `unreadable`),首次记录指纹是 `memory_embedder_identity_armed`;比对腿**自身失败一律拒启**(吞掉 = 门在场却没跑成)。⚠️ 这条行按「identity 变了」发,**不是**按「清了几行」发:当时库里恰好没有向量可清(上一轮已清过 / 缺席窗内被写成 NULL)照样发,`cleared: 0` —— 换 embedder 这件事在启动日志里不该无痕。真正零响亮的只有「相等」那一档。
|
|
177
|
+
- 清空**只丢可再生的向量缓存**(正文/frontmatter 分毫不动);清完**不回填**,行被下次写入时自然按新模型重嵌,期间检索退回词面档。⚠️ **误配代价诚实说**:把 `MODEL` 改错一次再改回来,向量**不会自动回来**——只随行被再次写入才重嵌;对写完就不动的记忆库,误配一次 = 向量面实质长期缺席(正文零损失,检索退 lexical)。要立刻恢复只能自己重写一遍语料。
|
|
178
|
+
- ⚠️ **换 embedder 请停机换:先 `scale 0`,再起**。指纹门管的是 **boot 面**;**滚动升级窗**里新副本清完列之后,仍在服役的旧副本还在按**旧**模型写向量,滚动结束后表里混着两个空间而元表已是新指纹 ⇒ 此后恒判「相等」,门再也不会复检。这是纪律面的残余,不假装根治(行级 embedder 记账列可结构性根治,超出本件射程)。滚动事故后的补救 = 手动清一次:`UPDATE agent_memory_engine_entry SET embedding = NULL WHERE embedding IS NOT NULL;`(同一条也可用于「误配后立刻重置」)。
|
|
179
|
+
- **没配 `MEMORY_EMBEDDER_*` 时门整个不装**(元表不动,旧指纹保留),启动日志 `memory_embedder_identity_absent` 说明:缺席期间**被改写过**的行,其 embedding 本来就被写成 NULL,所以「配置回归同一 identity 后向量续用」只对缺席期间**未被触碰**的行成立。
|
|
180
|
+
- 残余:同名模型在不同供应商若其实是不同实现(自托管 finetune 撞名),指纹辨不出——明知异实现时改 `MEMORY_EMBEDDER_MODEL` 名,或手动跑上面那条清列 SQL。`MEMORY_ENGINE_BACKEND=tidb` 无向量面(v1 词面档),没有这张元表也没有这道门。
|
|
173
181
|
📋 **档位对运维可见**:启动日志 `memory_engine_enabled` 行带 `vectorMode` 字段(取自后端实例的真值,不是配置推断)——`lexical` 在跑就必须看得见;配了 embedder 时同行还带 `embedderModel`/`embedderDim`,换模型这件事在日志里留痕。
|
|
174
182
|
- **org 记忆准入(design/170 件A,7.0.0 起 BREAKING)**:`org:*` 记忆 scope 分**两个来源**——部署自证
|
|
175
183
|
(env `MEMORY_SCOPE` 的 org 形 + **单用户部署**的 `projects[].defaultScopes` org 键)直通;**多租户**
|
|
@@ -343,6 +351,32 @@ MCP_ELICITATION_TTL_MS=300000 # 一张没人填的表单挂多久后释放(
|
|
|
343
351
|
- 总开关关着时四个节流钮解析照跑但无消费者——不设=行为不变。帧格式见
|
|
344
352
|
[`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quater](docs/ASSISTANT-WIRE-CONTRACT.md)(`elicitation` / `elicitation_complete`)。
|
|
345
353
|
|
|
354
|
+
**可选 — 父进程存活监视(`SEMA_PARENT_PID`,默认不装配)**
|
|
355
|
+
|
|
356
|
+
壳(cli/TUI/桌面)自己 spawn 引擎的**同机形**里,壳被 `SIGKILL`(崩溃、`kill -9`、OOM killer)之后没有
|
|
357
|
+
任何清理钩子跑得起来:引擎被 reparent 到 init **继续活着**,占着端口、库连接池和模型配额,而且再没有人
|
|
358
|
+
会给它发 `SIGTERM`。设了这个键,引擎就自己盯着那个 pid:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- **缺席 = 整件不装配**(opt-in)。不设 = 和以前**逐字节一致**,存量部署零变化。
|
|
365
|
+
- **探活** = `process.kill(pid, 0)`(不投递任何信号,只问"这个 pid 在不在")。`ESRCH` = 父不在;
|
|
366
|
+
**`EPERM` = 父还活着**(进程在,只是本进程无权给它发信号 —— 父跑在另一个 uid 下时很常见)。
|
|
367
|
+
- **连续两拍**(拍频固定 **15s**)都是 `ESRCH` 才判死,单拍抖动不误杀。判死后走的是**和 `SIGTERM`
|
|
368
|
+
逐字同一条**优雅排空腿(`DRAIN_GRACE_MS` 那条):in-flight 的 run 照常结算 / park,不是裸退出。
|
|
369
|
+
- **拍频不开旋钮**(15s 写死)。有真需求再议 —— 这里刻意不增殖一个只有一个人会调的键。
|
|
370
|
+
- 日志:装配时一行 `parent_watch_armed`(带 `pid`),自退时一行 `parent_watch_exit`(带两拍的时间戳)。
|
|
371
|
+
- 坏值(`0` / 负数 / 小数 / 非数字 / 超过 `2147483647`)**拒启并指路**:`0` 和负数在 `process.kill` 里的
|
|
372
|
+
语义是**进程组**,超出 int32 的值 `process.kill` 根本收不下(会变成"armed 了但永远探不动")。要关掉就
|
|
373
|
+
**不设**这个键。探活若给出既不是 `ESRCH` 也不是 `EPERM` 的 errno,会打一条(仅一条)
|
|
374
|
+
`parent_watch_probe_error` 并按"父还活着"处理 —— 监视此时是失效的,那行日志就是它唯一的告警。
|
|
375
|
+
- 🔴 **已知残余(不收窄,两条)**:① 两拍窗口(≤30s)内父 pid 被系统**复用**时,探活看到的是新进程,
|
|
376
|
+
监视判"父还活着"而不自退;② 父进程退出后成了**僵尸**(它自己的上级不回收子进程 —— 容器里的朴素
|
|
377
|
+
PID 1 是典型;终端 / launchd / systemd 不在此列),pid 表项仍在,探活同样判"活着"。两条同源:
|
|
378
|
+
`kill(pid, 0)` 是**弱身份**判据。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定。
|
|
379
|
+
|
|
346
380
|
**布尔旋钮的取值与极性(运维必读)**
|
|
347
381
|
|
|
348
382
|
布尔 env **只认 `true` / `false` 两个字面量**。写成 `1` / `yes` / `TRUE` ⇒ 该旋钮退回自己的缺省值,并在启动时
|
|
@@ -4,6 +4,16 @@ export type BatchState = "OPEN" | "ROUTING_UNBOUND" | "ROUTING_BOUND" | "ABORTED
|
|
|
4
4
|
export declare const ASK_TERMINAL: ReadonlySet<AskState>;
|
|
5
5
|
/** 批侧终态集合(无出边的 2 态)。 */
|
|
6
6
|
export declare const BATCH_TERMINAL: ReadonlySet<BatchState>;
|
|
7
|
+
/**
|
|
8
|
+
* #229:回决理由(`AskRow.decisionNote` → `decision_note` 列)的字符上限,**两条回决腿共用一个常量**。
|
|
9
|
+
*
|
|
10
|
+
* 🔴 为什么住在这个模块:两个消费者分别是 durable 腿的 zod 体(`http/routes/runs.ts` 的
|
|
11
|
+
* `AskDecisionBodySchema`)与 live 腿的手写 parser(`tool-approval.ts` 的 `parseToolApprovalResponse`),
|
|
12
|
+
* 两处都在 ask 状态机的**同一张行**上写同一列。上限各写一份字面量 = 同一列上两个口径,而分歧只会在
|
|
13
|
+
* 「一条腿收下、另一条腿 400」的那天才被发现。本模块是两腿唯一的共同上游(askId/batchId 铸造与转移表
|
|
14
|
+
* 都在这里),放这里不引新依赖边。
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_DECISION_NOTE_CHARS = 2048;
|
|
7
17
|
/** ask 转移合法性(表驱动,含拒绝同值自环——STREAM_PENDING→STREAM_PENDING 不在表里,故 false)。 */
|
|
8
18
|
export declare function canAskTransition(from: AskState, to: AskState): boolean;
|
|
9
19
|
/** 批转移合法性,同形。 */
|
|
@@ -28,6 +28,16 @@ import { createHash } from "node:crypto";
|
|
|
28
28
|
export const ASK_TERMINAL = new Set(["DECIDED", "PARKED", "DENIED", "VOID"]);
|
|
29
29
|
/** 批侧终态集合(无出边的 2 态)。 */
|
|
30
30
|
export const BATCH_TERMINAL = new Set(["ROUTING_BOUND", "ABORTED"]);
|
|
31
|
+
/**
|
|
32
|
+
* #229:回决理由(`AskRow.decisionNote` → `decision_note` 列)的字符上限,**两条回决腿共用一个常量**。
|
|
33
|
+
*
|
|
34
|
+
* 🔴 为什么住在这个模块:两个消费者分别是 durable 腿的 zod 体(`http/routes/runs.ts` 的
|
|
35
|
+
* `AskDecisionBodySchema`)与 live 腿的手写 parser(`tool-approval.ts` 的 `parseToolApprovalResponse`),
|
|
36
|
+
* 两处都在 ask 状态机的**同一张行**上写同一列。上限各写一份字面量 = 同一列上两个口径,而分歧只会在
|
|
37
|
+
* 「一条腿收下、另一条腿 400」的那天才被发现。本模块是两腿唯一的共同上游(askId/batchId 铸造与转移表
|
|
38
|
+
* 都在这里),放这里不引新依赖边。
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_DECISION_NOTE_CHARS = 2048;
|
|
31
41
|
const ASK_TRANSITIONS = {
|
|
32
42
|
STREAM_PENDING: ["DECIDED", "PARKING", "VOID"],
|
|
33
43
|
PARKING: ["PARKED", "DENIED", "VOID"],
|
package/dist/approval-card.d.ts
CHANGED
|
@@ -26,6 +26,71 @@ import type { AskRow } from "./plugins/approval-ask-store-sql.js";
|
|
|
26
26
|
* 「限长 + 脱敏」写成 **server 新增责任**(引擎无此层):spawning model 挑的名字是自由文本,
|
|
27
27
|
* 不能指望壳去截——一条 100KB 的 agentName 在 server 侧就该被拒,而不是变成一张撑爆呈卡面的卡。 */
|
|
28
28
|
export declare const MAX_AGENT_NAME = 200;
|
|
29
|
+
/**
|
|
30
|
+
* **一条**规则候选的形 —— 同步腿(`ApprovalCardSchema.ruleSuggestions` / `card_json`)与耐久腿
|
|
31
|
+
* (durable park 行的 `PendingCheckpoint.ruleSuggestions`,由 `plugins/checkpoint-store-sql.ts` 窄读)
|
|
32
|
+
* **共用这一份**。
|
|
33
|
+
*
|
|
34
|
+
* 🔴 单一属主(宪法 [2704]「schema 单一属主禁复制」):两条腿投的是 core 的**同一个** `RuleSuggestion`
|
|
35
|
+
* (同步腿 = `AskRequest.ruleSuggestions`,耐久腿 = `PendingAction.ruleSuggestions`,core
|
|
36
|
+
* `checkpoint-store.d.ts` 明写「CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`)」)。
|
|
37
|
+
* 各写一份 zod 必然漂——闭词表 `match` 加词、上限改口径,只改一处就出两种形。
|
|
38
|
+
*/
|
|
39
|
+
export declare const RuleSuggestionSchema: z.ZodObject<{
|
|
40
|
+
rule: z.ZodString;
|
|
41
|
+
match: z.ZodEnum<{
|
|
42
|
+
exact: "exact";
|
|
43
|
+
prefix: "prefix";
|
|
44
|
+
}>;
|
|
45
|
+
command: z.ZodString;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
/**
|
|
48
|
+
* {@link RuleSuggestionSchema} 的**不限长**孪生 —— 只判**结构**(两个字符串 + 闭词表 `match`),长度由
|
|
49
|
+
* 调用方在**脱敏之后**自己截。
|
|
50
|
+
*
|
|
51
|
+
* 🔴 为什么必须有它(codex 对抗复审 [medium],验真后修):`redactSecrets` **会变长**(一条
|
|
52
|
+
* `password=…` 换成更长的遮蔽标记)。拿带 `.max()` 的形去校验**脱敏前**的原文,再脱敏,产出的就可能是
|
|
53
|
+
* 一条超限的文本;而任何**回读**路径若再跑一遍同一个函数,那一条会在第二次校验时被整条丢掉 —— 于是
|
|
54
|
+
* 「落库时在、读回时不在」,并且**只发生在有回读的那条腿上**(SQL 有列回读、LOCAL 直接从 blob 投),
|
|
55
|
+
* 两个后端对同一份素材给出不同的卡面。同族先例逐字在 `tool-approval.ts` 的 `persistedRuleShadowed`
|
|
56
|
+
* 发帧点(「先脱敏再截,一次算定」)。
|
|
57
|
+
*
|
|
58
|
+
* ⇒ 正确顺序:**结构判(本 schema)→ 脱敏 → 截长 → 结果按 {@link RuleSuggestionSchema} 必然合形**。
|
|
59
|
+
* 这样函数对自己的输出**幂等**,两条腿同形。
|
|
60
|
+
*
|
|
61
|
+
* 🔴 **未知键 `strip`(合并码重扫放宽,重扫二轮改准形):窄读只判三键结构,多余键剥掉、且不拒收**。
|
|
62
|
+
* base 带 `.strict()`,而 zod 4 的 `.extend()` **保留** strict —— core 给 `RuleSuggestion` 加一个 additive
|
|
63
|
+
* 字段(它对这条面一贯的做法),逐条 `safeParse` 会**条条失败**,于是耐久腿的整只候选静默清零、列落
|
|
64
|
+
* NULL、读口省键,把「有供给」谎报成「无供给」,正是本键存在理由的反面(`checkpoint-store-sql.ts` 顶注与
|
|
65
|
+
* wire 契约都写死「缺席=真的没有候选」)。同步腿的 `buildApprovalCard` 是显式三键投影、对加字段天然免疫
|
|
66
|
+
* ⇒ 不放宽这一份,两条腿会在「上游长一格」这条轴上分家。
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ **写的是 `.strip()` 不是 `.loose()`**(重扫二轮,实测更正):zod 4 的 `loose` = **passthrough** ——
|
|
69
|
+
* 未知键**原样留在** `parsed.data` 里,与本注和契约文写的「多余键剥掉」相反。放宽要的是「不拒收」,
|
|
70
|
+
* 剥键是另一件事,`.loose()` 只做到了前者。旧形下真正在剥键的是 {@link boundedRuleSuggestions} 里那三行
|
|
71
|
+
* 显式投影,于是本 schema 对「剥键」这条判据是**空跑**;而它是**导出符号**,output 类型带 index signature,
|
|
72
|
+
* 任何新消费者拿 `parsed.data` 直投就把上游未知键(其上**没有** `redactSecrets` 覆盖)带上跨租户可见的
|
|
73
|
+
* `GET /v1/approvals`。⇒ 改默认 strip 形:additive 加键照收、未知键当场剥掉,两个意图各自成立。
|
|
74
|
+
* 落库/读回的 {@link ApprovalCardSchema} 仍 `.strict()`(投影只写三键,多余键根本到不了那一层)。
|
|
75
|
+
*/
|
|
76
|
+
export declare const RuleSuggestionRawSchema: z.ZodObject<{
|
|
77
|
+
match: z.ZodEnum<{
|
|
78
|
+
exact: "exact";
|
|
79
|
+
prefix: "prefix";
|
|
80
|
+
}>;
|
|
81
|
+
rule: z.ZodString;
|
|
82
|
+
command: z.ZodString;
|
|
83
|
+
}, z.core.$strip>;
|
|
84
|
+
/**
|
|
85
|
+
* 候选**基数**的 server 执法上限。两条腿同用。
|
|
86
|
+
*
|
|
87
|
+
* 🔴 **两个数,别混**(合并码重扫,档实对齐):core 的契约是 **≤2**(`exact` 恒 index 0,至多再跟一条
|
|
88
|
+
* reviewed `prefix` 形 —— core `checkpoint-store.d.ts` 与 `permission-rule-model.d.ts` 逐字),那是**今天
|
|
89
|
+
* 真实的**上限;本常量是 server 侧的**容忍帽**,刻意留在 4:一个被改坏/未来放宽的上游不该让运维队列
|
|
90
|
+
* 的一行被整只拒收(同步腿这一格进的是 `.strict()` 的卡 schema,收紧到 2 会把「多给一条展示用候选」
|
|
91
|
+
* 变成铸卡失败——在一条纯展示轴上换来一次 park,方向反了)。⇒ 消费端按 **≤4** 布局,契约文同口径成文。
|
|
92
|
+
*/
|
|
93
|
+
export declare const MAX_RULE_SUGGESTIONS = 4;
|
|
29
94
|
/**
|
|
30
95
|
* design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
|
|
31
96
|
*
|
package/dist/approval-card.js
CHANGED
|
@@ -28,6 +28,59 @@ import { MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
|
|
|
28
28
|
export const MAX_AGENT_NAME = 200;
|
|
29
29
|
/** 中性投影里的自由文本/标识符统一限长(工具名、toolCallId、sourceTaskId)。 */
|
|
30
30
|
const MAX_IDENT = 200;
|
|
31
|
+
/**
|
|
32
|
+
* **一条**规则候选的形 —— 同步腿(`ApprovalCardSchema.ruleSuggestions` / `card_json`)与耐久腿
|
|
33
|
+
* (durable park 行的 `PendingCheckpoint.ruleSuggestions`,由 `plugins/checkpoint-store-sql.ts` 窄读)
|
|
34
|
+
* **共用这一份**。
|
|
35
|
+
*
|
|
36
|
+
* 🔴 单一属主(宪法 [2704]「schema 单一属主禁复制」):两条腿投的是 core 的**同一个** `RuleSuggestion`
|
|
37
|
+
* (同步腿 = `AskRequest.ruleSuggestions`,耐久腿 = `PendingAction.ruleSuggestions`,core
|
|
38
|
+
* `checkpoint-store.d.ts` 明写「CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`)」)。
|
|
39
|
+
* 各写一份 zod 必然漂——闭词表 `match` 加词、上限改口径,只改一处就出两种形。
|
|
40
|
+
*/
|
|
41
|
+
export const RuleSuggestionSchema = z
|
|
42
|
+
.object({ rule: z.string().max(MAX_RULE_TEXT_CHARS), match: z.enum(["exact", "prefix"]), command: z.string().max(MAX_RULE_TEXT_CHARS) })
|
|
43
|
+
.strict();
|
|
44
|
+
/**
|
|
45
|
+
* {@link RuleSuggestionSchema} 的**不限长**孪生 —— 只判**结构**(两个字符串 + 闭词表 `match`),长度由
|
|
46
|
+
* 调用方在**脱敏之后**自己截。
|
|
47
|
+
*
|
|
48
|
+
* 🔴 为什么必须有它(codex 对抗复审 [medium],验真后修):`redactSecrets` **会变长**(一条
|
|
49
|
+
* `password=…` 换成更长的遮蔽标记)。拿带 `.max()` 的形去校验**脱敏前**的原文,再脱敏,产出的就可能是
|
|
50
|
+
* 一条超限的文本;而任何**回读**路径若再跑一遍同一个函数,那一条会在第二次校验时被整条丢掉 —— 于是
|
|
51
|
+
* 「落库时在、读回时不在」,并且**只发生在有回读的那条腿上**(SQL 有列回读、LOCAL 直接从 blob 投),
|
|
52
|
+
* 两个后端对同一份素材给出不同的卡面。同族先例逐字在 `tool-approval.ts` 的 `persistedRuleShadowed`
|
|
53
|
+
* 发帧点(「先脱敏再截,一次算定」)。
|
|
54
|
+
*
|
|
55
|
+
* ⇒ 正确顺序:**结构判(本 schema)→ 脱敏 → 截长 → 结果按 {@link RuleSuggestionSchema} 必然合形**。
|
|
56
|
+
* 这样函数对自己的输出**幂等**,两条腿同形。
|
|
57
|
+
*
|
|
58
|
+
* 🔴 **未知键 `strip`(合并码重扫放宽,重扫二轮改准形):窄读只判三键结构,多余键剥掉、且不拒收**。
|
|
59
|
+
* base 带 `.strict()`,而 zod 4 的 `.extend()` **保留** strict —— core 给 `RuleSuggestion` 加一个 additive
|
|
60
|
+
* 字段(它对这条面一贯的做法),逐条 `safeParse` 会**条条失败**,于是耐久腿的整只候选静默清零、列落
|
|
61
|
+
* NULL、读口省键,把「有供给」谎报成「无供给」,正是本键存在理由的反面(`checkpoint-store-sql.ts` 顶注与
|
|
62
|
+
* wire 契约都写死「缺席=真的没有候选」)。同步腿的 `buildApprovalCard` 是显式三键投影、对加字段天然免疫
|
|
63
|
+
* ⇒ 不放宽这一份,两条腿会在「上游长一格」这条轴上分家。
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **写的是 `.strip()` 不是 `.loose()`**(重扫二轮,实测更正):zod 4 的 `loose` = **passthrough** ——
|
|
66
|
+
* 未知键**原样留在** `parsed.data` 里,与本注和契约文写的「多余键剥掉」相反。放宽要的是「不拒收」,
|
|
67
|
+
* 剥键是另一件事,`.loose()` 只做到了前者。旧形下真正在剥键的是 {@link boundedRuleSuggestions} 里那三行
|
|
68
|
+
* 显式投影,于是本 schema 对「剥键」这条判据是**空跑**;而它是**导出符号**,output 类型带 index signature,
|
|
69
|
+
* 任何新消费者拿 `parsed.data` 直投就把上游未知键(其上**没有** `redactSecrets` 覆盖)带上跨租户可见的
|
|
70
|
+
* `GET /v1/approvals`。⇒ 改默认 strip 形:additive 加键照收、未知键当场剥掉,两个意图各自成立。
|
|
71
|
+
* 落库/读回的 {@link ApprovalCardSchema} 仍 `.strict()`(投影只写三键,多余键根本到不了那一层)。
|
|
72
|
+
*/
|
|
73
|
+
export const RuleSuggestionRawSchema = RuleSuggestionSchema.extend({ rule: z.string(), command: z.string() }).strip();
|
|
74
|
+
/**
|
|
75
|
+
* 候选**基数**的 server 执法上限。两条腿同用。
|
|
76
|
+
*
|
|
77
|
+
* 🔴 **两个数,别混**(合并码重扫,档实对齐):core 的契约是 **≤2**(`exact` 恒 index 0,至多再跟一条
|
|
78
|
+
* reviewed `prefix` 形 —— core `checkpoint-store.d.ts` 与 `permission-rule-model.d.ts` 逐字),那是**今天
|
|
79
|
+
* 真实的**上限;本常量是 server 侧的**容忍帽**,刻意留在 4:一个被改坏/未来放宽的上游不该让运维队列
|
|
80
|
+
* 的一行被整只拒收(同步腿这一格进的是 `.strict()` 的卡 schema,收紧到 2 会把「多给一条展示用候选」
|
|
81
|
+
* 变成铸卡失败——在一条纯展示轴上换来一次 park,方向反了)。⇒ 消费端按 **≤4** 布局,契约文同口径成文。
|
|
82
|
+
*/
|
|
83
|
+
export const MAX_RULE_SUGGESTIONS = 4;
|
|
31
84
|
/** 已 redact 的 ask 文案上限(`AskRequest.message` 的投影)。 */
|
|
32
85
|
const MAX_MESSAGE = 8192;
|
|
33
86
|
/**
|
|
@@ -117,12 +170,7 @@ export const ApprovalCardSchema = z
|
|
|
117
170
|
* ⇒ 处置 = 不缩键(缩了,同副本重连这条真实可用的路也一起没了),把「durable 回决腿的规则位」
|
|
118
171
|
* 登记为后续件(车4 域)。
|
|
119
172
|
*/
|
|
120
|
-
ruleSuggestions: z
|
|
121
|
-
.array(z
|
|
122
|
-
.object({ rule: z.string().max(MAX_RULE_TEXT_CHARS), match: z.enum(["exact", "prefix"]), command: z.string().max(MAX_RULE_TEXT_CHARS) })
|
|
123
|
-
.strict())
|
|
124
|
-
.max(4)
|
|
125
|
-
.optional(),
|
|
173
|
+
ruleSuggestions: z.array(RuleSuggestionSchema).max(MAX_RULE_SUGGESTIONS).optional(),
|
|
126
174
|
/** 委派出处(子代 ask 才在场;判别键 = `fromSubagent`,core RB-39②)。 */
|
|
127
175
|
fromSubagent: z.literal(true).optional(),
|
|
128
176
|
sourceTaskId: z.string().max(MAX_IDENT).optional(),
|
|
@@ -214,7 +214,23 @@ export interface ReconcileStats {
|
|
|
214
214
|
singleMintAskOnly: number;
|
|
215
215
|
singleMintCheckpointOnly: number;
|
|
216
216
|
singleMintNeither: number;
|
|
217
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* 祖先冻结 approver 层 fold 中途铸点(#168 件1④):退出硬相等,不是 mismatch 也不是单铸。
|
|
219
|
+
*
|
|
220
|
+
* 🔴 **今天恒零,而且是结构性的**(扫描P2,2026-08-12 亲读验真;登记而不假称已解):读口
|
|
221
|
+
* `findCheckpointCandidatesForAsk` 按 `session_id = ask.sessionId` 查,`session_id` 列写的是
|
|
222
|
+
* `cp.sessionId`,而 core 每个 checkpoint 铸点都填 `sourceTaskId: sessionId`
|
|
223
|
+
* (`runner/prepare-task.js` 四处;`checkpoint-store.d.ts` 逐字「= sessionId at the mint」)⇒ 读口
|
|
224
|
+
* 返回的候选恒满足 `c.sourceTaskId === ask.sessionId`;而判据 1 命中要 `c.sourceTaskId ===
|
|
225
|
+
* ask.sourceTaskId`,祖先层否决要 `ask.sourceTaskId !== ask.sessionId` —— 两者互斥,这一臂在生产
|
|
226
|
+
* 接线下一次也不会被返回。
|
|
227
|
+
*
|
|
228
|
+
* 所以**别把零读数当健康信号**(与 {@link unmatchableNoHash} 的「恒零才是健康态」正相反:那条能非零,
|
|
229
|
+
* 这条不能)。根因是本文件 `reconcileOne` 里已登记的读口 session 维缺口(委派腿的候选压根进不来),
|
|
230
|
+
* 修它需要自带判据 + 真 checkpoint 店的宿主/子代双 session 端到端钉,不在扫描批的口径内。
|
|
231
|
+
* 否决臂**不删**:它是纯减法的安全臂,读口一修好就重新承重。结构事实的钉:
|
|
232
|
+
* `test/approval-reconciler.test.ts` 的「扫描P2 ④ 结构钉」(翻转钉——读口修好那天它会变红)。
|
|
233
|
+
*/
|
|
218
234
|
ancestorFoldMint: number;
|
|
219
235
|
parked: number;
|
|
220
236
|
denied: number;
|
|
@@ -56,6 +56,12 @@ export interface MemoryWriteBoundaryFacts {
|
|
|
56
56
|
coreRemoteExecutionEnv: boolean;
|
|
57
57
|
/** 部署声明 `MEMORY_PERSISTENCE_CAPABLE`(三态)。两极都让本模块闭嘴,但理由不同 —— 见各臂注释。 */
|
|
58
58
|
declaredCapable: boolean | undefined;
|
|
59
|
+
/** 本部署的记忆引擎后端(`config.memoryEngineBackend`)。**只进文案**,不参与任何判决 —— 它在这里的
|
|
60
|
+
* 唯一作用是把「怎么把根挪进围栏」这句话指向**在该腿上真的有效**的那个旋钮:`MEMORY_ENGINE_DIR` 只被
|
|
61
|
+
* file 腿读(memory-scope 的 `resolveMemoryEngineRoot`),pg/tidb 腿的根是
|
|
62
|
+
* `<LOCAL_DATA_ROOT|数据根>/memory-work`(boot/stores.ts 两支),那条腿上指 MEMORY_ENGINE_DIR 是让
|
|
63
|
+
* operator 去拧一个不接线的旋钮 —— 修不好还以为自己修了。 */
|
|
64
|
+
engineBackend: "file" | "pg" | "tidb";
|
|
59
65
|
/** boot 期算得出的文件工具围栏根。空数组 = 算不出(每任务临时目录 / 未配 workspace)⇒ 不判。 */
|
|
60
66
|
containmentRoots: readonly string[];
|
|
61
67
|
}
|
|
@@ -63,7 +63,7 @@ function isInside(child, root) {
|
|
|
63
63
|
* 当新闻报。
|
|
64
64
|
*/
|
|
65
65
|
export function buildMemoryWriteBoundaryAudit(facts) {
|
|
66
|
-
const { memoryRoot, lane, hostSemanticsLane, coreRemoteExecutionEnv, declaredCapable, containmentRoots } = facts;
|
|
66
|
+
const { memoryRoot, lane, hostSemanticsLane, coreRemoteExecutionEnv, declaredCapable, containmentRoots, engineBackend } = facts;
|
|
67
67
|
if (memoryRoot === undefined)
|
|
68
68
|
return { warnings: [] }; // 引擎未接
|
|
69
69
|
if (declaredCapable !== undefined)
|
|
@@ -93,6 +93,14 @@ export function buildMemoryWriteBoundaryAudit(facts) {
|
|
|
93
93
|
return { warnings: [] }; // 围栏根算不出 ⇒ 不判(见头注)
|
|
94
94
|
if (containmentRoots.some((root) => isInside(memoryRoot, root)))
|
|
95
95
|
return { warnings: [] };
|
|
96
|
+
// 第一条恢复路径按**后端腿**指对旋钮(2026-08-12 复扫 low:pg/tidb 腿上 MEMORY_ENGINE_DIR 不接线,
|
|
97
|
+
// 而「in-process + MEMORY_ENGINE_BACKEND=pg + 未配 LOCAL_DATA_ROOT」恰恰是这条 warn 的默认命中形 ——
|
|
98
|
+
// 照旧文案去拧只会一直修不好)。另外两条(additionalDirectories / MEMORY_PERSISTENCE_CAPABLE=false)
|
|
99
|
+
// 两条腿都真有效,不分腿。
|
|
100
|
+
const relocateRoot = engineBackend === "file"
|
|
101
|
+
? `point MEMORY_ENGINE_DIR at a path under the workspace`
|
|
102
|
+
: `point LOCAL_DATA_ROOT at a path under the workspace (on the "${engineBackend}" memory plane the root is ` +
|
|
103
|
+
`<data root>/memory-work and MEMORY_ENGINE_DIR is inert — only the file engine reads it)`;
|
|
96
104
|
return {
|
|
97
105
|
warnings: [
|
|
98
106
|
{
|
|
@@ -100,7 +108,7 @@ export function buildMemoryWriteBoundaryAudit(facts) {
|
|
|
100
108
|
detail: `the memory engine root (${memoryRoot}) is outside every file-tool containment root known at boot ` +
|
|
101
109
|
`(${containmentRoots.join(", ")}). Memory has no write TOOL — the model saves by writing files into that root — ` +
|
|
102
110
|
`so a "remember this" turn will be answered with a save the file tools structurally refuse (path_not_in_root). ` +
|
|
103
|
-
`Fix it in one of three ways:
|
|
111
|
+
`Fix it in one of three ways: ${relocateRoot}, hand the root to tasks via ` +
|
|
104
112
|
`additionalDirectories, or declare MEMORY_PERSISTENCE_CAPABLE=false so the model is told up front that this ` +
|
|
105
113
|
`deployment cannot save instead of receipting a write that never lands.`,
|
|
106
114
|
},
|
|
@@ -33,7 +33,7 @@ import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScr
|
|
|
33
33
|
import { FleetEventBus } from "../fleet/fleet-bus.js";
|
|
34
34
|
import { composeHooks, createTaskHooks } from "../hooks/hook-runner.js";
|
|
35
35
|
import { explicitOperatorOk } from "../http/server.js";
|
|
36
|
-
import { matchCatalogModel, modelSupportsImages, resolveTaskModel } from "../model-select.js";
|
|
36
|
+
import { isModelAllowlisted, matchCatalogModel, modelSupportsImages, resolveTaskModel, terminalCatalogName } from "../model-select.js";
|
|
37
37
|
import { PerTaskImageRegistry, resolveSandboxImageRef } from "../per-task-image.js";
|
|
38
38
|
import { isRemoteScratchpadLane, remoteScratchpadDirFor } from "../plugins/remote-scratchpad.js";
|
|
39
39
|
import { bindAttachmentsForTask } from "../plugins/task-attachment-store.js";
|
|
@@ -83,7 +83,7 @@ export function createResolveSpec(ctx) {
|
|
|
83
83
|
// 翻译表要是再开第三个算点,「顶层键 vs bundle defaultMode 谁赢」这条优先序就有三份可以各自漂移的
|
|
84
84
|
// 副本,而漂移在这条轴上的形态是「壳发的 bundle bypass 在这一面生效、在那一面没生效」——三审同点。
|
|
85
85
|
const effMode = effectivePermissionMode(body, gated.parsedSettings.settings);
|
|
86
|
-
const lane = bindSettingsCwdEnvAndModel(body, auth, gated, effMode);
|
|
86
|
+
const lane = bindSettingsCwdEnvAndModel(body, auth, gated, effMode, opts);
|
|
87
87
|
const anchors = await resolveHistoryAnchors(body, auth);
|
|
88
88
|
const spec = assembleSpecLiteral(body, auth, opts, { ...gated, ...lane, ...anchors }, effMode);
|
|
89
89
|
const folded = await foldGovernanceAndSettings(body, auth, spec, gated, effMode);
|
|
@@ -210,7 +210,7 @@ export function createResolveSpec(ctx) {
|
|
|
210
210
|
/** 阶段②(settings·cwd·env):吃 请求体 + auth + 阶段①的 objective/parsedSettings,吐 taskHooks、
|
|
211
211
|
* additionalDirectories、档位展开后的 wireCatalog 与选中的 picked;副作用=per-session cwd/shellEnv 注册与
|
|
212
212
|
* honored/ignored 日志、未知模型回落与无视觉模型降级的计数/告警。 */
|
|
213
|
-
const bindSettingsCwdEnvAndModel = (body, auth, gated, effMode) => {
|
|
213
|
+
const bindSettingsCwdEnvAndModel = (body, auth, gated, effMode, opts) => {
|
|
214
214
|
const { objective, parsedSettings } = gated;
|
|
215
215
|
// [#40 / TOC cwd seam] register the caller's launch dir so the HOST factory runs the agent there (read by
|
|
216
216
|
// ctx.sessionId). 🔒 GATED: only the single-user host lane (cwdHonored) honors it; on any other lane / multi-tenant
|
|
@@ -335,7 +335,22 @@ export function createResolveSpec(ctx) {
|
|
|
335
335
|
// the Runner; a refresh-time tier change makes this gate briefly AHEAD of the old Runner snapshot (a new
|
|
336
336
|
// tier word then fails loud in core instead of silently degrading — honest during the restart window).
|
|
337
337
|
const wireCatalog = expandTiers(config.models, config.tiers) ?? config.models;
|
|
338
|
-
|
|
338
|
+
// #233 / A-002.8:`atModelAllowlist` 与门目录同源(增广视图)——名单按 catalog name 写,而用户可以
|
|
339
|
+
// 用档位词/别名/id 指同一只模型,归一在 isModelAllowlisted 里做(model-select.ts 顶注)。
|
|
340
|
+
const picked = resolveTaskModel(body.model ?? parsedSettings.settings?.model, objective, wireCatalog, config.atModelAllowlist);
|
|
341
|
+
// #233 拒形分流(clay 终裁 (a)):**fresh 提交** 400 `request.model_not_allowed`(治理拒——模型在目录
|
|
342
|
+
// 里但名单未含,与"目录里没有"是两回事,故与 request.unknown_reference 分码);**resume 续跑照旧**
|
|
343
|
+
// ——名单管"用户可点什么",resume 不是一次新选择;而本文件的 appendSystemPrompt 与 compactionModel
|
|
344
|
+
// 两处 fresh-vs-resume 分流注、加 model-select.ts 的 [865]② 头注,三处都已成文"砖死 resume 比降级
|
|
345
|
+
// 糟"。留声(warn+计数)让治理面看得见还有多少存量会话跑在名单外。
|
|
346
|
+
if (picked.notAllowed !== undefined) {
|
|
347
|
+
const { requested, resolved, source } = picked.notAllowed;
|
|
348
|
+
if (opts?.leg === "fresh") {
|
|
349
|
+
throw new HttpError(400, `model "${requested.slice(0, 120)}" is not in this deployment's @-mention allowlist (it resolves to catalog model "${resolved}") — the allowlist is configured by the operator (config-center models.atModelAllowlist)`, { code: "request.model_not_allowed" });
|
|
350
|
+
}
|
|
351
|
+
metrics.inc("task_model_not_allowlisted_resume_total");
|
|
352
|
+
logger.warn("task_model_not_allowlisted_resume", { requested: requested.slice(0, 120), resolved, source, sessionId: auth?.sessionId ?? null, note: "resumed leg keeps running the model it was started on — the allowlist gates fresh picks only" });
|
|
353
|
+
}
|
|
339
354
|
// [865]② 降级永远可见:fresh submit 的未知 body.model 已在 HTTP 门 400(不会到这);走到这的未知 ref
|
|
340
355
|
// 只剩 RESUME 重放(模型事后被移出目录)与 settings.model(lenient 文档面)——落 default 可跑,但必须
|
|
341
356
|
// 有声(session 中途换模型是最恶性的上下文污染路径,静默=病灶本体)。
|
|
@@ -650,6 +665,18 @@ export function createResolveSpec(ctx) {
|
|
|
650
665
|
logger.warn("compaction_model_unknown_fallback", { requested: body.compactionModel.slice(0, 120), sessionId: auth?.sessionId ?? null });
|
|
651
666
|
return {};
|
|
652
667
|
}
|
|
668
|
+
// #233 / A-002.8 第二执法点:`compactionModel` 也是用户逐 turn 选择通道(一次显式的省钱换挡),
|
|
669
|
+
// 与 body.model 同门同拒形。归一同源(isModelAllowlisted 收增广目录 + 名单)。resume 照旧续跑
|
|
670
|
+
// (终裁 (a)):这条腿上"照旧"= 保留该 compactionModel,只留声——把它丢回 summarize 角色是
|
|
671
|
+
// **静默换价**,正是本字段当初为之存在的病灶。
|
|
672
|
+
if (!isModelAllowlisted(cm, wireCatalog, config.atModelAllowlist)) {
|
|
673
|
+
const terminal = terminalCatalogName(cm, wireCatalog) ?? cm;
|
|
674
|
+
if (opts?.leg === "fresh") {
|
|
675
|
+
throw new HttpError(400, `compactionModel "${body.compactionModel.slice(0, 120)}" is not in this deployment's @-mention allowlist (it resolves to catalog model "${terminal}") — the allowlist is configured by the operator (config-center models.atModelAllowlist)`, { code: "request.model_not_allowed" });
|
|
676
|
+
}
|
|
677
|
+
metrics.inc("task_model_not_allowlisted_resume_total");
|
|
678
|
+
logger.warn("task_model_not_allowlisted_resume", { requested: body.compactionModel.slice(0, 120), resolved: terminal, source: "compaction", sessionId: auth?.sessionId ?? null, note: "resumed leg keeps the compaction model it was started with — the allowlist gates fresh picks only" });
|
|
679
|
+
}
|
|
653
680
|
return { compactionModel: cm };
|
|
654
681
|
})()),
|
|
655
682
|
// E7 (shell-host contract): reasoning-effort selection threaded to core's ThinkingLevel. Defensive
|
|
@@ -116,7 +116,7 @@ export interface RunnerDepsCtx {
|
|
|
116
116
|
}
|
|
117
117
|
/** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
|
|
118
118
|
* 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
|
|
119
|
-
export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores">;
|
|
119
|
+
export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "onNotice">;
|
|
120
120
|
/**
|
|
121
121
|
* design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
|
|
122
122
|
* `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
|
|
@@ -145,7 +145,46 @@ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "
|
|
|
145
145
|
* 属性,比再抽一层共享基座更强的同源保证),不重复收纳进这里。
|
|
146
146
|
*/
|
|
147
147
|
/** 基座真实消费的窄面(Pick)——subRunner 调用点(main.ts)只需凑这 13 个字段,不必造全量 ctx。 */
|
|
148
|
-
export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "hands" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
|
|
148
|
+
export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "hands" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores" | "logger">;
|
|
149
|
+
/**
|
|
150
|
+
* core `EngineNotice` → 结构化 `engine_notice` 行的**唯一**转发体(#240②)。
|
|
151
|
+
*
|
|
152
|
+
* 🔴 为什么是具名导出而不是就地闭包(合并码重扫):这一席此前只落在共享基座里,于是 leader 车道
|
|
153
|
+
* (`src/leader/wire.ts` 自拼 deps 的五处 `new Runner({...})`)一处都没有 —— core 的契约是「接线 ⇒ 走
|
|
154
|
+
* sink,缺席 ⇒ 逐字打 `console.warn`」,所以缺席不是静默而是**同一部署两条通告通道**:主车道进结构化
|
|
155
|
+
* JSON 行,leader 车道落裸 stderr,按 code 采集(`tool_result.offload_put_failed` 等)的运维面漏掉后者。
|
|
156
|
+
* 两处各写一份转发体必漂(键名/字段集),故收成一个函数;入参取**结构形** logger(leader 的 cfg 只带
|
|
157
|
+
* 可选 `warn`/`info` 两格,不是全形 `Logger`)。
|
|
158
|
+
*
|
|
159
|
+
* 🔴 `warn` 是**必填**(codex 复审第一轮,验真后修):可选链 `logger.warn?.(…)` 形在「只装了 `info` 的
|
|
160
|
+
* logger」上会把通告**吞掉** —— 比不接线还坏(不接线时 core 至少还打它自己的 `console.warn`)。所以
|
|
161
|
+
* 「有没有 warn」这个判断留在**装配点**:没有就别铸这一席,让 core 的响亮权留在原处。
|
|
162
|
+
*/
|
|
163
|
+
export declare function createEngineNoticeForwarder(logger: {
|
|
164
|
+
warn: (msg: string, fields?: Record<string, unknown>) => void;
|
|
165
|
+
}): NonNullable<RunnerDeps["onNotice"]>;
|
|
166
|
+
/**
|
|
167
|
+
* `onNotice` **席**的装配判据本身 —— 「`warn` 真在场才铸席」这一条从此是**一个可直接调用的函数**,
|
|
168
|
+
* 不是散在装配点、只能靠扫源码文本去猜的一行三元(codex 对抗复审 R1-[medium],验真后修)。
|
|
169
|
+
*
|
|
170
|
+
* 病(实测):把 leader 的门写成 `cfg.logger ? ((m, x) => cfg.logger?.warn?.(m, x)) : undefined` 时,
|
|
171
|
+
* 「装配点切片里出现过 `warn` 这个词」这类**源码文本**判据一律照绿 —— 而只装了 `info` 的部署会拿到一个
|
|
172
|
+
* truthy 闭包、照样铸席,通告随后被可选链**静默吞掉**,比不铸席更坏(不铸时 core 至少还打它自己的
|
|
173
|
+
* `console.warn`,响亮权留在原处)。任何「在这段文本里找关键字」的门都挡不住这一族变异,因为病灶在
|
|
174
|
+
* **值**(闭包 truthy)而不在字面。⇒ 把判据搬进函数,让它可以被**行为**测:info-only ⇒ 空席;带 warn ⇒
|
|
175
|
+
* 具名转发体。
|
|
176
|
+
*
|
|
177
|
+
* 命名(CLAUDE.md 工厂律):返回的袋子里装的是**活闭包**(有行为)⇒ `create*`,不是 `build*`。
|
|
178
|
+
* 返回**展开形**(`{}` 或 `{ onNotice }`)而不是 `onNotice: undefined`。理由是**键卫生**,不是 core 行为
|
|
179
|
+
* (R3 复扫更正:core 5.28 的 `deliverEngineNotice` 只判 `typeof onNotice === "function"`,显式 `undefined`
|
|
180
|
+
* 与键缺席在 core 那侧行为逐字相同、响亮权都留在 core——旧句「显式 undefined 也算 host 接管」与真码相反):
|
|
181
|
+
* 展开形让 `new Runner({...seat})` 的键集只随真装配变化,EXPECTED_SHARED_KEYS 类的键集绊线才数得准。
|
|
182
|
+
*/
|
|
183
|
+
export declare function createEngineNoticeSeat(logger: {
|
|
184
|
+
warn?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
185
|
+
} | undefined): {
|
|
186
|
+
onNotice?: NonNullable<RunnerDeps["onNotice"]>;
|
|
187
|
+
};
|
|
149
188
|
export declare function createSharedRunnerDeps(ctx: SharedRunnerDepsCtx): SharedRunnerDeps;
|
|
150
189
|
export declare function createRunnerDeps(ctx: RunnerDepsCtx): RunnerDeps;
|
|
151
190
|
//# sourceMappingURL=runner-deps.d.ts.map
|
package/dist/boot/runner-deps.js
CHANGED
|
@@ -25,11 +25,54 @@ export function createRunnerDepsOnAsk(toolApproval) {
|
|
|
25
25
|
return undefined;
|
|
26
26
|
return (req, signal) => toolApproval.ask(req, signal);
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* core `EngineNotice` → 结构化 `engine_notice` 行的**唯一**转发体(#240②)。
|
|
30
|
+
*
|
|
31
|
+
* 🔴 为什么是具名导出而不是就地闭包(合并码重扫):这一席此前只落在共享基座里,于是 leader 车道
|
|
32
|
+
* (`src/leader/wire.ts` 自拼 deps 的五处 `new Runner({...})`)一处都没有 —— core 的契约是「接线 ⇒ 走
|
|
33
|
+
* sink,缺席 ⇒ 逐字打 `console.warn`」,所以缺席不是静默而是**同一部署两条通告通道**:主车道进结构化
|
|
34
|
+
* JSON 行,leader 车道落裸 stderr,按 code 采集(`tool_result.offload_put_failed` 等)的运维面漏掉后者。
|
|
35
|
+
* 两处各写一份转发体必漂(键名/字段集),故收成一个函数;入参取**结构形** logger(leader 的 cfg 只带
|
|
36
|
+
* 可选 `warn`/`info` 两格,不是全形 `Logger`)。
|
|
37
|
+
*
|
|
38
|
+
* 🔴 `warn` 是**必填**(codex 复审第一轮,验真后修):可选链 `logger.warn?.(…)` 形在「只装了 `info` 的
|
|
39
|
+
* logger」上会把通告**吞掉** —— 比不接线还坏(不接线时 core 至少还打它自己的 `console.warn`)。所以
|
|
40
|
+
* 「有没有 warn」这个判断留在**装配点**:没有就别铸这一席,让 core 的响亮权留在原处。
|
|
41
|
+
*/
|
|
42
|
+
export function createEngineNoticeForwarder(logger) {
|
|
43
|
+
return (notice) => logger.warn("engine_notice", { code: notice.code, message: notice.message, ...(notice.detail !== undefined ? { detail: notice.detail } : {}) });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `onNotice` **席**的装配判据本身 —— 「`warn` 真在场才铸席」这一条从此是**一个可直接调用的函数**,
|
|
47
|
+
* 不是散在装配点、只能靠扫源码文本去猜的一行三元(codex 对抗复审 R1-[medium],验真后修)。
|
|
48
|
+
*
|
|
49
|
+
* 病(实测):把 leader 的门写成 `cfg.logger ? ((m, x) => cfg.logger?.warn?.(m, x)) : undefined` 时,
|
|
50
|
+
* 「装配点切片里出现过 `warn` 这个词」这类**源码文本**判据一律照绿 —— 而只装了 `info` 的部署会拿到一个
|
|
51
|
+
* truthy 闭包、照样铸席,通告随后被可选链**静默吞掉**,比不铸席更坏(不铸时 core 至少还打它自己的
|
|
52
|
+
* `console.warn`,响亮权留在原处)。任何「在这段文本里找关键字」的门都挡不住这一族变异,因为病灶在
|
|
53
|
+
* **值**(闭包 truthy)而不在字面。⇒ 把判据搬进函数,让它可以被**行为**测:info-only ⇒ 空席;带 warn ⇒
|
|
54
|
+
* 具名转发体。
|
|
55
|
+
*
|
|
56
|
+
* 命名(CLAUDE.md 工厂律):返回的袋子里装的是**活闭包**(有行为)⇒ `create*`,不是 `build*`。
|
|
57
|
+
* 返回**展开形**(`{}` 或 `{ onNotice }`)而不是 `onNotice: undefined`。理由是**键卫生**,不是 core 行为
|
|
58
|
+
* (R3 复扫更正:core 5.28 的 `deliverEngineNotice` 只判 `typeof onNotice === "function"`,显式 `undefined`
|
|
59
|
+
* 与键缺席在 core 那侧行为逐字相同、响亮权都留在 core——旧句「显式 undefined 也算 host 接管」与真码相反):
|
|
60
|
+
* 展开形让 `new Runner({...seat})` 的键集只随真装配变化,EXPECTED_SHARED_KEYS 类的键集绊线才数得准。
|
|
61
|
+
*/
|
|
62
|
+
export function createEngineNoticeSeat(logger) {
|
|
63
|
+
const warn = logger?.warn?.bind(logger);
|
|
64
|
+
return warn ? { onNotice: createEngineNoticeForwarder({ warn }) } : {};
|
|
65
|
+
}
|
|
28
66
|
export function createSharedRunnerDeps(ctx) {
|
|
29
67
|
// 具名带类型的字面量(非裸 return)——deps-literal-shape-gate 的 POINTS 按 `const X: T = {` 咬装配
|
|
30
68
|
// 字面量,裸 return 形在它的覆盖外(复审 2026-07-30 F5):conditional-spread 病在这里就会失检。
|
|
31
69
|
const shared = {
|
|
32
70
|
brain: ctx.brain,
|
|
71
|
+
// core 5.28.0 EngineNotice 席(#240②,^5.28.0 floor 的编译锚之一):结构化运维通告——function 席
|
|
72
|
+
// **替换** console.warn 行(core 契约:host 接管即拥有响亮权,不双发),故必须真转发不吞。三族
|
|
73
|
+
// (config.env_timeout_discarded / config.materialize_env_discarded / tool_result.offload_put_failed)
|
|
74
|
+
// 全部按 code 落 logger.warn,detail 原样透传;共享基座=两 Runner(main/sub)同源同席([1543] 纪律)。
|
|
75
|
+
onNotice: createEngineNoticeForwarder(ctx.logger),
|
|
33
76
|
// core 1.265: the active tier table (tier words + CC aliases → catalog keys, expandTiers at Runner
|
|
34
77
|
// construction; empty = INERT by core contract). Same mutateInPlace reference applyEffective fills — a
|
|
35
78
|
// refresh-time tier change is restart-to-apply, same tier as models.
|
|
@@ -78,12 +78,34 @@ export function createSessionFaces(ctx) {
|
|
|
78
78
|
if ("active" in runs)
|
|
79
79
|
return { active: runs.active };
|
|
80
80
|
// checkpoint rows carry no owner column → owner-guarded via a session_meta.owner sub-select, so they MUST
|
|
81
|
-
// run while session_meta still exists (before the history delete below); tool_result
|
|
82
|
-
// `
|
|
81
|
+
// run while session_meta still exists (before the history delete below); tool_result selects by its
|
|
82
|
+
// RECORDED PROVENANCE column (`owner_session_id`) and stays route-guarded too (see its deleteBySession doc).
|
|
83
83
|
if (checkpointStore)
|
|
84
84
|
await checkpointStore.deleteBySession(sessionId, owner);
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// optional extra(R3 复扫更正:不再是「SQL twins only」——core 5.28 起 FileToolResultStore **实现了**
|
|
86
|
+
// deleteBySession,local 车道这条腿**真的会跑**。File 孪生的形:同步遍历目录、无 .owner.json 边车的
|
|
87
|
+
// 存量文件计入 unattributable 不删——所以 local 部署上这条 warn 在有 5.28 前存量时会以大数出现,
|
|
88
|
+
// 这是诚实读数不是事故。⚠️ R4 复扫再纠:**local 无 TTL 腿**(File 孪生没有 reapOlderThan,
|
|
89
|
+
// reapers 的可选链在 local 恒 no-op)⇒ 这些无主存量今天在 local 上**没有任何清理路径**
|
|
90
|
+
// (会话删除按契约不动它、TTL 也够不着)——只占磁盘不泄权,单用户 CC 姿态下按已知缺口登记,
|
|
91
|
+
// 清理腿(File 孪生补 reapOlderThan 或一次性迁移铸边车)是独立后续件,别把本注读成已有补偿。)
|
|
92
|
+
if (toolResultStore) {
|
|
93
|
+
const report = await toolResultStore.deleteBySession?.(sessionId);
|
|
94
|
+
// 🔴 **回执不许丢**(codex 复审 R1-[high],验真后修):core 把 `unattributable` 写成「the caller
|
|
95
|
+
// learns the deletion was **incomplete** rather than being told a clean "done"」——无出处的行按
|
|
96
|
+
// 契约永不删除,协调器把报告丢掉就等于把这句话吞了,而删除面吞掉的是**删除权**的真相。
|
|
97
|
+
// 这一格是**全库**无出处行数(core InMemory 孪生口径),读法 = 「本店存在没人认领的字节,任何
|
|
98
|
+
// 会话删除都不可能完整」,不是「本会话残留了 N 行」;实体清理归 TTL(`reapOlderThan`)。
|
|
99
|
+
// ⚠️ 登记后续件:把它也带上 DELETE 的 HTTP 回执是**加 wire 键**,归属主裁决,不在本批自作主张;
|
|
100
|
+
// 本批先保证它不再无声消失(结构化 warn = 运维查得到、审计追得上)。
|
|
101
|
+
if (report !== undefined && report.unattributable > 0) {
|
|
102
|
+
logger.warn("tool_result_unattributable_rows_survive_session_delete", {
|
|
103
|
+
sessionId,
|
|
104
|
+
deleted: report.deleted,
|
|
105
|
+
unattributable: report.unattributable,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
87
109
|
// E18 resume-at anchors are per-session privacy-relevant metadata → purge ALL of them with the session, scoped
|
|
88
110
|
// by session_id ALONE (the route already proved session ownership at the DELETE gate). A per-ROW owner guard
|
|
89
111
|
// here would LEAK: an anchor's owner is the per-run submitting principal, which diverges from the canonical
|