@sema-agent/server 7.129.1 → 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 (117) hide show
  1. package/MIGRATION.md +102 -0
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +48 -9
  5. package/dist/audit.js +4 -1
  6. package/dist/bench/s1/live-deps.js +2 -0
  7. package/dist/boot/resolve-spec.js +43 -16
  8. package/dist/boot/stage-02-web-admin-pull.js +9 -1
  9. package/dist/boot/stage-10-http-server.d.ts +1 -1
  10. package/dist/boot/stage-10-http-server.js +7 -2
  11. package/dist/capabilities/builtin-tools.js +2 -1
  12. package/dist/capabilities/composite-members.d.ts +1 -0
  13. package/dist/capabilities/composite-members.js +1 -0
  14. package/dist/capabilities/hands-lane.js +2 -2
  15. package/dist/capabilities/host-tool-names.d.ts +17 -0
  16. package/dist/capabilities/host-tool-names.js +15 -0
  17. package/dist/capabilities/mcp-probe.js +19 -6
  18. package/dist/capabilities/repo-tools.js +4 -3
  19. package/dist/capabilities/scenarios.d.ts +34 -0
  20. package/dist/capabilities/scenarios.js +20 -4
  21. package/dist/capabilities/select-environment-tool.js +2 -1
  22. package/dist/capabilities/send-user-file-tool.d.ts +0 -1
  23. package/dist/capabilities/send-user-file-tool.js +2 -2
  24. package/dist/capabilities/skills.js +1 -1
  25. package/dist/capabilities/tool-defer.js +2 -1
  26. package/dist/config-catalog.js +16 -5
  27. package/dist/config-invariants.js +2 -2
  28. package/dist/config-provider.js +2 -2
  29. package/dist/config-types.d.ts +6 -0
  30. package/dist/config.d.ts +2 -2
  31. package/dist/config.js +18 -17
  32. package/dist/elicitation.d.ts +7 -0
  33. package/dist/elicitation.js +20 -5
  34. package/dist/exec-bounds.d.ts +6 -6
  35. package/dist/hooks/hook-llm.d.ts +11 -1
  36. package/dist/hooks/hook-llm.js +11 -14
  37. package/dist/hooks/hook-runner.d.ts +55 -9
  38. package/dist/hooks/hook-runner.js +86 -16
  39. package/dist/http/admission.js +2 -2
  40. package/dist/http/approval-sweeps.js +2 -2
  41. package/dist/http/checkpoint-ctx.d.ts +12 -11
  42. package/dist/http/checkpoint-ctx.js +4 -4
  43. package/dist/http/dispatch.js +1 -1
  44. package/dist/http/resume-legs.js +6 -5
  45. package/dist/http/routes/approvals-assistant.js +1 -1
  46. package/dist/http/routes/capabilities.js +13 -5
  47. package/dist/http/routes/runs.js +18 -14
  48. package/dist/http/routes/sessions.js +22 -10
  49. package/dist/http/routes/tasks.js +16 -11
  50. package/dist/http/scenario-read-face.d.ts +14 -0
  51. package/dist/http/scenario-read-face.js +5 -0
  52. package/dist/http/send.d.ts +10 -0
  53. package/dist/http/send.js +6 -0
  54. package/dist/http/server.d.ts +16 -4
  55. package/dist/http/server.js +2 -2
  56. package/dist/http/stored-body-replay.d.ts +10 -3
  57. package/dist/http/stored-body-replay.js +27 -6
  58. package/dist/http/wire-types.d.ts +18 -5
  59. package/dist/index.d.ts +2 -1
  60. package/dist/index.js +2 -1
  61. package/dist/mcp-assertion-header.d.ts +2 -2
  62. package/dist/mcp-assertion-header.js +3 -3
  63. package/dist/model-entry-refusal.d.ts +1 -7
  64. package/dist/model-route-endpoint.d.ts +51 -11
  65. package/dist/model-route-endpoint.js +45 -23
  66. package/dist/model-route-gap.d.ts +5 -4
  67. package/dist/model-route-gap.js +1 -1
  68. package/dist/model-select.d.ts +58 -0
  69. package/dist/model-select.js +31 -0
  70. package/dist/namespaced-rule-name.d.ts +7 -4
  71. package/dist/namespaced-rule-name.js +4 -3
  72. package/dist/observability/fail-open.d.ts +16 -7
  73. package/dist/observability/fail-open.js +10 -2
  74. package/dist/plugins/local-session-store.js +2 -2
  75. package/dist/plugins/one-time-migrations.d.ts +21 -1
  76. package/dist/plugins/one-time-migrations.js +33 -1
  77. package/dist/plugins/remote-env-adb.js +5 -4
  78. package/dist/plugins/remote-env-local-docker.js +5 -4
  79. package/dist/plugins/remote-env-ssh.js +5 -4
  80. package/dist/question.d.ts +18 -9
  81. package/dist/question.js +21 -13
  82. package/dist/request-key-closure.d.ts +2 -2
  83. package/dist/request-key-closure.js +16 -9
  84. package/dist/rule-entry-list.d.ts +124 -17
  85. package/dist/rule-entry-list.js +140 -24
  86. package/dist/run-local.d.ts +3 -3
  87. package/dist/run-local.js +8 -7
  88. package/dist/runs.d.ts +42 -2
  89. package/dist/runs.js +45 -2
  90. package/dist/runtime-caps-resolver.d.ts +7 -0
  91. package/dist/runtime-caps-resolver.js +1 -0
  92. package/dist/session-titler.d.ts +4 -1
  93. package/dist/session-titler.js +13 -3
  94. package/dist/spec-fields.d.ts +8 -4
  95. package/dist/stored-request-hooks-m20.d.ts +23 -0
  96. package/dist/stored-request-hooks-m20.js +124 -0
  97. package/dist/stored-request-tool-axes-m19.d.ts +29 -0
  98. package/dist/stored-request-tool-axes-m19.js +27 -0
  99. package/dist/task-mcp.d.ts +55 -60
  100. package/dist/task-mcp.js +197 -96
  101. package/dist/task-settings.d.ts +41 -5
  102. package/dist/task-settings.js +39 -25
  103. package/dist/tool-axis-words.d.ts +14 -17
  104. package/dist/trace/core-keyset-guard.d.ts +2 -2
  105. package/dist/trace/ledger-sink.d.ts +29 -0
  106. package/dist/trace/ledger-sink.js +11 -0
  107. package/dist/trace/project.d.ts +6 -0
  108. package/dist/trace/project.js +11 -0
  109. package/dist/trace/wire-projection-faces.d.ts +1 -1
  110. package/dist/trace/wire-projection-faces.js +1 -1
  111. package/dist/user-authored-text.d.ts +7 -0
  112. package/dist/user-authored-text.js +3 -0
  113. package/dist/web-admin-client/apply-effective.js +10 -5
  114. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  115. package/dist/web-admin-client/types.d.ts +1 -1
  116. package/package.json +2 -2
  117. package/scripts/drain-check-local.mjs +3 -2
