@sema-agent/server 7.130.0 → 7.131.0

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.
Files changed (103) hide show
  1. package/MIGRATION.md +87 -0
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +36 -8
  5. package/dist/audit.js +4 -1
  6. package/dist/bench/s1/live-deps.js +2 -0
  7. package/dist/boot/resolve-spec.js +38 -15
  8. package/dist/boot/stage-02-web-admin-pull.js +9 -1
  9. package/dist/boot/stage-10-http-server.js +6 -3
  10. package/dist/capabilities/builtin-tools.js +2 -1
  11. package/dist/capabilities/composite-members.d.ts +1 -0
  12. package/dist/capabilities/composite-members.js +1 -0
  13. package/dist/capabilities/hands-lane.js +2 -2
  14. package/dist/capabilities/host-tool-names.d.ts +17 -0
  15. package/dist/capabilities/host-tool-names.js +15 -0
  16. package/dist/capabilities/mcp-probe.js +3 -3
  17. package/dist/capabilities/repo-tools.js +4 -3
  18. package/dist/capabilities/scenarios.d.ts +8 -7
  19. package/dist/capabilities/scenarios.js +5 -4
  20. package/dist/capabilities/select-environment-tool.js +2 -1
  21. package/dist/capabilities/send-user-file-tool.d.ts +0 -1
  22. package/dist/capabilities/send-user-file-tool.js +2 -2
  23. package/dist/capabilities/tool-defer.js +2 -1
  24. package/dist/config-catalog.js +16 -5
  25. package/dist/config-invariants.js +2 -2
  26. package/dist/config-provider.js +2 -2
  27. package/dist/config-types.d.ts +6 -0
  28. package/dist/config.d.ts +2 -2
  29. package/dist/config.js +18 -17
  30. package/dist/elicitation.d.ts +7 -0
  31. package/dist/elicitation.js +20 -5
  32. package/dist/hooks/hook-llm.js +2 -2
  33. package/dist/hooks/hook-runner.d.ts +54 -8
  34. package/dist/hooks/hook-runner.js +86 -16
  35. package/dist/http/admission.js +2 -2
  36. package/dist/http/approval-sweeps.js +2 -2
  37. package/dist/http/checkpoint-ctx.d.ts +12 -11
  38. package/dist/http/checkpoint-ctx.js +4 -4
  39. package/dist/http/dispatch.js +1 -1
  40. package/dist/http/resume-legs.js +6 -5
  41. package/dist/http/routes/approvals-assistant.js +1 -1
  42. package/dist/http/routes/capabilities.js +6 -16
  43. package/dist/http/routes/runs.js +6 -10
  44. package/dist/http/routes/sessions.js +22 -10
  45. package/dist/http/routes/tasks.js +16 -11
  46. package/dist/http/scenario-read-face.d.ts +14 -0
  47. package/dist/http/scenario-read-face.js +5 -0
  48. package/dist/http/send.d.ts +10 -0
  49. package/dist/http/send.js +6 -0
  50. package/dist/http/server.d.ts +16 -12
  51. package/dist/http/server.js +2 -2
  52. package/dist/http/stored-body-replay.d.ts +10 -3
  53. package/dist/http/stored-body-replay.js +27 -6
  54. package/dist/http/wire-types.d.ts +18 -5
  55. package/dist/index.d.ts +2 -1
  56. package/dist/index.js +2 -1
  57. package/dist/mcp-assertion-header.d.ts +2 -2
  58. package/dist/mcp-assertion-header.js +3 -3
  59. package/dist/model-entry-refusal.d.ts +1 -7
  60. package/dist/model-route-endpoint.d.ts +51 -11
  61. package/dist/model-route-endpoint.js +45 -23
  62. package/dist/model-route-gap.d.ts +5 -4
  63. package/dist/model-route-gap.js +1 -1
  64. package/dist/model-select.d.ts +58 -0
  65. package/dist/model-select.js +31 -0
  66. package/dist/namespaced-rule-name.d.ts +7 -4
  67. package/dist/namespaced-rule-name.js +4 -3
  68. package/dist/observability/fail-open.d.ts +11 -6
  69. package/dist/observability/fail-open.js +5 -1
  70. package/dist/plugins/local-session-store.js +2 -2
  71. package/dist/plugins/one-time-migrations.d.ts +10 -1
  72. package/dist/plugins/one-time-migrations.js +16 -1
  73. package/dist/question.d.ts +18 -9
  74. package/dist/question.js +21 -13
  75. package/dist/request-key-closure.d.ts +2 -2
  76. package/dist/request-key-closure.js +16 -9
  77. package/dist/rule-entry-list.d.ts +124 -17
  78. package/dist/rule-entry-list.js +140 -24
  79. package/dist/run-local.d.ts +3 -3
  80. package/dist/run-local.js +8 -7
  81. package/dist/runs.d.ts +42 -2
  82. package/dist/runs.js +45 -2
  83. package/dist/spec-fields.d.ts +8 -4
  84. package/dist/stored-request-hooks-m20.d.ts +23 -0
  85. package/dist/stored-request-hooks-m20.js +124 -0
  86. package/dist/stored-request-tool-axes-m19.d.ts +1 -1
  87. package/dist/task-mcp.d.ts +41 -77
  88. package/dist/task-mcp.js +143 -75
  89. package/dist/task-settings.d.ts +41 -5
  90. package/dist/task-settings.js +39 -25
  91. package/dist/trace/core-keyset-guard.d.ts +2 -2
  92. package/dist/trace/ledger-sink.d.ts +29 -0
  93. package/dist/trace/ledger-sink.js +11 -0
  94. package/dist/trace/project.d.ts +6 -0
  95. package/dist/trace/project.js +11 -0
  96. package/dist/trace/wire-projection-faces.d.ts +1 -1
  97. package/dist/trace/wire-projection-faces.js +1 -1
  98. package/dist/user-authored-text.d.ts +7 -0
  99. package/dist/user-authored-text.js +3 -0
  100. package/dist/web-admin-client/apply-effective.js +10 -5
  101. package/dist/web-admin-client/types.d.ts +1 -1
  102. package/package.json +2 -2
  103. package/scripts/drain-check-local.mjs +3 -2
@@ -15,12 +15,15 @@
15
15
  * (`MODEL_GATEWAY_FALLBACK_URLS`)是主网关的镜像,没有主就没有镜像 ⇒ 拒。
16
16
  *
17
17
  * ## 执法点(判据只在这里,执法点在调用处 —— 与 S-475 `gateway-url-userinfo` 同形)
18
- * · boot:`config.ts` 九域解析之后的跨域不变量区(锚 `CROSS-INVARIANT:model-route-endpoint`)⇒ 拒启;经 `model-route-gap.ts`
19
- * 的 {@link judgeModelRouteGap}(env 读成本文件判据要的切面,与包根导出的纯判官是**同一只**);严格名册
20
- * 布防(`isRosterGateArmed`)时 env 腿是占位、从不被派发 ⇒ 不判它(备用网关那一条照判);
21
- * · 热路径:`web-admin-client/apply-effective.ts` 的模型面 stage ⇒ 模型面组拒(opus 腿 O3:同候选其余组照落)、上一代模型面继续服务;
22
- * · run-local:配置源解析完之后按真要派发的表再判一次(`run-local.ts`,7.98.0 合并复审 F2)—— 本入口没有名册门,
23
- * loadConfig 对严格名册布防的占位豁免在这里兑现不了;没布防时恒惰性;
18
+ * **判官只判「将要服务的那张表」,拒因句按视图说真话**(视图词 = {@link ModelTableLeg} 两词,进判据入参,不靠调用方拼句):
19
+ * · boot:装配第 2 段**目录应用之后**的跨域不变量区(`boot/stage-02-web-admin-pull.ts`,锚 `CROSS-INVARIANT:model-route-endpoint`)⇒ 拒启 ——
20
+ * 经 {@link judgeServedModelRoute}:目录被采纳 ⇒ 判目录表(目录视图),目录缺席 / 被整拒 / 没有启用条目 ⇒ 判 env 表(env 视图);
21
+ * 严格名册布防(`isRosterGateArmed`)时 env 表是占位、从不被派发 ⇒ 不判它(备用网关那一条与表无关,照判)。
22
+ * 不在 `loadConfig()`:那是目录应用**之前**,env 表还不知道会不会服务(目录一旦采纳就整张换掉它);
23
+ * · 包根导出的 `judgeModelRouteGap(env)`(`model-route-gap.ts`)问的是**env 腿**:「这份 env 交给引擎、没有目录时起不起得来」—— 同一只判据的 env 视图,
24
+ * 目录在场时起服以目录为准;
25
+ * · 热路径:`web-admin-client/apply-effective.ts` 的模型面 stage ⇒ 模型面组拒(opus 腿 O3:同候选其余组照落)、上一代模型面继续服务(目录视图);
26
+ * · run-local:配置源解析完之后按真要派发的表判(`run-local.ts`)—— 本入口没有名册门,不豁免占位;
24
27
  * · 登记:`config-invariants.ts` 的 `model-route-endpoint` 条(表的「不许有表外不变量」职责);
25
28
  * · 派发面:titler / hook 选模型(`hooks/hook-llm.ts`)与降级腿 / 启动摘要(`brain.ts`)求有效端点时调
26
29
  * {@link openaiRouteBaseUrl},不再各写一个 `model.baseUrl || gatewayBaseUrl`。
@@ -34,6 +37,7 @@
34
37
  * `brain.ts` / `hooks/hook-llm.ts` 都可以安全引它。
35
38
  */
36
39
  import type { Model } from "@sema-agent/core";
40
+ import { type ModelTableLeg } from "./model-select.js";
37
41
  /** 机读码(拼法随 `config.*` 家族,按**规则**命名不按键名):一条 openai 路由解析不到端点。 */
38
42
  export declare const MODEL_ROUTE_ENDPOINT_MISSING_CODE = "config.model_route_endpoint_missing";
39
43
  /** 同族第二码:备用网关设了、主网关没设。与上一码分开是因为出路不同(补主网关 / 撤备用 vs 补主网关 / 给条目 baseUrl)。 */
@@ -43,7 +47,7 @@ export declare const GATEWAY_FALLBACK_WITHOUT_PRIMARY_CODE = "config.gateway_fal
43
47
  *
44
48
  * 与上面的**码**是两根轴:码是组拒的机读词(`ModelPlaneRefusalCode`,settings-schema 属主),这里是**出路**:
45
49
  * · `endpoint_missing` —— 缺的是网关端点:openai 兼容腿上的条目解析不到 base URL(或备用网关设了、主网关没设);
46
- * 出路 = 设 `MODEL_GATEWAY_BASEURL`,或给条目自己的 `baseUrl`;
50
+ * 出路 = 设 `MODEL_GATEWAY_BASEURL`,或(目录视图)给条目自己的 `baseUrl` / (env 视图)挂 anthropic 路由、或不设 `MODEL_ID` 让目录缺省服务;
47
51
  * · `lane_credential_missing` —— 缺的是**车道凭据**:条目的 provider 词是 `anthropic`(用户选的是 anthropic 车道),
48
52
  * 却因 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 都没有而没装上 anthropic 路由、落进了 openai 腿,那条腿又没有端点;
49
53
  * 出路 = 补那两把钥匙之一(设网关是另一条出路,拒因句一并给);
@@ -75,8 +79,11 @@ export type OpenaiRouteEndpoint = {
75
79
  readonly ok: false;
76
80
  readonly refusal: string;
77
81
  };
78
- /** 单条 openai 路由的有效端点 / 拒因(拒因与部署级判据是**同一只**铸点 {@link routeEndpointMissingRefusal})。 */
79
- export declare function resolveOpenaiRouteEndpoint(routeName: string, routeBaseUrl: string | undefined, gatewayBaseUrl: string | undefined): OpenaiRouteEndpoint;
82
+ /**
83
+ * 单条 openai 路由的有效端点 / 拒因(拒因与部署级判据是**同一只**铸点 {@link routeEndpointMissingRefusal};`leg` = 这条路由所在的表的视图词)。
84
+ * `route` = 这条路由的条目(只读 `baseUrl` 与 `provider`);条目都没有(模型名解不出)⇒ 传 `undefined`,按「无自有 baseUrl、非 anthropic 词」判。
85
+ */
86
+ export declare function resolveOpenaiRouteEndpoint(routeName: string, route: Pick<Model, "baseUrl" | "provider"> | undefined, gatewayBaseUrl: string | undefined, leg: ModelTableLeg): OpenaiRouteEndpoint;
80
87
  /** 部署级判据读的切面(`ServiceConfig` 与 `CrossInvariantView` 都结构满足)。 */