package/dist/run-local.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { DEFAULT_AT_MENTION, DEFAULT_MODEL_REF, resolveTaskModel } from "./model-select.js";
3
3
  import { defaultModelRefusal, modelUnavailableMessage } from "./model-entry-refusal.js";
4
+ import { servedModelTableLeg } from "./model-select.js";
4
5
  import { createDurableAbsenceSeat } from "./observability/corrupt-read-seat.js";
5
6
  import { outwardPlane } from "./terminal.js";
6
7
  import { join } from "node:path";
@@ -36,13 +37,13 @@ import { createInstructionSourceSeat } from "./instruction-source-notice.js";
36
37
  import { loadDeploymentSkills } from "./capabilities/skills.js";
37
38
  import { createRepoClient } from "./capabilities/repo-tools.js";
38
39
  import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
39
- import { buildScenarios, gateScenarioRequest, selectScenario, centerScenarios } from "./capabilities/scenarios.js";
40
+ import { buildScenarios, createScenarioGate, selectScenario, centerScenarios } from "./capabilities/scenarios.js";
40
41
  import { createTaskListLane } from "./boot/task-list-lane.js";
41
42
  import { pickHandsRunner, withoutHands } from "./capabilities/hands-lane.js";
42
43
  import { HttpError, sessionIdShapeIssue } from "./security.js";
43
44
  import { createMemoryEngineIncidentSink } from "./memory-incident-sink.js";
44
45
  import { fileMemoryEngineRoot, memoryEngineBackendFor, memoryProjectKeyFor, memorySpecForRequest } from "./memory-scope.js";
45
- import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
46
+ import { judgeServedModelRoute } from "./model-route-endpoint.js";
46
47
  import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
47
48
  import { buildPricing, cappedCeiling, createTracer } from "./budget.js";
48
49
  import { createSharedRunnerDeps } from "./boot/runner-deps.js";
@@ -386,12 +387,12 @@ export async function runLocal(argv, deps = {}) {
386
387
  else
387
388
  logger.warn("config_source_unreachable_using_env", { source: configProvider.kind, err: String(err) });
388
389
  }
389
- const noDefaultModel = defaultModelRefusal(config, "env");
390
+ const noDefaultModel = defaultModelRefusal(config, servedModelTableLeg(config));
390
391
  if (noDefaultModel !== undefined)
391
392
  throw new Error(noDefaultModel);
392
- const endpointRefusal = modelRouteEndpointRefusal(config);
393
- if (endpointRefusal !== undefined)
394
- throw new Error(endpointRefusal);
393
+ const routeVerdict = judgeServedModelRoute(config, { envTableIsPlaceholder: false });
394
+ if (routeVerdict !== undefined)
395
+ throw new Error(routeVerdict.reason);
395
396
  const unpricedCeiling = buildUnpricedCeilingNotice(config.models, config);
396
397
  if (unpricedCeiling)
397
398
  logger.warn(unpricedCeiling.event, unpricedCeiling.fields);