81
88
  export interface ModelRouteEndpointView {
82
89
  readonly gatewayBaseUrl?: string;
@@ -96,7 +103,7 @@ export interface ModelRouteEndpointView {
96
103
  * `default` 键是目录里另一条的别名(env 腿 `{default: m, [m.id]: m}`、center 腿 `models.default = models[picked]`),
97
104
  * 与别的键指同一只对象时不重复点名;只有它一个键时(手搓夹具)照样点名 `default`。
98
105
  */
99
- export declare function modelRouteEndpointRefusal(view: ModelRouteEndpointView): string | undefined;
106
+ export declare function modelRouteEndpointRefusal(view: ModelRouteEndpointView, leg: ModelTableLeg): string | undefined;
100
107
  /** S-768:部署级判决的结构化形 —— 码与拒因句由**同一次**判决给出(一个不变量 id 两个码,组拒码在判决那刻当数据带出,不从句子里抠)。 */
101
108
  export interface ModelRouteEndpointVerdict {
102
109
  readonly code: typeof GATEWAY_FALLBACK_WITHOUT_PRIMARY_CODE | typeof MODEL_ROUTE_ENDPOINT_MISSING_CODE;
@@ -107,6 +114,39 @@ export interface ModelRouteEndpointVerdict {
107
114
  /**
108
115
  * **判据属主**(S-768 起的结构化形;{@link modelRouteEndpointRefusal} 是它的句子投影):`undefined` = 通过。判序即 {@link modelRouteEndpointRefusal}
109
116
  * 头注所列 —— ① 备用无主先说,② 逐条目解析不到端点全部点名。加一枝 = 在这里同时给出码与句(返回型强制两者同在)。
117
+ * `leg` = `view.models` 是哪一张表({@link ModelTableLeg}):判据本身两视图同一把,**句子**按视图铸(出路在 env 表与目录表上不是同一组)。
118
+ */
119
+ export declare function judgeModelRouteEndpoint(view: ModelRouteEndpointView, leg: ModelTableLeg): ModelRouteEndpointVerdict | undefined;
120
+ /** {@link judgeServedModelRoute} 读的切面:路由判据的切面 + 「服务的是哪张表」的事实(`ServiceConfig` 结构满足)。 */
121
+ export interface ServedModelRouteView extends ModelRouteEndpointView {
122
+ readonly model: object;
123
+ readonly envModel: object;
124
+ }
125
+ /** {@link judgeServedModelRoute} 的旁路入参。 */
126
+ export interface ServedModelRouteOptions {
127
+ /**
128
+ * env 表是不是**占位**(只在服务的是 env 表时有意义)。严格名册布防(`isRosterGateArmed`:远程 registry ∧ 非 dryRun ∧ `CONFIG_REQUIRE_ROSTER=true`)=
129
+ * 运维声明 env `MODEL_ID` 是占位:名册落地前计费提交 503、落地后整表换装 ⇒ env 表从不被派发,不在「能派发到的」射程里(备用网关那一条与表无关,照判)。
130
+ * 布防是中心接入的事实,配置读不出 ⇒ 由调用方给。没有名册门的入口(run-local)传 `false`。
131
+ */
132
+ readonly envTableIsPlaceholder: boolean;
133
+ }
134
+ /**
135
+ * **起服段的路由判官:只判「将要服务的那张表」**。`undefined` = 通过。
136
+ *
137
+ * 服务的是哪张表由 {@link servedModelTableLeg} 读出来,判据与句子随它走(同一只属主判据 {@link judgeModelRouteEndpoint},零第二判官):
138
+ * · 目录被采纳 ⇒ 判目录表(目录视图;模型面 stage 已对同一张表判过同一只判据,这里是装配终点上的复核);
139
+ * · 目录缺席 / 被整拒 / 没有启用条目 ⇒ 判 env 表(env 视图);占位豁免见 {@link ServedModelRouteOptions.envTableIsPlaceholder}。
140
+ *
141
+ * 必须在**目录应用之后**调(装配第 2 段 / run-local 配置源解析完):在那之前没有「将要服务的表」可判 ——
142
+ * 对 env 表提前拒启,会把「条目写进目录、再设 `MODEL_ID`」的部署无谓拒掉(目录一旦采纳,引擎从不读 env `MODEL_ID`)。
143
+ */
144
+ export declare function judgeServedModelRoute(config: ServedModelRouteView, opts: ServedModelRouteOptions): ModelRouteEndpointVerdict | undefined;
145
+ /**
146
+ * **没有目录要应用的直接装配**(开发脚本 / 基准 / 嵌入方的纯 env 形:`loadConfig()` → `createBrain` → `Runner`)的拒启口:服务的表就是 `loadConfig()` 铸的 env 表,
147
+ * 判决 ⇒ 抛(`Error.message` = 判决的 `reason` 原样)。`loadConfig()` 不判路由 —— 没有这一步,缺端点的 env 行会一路跑到第一次模型调用才失败(基准里是十次失败调用、
148
+ * 全零统计、退出 0)。装配第 2 段与 `run-local` 要锚、要在目录应用之后判,调的是 {@link judgeServedModelRoute};本函数只是它的抛出形。
149
+ * 源码类钉(`test/served-model-route.test.ts` ⑨):`src/` 与 `scripts/` 里每个调 `loadConfig()` 的入口,要么在同文件里判过路由,要么是登记在册的例外。
110
150
  */
111
- export declare function judgeModelRouteEndpoint(view: ModelRouteEndpointView): ModelRouteEndpointVerdict | undefined;
151
+ export declare function assertServedModelRoute(config: ServedModelRouteView, opts: ServedModelRouteOptions): void;
112
152
  //# sourceMappingURL=model-route-endpoint.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { dispatchBrainLane } from "./model-provider.js";
2
- import { DEFAULT_MODEL_REF } from "./model-select.js";
2
+ import { DEFAULT_MODEL_REF, servedModelTableLeg } from "./model-select.js";
3
3
  export const MODEL_ROUTE_ENDPOINT_MISSING_CODE = "config.model_route_endpoint_missing";
4
4
  export const GATEWAY_FALLBACK_WITHOUT_PRIMARY_CODE = "config.gateway_fallback_without_primary";
5
5
  export const MODEL_ROUTE_GAP_WORDS = ["endpoint_missing", "lane_credential_missing", "none"];
@@ -10,14 +10,19 @@ function declared(url) {
10
10
  export function openaiRouteBaseUrl(routeBaseUrl, gatewayBaseUrl) {
11
11
  return declared(routeBaseUrl) ?? declared(gatewayBaseUrl);
12
12
  }
13
- export function resolveOpenaiRouteEndpoint(routeName, routeBaseUrl, gatewayBaseUrl) {
14
- const baseUrl = openaiRouteBaseUrl(routeBaseUrl, gatewayBaseUrl);
15
- return baseUrl !== undefined ? { ok: true, baseUrl } : { ok: false, refusal: routeEndpointMissingRefusal([`"${routeName}"`]) };
13
+ function unresolvedRouteLabel(name, provider) {
14
+ return provider === "anthropic"
15
+ ? `"${name}" (provider "anthropic", but no Anthropic route is mounted — ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN are unset — so it falls to the openai-compatible lane)`
16
+ : `"${name}"`;
16
17
  }
17
- export function modelRouteEndpointRefusal(view) {
18
- return judgeModelRouteEndpoint(view)?.reason;
18
+ export function resolveOpenaiRouteEndpoint(routeName, route, gatewayBaseUrl, leg) {
19
+ const baseUrl = openaiRouteBaseUrl(route?.baseUrl, gatewayBaseUrl);
20
+ return baseUrl !== undefined ? { ok: true, baseUrl } : { ok: false, refusal: routeEndpointMissingRefusal([unresolvedRouteLabel(routeName, route?.provider)], leg) };
19
21
  }
20
- export function judgeModelRouteEndpoint(view) {
22
+ export function modelRouteEndpointRefusal(view, leg) {
23
+ return judgeModelRouteEndpoint(view, leg)?.reason;
24
+ }
25
+ export function judgeModelRouteEndpoint(view, leg) {
21
26
  const gateway = declared(view.gatewayBaseUrl);
22
27
  if (gateway === undefined && view.gatewayFallbackUrls.length > 0) {
23
28
  const reason = (`env MODEL_GATEWAY_FALLBACK_URLS is set but MODEL_GATEWAY_BASEURL is not [${GATEWAY_FALLBACK_WITHOUT_PRIMARY_CODE}] — ` +
@@ -38,28 +43,45 @@ export function judgeModelRouteEndpoint(view) {
38
43
  continue;
39
44
  if (openaiRouteBaseUrl(m.baseUrl, gateway) !== undefined)
40
45
  continue;
41
- const anthropicWordWithoutRoute = m.provider === "anthropic";
42
- laneCredentialMissing ||= anthropicWordWithoutRoute;
43
- unresolved.push(anthropicWordWithoutRoute
44
- ? `"${name}" (provider "anthropic", but no Anthropic route is mounted — ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN are unset — so it falls to the openai-compatible lane)`
45
- : `"${name}"`);
46
+ laneCredentialMissing ||= m.provider === "anthropic";
47
+ unresolved.push(unresolvedRouteLabel(name, m.provider));
46
48
  }
47
49
  if (unresolved.length === 0)
48
50
  return undefined;
49
- return { code: MODEL_ROUTE_ENDPOINT_MISSING_CODE, gap: laneCredentialMissing ? "lane_credential_missing" : "endpoint_missing", reason: routeEndpointMissingRefusal(unresolved) };
51
+ return { code: MODEL_ROUTE_ENDPOINT_MISSING_CODE, gap: laneCredentialMissing ? "lane_credential_missing" : "endpoint_missing", reason: routeEndpointMissingRefusal(unresolved, leg) };
50
52
  }
51
- function routeEndpointMissingRefusal(quotedNames) {
53
+ function routeEndpointMissingRefusal(quotedNames, leg) {
52
54
  const one = quotedNames.length === 1;
53
55
  const subject = one ? `model ${quotedNames[0]} rides` : `models ${quotedNames.join(", ")} ride`;
54
- return (`${subject} the openai-compatible lane but ${one ? "resolves" : "resolve"} to no endpoint ` +
55
- `[${MODEL_ROUTE_ENDPOINT_MISSING_CODE}] — ${one ? "it has no" : "none has a"} baseUrl of its own, and MODEL_GATEWAY_BASEURL is unset. ` +
56
- "MODEL_GATEWAY_BASEURL has no factory default: the former silent placeholder (this machine's port 8000) let such a " +
56
+ const head = `${subject} the openai-compatible lane but ${one ? "resolves" : "resolve"} to no endpoint [${MODEL_ROUTE_ENDPOINT_MISSING_CODE}] — `;
57
+ const noDefault = "MODEL_GATEWAY_BASEURL has no factory default: the former silent placeholder (this machine's port 8000) let such a " +
57
58
  "deployment start fine and then fail on the first model call, far from the cause (\"fetch failed … ECONNREFUSED\", or " +
58
- "a container dialling its own loopback when compose passes an empty value). " +
59
- `Set it explicitly — for a gateway on this machine: ${LOCAL_GATEWAY_HINT} — or give ${one ? "that model" : "each named model"} its own baseUrl in the catalog. ` +
60
- "(A model defined by MODEL_ID / MODEL_CHEAP_ID has no baseUrl of its own: it needs the gateway, or MODEL_PROVIDER=anthropic " +
61
- "with ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN. It is judged at boot even when a catalog will replace it later — it is what this " +
62
- "process serves whenever no catalog lands: the config source unreachable, a refused candidate, or zero enabled models — unless " +
63
- "CONFIG_REQUIRE_ROSTER=true on a remote registry declares it a placeholder that is never served.)");
59
+ "a container dialling its own loopback when compose passes an empty value). ";
60
+ switch (leg) {
61
+ case "catalog":
62
+ return (head +
63
+ `${one ? "it has no" : "none has a"} baseUrl of its own, and MODEL_GATEWAY_BASEURL is unset. ` +
64
+ noDefault +
65
+ `Set it explicitly — for a gateway on this machine: ${LOCAL_GATEWAY_HINT} — or give ${one ? "that model" : "each named model"} its own baseUrl in the catalog.`);
66
+ case "env":
67
+ return (head +
68
+ `${one ? "it is" : "they are"} defined by env (MODEL_ID / MODEL_CHEAP_ID), which gives ${one ? "it" : "them"} no baseUrl of its own, and MODEL_GATEWAY_BASEURL is unset. ` +
69
+ noDefault +
70
+ `This process serves the env model table because no catalog is in force (none configured, the config source unreachable, the candidate refused, or no enabled model in it). Pick one way out: ` +
71
+ `(1) set MODEL_GATEWAY_BASEURL explicitly — for a gateway on this machine: ${LOCAL_GATEWAY_HINT}; ` +
72
+ "(2) mount the Anthropic route — MODEL_PROVIDER=anthropic with ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN (plus ANTHROPIC_BASE_URL for a non-default endpoint); " +
73
+ "(3) leave MODEL_ID unset and let the default entry of a catalog (config.d/models.json, or the web-admin) serve — a catalog in force replaces the env model table. " +
74
+ "(With CONFIG_REQUIRE_ROSTER=true on a remote registry the env model is declared a placeholder that is never served, and is not judged here.)");
75
+ }
76
+ }
77
+ export function judgeServedModelRoute(config, opts) {
78
+ const leg = servedModelTableLeg(config);
79
+ const view = leg === "env" && opts.envTableIsPlaceholder ? { ...config, models: {} } : config;
80
+ return judgeModelRouteEndpoint(view, leg);
81
+ }
82
+ export function assertServedModelRoute(config, opts) {
83
+ const verdict = judgeServedModelRoute(config, opts);
84
+ if (verdict !== undefined)
85
+ throw new Error(verdict.reason);
64
86
  }
65
87
  //# sourceMappingURL=model-route-endpoint.js.map
@@ -81,8 +81,8 @@ export interface ModelRouteGapOptions {
81
81
  /**
82
82
  * 判官的读数(闭集三词,穷举 switch 消费):
83
83
  * · `none` —— 无缺口,仅此一键;
84
- * · 缺口两词 —— `code` = 起服拒启的机读码(与 `reason` 里的方括号码同一个词;`lane_credential_missing` 也带 `config.model_route_endpoint_missing`),
85
- * `reason` = 起服拒启句原文(点名条目与出路)。
84
+ * · 缺口两词 —— `code` = 机读码(与 `reason` 里的方括号码同一个词;`lane_credential_missing` 也带 `config.model_route_endpoint_missing`),
85
+ * `reason` = 起服判官对 env 表拒启时的句子原文(env 视图:点名条目与三条 env 出路)。
86
86
  */
87
87
  export type ModelRouteGapReading = {
88
88
  readonly gap: "none";
@@ -94,9 +94,10 @@ export type ModelRouteGapReading = {
94
94
  };
95
95
  }[ModelRouteGapBreach];
96
96
  /**
97
- * **起服前可调的模型路由缺口纯判官**(与起服判官同一实现;包根导出)。
97
+ * **起服前可调的模型路由缺口纯判官**(与起服判官判 env 表时同一实现、同一句拒因;包根导出)。答的是 **env 腿**:没有目录在管时,这份 env 起不起得来
98
+ * (目录在场时起服以目录为准,见文件头注)。
98
99
  *
99
- * @param env 一份 env 快照(只读 {@link MODEL_ROUTE_ENV_KEYS} 那八把键;`process.env` 也行 —— 起服段就是这样调的)。
100
+ * @param env 一份 env 快照(只读 {@link MODEL_ROUTE_ENV_KEYS} 那八把键;`process.env` 也行)。
100
101
  * @returns 三词之一;有缺口时带 `code` 与 `reason`(见 {@link ModelRouteGapReading})。
101
102
  */
102
103
  export declare function judgeModelRouteGap(env: ModelRouteEnv, opts?: ModelRouteGapOptions): ModelRouteGapReading;
@@ -72,7 +72,7 @@ export function judgeModelRouteGap(env, opts = {}) {
72
72
  gatewayFallbackUrls: r.gatewayFallbackUrls,
73
73
  anthropic: r.anthropicRouteMounted ? {} : undefined,
74
74
  models,
75
- });
75
+ }, "env");
76
76
  if (verdict === undefined)
77
77
  return { gap: "none" };
78
78
  return { gap: verdict.gap, code: verdict.code, reason: verdict.reason };
@@ -34,6 +34,41 @@ export declare function readAtMention(raw: unknown): AtMention | undefined;
34
34
  * `resolveModel("default", catalog)` 解它)。**单点**:读面要说「这台部署的主模型是谁」时读同一个词
35
35
  * (`auto-mode-face.ts` 的席解析器入参),两处各写一个 `"default"` 字面串正是漂移的成因。 */
36
36
  export declare const DEFAULT_MODEL_REF = "default";
37
+ /**
38
+ * **进程服务的是哪一张模型表**(闭集两词,消费方用穷举 switch —— 加一词不补臂 ⇒ 编译红)。路由端点判官与缺省模型判官的「视图词」:
39
+ * 拒因句的出路按视图说真话,不靠调用方拼句。
40
+ * · `env` —— env 表(`MODEL_ID` / `MODEL_CHEAP_ID` 铸的 `{default, [id]}`):**没有目录在管** —— 没配目录、配置源不可达且无 LKG、
41
+ * 候选被整拒、或候选没有一条启用条目。env 行永远没有自己的 `baseUrl`;
42
+ * · `catalog` —— 目录表:一份目录的模型面落地之后**整张换掉** env 表(`config.model = config.models.default`),引擎从不读 env `MODEL_ID`。
43
+ */
44
+ export declare const MODEL_TABLE_LEGS: readonly ["env", "catalog"];
45
+ export type ModelTableLeg = (typeof MODEL_TABLE_LEGS)[number];
46
+ /**
47
+ * 这个进程**此刻**服务的是哪一张表。读法只有一个事实:`config.model` 与 `config.envModel` 是不是同一个对象 ——
48
+ * `envModel` 在 `loadConfig` 铸一次、此后永不换,并与 `model` 在 boot 那一刻是同一只引用;`model` 的唯一写口是目录的模型面落地
49
+ * (`web-admin-client/apply-effective.ts` 的 commit 段,`config.model = config.models.default`)。所以「`model` 还是那只 env 模型」⇔
50
+ * 没有任何一份目录落过模型面 ⇔ 服务的是 env 表。不另记一个会与它漂开的「目录采纳了吗」旗子。
51
+ */
52
+ export declare function servedModelTableLeg(config: {
53
+ readonly model: object;
54
+ readonly envModel: object;
55
+ }): ModelTableLeg;
56
+ /**
57
+ * 目录服务、而 env 里还设着 `MODEL_ID` ⇒ 启动时说一行(沿 `config_env_set_but_ineffective` 通告族:值合法、却不参与服务)。
58
+ * 这不是拒:目录一旦被采纳就整张换掉 env 表,`MODEL_ID` 在这一形下本来就不是缺省来源(缺省来自目录)—— 运维该知道自己的那一行
59
+ * 没在服务,否则会以为改它有效。env 表服务(或没设 `MODEL_ID`)⇒ `undefined`。
60
+ *
61
+ * 不读 `process.env`:env 的读数在 `loadConfig` 铸 `envModel` 时已定(`envModel.id` 就是 `MODEL_ID`,空串 = 没设)。
62
+ * 返回 plain data(事件名 + 字段)。**唯一发声点** = `web-admin-client/apply-effective.ts` 的 `applyEffective` commit 之后、服务表由 env 换成目录的那一拍(进程一生至多一次):
63
+ * 启动(含 LKG / 按组回落)、`run-local`、迟到 boot、refresh 都经那一个函数,「恰好一次」是构造出来的,没有去重表。
64
+ */
65
+ export declare function buildEnvModelIdNotServedNotice(config: {
66
+ readonly model: Pick<Model, "id" | "name">;
67
+ readonly envModel: Pick<Model, "id">;
68
+ }): {
69
+ readonly event: string;
70
+ readonly fields: Record<string, unknown>;
71
+ } | undefined;
37
72
  /**
38
73
  * 本仓给一条**没选模型**的 task 铸的主模型 —— {@link DEFAULT_MODEL_REF} 在**展开**目录上(与引擎构造期 `expandTiers(models, tiers)`
39
74
  * 之后再解逐字同源)解到的那只**目录条目**(对象引用)。`boot/resolve-spec.ts` 每条 spec 都盖 `spec.model`,所以引擎眼里的 main
@@ -153,6 +188,29 @@ export type ModelImageInput = (typeof MODEL_IMAGE_INPUTS)[number];
153
188
  export declare function modelImageInput(model: {
154
189
  input?: unknown;
155
190
  } | undefined): ModelImageInput;
191
+ /**
192
+ * 「这个目录条目的 `reasoning` 位声明了什么」的**唯一读法**:`true` / `false` 原样,**缺席 = 未声明 = `undefined`**(不替没声明的模型答「是」也不答「否」)。
193
+ * `GET /v1/models` 的 `reasoning` 列与 `GET /v1/config/catalog` 的 `MODEL_REASONING` 行都投影它。非布尔(手写错形)与条目不在,同归未声明:没有可信的声明。
194
+ */
195
+ export declare function modelReasoningDecl(model: {
196
+ reasoning?: unknown;
197
+ } | undefined): boolean | undefined;
198
+ /** 两个能力位的**声明**(三态:`true` / `false` / 缺席 = 未声明)—— 铸 `Model` 上对应键的输入,见 {@link mintCapabilityKeys}。 */
199
+ export interface ModelCapabilityDecl {
200
+ readonly reasoning?: boolean;
201
+ readonly vision?: boolean;
202
+ }
203
+ /**
204
+ * 能力位声明 ⇒ `Model` 上的两个键,**缺席不铸键**:`reasoning` 原样;`vision` 翻成 `input`(`true` ⇒ `["text","image"]`,`false` ⇒ `["text"]`)。
205
+ * env 腿(`MODEL_REASONING` / `MODEL_VISION`)、cheap 槽(`MODEL_CHEAP_*`)与中心腿(`models[].reasoning` / `vision`)都经它铸 —— 三条腿一个铸法、一个「缺席」的意思。
206
+ * 读回的唯一读法是 {@link modelReasoningDecl} 与 {@link modelImageInput}(带图预检与 `GET /v1/models` 同读后者)。
207
+ *
208
+ * 🔴 **本仓唯一的类型接缝**:引擎 7.51.2 的 `Model` 型面把 `reasoning` / `input` 写成必填,而它自己的读法对缺席有定义 ——
209
+ * `input` 缺席 ⇒ 支持图(`modelSupportsVision`:`model?.input === undefined ? true : …`),`reasoning` 缺席 ⇒ 真值判假(`!!model.reasoning`)。所以交给引擎的目录里缺席的键是安全的,
210
+ * 只是型面还没跟上;返回型因此对 `Pick<Model, …>` 说了一句「可能为空」的谎,把断言收在这一处 —— 调用点的 `Model` 字面量照旧带 `Model` 注解(其余每个键仍被 tsc 逐个核,
211
+ * 读表门按注解收字面量的 core 键)。型面归引擎:引擎把这两位放成可选之日,本接缝(返回型 + 这一处断言)整件删除。
212
+ */
213
+ export declare function mintCapabilityKeys(decl: ModelCapabilityDecl): Pick<Model, "reasoning" | "input">;
156
214
  /**
157
215
  * 带图预检:只有目录条目**明说**不收图({@link modelImageInput} 为 `"no"`)才为假 —— 提交时据此计数并告警(引擎会把这些图换成给模型的
158
216
  * 占位句);没声明的条目为真(不出信号,图原样交给模型)。
@@ -7,6 +7,25 @@ export function readAtMention(raw) {
7
7
  return r.success ? r.data : undefined;
8
8
  }
9
9
  export const DEFAULT_MODEL_REF = "default";
10
+ export const MODEL_TABLE_LEGS = ["env", "catalog"];
11
+ export function servedModelTableLeg(config) {
12
+ return config.model === config.envModel ? "env" : "catalog";
13
+ }
14
+ export function buildEnvModelIdNotServedNotice(config) {
15
+ if (servedModelTableLeg(config) !== "catalog" || config.envModel.id === "")
16
+ return undefined;
17
+ const served = config.model.name || config.model.id;
18
+ return {
19
+ event: "config_env_set_but_ineffective",
20
+ fields: {
21
+ env: "MODEL_ID",
22
+ raw: config.envModel.id,
23
+ problem: `a catalog is in force and replaces the env model table — the catalog default "${served}" serves this process, and MODEL_ID (with MODEL_CHEAP_ID) takes no part in it; remove the env line or point the catalog's default at the model you mean`,
24
+ servedBy: "catalog",
25
+ catalogDefault: served,
26
+ },
27
+ };
28
+ }
10
29
  export function resolveMainModel(plane) {
11
30
  const expanded = expandTiers(plane.models, plane.tiers ?? {}) ?? plane.models;
12
31
  return coreResolveTaskModel({ model: DEFAULT_MODEL_REF }, { ...(expanded !== undefined ? { models: expanded } : {}) }).model;
@@ -101,6 +120,18 @@ export function modelImageInput(model) {
101
120
  return "undeclared";
102
121
  return model.input.includes("image") ? "yes" : "no";
103
122
  }
123
+ export function modelReasoningDecl(model) {
124
+ return typeof model?.reasoning === "boolean" ? model.reasoning : undefined;
125
+ }
126
+ const CAPABILITY_MODEL_KEYS = { reasoning: "reasoning", input: "input" };
127
+ export function mintCapabilityKeys(decl) {
128
+ const keys = {};
129
+ if (decl.reasoning !== undefined)
130
+ keys[CAPABILITY_MODEL_KEYS.reasoning] = decl.reasoning;
131
+ if (decl.vision !== undefined)
132
+ keys[CAPABILITY_MODEL_KEYS.input] = decl.vision ? ["text", "image"] : ["text"];
133
+ return keys;
134
+ }
104
135
  export function modelSupportsImages(model) {
105
136
  return modelImageInput(model) !== "no";
106
137
  }
@@ -9,7 +9,7 @@
9
9
  * **调它的面**(点名,不是「每个面」):运维三张名表(`APPROVAL_REQUIRE` / `DENY` / `NEVER_AUTO`)与中心下发的同名表、请求
10
10
  * `settings.permissions.{ask,deny}`(经 `config.ts` 的 `unmatchableToolNamesRefusal`)。会话规则写口直接调 core 的 `assertSessionRuleNamesCurrent`
11
11
  * (同一只判官的店形);引擎 allow 层的条目表(请求 `settings.permissions.allow`、运维 `APPROVAL_GRANT`、技能两只清单)交引擎 `compileAllowList`
12
- * (名册 = {@link NO_DECLARED_TOOL_NAMES};判官判非现役的条目读成 `tool_name`)。
12
+ * (名册 = {@link TOOL_NAME_ROSTER};判官判非现役的条目读成 `tool_name`,名册里有的只差大小写读成 `near_miss`)。
13
13
  * **不调它的面**:`/v1/rules` 的持久规则写口与 `cc-import` 导入口(`http/routes/rules.ts`、`rules-consent.ts` 与两只持久规则店零判官调用)—— 如实未接,
14
14
  * 在那里写铸不出的服务器段仍被收下、永不命中;把它接上判官 = 另一件事(`unmatchableRuleNameOf` 就是现成的那一只)。
15
15
  * 子代的工具名表 `agents[].allowTools` / `denyTools` 不是本文件的面:core 7.45.0 #1377 / #1385 起名字归引擎在开跑前一只判(三来源同判,`spec-fields.ts`
@@ -28,10 +28,13 @@ import { type RuleToolNameVerdict } from "@sema-agent/core";
28
28
  */
29
29
  export declare function ruleEntryToolName(entry: string): string;
30
30
  /**
31
- * 判官的名册:本仓的配置表 / 请求表 / 会话规则写口都不声明「部署在退役名下挂了自有工具」(本仓没有这样的工具;boot / 请求期也没有哪一跑的名册可看)
32
- * ⇒ 恒为空集。引擎判官、`compileAllowList` 的第三参、会话规则店同读这一只。
31
+ * **名册 —— 本仓唯一的一张**:引擎目录的全部工具名(`catalogToolNames()`:引擎自己出的每一只工具的线上名,别名在内)∪ 本仓自己铸给模型的工具名({@link HOST_TOOL_NAMES}:取自铸它们的那一处的常量,不手抄)。
32
+ * 引擎判官 `judgeRuleToolName` 与 `compileAllowList` 的第三参读它:名册里有的名字是「现役」,条目只差大小写的读成 `near_miss`(`BASH` ⇒ 现名 `Bash`、`SENDUSERFILE` ⇒ `SendUserFile`),
33
+ * 退役名 / 无前缀短名 / 对端段铸不出的名字照旧读成 `tool_name`。引擎在开跑时读的是这一 run 真挂着的名册 —— 受理口读不到它,读得到的就是上面这两份:任何部署都带的引擎工具,加本仓自己挂的工具。
34
+ * 每一处读条目名的地方(请求体的规则表与工具名清单、技能清单、运维 / 中心的审批名单、会话规则写口)传这一张;**读成空 = 放行 / 静默收窄**的那些表因此不再有「空名册读不出孪生」的一读。
35
+ * 界:调用方自带的宿主工具(嵌入方 `createHttpServer` 之外自挂的)不在其中 —— 它们的孪生仍只由引擎在开跑时告知运维。
33
36
  */
34
- export declare const NO_DECLARED_TOOL_NAMES: ReadonlySet<string>;
37
+ export declare const TOOL_NAME_ROSTER: ReadonlySet<string>;
35
38
  /** 一个规则名永远匹配不到工具的判词(闭集)= 引擎判官的非现行判词。 */
36
39
  export type UnmatchableRuleName = Exclude<RuleToolNameVerdict, {
37
40
  kind: "current";
@@ -1,11 +1,12 @@
1
- import { PROTOCOL_TABLE, judgeRuleToolName, parsePermissionRule } from "@sema-agent/core";
1
+ import { PROTOCOL_TABLE, catalogToolNames, judgeRuleToolName, parsePermissionRule } from "@sema-agent/core";
2
+ import { HOST_TOOL_NAMES } from "./capabilities/host-tool-names.js";
2
3
  import { displayListEntry } from "./list-entry-display.js";
3
4
  export function ruleEntryToolName(entry) {
4
5
  return parsePermissionRule(entry).toolName;
5
6
  }
6
- export const NO_DECLARED_TOOL_NAMES = new Set();
7
+ export const TOOL_NAME_ROSTER = new Set([...catalogToolNames(), ...HOST_TOOL_NAMES]);
7
8
  export function unmatchableRuleNameOf(name) {
8
- const verdict = judgeRuleToolName(name, NO_DECLARED_TOOL_NAMES);
9
+ const verdict = judgeRuleToolName(name, TOOL_NAME_ROSTER);
9
10
  return verdict.kind === "current" ? undefined : verdict;
10
11
  }
11
12
  export function describeUnmatchableRuleName(v, name) {
@@ -93,7 +93,7 @@ export declare const FAIL_OPEN_TAGS: {
93
93
  };
94
94
  readonly "server.hitl.frame-undelivered-stream-closed": {
95
95
  readonly cls: "P-DEBT";
96
- readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
96
+ readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族,且只在**无账本孪生**(无 durable run 行)的部署上——有孪生的腿直写面已死时帧在账本里、不算丢——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
97
97
  };
98
98
  readonly "server.stream.frame-dropped-stream-closed": {
99
99
  readonly cls: "F";
@@ -243,6 +243,10 @@ export declare const FAIL_OPEN_TAGS: {
243
243
  readonly cls: "F";
244
244
  readonly note: "S-995(`hooks/hook-runner.ts` 的 `toolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 形只问契约包的 `hookMatcherForm`(literal 形〔名字表 / 空 / `*`〕两档都直接问 `hookMatcherSelects`、不占预算;regex 形 regex 档把同一只契约函数交进 vm 带每次求值各一份的固定预算〔超时 = 灾难回溯 ⇒ 确定的 400 拒,不是未判、不登记〕、literal 档不适用;invalid 形 regex 档 = 确定的 400 拒〔S-1455 收尾:不是未判、不登记;运行期另记 `hook_matcher_invalid`〕、literal 档不适用)。现在只有一种 reason —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)。`invalid_regex` 臂自 S-1455 收尾(7.126.0)起退役:它是确定性的坏配置、不是「判不了」,与超预算同类 ⇒ 受理当场 400。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(超长 matcher)显形。";
245
245
  };
246
+ readonly "server.hooks.resume-reader-refused": {
247
+ readonly cls: "P-DEBT";
248
+ readonly note: "续跑重放(`task-settings.ts` 的 `parseTaskSettings`,经 `boot/resolve-spec.ts` 装配)读存下的请求体里的 `settings.hooks`,而 hooks 配置的唯一读者(契约包分类器,`hooks/hook-runner.ts` 的 `parseHooksConfig`)判不过、也没被一次性迁移 M20 接住(窗已关,或拒因不属 M20 的两形 —— 藏着守卫迹象的未知字段 / JSON `__proto__` 事件键)⇒ 这份 hooks **整只不采纳**(`task_settings_deferred` 记账),任务照常续跑 —— 其中若有真在拦的 `PreToolUse` 钩子,续跑后不再拦。M20 窗开着时那两形不进这一臂(剥键重读、守卫照跑,一行 `hooks_read_as_before_upgrade`)。只在 hooks 会被采纳的单用户车道计数(多租户整只忽略 hooks,无物可丢);fresh 提交不进这一臂(受理面 400)。放行的最坏后果:升级前停驻、体里 hooks 带那两形之一的任务,在窗关之后才续跑的(发布后才升级的部署、调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的部署、滚动升级里旧副本在窗内新写的行),或体里 hooks 带别的坏形的,失去这份 hooks 里原本真在拦的钩子。方向已裁:持久数据面走 M20 窗内迁移,窗关后整份不采纳 + 本计数(存量停驻行此时已被收割,计数应趋零)。detail = `leg=`。计数非零 = 有任务带着被读者拒掉的存量 hooks 在续跑。";
249
+ };
246
250
  readonly "server.hooks.notice-sink-threw": {
247
251
  readonly cls: "F";
248
252
  readonly note: "#281:hook 观测回调(`onHookNotice`,部署把它接到 fleet 流)自己抛了。放行的最坏后果 = **这一条观测帧**缺席;补偿是结构性的:①日志终点在投递之前就已逐字落定(`hook_command_failed` / `hook_command_timeout` / `hook_command_spawn_failed` 一族),运维面零回退;②观测帧不参与任何门/裁决(纯 observe,#281 全族判据里「裁决面一个字节不动」是承重条款),故 F 类。不放行的代价严重不对称:异常会掀回 core 的 hook 回调栈,把一次**观察动作**变成一次**工具调用失败** —— 「观察一件事的动作反过来改变了那件事」正是本 tag 要根除的形,而它伤的恰恰是 hook 已经坏掉、用户最需要任务照跑的那一刻。留痕承重:静默吞掉之后「这台机器为什么一条 hook 通知都没有」在遥测里与「hook 从来没坏过」同形。";
@@ -474,11 +478,12 @@ export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
474
478
  * 只有 open 帧是"人在环这道门被跳过"的证据,其余都是体验损失。
475
479
  *
476
480
  * ⚠️ **两条 question 臂在生产上已不可达**([ref] 之后,2026-08-07):本函数唯一的生产调用点是
477
- * `routes/tasks.ts` 的 `emitAsk` 断流臂,而 question 与 question_complete 两型帧都先过同文件
478
- * `emitQuestion` 的断流守卫(两道守卫同步相邻、其间无 await)并在断流时 throw,永不抵达这里;另一条
479
- * question 路(`runs.ts` 的 durable append)根本不经 emitAsk。在产的只剩 elicitation 族与 default。
480
- * 两臂**保留不删**:它们是本映射的语义定义(单测直打的纯函数),elicit 侧若哪天照 question 收紧、或
481
- * 新增第四条 emit 腿忘了守卫,留着的臂是正确落点而不是死码。读遥测时按上面这段判在产分布。
481
+ * `routes/tasks.ts` 的 `emitAsk` 断流臂,而 question 与 question_complete 两型帧的直写口
482
+ * (同文件 `writeQuestionLive`)先过断流守卫(两道守卫同步相邻、其间无 await)并在断流时 throw,永不抵达这里;
483
+ * 另一条 question 路(`runs.ts` 的 durable append)根本不经 emitAsk。elicitation 族自账本孪生起也只在**无 durable
484
+ * run 行**的部署上抵达这里(有孪生的腿直写面已死时孪生即送达,不碰死连接)。在产的只剩那一形与 default。
485
+ * 两臂**保留不删**:它们是本映射的语义定义(单测直打的纯函数),新增第四条 emit 腿忘了守卫时,
486
+ * 留着的臂是正确落点而不是死码。读遥测时按上面这段判在产分布。
482
487
  */
483
488
  export declare function failOpenTagForDroppedFrame(frameType: string): FailOpenTag;
484
489
  /** {@link createFailOpenRecorder} 返回的活对象(有行为、有状态 ⇒ `create*` 而非 `build*`)。 */
@@ -74,7 +74,7 @@ export const FAIL_OPEN_TAGS = {
74
74
  },
75
75
  "server.hitl.frame-undelivered-stream-closed": {
76
76
  cls: "P-DEBT",
77
- note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
77
+ note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族,且只在**无账本孪生**(无 durable run 行)的部署上——有孪生的腿直写面已死时帧在账本里、不算丢——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
78
78
  },
79
79
  "server.stream.frame-dropped-stream-closed": {
80
80
  cls: "F",
@@ -224,6 +224,10 @@ export const FAIL_OPEN_TAGS = {
224
224
  cls: "F",
225
225
  note: "S-995(`hooks/hook-runner.ts` 的 `toolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 形只问契约包的 `hookMatcherForm`(literal 形〔名字表 / 空 / `*`〕两档都直接问 `hookMatcherSelects`、不占预算;regex 形 regex 档把同一只契约函数交进 vm 带每次求值各一份的固定预算〔超时 = 灾难回溯 ⇒ 确定的 400 拒,不是未判、不登记〕、literal 档不适用;invalid 形 regex 档 = 确定的 400 拒〔S-1455 收尾:不是未判、不登记;运行期另记 `hook_matcher_invalid`〕、literal 档不适用)。现在只有一种 reason —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)。`invalid_regex` 臂自 S-1455 收尾(7.126.0)起退役:它是确定性的坏配置、不是「判不了」,与超预算同类 ⇒ 受理当场 400。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(超长 matcher)显形。",
226
226
  },
227
+ "server.hooks.resume-reader-refused": {
228
+ cls: "P-DEBT",
229
+ note: "续跑重放(`task-settings.ts` 的 `parseTaskSettings`,经 `boot/resolve-spec.ts` 装配)读存下的请求体里的 `settings.hooks`,而 hooks 配置的唯一读者(契约包分类器,`hooks/hook-runner.ts` 的 `parseHooksConfig`)判不过、也没被一次性迁移 M20 接住(窗已关,或拒因不属 M20 的两形 —— 藏着守卫迹象的未知字段 / JSON `__proto__` 事件键)⇒ 这份 hooks **整只不采纳**(`task_settings_deferred` 记账),任务照常续跑 —— 其中若有真在拦的 `PreToolUse` 钩子,续跑后不再拦。M20 窗开着时那两形不进这一臂(剥键重读、守卫照跑,一行 `hooks_read_as_before_upgrade`)。只在 hooks 会被采纳的单用户车道计数(多租户整只忽略 hooks,无物可丢);fresh 提交不进这一臂(受理面 400)。放行的最坏后果:升级前停驻、体里 hooks 带那两形之一的任务,在窗关之后才续跑的(发布后才升级的部署、调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的部署、滚动升级里旧副本在窗内新写的行),或体里 hooks 带别的坏形的,失去这份 hooks 里原本真在拦的钩子。方向已裁:持久数据面走 M20 窗内迁移,窗关后整份不采纳 + 本计数(存量停驻行此时已被收割,计数应趋零)。detail = `leg=`。计数非零 = 有任务带着被读者拒掉的存量 hooks 在续跑。",
230
+ },
227
231
  "server.hooks.notice-sink-threw": {
228
232
  cls: "F",
229
233
  note: "#281:hook 观测回调(`onHookNotice`,部署把它接到 fleet 流)自己抛了。放行的最坏后果 = **这一条观测帧**缺席;补偿是结构性的:①日志终点在投递之前就已逐字落定(`hook_command_failed` / `hook_command_timeout` / `hook_command_spawn_failed` 一族),运维面零回退;②观测帧不参与任何门/裁决(纯 observe,#281 全族判据里「裁决面一个字节不动」是承重条款),故 F 类。不放行的代价严重不对称:异常会掀回 core 的 hook 回调栈,把一次**观察动作**变成一次**工具调用失败** —— 「观察一件事的动作反过来改变了那件事」正是本 tag 要根除的形,而它伤的恰恰是 hook 已经坏掉、用户最需要任务照跑的那一刻。留痕承重:静默吞掉之后「这台机器为什么一条 hook 通知都没有」在遥测里与「hook 从来没坏过」同形。",
@@ -12,7 +12,7 @@ import { carriedIncarnation, mintSessionIncarnation, sessionFaceFromRow } from "
12
12
  import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
13
13
  import { noteReplayInSessionOpenScope, openSessionForItself } from "../session-records-skipped-notice.js";
14
14
  import { createCorruptReadFailOpenSeat } from "../observability/corrupt-read-seat.js";
15
- import { buildObjectivePreview } from "../user-authored-text.js";
15
+ import { buildObjectivePreview, isAttachmentFrame } from "../user-authored-text.js";
16
16
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
17
17
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
18
18
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -21,7 +21,7 @@ function userText(entry) {
21
21
  if (entry.type !== "message")
22
22
  return null;
23
23
  const msg = entry.message;
24
- if (msg.role !== "user")
24
+ if (msg.role !== "user" || isAttachmentFrame(msg))
25
25
  return null;
26
26
  const c = msg.content;
27
27
  if (typeof c === "string")
@@ -181,6 +181,15 @@
181
181
  * 停驻的行)、把 `APPROVAL_TERMINAL_BACKSTOP_MS` 调长的部署、滚动升级里仍跑 7.129.x 的副本在窗内新写的行。与 M7 / M18 一样如实登记,不为它们加长窗。
182
182
  * **它是上游字段标准的限时例外**:引擎对 `toolAxes` 的标准是「坏键不丢、坏词不默认」(core `mcp-server-spec.d.ts`),窗内这一臂恰恰剔坏留好 ——
183
183
  * 依据是持久数据律(存量数据不能「变红」,只许一次性迁移、窗关删码),只对升级前已经按这种读法跑过的存量体,且对运维响亮。新提交与部署腿不受它影响。
184
+ * · **M20 续跑重放腿上的存量 `settings.hooks`**(7.131.0)—— hooks 配置的唯一读者是契约包的 `classifyHooksConfig`(经 `parseHooksConfig`);升级前的读者(12.x zod 面)对两形照收:
185
+ * 条目 / 匹配器上**契约不认识的字段里藏着守卫迹象**(未知字段被剥掉,同份里真在拦的 `PreToolUse` 钩子照拦)与 JSON 写出的 **`__proto__` 事件键**(被跳过)。现读者对这两形整份拒,
186
+ * 续跑重放腿重放的是升级前存下的体 ⇒ 停驻任务续跑后整份 hooks 不采纳、守卫不再拦。与 M19 同形:不改写存下的字节,是一只**窗内才生效的行为臂** —— 窗开着时(运行期读本表 M20 行的
187
+ * `removableAfter`,见 {@link migrationWindowOpen}),**只在续跑重放腿**、**只在会采纳 hooks 的单用户车道**、**只对读者拒因全部属这两形**的存体:把读者点名的那一块(条目 / 匹配器)里按类型确属未知的键
188
+ * 一次全剥(12.x 读者对一个条目本来就是剥掉全部未知字段、保留其余),剩下的文档**重新过同一只读者**,过了就采纳;整次重读恰一行运维 warn `hooks_read_as_before_upgrade`(点名 sessionId 与被剥的坐标)。
189
+ * **不复制旧读者**:`stored-request-hooks-m20.ts` 不判值形、不判整份是否合法(那是读者的答案),只做「读者点名了哪些块、这一块里哪些键按它的类型确属未知、整块剥掉再问一次,剥到读者受理为止」;
190
+ * 「确属未知」按该条目自己的 `type` 取契约字段名表(不用各型字段的并集,表由契约类型面穷举)。拒因里混进别的(真坏的守卫条目、点名了本型契约字段的拒因 …)⇒ 不迁移,整份照拒。
191
+ * 窗关之后 ⇒ 续跑整份不采纳,fail-open 登记 `server.hooks.resume-reader-refused` 照打(可见);fresh 腿不受影响(受理面照 400)。没有起服读数(它不写库)。
192
+ * 窗 = 发布日 + 30 天 = 停驻行的终局兜底窗缺省 —— 与 M19 同一条论证与同三类逃出窗的部署(发布后才升级的、调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的、滚动升级里旧副本在窗内新写的行),如实登记。
184
193
  *
185
194
  * ## 幂等
186
195
  * 改写的几条都可重复跑(M4:`detail IS NOT NULL AND … = 'null'`;M5:列在场探针 + `WHERE incarnation = ''`;M6:`WHERE error_code IS NULL`;
@@ -306,7 +315,7 @@ export declare const ONE_TIME_MIGRATIONS: readonly OneTimeMigrationWindow[];
306
315
  /**
307
316
  * 一条登记行的窗**此刻**开着没有(`today` = UTC 日历日 YYYY-MM-DD):未发布形(`removableAfter: null`)⇒ 开;否则今天不晚于 `removableAfter` ⇒ 开
308
317
  * (窗关当天仍开,与到期门「今天严格晚于才红」同一条边界)。
309
- * 只给**窗内才生效的行为臂**用(M10 的认领、M19 的续跑重读):那种臂过了窗就停,不等删码车 —— 删码因此是零行为变化,而窗只写在本表这一处。
318
+ * 只给**窗内才生效的行为臂**用(M10 的认领、M19 / M20 的续跑重读):那种臂过了窗就停,不等删码车 —— 删码因此是零行为变化,而窗只写在本表这一处。
310
319
  */
311
320
  export declare function migrationWindowOpen(window: Pick<OneTimeMigrationWindow, "removableAfter">, today: string): boolean;
312
321
  /** local 车道上 SQL 独有的几条(M4 / M5 / M8 / M9 / M11 / M17 / M18)的读数(M6 两车道都有,local 腿的读数由 `LocalBackend.runOneTimeMigrations` 现算):那几张表 / 列在文件底座上从来不存在 ⇒ 如实答 absent / 0。 */
@@ -149,7 +149,7 @@ export const ONE_TIME_MIGRATIONS = [
149
149
  removal: "删模式词读法记号的判据与拒臂:`http/stored-body-replay.ts` 的 `permissionModeReadingUnstated`;`http/checkpoint-ctx.ts` 的 `permissionModeReadingStated` / `permissionModeReadingRefusal` / `PERMISSION_MODE_READINGS`、" +
150
150
  "`StoredBodyReplayRefusal` 的 `permission_mode_reading` 臂及其日志字段与答复臂、`buildCheckpointCtx` 与 `withPermissionModeRewritten` 两处记号写点" +
151
151
  "(后者删后 `resume-legs.ts` `drivePlanReview` 里对它的那一处调用回到只换体的 `{ ...ctx, body }`)、`CheckpointCtx.permissionModeReading`、" +
152
- "`http/stored-body-replay.ts` `storedBodyReplayRefusal` 的 `autonomy` 形参(只服务这一问)与四个问点(`resume-legs.ts` 两处、`approval-sweeps.ts`、`routes/runs.ts`)传 `deps.config.autonomy` 的那一句",
152
+ "`http/stored-body-replay.ts` 里 `StoredReplayDeployment` 的 `autonomy` 位与 `storedBodyReplayRefusal` 读 `deployment.autonomy` 的那一句(这一位只服务这一问;四个问点传 `deps.config` 整份,home 位还服务 `permissions` 一问,不动)",
153
153
  afterRemoval: "没有响亮臂:删码之后仍停驻、在 7.121.x 及以前写下的行(把 `APPROVAL_TERMINAL_BACKSTOP_MS` 调长的部署,或跳过了带本条的版本直接升的库)在设了 " +
154
154
  "`AUTONOMY=plan` 的部署上按体里的原词重放(运维的 plan 不再被按段重钳)—— 升级前把这些停驻的 run 决掉 / 取消,或升级窗内设 `AUTONOMY=read-only`",
155
155
  reportKeys: [],
@@ -258,6 +258,21 @@ export const ONE_TIME_MIGRATIONS = [
258
258
  "调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的部署、滚动升级里旧副本在窗内新写的行(头注 M19 段)",
259
259
  reportKeys: [],
260
260
  },
261
+ {
262
+ id: "M20",
263
+ introducedIn: "7.131.0",
264
+ releasedOn: "2026-10-16",
265
+ removableAfter: "2026-11-15",
266
+ removal: "删 `src/stored-request-hooks-m20.ts` 整只文件(`storedHooksRereadM20` / `rereadStoredHooksM20` / `StoredHooksRereadM20` / `ENTRY_FIELD_NAMES` / `MATCHER_FIELD_NAMES` / `nodeAt` / `cloneDoc` / `removeKeyAt`);" +
267
+ "`boot/resolve-spec.ts` 里递给 `parseTaskSettings` 的重读座实参(`opts?.leg === \"fresh\" || !taskHooksHonored(config) ? {} : { storedHooksReread: storedHooksRereadM20(…) }`)与它的 import、" +
268
+ "`hooks_read_as_before_upgrade` 那一段 warn;`task-settings.ts` 的 `ParseTaskSettingsSeats` / `ParseTaskSettingsResult.hooksReread` / `parseTaskSettings` 第二形参与 hooks 臂里的重读那一支;" +
269
+ "`hooks/hook-runner.ts` 的 `ParseHooksConfigResult.pieces` / `HooksRefusalPiece`(若再无别的读者)与 `hooksCoordinate` 的 export;" +
270
+ "`test/hooks-stored-reread-m20.test.ts`(窗关那一格 —— 整份不采纳 + `server.hooks.resume-reader-refused` 计一笔 —— 改钉到 `test/hooks-entry-admission.test.ts` 的续跑腿组)",
271
+ afterRemoval: "没有新的臂,接住它的是今天的读法(删码零行为变化:窗关那一刻起重读臂已经不生效):升级前存下的请求体里带这两形的 `settings.hooks`,续跑重放时整份不采纳(其中真在拦的 `PreToolUse` 钩子也不再拦)," +
272
+ "运维 warn `task_settings_deferred`(字段 `hooks`)+ fail-open 登记 `server.hooks.resume-reader-refused`(计数 + 探针 + 一次性 warn,census 第 140 行),任务照常续跑 —— " +
273
+ "出路是把这类停驻任务决掉、删掉那个未知字段 / `__proto__` 键后重新提交。会撞上它的是活过窗的升级前停驻行:发布后才升级的部署、调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的部署、滚动升级里旧副本在窗内新写的行(头注 M20 段)",
274
+ reportKeys: [],
275
+ },
261
276
  ];
262
277
  export function migrationWindowOpen(window, today) {
263
278
  return window.removableAfter === null || today <= window.removableAfter;
@@ -23,20 +23,29 @@ export interface QuestionFrame {
23
23
  /** "question" only: `serverNowMs + expiresInMs` — absolute deadline on the server's clock. */
24
24
  expiresAtMs?: number;
25
25
  }
26
- /** The per-run context `onQuestion` recovers via ALS. `emit` delivers a frame to the run's live stream; `abortSignal`
27
- * (the run's own cancel signal) releases a question parked awaiting a human when the run aborts. */
26
+ /** The per-run context `onQuestion` recovers via ALS. `emit` delivers a frame to the run's stream; `abortSignal`
27
+ * (the run's own cancel signal) releases a question parked awaiting a human when the run aborts.
28
+ *
29
+ * 🔴 ONE LAW for every leg: a question frame that enters a run's stream has a LEDGER TWIN (the row
30
+ * `GET /v1/runs/:id/events` replays — a page reload recovers the open card from it); the leg decides only whether it
31
+ * ALSO writes a live socket. The background leg has no socket, so its `emit` IS the durable append; the synchronous
32
+ * stream leg lands the same frame in the ledger first and then writes the SSE connection (`createLedgerTwinEmit`).
33
+ * The ledger carries the CARD, not the answer door: `POST /v1/questions/:id/respond` stays live-only (this replica's
34
+ * memory, `ttlMs`), so a replayed card is answerable only until its own `expiresAtMs` and only on this replica. */
28
35
  export interface QuestionRunContext {
29
36
  taskId: string;
30
37
  owner: string | null;
31
38
  emit: (frame: QuestionFrame) => void | Promise<void>;
32
39
  abortSignal?: AbortSignal;
33
- /** OPTIONAL reachability predicate for this leg's delivery face, consulted by {@link QuestionCoordinator.hasLiveContext}
34
- * (the park-vs-live adjudication probe), NOT by `question` itself. CONSTRAINT: a leg whose face can go away WHILE the
35
- * leg keeps running MUST supply it — `/v1/tasks/stream` under `x-detach-on-disconnect: true` keeps executing inside
36
- * this ALS scope after the client left, and `emit` there writes to a dead socket and is silently dropped (question
37
- * frames have no durable ledger twin, unlike SendUserFile's ledgerSink). Without the predicate the adjudication would
38
- * read "live" and hand the leg to a face nobody can reach. ABSENT ⇒ always reachable, which is exactly right for the
39
- * background leg (emit appends to the durable events tail, readable whenever a client attaches). */
40
+ /** OPTIONAL reachability predicate for this leg's LIVE delivery face — one reading, two consumers:
41
+ * {@link QuestionCoordinator.hasLiveContext} (the park-vs-live adjudication probe) and the OPEN frame itself (a face
42
+ * that cannot show the card right now refuses the ask on the spot, the same failed-delivery arm, instead of parking it
43
+ * to TTL on a ledger row nobody is watching). CONSTRAINT: a leg whose face can go away WHILE the leg keeps running MUST
44
+ * supply it — `/v1/tasks/stream` under `x-detach-on-disconnect: true` keeps executing inside this ALS scope after the
45
+ * client left. Without the predicate the adjudication would read "live" and hand the leg to a face nobody can reach.
46
+ * ABSENT ⇒ always reachable, which is exactly right for the background leg (its face IS the durable events tail,
47
+ * readable whenever a client attaches). The CLOSE frame never asks it: it is a record, and it lands in the ledger twin
48
+ * whether or not the socket is still there. */
40
49
  deliverable?: () => boolean;
41
50
  }
42
51
  /** The per-run question window the deployment owns (core caps nothing). Env: `QUESTION_MAX_CONCURRENT_PER_RUN` /