@@ -625,7 +626,7 @@ export async function runLocal(argv, deps = {}) {
625
626
  const sessionId = args.session ?? uuidv7();
626
627
  let cap;
627
628
  try {
628
- scenarioName = gateScenarioRequest(undefined, args.scenario, config.defaultScenario, Object.keys(scenarios), logger);
629
+ scenarioName = await createScenarioGate({ config, scenarios, principalCaps: undefined, logger }, "enforce")(undefined, args.scenario);
629
630
  cap = selectScenario(scenarios, scenarioName)({ scenario: scenarioName, objective: args.objective }, undefined, { sessionId });
630
631
  }
631
632
  catch (err) {
package/dist/runs.d.ts CHANGED
@@ -137,6 +137,38 @@ export declare function releaseKeyIfZeroWorkRefusal(runStore: {
137
137
  * surfaces the degrade (a metric) so a SYSTEMATIC failure — e.g. an un-migrated DB missing `resume_anchor` — is
138
138
  * observable instead of a silent feature outage.
139
139
  */
140
+ /**
141
+ * 「这一次用户消息提交是不是一句**提问**」的唯一读法 —— 三处用户回退锚(后台腿与续跑腿的 {@link TurnAnchorCapture}、`/v1/tasks/stream`
142
+ * 腿的 `userMsgEntryId`)同调这一只。
143
+ *
144
+ * 目标声明了附件时,引擎把每只附件铸成**一条自己的用户消息**,紧跟在目标后面提交(core `human_input.attachments` 顶注:
145
+ * 「each attachment is delivered as its own message after the objective; its `message_committed` is the delivery」)。
146
+ * 所以一次带 N 只附件的提交会出 1 + N 次 `message_committed{role:"user"}`,而「回到这条提问」的锚要的是**人的那条**
147
+ * (core 的 `resumeAt` 认它:「a `resumeAt` that keeps the message keeps its group」;以附件帧作 `resumeAt` 引擎拒 `resume_at.not_a_message`),
148
+ * 不是排在最后的附件帧。目标**之前**还可能有引擎排队的注入消息同批提交(亲读 core `engine/harness/agent-harness.js` 的 `takePrompts`:
149
+ * 排队消息 ⇒ 目标 ⇒ 附件帧;真案 = 工作区是 git 仓且带图时,git 状态帧单独排在目标前面),所以「窗口里第一次用户提交」不是判据。
150
+ *
151
+ * **为什么是「窗口 + 严格吻合」而不是读标记**:`message_committed` 上只有 `entryId / role / toolCallId?`,附件帧与用户真提交在事件上**不可辨认**;
152
+ * 落盘的行上有标记(`attachment` 键;引擎收尾的中断标记带 `provenance: "engine-note"`),但事件到手的那一刻本仓读不到那一行
153
+ * (会话店面没有按条目读的口,只有整日志导出),所以只能数。数的前提是「目标之后紧接恰好 N 只帧、其后才是别的」—— 前提成立才落锚,
154
+ * **不成立就不猜**:猜错的锚会落在附件帧上(引擎拒)或引擎的中断标记上(回到的不是用户那句)。
155
+ *
156
+ * 读法:
157
+ * · `human_input{delivery:"applied"}` 带 N 只附件 ⇒ 开窗;窗内的用户提交只攒、不落锚(此刻还不知道它们是不是『目标 + N 只帧』)。
158
+ * · 窗被**非用户提交**(第一次助手消息 / 工具结果)关上 ⇒ 窗内共 k 次用户提交,k ≥ N+1 时提问 = 倒数第 N+1 次(严格吻合);k < N+1(帧数不符)⇒ 不猜。
159
+ * · 窗被 `turn_end` 先切断 ⇒ 第一次模型调用没出任何提交就收了(halt / interrupt / 空失败):窗里可能夹着引擎收尾的中断标记,数 N 会落在帧或标记上 ⇒ 不猜。
160
+ * · 窗内又应用了一条别的输入(`human_input{applied}`,如第一次模型调用前排进来的 steer;事件先于它的正文提交到手)⇒ 窗里多了一条别人的消息,数 N 会落在帧上 ⇒ 窗作废、不猜,
161
+ * 之后的用户提交(含那条 steer 正文)是窗口之外的提交,最后一次赢。排队未应用(`delivery: "queued"`)的输入不动窗。
162
+ * · 「不猜」= 返回 `undefined`、这次提交不落锚;拿这次 run 的 taskId 去 `resumeAt` 得 404 `resume_at.unknown_event`(文案点名这一类成因)。
163
+ * 窗口之外的用户提交(如 interrupt 带话的 steer 正文,在 `turn_end` 之后提交)照修前读法(是提问,最后一次赢)。
164
+ * 返回这次提交之后的提问 `entryId`(调用方照旧「最后一次赢」地落锚),不是提问相关的提交返回 `undefined`。
165
+ *
166
+ * 退役条件:引擎在落盘的帧上(`message_committed`)带上附件标记之日,本读法整体换成「按标记判一次」(零计数、无窗口、无拒臂),
167
+ * 窗口 / 严格吻合 / 不猜三臂与 `resume_at.unknown_event` 文案里对应的那一句一并删除。
168
+ */
169
+ export declare function createPromptCommitReader(): (ev: {
170
+ readonly type: string;
171
+ }) => string | undefined;
140
172
  export declare class TurnAnchorCapture {
141
173
  private readonly capture;
142
174
  private readonly onCaptureFail?;
@@ -151,6 +183,8 @@ export declare class TurnAnchorCapture {
151
183
  /** The turn's FIRST text_delta eventId = the message's stable resume handle (also stamped on the durable text event). */
152
184
  firstTextEventId: string | undefined;
153
185
  private turnHadTool;
186
+ /** 用户提交是不是提问的唯一读法({@link createPromptCommitReader};附件帧不是提问)。 */
187
+ private readonly promptCommit;
154
188
  constructor(capture: ((eventId: string) => Promise<void>) | undefined, onCaptureFail?: (() => void) | undefined,
155
189
  /** R8 (CC-parity rewind): capture a USER-message anchor straight from the
156
190
  * `message_committed{role:"user"}` event — its `entryId` is the persisted user-message `SessionTreeEntry.id`,
@@ -160,9 +194,15 @@ export declare class TurnAnchorCapture {
160
194
  * Keyed by the run's taskId (the handle the shell already holds from the POST response — no eventId→entryId
161
195
  * side map / no `getLeafId` inference needed). The assistant-turn capture above stays as an additive bonus. */
162
196
  captureUser?: ((entryId: string) => Promise<void>) | undefined);
197
+ /** A `human_input` arrived — the objective's declared attachments open the frame window of {@link createPromptCommitReader}. */
198
+ onHumanInput(ev: {
199
+ readonly type: string;
200
+ }): void;
163
201
  /** A `message_committed` arrived — capture a USER-message rewind anchor (CC parity). Best-effort (a failure only
164
- * makes that one prompt non-rewindable). Only `role==="user"`; assistant/toolResult committed entries are handled
165
- * by the turn-text path (assistant) or deliberately not anchored (toolResult — `resumeAt` rejects them). */
202
+ * makes that one prompt non-rewindable). Only a user commit that is a PROMPT (not an attachment frame trailing the
203
+ * objective — {@link createPromptCommitReader}; an attachment window is decided when a non-user commit closes it, so
204
+ * the anchor of an attachment-carrying prompt lands on THAT call); assistant/toolResult committed entries are handled by
205
+ * the turn-text path (assistant) or deliberately not anchored (toolResult — `resumeAt` rejects them). */
166
206
  onMessageCommitted(role: string | undefined, entryId: string | undefined): Promise<void>;
167
207
  /** A text_delta arrived — latch the first DEFINED eventId as the message handle (eventId is optional on the stream). */
168
208
  onText(eventId: string | undefined): void;
package/dist/runs.js CHANGED
@@ -76,6 +76,7 @@ const PREPARE_REFUSAL_HTTP_STATUS = {
76
76
  "config.skill_listing_budget_invalid": 400,
77
77
  "config.user_invoked_skill_unknown": 400,
78
78
  "config.image_invalid": 400,
79
+ "config.user_attachment_invalid": 400,
79
80
  "config.tool_face_control": 400,
80
81
  "config.memory_capture_unsupported": 409,
81
82
  };
@@ -109,20 +110,58 @@ export async function releaseKeyIfZeroWorkRefusal(runStore, taskId, result) {
109
110
  if (syncRefusalHttpStatus(result) !== undefined)
110
111
  await runStore.releaseIdemKey(taskId);
111
112
  }
113
+ export function createPromptCommitReader() {
114
+ let attachmentWindow;
115
+ return (ev) => {
116
+ if (ev.type === "human_input") {
117
+ const h = ev;
118
+ if (h.delivery !== "applied")
119
+ return undefined;
120
+ attachmentWindow = Array.isArray(h.attachments) && h.attachments.length > 0 ? { frames: h.attachments.length, commits: [] } : undefined;
121
+ return undefined;
122
+ }
123
+ if (ev.type === "turn_end") {
124
+ attachmentWindow = undefined;
125
+ return undefined;
126
+ }
127
+ if (ev.type !== "message_committed")
128
+ return undefined;
129
+ const c = ev;
130
+ const entryId = typeof c.entryId === "string" && c.entryId.length > 0 ? c.entryId : undefined;
131
+ if (c.role === "user") {
132
+ if (attachmentWindow !== undefined) {
133
+ attachmentWindow.commits.push(entryId);
134
+ return undefined;
135
+ }
136
+ return entryId;
137
+ }
138
+ const closed = attachmentWindow;
139
+ attachmentWindow = undefined;
140
+ if (closed === undefined || closed.commits.length < closed.frames + 1)
141
+ return undefined;
142
+ return closed.commits[closed.commits.length - 1 - closed.frames];
143
+ };
144
+ }
112
145
  export class TurnAnchorCapture {
113
146
  capture;
114
147
  onCaptureFail;
115
148
  captureUser;
116
149
  firstTextEventId;
117
150
  turnHadTool = false;
151
+ promptCommit = createPromptCommitReader();
118
152
  constructor(capture, onCaptureFail, captureUser) {
119
153
  this.capture = capture;
120
154
  this.onCaptureFail = onCaptureFail;
121
155
  this.captureUser = captureUser;
122
156
  }
157
+ onHumanInput(ev) {
158
+ this.promptCommit(ev);
159
+ }
123
160
  async onMessageCommitted(role, entryId) {
124
- if (role === "user" && entryId && this.captureUser) {
125
- await this.captureUser(entryId).catch(() => this.onCaptureFail?.());
161
+ const committed = { type: "message_committed", role, entryId };
162
+ const prompt = this.promptCommit(committed);
163
+ if (prompt !== undefined && this.captureUser) {
164
+ await this.captureUser(prompt).catch(() => this.onCaptureFail?.());
126
165
  }
127
166
  }
128
167
  onText(eventId) {
@@ -135,6 +174,7 @@ export class TurnAnchorCapture {
135
174
  this.turnHadTool = true;
136
175
  }
137
176
  async onTurnEnd() {
177
+ this.promptCommit({ type: "turn_end" });
138
178
  if (this.firstTextEventId && !this.turnHadTool && this.capture) {
139
179
  await this.capture(this.firstTextEventId).catch(() => this.onCaptureFail?.());
140
180
  }
@@ -373,6 +413,9 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
373
413
  case "status":
374
414
  metrics?.inc("brain_retry_total", { phase: String(ev.phase) });
375
415
  break;
416
+ case "human_input":
417
+ anchor.onHumanInput(ev);
418
+ break;
376
419
  case "message_committed":
377
420
  await anchor.onMessageCommitted(ev.role, ev.entryId);
378
421
  break;
@@ -175,6 +175,13 @@ export interface PrincipalEntitlementsClient {
175
175
  * FAIL-OPEN on error / older center without scenario keys / no principal (undefined = no governance, legacy passthrough).
176
176
  * Scenario switching is not a privilege-amplifying face; a center blip must not pin users to `default`. */
177
177
  scenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
+ /**
179
+ * {@link PrincipalEntitlementsClient.scenarioRuling} 的**只读**孪生(同 {@link PrincipalEntitlementsClient.peekRuntimeCaps} 的界):同一只缓存 / 同一条抓取 /
180
+ * 同一份 fail-open 判决(`undefined` = 无治理),唯一的差别是 center 硬失败时本腿铸的 fail-closed 降级项**只对读面自己算数**,提交腿不认、照常重抓。
181
+ * 读面(`GET /v1/capabilities/mcp?scenario=` 的场景门)问中心裁定时必须走这一只 —— 经 `scenarioRuling` 问,center 抖动的那一拍会替这个主体的**真提交**
182
+ * 留下 `{allowWorkflows:false, allowFork:false, autoMode:false}` 的短窗 deny(读面成了别人被拒的原因,与 `peekRuntimeCaps` 的病灶同一类)。
183
+ */
184
+ peekScenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
185
  /** (stage7 P5) The resolved per-principal execution-lane ruling for resolveSpec's
179
186
  * gateExecutionLane. FAIL-OPEN + audit on error (this phase is "honest-but-bypassable";
180
187
  * tightening to fail-closed is a future, separate decision) / older center without the execution key / no principal. */
@@ -167,6 +167,7 @@ export function createPrincipalEntitlementsClient(opts) {
167
167
  resolveRuntimeCaps: async (principal) => (principal ? (await load(principal)).caps : undefined),
168
168
  peekRuntimeCaps: async (principal) => (principal ? (await load(principal, false)).caps : undefined),
169
169
  scenarioRuling: async (principal) => (principal ? (await load(principal)).scenario : undefined),
170
+ peekScenarioRuling: async (principal) => (principal ? (await load(principal, false)).scenario : undefined),
170
171
  executionRuling: async (principal) => (principal ? (await load(principal)).execution : undefined),
171
172
  };
172
173
  }
@@ -14,10 +14,12 @@
14
14
  * main model; bounded + timed out).
15
15
  *
16
16
  * FAILURE POSTURE: everything is best-effort — a generation/store failure logs and leaves title null (the
17
- * list falls back to objectivePreview). An in-process pending set dedupes concurrent submits on the same
17
+ * list falls back to objectivePreview). The fail-open tag it leaves names the CAUSE FAMILY (one tag, one family):
18
+ * the llm face's failure CODE picks it (`failOpenTagFor`), never the error's wording. An in-process pending set dedupes concurrent submits on the same
18
19
  * session; the store's IS NULL guard closes the cross-replica race (double generation costs one extra cheap
19
20
  * call at worst, and exactly one title lands).
20
21
  */
22
+ import type { HookLlmFailureCode } from "./hooks/hook-runner.js";
21
23
  export interface SessionTitlerDeps {
22
24
  /** Secret scrub applied to the RAW llm text before sanitize/persist (workflow audit 2026-07-13: the
23
25
  * objective may carry a pasted secret and the model can echo it into the title — titles are persisted,
@@ -42,6 +44,7 @@ export interface SessionTitlerDeps {
42
44
  } | {
43
45
  ok: false;
44
46
  error: string;
47
+ code?: HookLlmFailureCode;
45
48
  }>;
46
49
  logger?: {
47
50
  debug?(msg: string, meta?: unknown): void;
@@ -8,6 +8,16 @@ const LLM_TIMEOUT_MS = 15_000;
8
8
  const NO_ROW_RETRY_MS = 20_000;
9
9
  const FAIL_CAUSE_KEY_CHARS = 80;
10
10
  const SEEN_CAP = 10_000;
11
+ function failOpenTagFor(code) {
12
+ switch (code) {
13
+ case "redirect_refused":
14
+ return "server.session-titler.redirect-refused";
15
+ case "no_content":
16
+ case "hard_timeout":
17
+ case undefined:
18
+ return "server.session-titler.gateway-unreachable";
19
+ }
20
+ }
11
21
  export function sanitizeTitle(raw) {
12
22
  const oneLine = raw.replace(/[\r\n]+/g, " ").replace(CONTROL_AND_BIDI_STRIP_RE, "").trim();
13
23
  const unquoted = oneLine.replace(/^["'「『]+/, "").replace(/["'」』]+$/, "").trim();
@@ -25,10 +35,10 @@ export function buildTitlePrompt(objective) {
25
35
  }
26
36
  export function createSessionTitler(deps) {
27
37
  const seen = new Set();
28
- const logFailure = (sessionId, error) => {
38
+ const logFailure = (sessionId, error, code) => {
29
39
  const safe = redactSecrets(error);
30
40
  deps.logger?.debug?.("session_title_failed", { sessionId, error: safe.slice(0, 200) });
31
- recordFailOpen("server.session-titler.gateway-unreachable", safe.slice(0, FAIL_CAUSE_KEY_CHARS));
41
+ recordFailOpen(failOpenTagFor(code), safe.slice(0, FAIL_CAUSE_KEY_CHARS));
32
42
  };
33
43
  const probeOrContinue = async (sessionId, continueWord) => deps
34
44
  .probeTitle(sessionId)
@@ -65,7 +75,7 @@ export function createSessionTitler(deps) {
65
75
  const r = await deps.llm({ prompt: buildTitlePrompt(objective), timeoutMs: LLM_TIMEOUT_MS, model });
66
76
  if (!r.ok) {
67
77
  deps.metrics?.inc("session_title_total", { outcome: "llm_failed" });
68
- logFailure(sessionId, r.error);
78
+ logFailure(sessionId, r.error, r.code);
69
79
  return;
70
80
  }
71
81
  const title = sanitizeTitle(deps.redact ? deps.redact(r.text) : r.text);
@@ -51,7 +51,7 @@ export declare const MAX_TASK_AGENTS = 32;
51
51
  * `ruleEntryListShapeOk`):引擎直接 `for (const entry of list)` —— 一个裸字符串 `"Bash"` 会被逐字符迭代成 `B` / `a` / `s` / `h` 四条
52
52
  * (deny 侧 = 意图静默落空,安全轴上的放宽方向)。条目的**文法与名字**是引擎的读法:允侧读不出的条目(`*` / 写路径 / 域名 / 退役名 …)引擎在开跑时发
53
53
  * `permission.allowed_tool_not_granted`(受众 = 用户);拒侧读成空的条目(文法或名字层)在 `resolveSpec` 拒(提交 400 / 续跑 409,`rule-entry-list.ts` 的
54
- * `skillDenyInertEntryOf`,判据 = 引擎的条目读法)。形对之后两道门再问 {@link skillToolListRefusal}(条数帽 ⇒ 400)。
54
+ * `inertToolListEntryOf`,判据 = 引擎的条目读法;同一只函数也拒请求的工具名选择器清单里的近似名 / 退役名)。形对之后两道门再问 {@link skillToolListRefusal}(条数帽 ⇒ 400)。
55
55
  * 条数帽 = 条目表那一只(`rule-entry-list.ts` `allowLayerEntryCountIssue`,准备开销的界;与请求 allow 桶、运维表同一只),
56
56
  * 在 {@link skillToolListRefusal} 里判;无长度帽([ref])。
57
57
  */
@@ -67,12 +67,16 @@ export declare function skillToolListRefusal(skill: Record<string, unknown>): st
67
67
  * 白名单外键 fail-loud —— 与 resilience/limits 的「容忍未知键」相反:agents 是能力定义,静默丢字段 =
68
68
  * 定义被悄悄削(一个丢掉的 isolation 就是放宽),比 400 更危险,故整条拒。`allowTools` / `denyTools` 本门只判形与条数(`toolList`),
69
69
  * **名字归引擎一只判**(core 7.49.0 #1623 / #1433 第二步起与根 run 的 `allowTools` / `excludeTools` 是同一只选择器,开跑前,三来源同判):core 7.51.0 #1736 起
70
- * 没有任何工具答应的名字(无人挂的名、大小写孪生 / 退役名 `bash` / `BASH`)**不拒 run** —— 条目原样保留、经 onError(config)告知一次(带该写的拼法);
71
- * 只有任何工具名都不可能有的拼法(`grammar`)与非字符串条目 ⇒ 整 run 拒 `config.agent.unknown_tool` —— 主车道的委派工具带 extraTools 工厂,本仓在同一处
70
+ * 没有任何工具答应的名字(无人挂的名、大小写孪生 / 退役名 `bash` / `BASH`)**引擎不拒 run** —— 条目原样保留、经 onError(config)告知一次(带该写的拼法);
71
+ * 其中**写错现名的**(`denyTools:["BASH"]` 想禁的照用 = 静默放行;`allowTools:["BASH"]` 想放的什么也没放 = 静默收窄)由 `resolveSpec` 前置拒:**限制侧**(`denyTools`)四类全拒(近似名 · 退役名 · 文法读不出 · `*`),
72
+ * **放行侧**(`allowTools`)只拒近似名 —— 放行侧的退役名 / 内容形 / 文法读不出照 7.130.0 受理、交引擎既有通告(文法读不出的放行条目引擎在子代 prepare 整 run 拒 `config.agent.unknown_tool`,根 `allowTools` 同步腿 400 `config.tool_face_control`;`rule-entry-list.ts` `inertToolListEntryOf`:近似名提交 400 `config.agent.unknown_tool` 点名现名、续跑按方向分流;
73
+ * 判官仍是引擎的条目读法,不是第二个判官);
74
+ * **真未知名**(没有近似也没有退役对应:懒挂载的合法形)照旧只告运维、不拒。
75
+ * 任何工具名都不可能有的拼法(`grammar`)与非字符串条目引擎照旧整 run 拒 `config.agent.unknown_tool` —— 主车道的委派工具带 extraTools 工厂,本仓在同一处
72
76
  * 声明它的名册(core 7.46.0 `extraToolNames`;工厂与名册读同一批工具对象,`capabilities/builtin-tools.ts` `createSubagentExtraTools`),引擎因此列得出子代工具池。
73
77
  * 两张表管子代可挂载的**全部**工具:委派池、hands 内建、引擎自己的工具(`Monitor` / `Artifact` / `Skill` / 记忆工具 …)与父的 MCP 挂载 ——
74
78
  * `denyTools:["Monitor"]` ⇒ 子代没有 `Monitor`;`allowTools` 只点了调用方工具 ⇒ 子代只剩那几只与它这条腿的协议工具。`denyTools` 的 `Tool(content)` ⇒ 整只工具。
75
- * 本仓不在受理口另做名字对照(那会是同一问的第二个判官)。
79
+ * 本门不做名字对照(拒侧的那一判在 `resolveSpec`,见上;本门只判形与条数)。
76
80
  * ⚠️ 键判断必须 Object.hasOwn:`in` 走原型链,JSON 里的 "toString"/"constructor"/"__proto__" 键会被
77
81
  * 误判为白名单字段,随后 SHAPES[k].ok 不是函数 ⇒ TypeError(fresh 500 / resume 砖;cross-review 复现)。 */
78
82
  export declare function taskAgentIssue(item: unknown): string | null;
@@ -0,0 +1,23 @@
1
+ import { type ParseHooksConfigResult, type RunnableHooksConfig } from "./hooks/hook-runner.js";
2
+ /** 一次重读的产物:读者对剥键之后的文档的答案,与被剥掉的每个坐标(出门形,经契约包的脱敏出门)。 */
3
+ export interface StoredHooksRereadM20Result {
4
+ readonly config: RunnableHooksConfig;
5
+ readonly stripped: readonly string[];
6
+ }
7
+ /** 重读座:存下的 `settings.hooks` 原值 → 迁移产物(`undefined` = 不迁移,调用方走原臂)。 */
8
+ export type StoredHooksRereadM20 = (raw: unknown) => StoredHooksRereadM20Result | undefined;
9
+ /** 读者(= `parseHooksConfig`);作形参只为让「读者交回什么拒因」可被替身喂(默认值就是生产读者)。 */
10
+ export type HooksReader = (raw: unknown) => ParseHooksConfigResult;
11
+ /**
12
+ * 重读一份存体 hooks:读者拒因全部属于两形 ⇒ 剥掉被点名的块里按类型确属未知的键、再问读者,**剥到读者受理为止**,过了就交回产物 + 被剥坐标;否则 `undefined`(原臂:整份不采纳)。
13
+ * 调用方只在读者**已经拒过**这份原值之后才来问;读者一开始就受理的值不进这里(进来也返回 `undefined`:没有可迁移的东西)。
14
+ *
15
+ * 终止:循环体以一次读开头(最后一次剥键之后必有一次读);继续下一轮的条件是本轮真从文档里剥掉了至少一个对象键,文档只减不增 ⇒ 必停;同一块只会被点名一次 ⇒ 轮数 ≤ 被点名的块数 + `__proto__` 键数。
16
+ */
17
+ export declare function rereadStoredHooksM20(raw: unknown, read?: HooksReader): StoredHooksRereadM20Result | undefined;
18
+ /**
19
+ * 此刻(`today` = UTC 日历日 YYYY-MM-DD)M20 的窗开着 ⇒ 交回重读座;关了 ⇒ `undefined`(今天的读法)。窗只读登记表 M20 那一行
20
+ * (`migrationWindowOpen`,与 M10 / M19 同一只判据)。登记表里没有 M20 ⇒ 响亮抛:重读臂活过了它的登记行,该随那一行一起删。
21
+ */
22
+ export declare function storedHooksRereadM20(today: string): StoredHooksRereadM20 | undefined;
23
+ //# sourceMappingURL=stored-request-hooks-m20.d.ts.map
@@ -0,0 +1,124 @@
1
+ import { HOOK_ENTRY_TYPES } from "@sema-agent/settings-schema/hooks";
2
+ import { hooksCoordinate, parseHooksConfig } from "./hooks/hook-runner.js";
3
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./plugins/one-time-migrations.js";
4
+ const ENTRY_FIELD_NAMES = {
5
+ command: { type: true, if: true, timeout: true, statusMessage: true, once: true, command: true, shell: true, async: true, asyncRewake: true },
6
+ prompt: { type: true, if: true, timeout: true, statusMessage: true, once: true, prompt: true, model: true },
7
+ agent: { type: true, if: true, timeout: true, statusMessage: true, once: true, prompt: true, model: true },
8
+ http: { type: true, if: true, timeout: true, statusMessage: true, once: true, url: true, headers: true, allowedEnvVars: true },
9
+ mcp_tool: { type: true, if: true, timeout: true, statusMessage: true, once: true, server: true, tool: true, input: true },
10
+ };
11
+ const MATCHER_FIELD_NAMES = { matcher: true, hooks: true };
12
+ function isRecord(v) {
13
+ return v !== null && typeof v === "object" && !Array.isArray(v);
14
+ }
15
+ function isEntryType(v) {
16
+ return typeof v === "string" && HOOK_ENTRY_TYPES.includes(v);
17
+ }
18
+ function nodeAt(node, path) {
19
+ let cur = node;
20
+ for (const seg of path) {
21
+ if (Array.isArray(cur))
22
+ cur = typeof seg === "number" ? cur[seg] : undefined;
23
+ else if (isRecord(cur) && typeof seg === "string")
24
+ cur = Object.hasOwn(cur, seg) ? cur[seg] : undefined;
25
+ else
26
+ return undefined;
27
+ }
28
+ return cur;
29
+ }
30
+ function contractFieldsOf(code, block) {
31
+ if (!isRecord(block))
32
+ return undefined;
33
+ if (code === "guard-matcher-invalid")
34
+ return MATCHER_FIELD_NAMES;
35
+ const type = Object.hasOwn(block, "type") ? block.type : undefined;
36
+ return isEntryType(type) ? ENTRY_FIELD_NAMES[type] : undefined;
37
+ }
38
+ function stripTargetsOf(piece, doc) {
39
+ const [root, ...rel] = piece.path;
40
+ if (root !== "hooks")
41
+ return undefined;
42
+ if ((piece.code === "unknown-event" || piece.code === "guard-under-unknown-event") && rel.length === 1 && rel[0] === "__proto__")
43
+ return [rel];
44
+ if ((piece.code === "guard-entry-invalid" || piece.code === "guard-matcher-invalid") && typeof piece.field === "string") {
45
+ const block = nodeAt(doc, rel);
46
+ const known = contractFieldsOf(piece.code, block);
47
+ if (known === undefined || !isRecord(block) || Object.hasOwn(known, piece.field) || !Object.hasOwn(block, piece.field))
48
+ return undefined;
49
+ return Object.keys(block)
50
+ .filter((key) => !Object.hasOwn(known, key))
51
+ .map((key) => [...rel, key]);
52
+ }
53
+ return undefined;
54
+ }
55
+ function cloneDoc(root) {
56
+ const isContainer = (v) => Array.isArray(v) || isRecord(v);
57
+ const shell = (v) => (Array.isArray(v) ? [] : isRecord(v) ? {} : v);
58
+ const copy = shell(root);
59
+ const pending = isContainer(root) ? [[root, copy]] : [];
60
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
61
+ const [source, target] = next;
62
+ if (Array.isArray(source)) {
63
+ for (const value of source) {
64
+ const child = shell(value);
65
+ target.push(child);
66
+ if (isContainer(value))
67
+ pending.push([value, child]);
68
+ }
69
+ }
70
+ else if (isRecord(source)) {
71
+ for (const [key, value] of Object.entries(source)) {
72
+ const child = shell(value);
73
+ Object.defineProperty(target, key, { value: child, writable: true, enumerable: true, configurable: true });
74
+ if (isContainer(value))
75
+ pending.push([value, child]);
76
+ }
77
+ }
78
+ }
79
+ return copy;
80
+ }
81
+ function removeKeyAt(doc, path) {
82
+ const key = path[path.length - 1];
83
+ const parent = nodeAt(doc, path.slice(0, -1));
84
+ if (!isRecord(parent) || typeof key !== "string" || !Object.hasOwn(parent, key))
85
+ return false;
86
+ delete parent[key];
87
+ return true;
88
+ }
89
+ export function rereadStoredHooksM20(raw, read = parseHooksConfig) {
90
+ const doc = cloneDoc(raw);
91
+ const stripped = [];
92
+ for (;;) {
93
+ const r = read(doc);
94
+ if (r.config !== undefined)
95
+ return stripped.length > 0 ? { config: r.config, stripped } : undefined;
96
+ const pieces = r.pieces;
97
+ if (pieces === undefined || pieces.length === 0)
98
+ return undefined;
99
+ const targets = [];
100
+ for (const piece of pieces) {
101
+ const pieceTargets = stripTargetsOf(piece, doc);
102
+ if (pieceTargets === undefined)
103
+ return undefined;
104
+ for (const target of pieceTargets)
105
+ targets.push(target);
106
+ }
107
+ let removedAny = false;
108
+ for (const target of targets) {
109
+ if (!removeKeyAt(doc, target))
110
+ continue;
111
+ stripped.push(hooksCoordinate(["hooks", ...target]));
112
+ removedAny = true;
113
+ }
114
+ if (!removedAny)
115
+ return undefined;
116
+ }
117
+ }
118
+ export function storedHooksRereadM20(today) {
119
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M20");
120
+ if (window === undefined)
121
+ throw new Error("stored-request-hooks-m20: the one-time migration registry has no M20 row (the replay-leg stored-hooks re-read window) — the re-read arm outlived its registry row; delete it with that row");
122
+ return migrationWindowOpen(window, today) ? (raw) => rereadStoredHooksM20(raw) : undefined;
123
+ }
124
+ //# sourceMappingURL=stored-request-hooks-m20.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 一次性迁移 M19 —— 续跑重放腿上,升级前存下的请求体里引擎判官判不过的 `mcpServers[].toolAxes`,窗内按升级前的读法重读一次。
3
+ * 🔴 M19 · 窗口见 `plugins/one-time-migrations.ts` 的 ONE_TIME_MIGRATIONS 登记表(窗、删法、删后由谁接住只写在那一行)。
4
+ *
5
+ * ## 为什么要它(持久数据面)
6
+ * 请求腿读 `toolAxes` 的判官是引擎根导出的 `readMcpToolAxes`(整份一坏全拒、不默认)。升级前的请求腿读者是另一种读法:不是对象(含 `null`)⇒ 整键忽略、
7
+ * server 照挂;是对象 ⇒ 逐成员判形,过不了形的成员剔掉、好的留下。续跑重放腿重放的是**升级前存下的**请求体(`checkpoint_ctx` 里那一份),那时这台 server
8
+ * 是挂着的 —— 一条停驻在它某只工具审批上的任务,升级后若这台 server 整条不进,批准即撞引擎的 `resume.tool_unavailable`、检查点重开、每次重试重放同一份体
9
+ * ⇒ 永远批不过去。持久数据面不能「翻红」,所以给它一次性迁移:窗开着时,续跑重放腿把判官判不过的那一格按升级前的读法重读一次,产物**再交同一只判官**
10
+ * (不过 ⇒ 原臂:整条不进);真挂上的这类 server 由请求腿说一行运维 warn `mcp_tool_axes_read_as_before_upgrade`。窗关之后回到今天的读法,删码零行为变化。
11
+ *
12
+ * ## 可达面(结构上只在续跑重放腿)
13
+ * · 本模块的值只被 `boot/resolve-spec.ts` 引用一处,挂在 `opts.leg` 不是 `"fresh"` 的那一臂(续跑家族经 `http/resume-legs.ts` 的 `resolveResumeSpec`
14
+ * 递 `leg: "resume"`);新提交腿递 `"fresh"` 拿不到重读座,且在腿首已经被同一只判官 400 掉(`assertRequestMcpEntries`),读器根本见不到坏形;
15
+ * · 请求腿(`task-mcp.ts`)只 import 本模块的**型**:重读座是 `resolveRequestMcp` 的一个形参,没人递就没有;探测口(`validateRequestMcp`)没有这个形参;
16
+ * · 部署腿(`web-admin-client/skills-mcp.ts` 的 `resolveMcpServers`)是另一只铸 spec 处,不读本模块。
17
+ * 机器锚:`test/mcp-tool-axes-m19.test.ts` 的结构格。
18
+ */
19
+ import type { McpServerSpec } from "@sema-agent/core";
20
+ type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>;
21
+ /** 重读座:一份原始 `toolAxes` 声明 → 升级前的读法读出的那一份(`undefined` = 升级前读成「没声明」)。产物由调用方再交引擎判官。 */
22
+ export type StoredToolAxesRereadM19 = (declared: unknown) => ToolAxes | undefined;
23
+ /**
24
+ * 此刻(`today` = UTC 日历日 YYYY-MM-DD)M19 的窗开着 ⇒ 交回重读座;关了 ⇒ `undefined`(今天的读法)。窗只读登记表 M19 那一行
25
+ * (`migrationWindowOpen`,与 M10 的认领窗同一只判据)。登记表里没有 M19 ⇒ 响亮抛:重读臂活过了它的登记行,该随那一行一起删。
26
+ */
27
+ export declare function storedToolAxesRereadM19(today: string): StoredToolAxesRereadM19 | undefined;
28
+ export {};
29
+ //# sourceMappingURL=stored-request-tool-axes-m19.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./plugins/one-time-migrations.js";
2
+ import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
3
+ function readToolAxesAsServer7129M19(declared) {
4
+ if (!declared || typeof declared !== "object" || Array.isArray(declared))
5
+ return undefined;
6
+ const axes = {};
7
+ for (const [tool, ax] of Object.entries(declared)) {
8
+ if (!ax || typeof ax !== "object" || Array.isArray(ax))
9
+ continue;
10
+ const a = ax;
11
+ const entry = {
12
+ ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
13
+ ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
14
+ ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
15
+ };
16
+ if (Object.keys(entry).length > 0)
17
+ axes[tool] = entry;
18
+ }
19
+ return Object.keys(axes).length > 0 ? axes : undefined;
20
+ }
21
+ export function storedToolAxesRereadM19(today) {
22
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M19");
23
+ if (window === undefined)
24
+ throw new Error("stored-request-tool-axes-m19: the one-time migration registry has no M19 row (the replay-leg toolAxes re-read window) — the re-read arm outlived its registry row; delete it with that row");
25
+ return migrationWindowOpen(window, today) ? readToolAxesAsServer7129M19 : undefined;
26
+ }
27
+ //# sourceMappingURL=stored-request-tool-axes-m19.js.map