@sema-agent/server 3.11.0 → 3.13.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.
package/USAGE.md CHANGED
@@ -67,6 +67,24 @@ ANTHROPIC_MAX_RETRIES=10 # 云 Anthropic 腿
67
67
  - 断路器**只在配了 `MODEL_GATEWAY_FALLBACK_URLS`(≥2 路)时才有意义**——它的价值是"开路即快速失败 → failover 立刻切备";单网关下它是 no-op(启动日志 `circuitBreakerNoop` 会提示)。只 `network/server/rate_limit` 计入连败,`auth`/`invalid_request` 不计(坏 key 熔断整网关无意义)。备用网关(最后一路)不套断路器。
68
68
  - **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
69
69
  - 断路器状态:配了 `SESSION_BACKEND=tidb` 时自动用**跨副本共享态**(TiDB `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(tidb)`/`in-process`/`off`。详见 `design/27`。
70
+
71
+ **可选 — WebSearch 后端(core 只留注入口,不自带任何 provider)**
72
+ ```bash
73
+ WEB_SEARCH_PROVIDER=brave|tavily|searxng # 缺席 = 不装配(WebSearch 工具根本不挂,不是挂了报错)
74
+ WEB_SEARCH_API_KEY=… # brave/tavily 必填;只进 backend 闭包,永不进模型 prompt 或工具参数
75
+ WEB_SEARCH_ENDPOINT=https://searx.example # searxng 必填;brave/tavily 下是可选的 base-URL 覆盖(代理/测试)
76
+ WEB_SEARCH_MAX_RESULTS=10 # 1..20,返给模型的条数上限
77
+ WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟
78
+ ```
79
+ - 结果是**不可信输入**:core 会 `delimitUntrusted` 围栏并**重新施加** `allowed_domains`/`blocked_domains`
80
+ 地板,所以 backend 遵不遵守 `opts` 是优化不是正确性要求 —— 换 provider(包括换成自建 SearXNG)
81
+ 不会削弱域名地板。
82
+ - 🔴 **`WEB_SEARCH_ENDPOINT` 的语义是「引擎可达的地址」,不是「用户机器上可达的地址」**
83
+ (2026-07-31 与 cli 对齐的跨机契约句):壳(TUI/桌面/web)与引擎**可以不在同一台机器**。壳在用户
84
+ 笔记本上起的 localhost SearXNG,云端 worker 够不着 —— 所以「本地 SearXNG 兜底」这一档**只适用
85
+ 壳自 spawn 本地引擎的同机形**;壳连接远程引擎时该档整级跳过,由运维在**引擎侧**配 `WEB_SEARCH_*`。
86
+ 用户手填一个集中式 SearXNG 地址是合法形,照常放行。
87
+
70
88
  - **大工具结果落盘**(core 1.47/1.49):单条工具结果 > ~20000 字符时 core 把全文移出上下文、只留预览+ref,模型用 `read_tool_result` 按需分页回取。配了 TiDB 时自动用**durable `tool_result` 表**(跨副本 wake 仍能取回全文;否则 core 进程内默认 = 跨副本 wake 取不到→降级到预览,不崩)。`TOOL_RESULT_TTL_SEC`(默认 86400)按 TTL 回收(要 ≥ run 可恢复期)。启动日志 `toolResultStore` 回显 `shared(tidb)`/`in-process`。
71
89
 
72
90
  **可选 — 成本计量 + 预算闸(core 1.37)**
@@ -9,6 +9,26 @@
9
9
  */
10
10
  import { type ToolPolicy } from "@sema-agent/core";
11
11
  import type { ApprovalStore } from "./plugins/store-backend.js";
12
+ /**
13
+ * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
14
+ * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
15
+ *
16
+ * 🔴 由来(2026-07-31 缝合审):这判据本来是 `main.ts` 里的一行内联表达式,只枚举了
17
+ * requireApproval / deny / durableApproval **三格**,漏了 `approvalNeverAuto`。漏的根因不是手滑,
18
+ * 是 `config-types.ts` 当时把 neverAuto 描述成 "Subset of approvalRequire" —— 若真是子集,
19
+ * 枚举 requireApproval 就覆盖了它。但本文件 inv#2 的执行面逐字相反(neverAuto ⊄ requireApproval),
20
+ * 于是「单用户 + 只配 APPROVAL_NEVER_AUTO=Bash」这一**语义完全合理**的形被判成零门意图 ⇒
21
+ * 落进 `createAllowDenyPolicy({})` 全面放行 ⇒ 那张"永远问人"的表从未进入任何 policy,Bash 直接跑。
22
+ *
23
+ * 判定挪到**与 inv#2 同一个文件**就是为了这个:两者再想漂开,得有人同时改这两段。
24
+ * 新增任何"门意图"配置项时,这里必须同步加一格(下面的表驱动测试会点名漏的那格)。
25
+ */
26
+ export declare function hasOperatorGateIntent(config: {
27
+ approvalRequire: readonly string[];
28
+ approvalDeny: readonly string[];
29
+ approvalNeverAuto: readonly string[];
30
+ durableApproval: boolean;
31
+ }): boolean;
12
32
  /**
13
33
  * Durable approval policy (core 1.67 / design/45): returns three-state **`ask`** for gated tools instead of
14
34
  * polling. With `TaskSpec.durableApproval` set + a `checkpointStore` on the Runner, core turns that `ask` into
package/dist/approval.js CHANGED
@@ -8,6 +8,26 @@
8
8
  * dropped at the task deadline instead of holding the worker — design/06 F4 self-protection.
9
9
  */
10
10
  import { createApprovalPolicy, canonicalToolName, uuidv7 } from "@sema-agent/core";
11
+ /**
12
+ * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
13
+ * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
14
+ *
15
+ * 🔴 由来(2026-07-31 缝合审):这判据本来是 `main.ts` 里的一行内联表达式,只枚举了
16
+ * requireApproval / deny / durableApproval **三格**,漏了 `approvalNeverAuto`。漏的根因不是手滑,
17
+ * 是 `config-types.ts` 当时把 neverAuto 描述成 "Subset of approvalRequire" —— 若真是子集,
18
+ * 枚举 requireApproval 就覆盖了它。但本文件 inv#2 的执行面逐字相反(neverAuto ⊄ requireApproval),
19
+ * 于是「单用户 + 只配 APPROVAL_NEVER_AUTO=Bash」这一**语义完全合理**的形被判成零门意图 ⇒
20
+ * 落进 `createAllowDenyPolicy({})` 全面放行 ⇒ 那张"永远问人"的表从未进入任何 policy,Bash 直接跑。
21
+ *
22
+ * 判定挪到**与 inv#2 同一个文件**就是为了这个:两者再想漂开,得有人同时改这两段。
23
+ * 新增任何"门意图"配置项时,这里必须同步加一格(下面的表驱动测试会点名漏的那格)。
24
+ */
25
+ export function hasOperatorGateIntent(config) {
26
+ return (config.approvalRequire.length > 0 ||
27
+ config.approvalDeny.length > 0 ||
28
+ config.approvalNeverAuto.length > 0 || // ← 漏了这格,见上面的由来
29
+ config.durableApproval);
30
+ }
11
31
  /**
12
32
  * Durable approval policy (core 1.67 / design/45): returns three-state **`ask`** for gated tools instead of
13
33
  * polling. With `TaskSpec.durableApproval` set + a `checkpointStore` on the Runner, core turns that `ask` into
package/dist/brain.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { type Brain, type BreakerState } from "@sema-agent/core";
2
2
  import type { ServiceConfig } from "./config-types.js";
3
+ /**
4
+ * 配了快切层(failover ≥2 路 / 断路器)时,**每路**的重试上限。
5
+ *
6
+ * 取 2 的理由:留够吸收一次瞬时抖动(2 次递增退避 ≈ 500ms + 1000ms ≈ 1.5s),同时让外层在秒级而不是
7
+ * 分钟级拿到错误。断路器阈值 5 在这个值下 ≈ 7.5s 开路 —— 与它"快速失败让 failover 立刻切备"的设计意图
8
+ * 同量级。详见 createBrain 里 retryCap 那段的由来。
9
+ */
10
+ export declare const FAST_FAIL_MAX_RETRIES = 2;
3
11
  /**
4
12
  * Build the external "Brain" (LLM) from config.
5
13
  *
package/dist/brain.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import { createAnthropicBrain, createCircuitBreakerBrain, createDegradingBrain, createFailoverBrain, createOpenAIBrain, createRoutingBrain, } from "@sema-agent/core";
2
2
  import { resolveModelApiKey } from "./key-resolver.js";
3
+ /**
4
+ * 配了快切层(failover ≥2 路 / 断路器)时,**每路**的重试上限。
5
+ *
6
+ * 取 2 的理由:留够吸收一次瞬时抖动(2 次递增退避 ≈ 500ms + 1000ms ≈ 1.5s),同时让外层在秒级而不是
7
+ * 分钟级拿到错误。断路器阈值 5 在这个值下 ≈ 7.5s 开路 —— 与它"快速失败让 failover 立刻切备"的设计意图
8
+ * 同量级。详见 createBrain 里 retryCap 那段的由来。
9
+ */
10
+ export const FAST_FAIL_MAX_RETRIES = 2;
3
11
  const DEFAULT_RESILIENCE = {
4
12
  // Watchdog defaults ON — mirrors config.ts env defaults so a config object without a
5
13
  // resilience block gets the same posture. connect/firstToken per the [880] fleet audit
@@ -52,7 +60,32 @@ export function createBrain(config, deps = {}) {
52
60
  // 压过引擎默认 ⇒ core 把默认抬到 10 对 server 部署零效果。缺席不传之后引擎默认当家、抬默认自动继承。
53
61
  // (五报清查 R1① 的 server 半场:限流 provider 走的正是这条 openai 兼容腿,而它此前**零配置出口**——
54
62
  // 同文件的 anthropic 腿反倒早有 `ANTHROPIC_MAX_RETRIES`。钉在 test/brain.test.ts 的两条上。)
55
- const retryCap = config.gatewayMaxRetries !== undefined ? { maxRetries: config.gatewayMaxRetries } : {};
63
+ //
64
+ // 🔴 **拓扑感知的缺席值**(2026-08-01,core 2.9.0 提货验收当场实测出来的层间相互作用):
65
+ // core 2.9.0 把每次调用的重试默认抬到 10 且退避改递增形(`min(500·2^(n-1),32000)`),一次失败调用
66
+ // 要耗尽 **~180s** 才把错误交给外层(实测 179.7s)。而 failover / 断路器 / 反应式降级 **三层全在
67
+ // 每路 brain 的重试之外** —— 内层不放弃,外层看不见错误 ⇒ 主网关死了要 ~180s 才切备(旧形 ~0.6s)。
68
+ // 断路器更反讽:它要数失败次数才开路,每笔失败 180s、阈值 5 = 15 分钟,而它的存在理由正是"快速失败
69
+ // 让 failover 立刻切备"。
70
+ //
71
+ // 两边各自都对:core 的 10 次对**单 provider 被限流**是正解;server 的韧性栈对**多网关拓扑**是正解。
72
+ // **正确的默认取决于拓扑,而引擎不知道拓扑** —— 这条信息只有 server 有。
73
+ //
74
+ // ⚠️ 这**不是**又一次"中间层钉死引擎默认"(那正是本文件上一版刚修掉的病),三点区别:
75
+ // ① 显式 `GATEWAY_MAX_RETRIES` **恒赢** —— 运维的话压过我们的推断;
76
+ // ② 只在**外层真有快切层**时才收窄(≥2 路 failover 或断路器开);单路 = 缺席不传,引擎默认当家
77
+ // (那正是限流 provider 的形,10 次是对的);
78
+ // ③ 收窄**在启动日志里说出来**(`gatewayRetryCap` 字段),不是静默替换。
79
+ // 三个快切层,缺一个谓词就漏一族(反应式降级这一格是首版漏掉、被 brain 套件当场咬出来的):
80
+ // ① failover(≥2 路)② 断路器 ③ 反应式降级 —— 后者尤其要算:它的触发条件**就是** `rate_limit`,
81
+ // 而运维开 `MODEL_DEGRADE_REACTIVE` 等于明说"撞限流就换便宜模型,别在原地等"。若这里不收窄,
82
+ // 主模型会先把 10 次递增退避耗完(~180s)才轮到降级 —— 把运维的选择拖成一句空话。
83
+ const fastFailTopology = config.gatewayFallbackUrls.length > 0 || r.circuitBreaker || Boolean(config.degrade?.reactive);
84
+ const retryCap = config.gatewayMaxRetries !== undefined
85
+ ? { maxRetries: config.gatewayMaxRetries }
86
+ : fastFailTopology
87
+ ? { maxRetries: FAST_FAIL_MAX_RETRIES }
88
+ : {};
56
89
  // Local openai-compatible gateway stack: primary + optional same-protocol fallbacks (failover).
57
90
  const routes = [config.gatewayBaseUrl, ...config.gatewayFallbackUrls];
58
91
  const openaiBrains = routes.map((baseUrl, i) => {
@@ -144,7 +177,9 @@ export function createBrain(config, deps = {}) {
144
177
  get apiKey() {
145
178
  return fbApiKey();
146
179
  },
147
- ...retryCap, // 与主路同源:配了才传,没配让引擎默认当家(见上面 retryCap 的注)
180
+ // 🔴 degrade fallback **不吃** fastFail 收窄:它是最外层的兜底,外面**没有**别的切换层了,
181
+ // 所以它该按引擎默认那样耐心重试(限流场景正靠它)。显式 env 仍然恒赢。
182
+ ...(config.gatewayMaxRetries !== undefined ? { maxRetries: config.gatewayMaxRetries } : {}),
148
183
  fetchImpl,
149
184
  ...timeouts,
150
185
  });
@@ -170,6 +205,16 @@ export function brainSummary(config) {
170
205
  idleTimeoutMs: r.idleTimeoutMs || null,
171
206
  circuitBreaker: breakerActive,
172
207
  circuitBreakerRequested: r.circuitBreaker,
208
+ // 🔴 每路重试上限的**来源**必须可见(2026-08-01):收窄是我们按拓扑推断的,不是引擎默认,
209
+ // 静默替换等于让运维读不出"为什么这台机的失败切换比另一台快"。三态:
210
+ // `env:<n>` = 运维显式配了 GATEWAY_MAX_RETRIES(恒赢)
211
+ // `fast-fail:<n>`= 外层有快切层(failover/断路器),我们收窄到 n(见 createBrain 的由来注)
212
+ // `engine-default` = 缺席不传,引擎默认当家(单路限流 provider 的正解)
213
+ gatewayRetryCap: config.gatewayMaxRetries !== undefined
214
+ ? `env:${config.gatewayMaxRetries}`
215
+ : hasFailover || r.circuitBreaker || config.degrade?.reactive
216
+ ? `fast-fail:${FAST_FAIL_MAX_RETRIES}`
217
+ : "engine-default",
173
218
  circuitBreakerNoop: r.circuitBreaker && !hasFailover ? "needs ≥2 gateway routes" : null,
174
219
  // reactive degrade is active only if a valid catalog target resolves
175
220
  reactiveDegradeTo: config.degrade?.reactive && config.models?.[config.degrade.to] ? config.degrade.to : null,
@@ -493,9 +493,17 @@ export interface ServiceConfigFlat {
493
493
  * asks, the human only sees the rest". `APPROVAL_AUTO_BUDGET`. */
494
494
  approvalAutoBudget: number;
495
495
  /** design/80 D-E (inv#2 — safety asks are never budgetable): gated tools that ALWAYS require a human even when
496
- * the auto-budget is on — the "irreversible / never auto-approve" set. Subset of approvalRequire; empty = the
497
- * budget may auto-approve any required tool. (In-service every gated ask is gate.kind="human", so this set is
498
- * the ONLY way to keep an irreversible tool human-gated under an auto-budget.) `APPROVAL_NEVER_AUTO`. */
496
+ * the auto-budget is on — the "irreversible / never auto-approve" set. empty = the budget may auto-approve any
497
+ * required tool. (In-service every gated ask is gate.kind="human", so this set is the ONLY way to keep an
498
+ * irreversible tool human-gated under an auto-budget.) `APPROVAL_NEVER_AUTO`.
499
+ *
500
+ * 🔴 **NOT required to be a subset of `approvalRequire`**(2026-07-31 修口)。这里原来写的是
501
+ * "Subset of approvalRequire",而 `approval.ts` 的 inv#2 执行面逐字相反:
502
+ * 「even if it was not also listed in requireApproval — the footgun: neverAuto ⊄ requireApproval」。
503
+ * 两处文档互相矛盾**不是文字问题**:`main.ts` 的 `operatorGateIntent` 是照着"子集"那句写的
504
+ * ——若真是子集,枚举 `approvalRequire.length > 0` 就覆盖了它,漏掉看着无害。实际不是子集,于是
505
+ * 「只配 APPROVAL_NEVER_AUTO」这一形被判成"零门意图" ⇒ 落进单用户 allow-all 基线 ⇒ 那张表
506
+ * 从未进入任何 policy。判定现在单源在 `hasOperatorGateIntent()`(与 inv#2 同文件)。 */
499
507
  approvalNeverAuto: string[];
500
508
  /** design/80 D-G (HMAC = integrity, not identity; ✅ WIRED — the "SCAFFOLD, not yet wired" note here was STALE,
501
509
  * corrected 2026-07-25: consumed by `verifyDirectDoorProof` → `verifyApprovalHmac`, reached from the live decide
@@ -220,8 +220,11 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
220
220
  // codex 一轮生命周期状态机:①连接坑与 close 守卫在任何 await 之前同步建立(读存储期间断连不再
221
221
  // 漏清/漏还坑);②每个 await 之后查 closed;③所有拒绝/异常路径经同一 release。
222
222
  if (sse.sseConnections >= (deps.sessionEventsMaxConnections ?? 256)) {
223
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
224
- res.end(JSON.stringify({ error: "session-events connection cap reached — fall back to /head polling" }));
223
+ // 🔴 机器码而非裸 writeHead(2026-07-31 缝合审):这条 503 drain 503 走同一条流,
224
+ // drain 的带 `errorCode:"draining"`(冻结契约)。没有码,消费端只能靠自由文本字面区分
225
+ // 「连接帽 ⇒ 回落 /head 轮询(自愈,保持连接策略)」与「该重连替换实例」—— 正是本仓要根除的姿势。
226
+ res.setHeader("retry-after", "5");
227
+ sendError(res, 503, "state.sse_connection_cap", "session-events connection cap reached — fall back to /head polling");
225
228
  return;
226
229
  }
227
230
  sse.sseConnections++; // 同步占坑(查+占同拍,慢读并发不再超卖);此后一切路径必经 cleanup 还坑
@@ -247,8 +250,8 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
247
250
  const stallBound = deps.sessionEventsStallMs ?? 60_000;
248
251
  if (sse.sseOwnerLookups >= 64) {
249
252
  cleanup();
250
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
251
- res.end(JSON.stringify({ error: "session-events owner-lookup concurrency cap — retry" }));
253
+ res.setHeader("retry-after", "5");
254
+ sendError(res, 503, "state.sse_owner_lookup_cap", "session-events owner-lookup concurrency cap — retry");
252
255
  return;
253
256
  }
254
257
  sse.sseOwnerLookups++;
@@ -271,8 +274,8 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
271
274
  return; // 读期断连:close 守卫已还坑,别再写已死响应
272
275
  if (owner === "__stall__") {
273
276
  cleanup();
274
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
275
- res.end(JSON.stringify({ error: "session-events owner lookup stalled — retry" }));
277
+ res.setHeader("retry-after", "5");
278
+ sendError(res, 503, "state.sse_owner_lookup_stalled", "session-events owner lookup stalled — retry");
276
279
  return;
277
280
  }
278
281
  if (owner === undefined) {
@@ -364,8 +367,8 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
364
367
  unsubscribe = watch.subscribe(evSession, owner ?? null, onHead, onEnd); // owner 铸入条目(四轮跨副本围栏);已知条目=同步 replay 首帧;新条目=首拍探针 ≤hotMs
365
368
  if (!unsubscribe) {
366
369
  cleanup();
367
- res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
368
- res.end(JSON.stringify({ error: "session-events probe cap reached — fall back to /head polling" }));
370
+ res.setHeader("retry-after", "5");
371
+ sendError(res, 503, "state.sse_probe_cap", "session-events probe cap reached — fall back to /head polling");
369
372
  return;
370
373
  }
371
374
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
package/dist/main.js CHANGED
@@ -14,7 +14,7 @@ import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.
14
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
15
15
  import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadObserver } from "./plugins/web-search.js";
16
16
  import { createAuthorizer } from "./security.js";
17
- import { createDurableAskPolicy } from "./approval.js";
17
+ import { createDurableAskPolicy, hasOperatorGateIntent } from "./approval.js";
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
20
20
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
@@ -30,6 +30,7 @@ import { performMemorySync } from "./memory-sync.js";
30
30
  import { startOtlpExporter } from "./observability/otel-exporter.js";
31
31
  import { HEARTBEAT_MS, backgroundAgentOutput, taskHandleOutput, taskHandleStop } from "./runs.js";
32
32
  import { SQL_BLOB_DEFAULT_MAX_BYTES } from "./plugins/blob-backend.js";
33
+ import { counterStoreLabel } from "./plugins/store-backend.js";
33
34
  import { composeHooks } from "./hooks/hook-runner.js";
34
35
  import { createHookLlm } from "./hooks/hook-llm.js";
35
36
  import { startFleetClientFromEnv } from "./fleet-client.js";
@@ -367,7 +368,8 @@ async function main() {
367
368
  // backing store is wired (e.g. DB_BACKEND=local → no approvalStore/checkpointStore ⇒ approvalEnabled/durableEnabled
368
369
  // false), that is a MISCONFIG — do NOT mask it with allow-all; leave toolPolicy undefined so core's UNGATED warning
369
370
  // still surfaces it. So the baseline needs single-user turnkey ∧ zero expressed gate intent.
370
- const operatorGateIntent = config.approvalRequire.length > 0 || config.approvalDeny.length > 0 || config.durableApproval;
371
+ // 单源在 approval.ts(与 inv#2 同文件)—— 这里原是内联的三格枚举,漏了 approvalNeverAuto,详见该函数顶注。
372
+ const operatorGateIntent = hasOperatorGateIntent(config);
371
373
  const singleUserAutoAcceptBaseline = config.requirePrincipal !== true && !operatorGateIntent;
372
374
  // design/45 durable-checkpoint (core 1.67): opt-in (DURABLE_APPROVAL + a pool). When on, F4 uses durable
373
375
  // suspend/resume instead of the poll gate — a gated `ask` suspends the task (status:"suspended" + checkpoint)
@@ -869,9 +871,9 @@ async function main() {
869
871
  memory: memoryEngine ? `engine(${memoryEngine.root})` : "off",
870
872
  instanceId,
871
873
  rateLimitPerMin: config.rateLimitPerMin,
872
- rateLimit: config.rateLimitPerMin > 0 ? (backend ? `shared()` : "in-process") : "off",
874
+ rateLimit: config.rateLimitPerMin > 0 ? counterStoreLabel(backend?.rateLimiter, backend?.kind) : "off",
873
875
  maxPrincipalCostUsd: config.maxPrincipalCostUsd || null,
874
- costQuota: config.maxPrincipalCostUsd > 0 ? (backend ? `shared()` : "in-process") : "off",
876
+ costQuota: config.maxPrincipalCostUsd > 0 ? counterStoreLabel(backend?.costQuota, backend?.kind) : "off",
875
877
  // durable offload store for large tool results (cross-replica wake); else core's in-process default
876
878
  toolResultStore: toolResultStore ? (backend?.kind === "local" ? "file(local)" : "shared(sql)") : "in-process",
877
879
  otel: config.otel ? config.otel.endpoint : null,
@@ -186,6 +186,19 @@ export declare function assertCloudSnapshotBlobPosture(kind: "mysql" | "pg" | "l
186
186
  * field the 20G-workspace case needed; more knobs when a case shows up. Exported for tests
187
187
  * (fractional-MB rounding + partial-merge semantics need a regression lock). */
188
188
  export declare function snapshotBoundsFromConfig(config: ServiceConfig): FileSnapshotBounds;
189
+ /**
190
+ * 启动日志里「跨副本 or 进程内」那一格的标签。
191
+ *
192
+ * 🔴 判据必须是**那个 store 本身在不在**,不是「有没有 backend」(2026-07-31 缝合审)。
193
+ * `LocalBackend` 明确省略 `costQuota`/`rateLimiter`(见它类尾那行 "intentionally absent"),
194
+ * 而 `DB_BACKEND=local` 是**裸 boot 的默认值** —— 原来 main.ts 那两行用 `backend ? "shared()" : …` 判,
195
+ * 于是单机默认形配了 `RATE_LIMIT_PER_MIN` 会打出 `shared()`,实际跑的是每副本一份的进程内限流器。
196
+ * 运维扩到 N 副本时读这行判定"限流是跨副本的",真实额度是 N 倍。
197
+ * (同一个说谎病族在紧邻的 `breakerState` 上修过一次——"was hard-coded 'shared(tidb)' — lied under
198
+ * DB_BACKEND=pg"——`session:` 也跟着修了,这两行没跟上。顺带:原来的 `shared()` 括号是空的,
199
+ * 而兄弟行都写 `shared(${kind})`。)
200
+ */
201
+ export declare function counterStoreLabel(store: unknown, kind: string | undefined): string;
189
202
  /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
190
203
  * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured. */
191
204
  export declare function createStoreBackend(config: ServiceConfig): StoreBackend | undefined;
@@ -276,6 +276,21 @@ class LocalBackend {
276
276
  mysqlPool() { return undefined; }
277
277
  pgPool() { return undefined; }
278
278
  }
279
+ /**
280
+ * 启动日志里「跨副本 or 进程内」那一格的标签。
281
+ *
282
+ * 🔴 判据必须是**那个 store 本身在不在**,不是「有没有 backend」(2026-07-31 缝合审)。
283
+ * `LocalBackend` 明确省略 `costQuota`/`rateLimiter`(见它类尾那行 "intentionally absent"),
284
+ * 而 `DB_BACKEND=local` 是**裸 boot 的默认值** —— 原来 main.ts 那两行用 `backend ? "shared()" : …` 判,
285
+ * 于是单机默认形配了 `RATE_LIMIT_PER_MIN` 会打出 `shared()`,实际跑的是每副本一份的进程内限流器。
286
+ * 运维扩到 N 副本时读这行判定"限流是跨副本的",真实额度是 N 倍。
287
+ * (同一个说谎病族在紧邻的 `breakerState` 上修过一次——"was hard-coded 'shared(tidb)' — lied under
288
+ * DB_BACKEND=pg"——`session:` 也跟着修了,这两行没跟上。顺带:原来的 `shared()` 括号是空的,
289
+ * 而兄弟行都写 `shared(${kind})`。)
290
+ */
291
+ export function counterStoreLabel(store, kind) {
292
+ return store ? `shared(${kind ?? "unknown"})` : "in-process";
293
+ }
279
294
  /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
280
295
  * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured. */
281
296
  export function createStoreBackend(config) {
@@ -1,40 +1,3 @@
1
- /**
2
- * Durable backing store for offloaded large tool results (core 1.47 offload / 1.49 durable contract) —
3
- * SINGLE-FILE DUAL-DIALECT (design/158 A12 定型半场). ONE implementation, TWO dialects; the historical
4
- * `TiDBToolResultStore` / `PgToolResultStore` class names survive as thin ctor subclasses so every consumer
5
- * (store-backend.ts, the fake-pool unit suites, the real-DB integration suites) is untouched.
6
- *
7
- * Mirrors core's reference `TiDbToolResultStore` adapter. Without this, the Runner defaults to a
8
- * task-scoped in-memory store: an async run that wakes on ANOTHER replica can't `read_tool_result` an
9
- * offloaded result (the ref misses → the model is told it's unavailable; the preview still stands, so it
10
- * degrades, never crashes). A durable store survives wake/resume across the stateless fleet.
11
- *
12
- * core namespaces the ref as `tr_<sessionId>_<toolCallId>` (1.49) → globally unique → usable directly as
13
- * the PRIMARY KEY (no composite key needed). `put` is write-once (idempotent on replay/retry: a retry is
14
- * a NEW toolCallId → new ref, so a given ref never changes content). Retention is anchored to a run's
15
- * RECOVERABLE window via TTL (`reapOlderThan`): a resumable run may re-fetch an old ref after wake, but a
16
- * completed run only needs the preview for audit/replay — so a TTL comfortably exceeding the run lifetime
17
- * bounds storage without losing recoverable full-text.
18
- *
19
- * ── Dialect deltas, kept EXPLICIT ────────────────────────────────────────────────────────────────────
20
- * - `?` placeholders vs `$n`
21
- * - write-once insert = `INSERT IGNORE` vs `INSERT ... ON CONFLICT (ref) DO NOTHING`
22
- * - affectedRows vs rowCount (via SqlDriver)
23
- * - `SUBSTRING(content, ?, ?)` (1-indexed, comma form) vs the ANSI `SUBSTRING(content FROM $n::int FOR
24
- * $m::int)` positional form WITH explicit `::int` casts — node-pg binds bare params as text, and
25
- * `SUBSTRING(s FROM <text>)` is the POSIX-REGEX overload (it would treat $1 as a pattern); the cast
26
- * forces the positional integer overload. `CHAR_LENGTH` is ANSI and shared.
27
- * - content column: TiDB LONGTEXT vs PG TEXT (PG has no LONGTEXT; TEXT is unbounded), DATETIME(3) vs
28
- * TIMESTAMPTZ(3) — schema only (TiDB DDL in tidb-pool.ts; PG DDL self-contained below, exported for the
29
- * integration test + pg-pool.ts's central apply).
30
- * - `put`'s UTF-16 sanitize is a GENUINE algorithm divergence, not just SQL text — see the dialect branch
31
- * inline for why.
32
- * - ESCAPE-clause literal: TiDB spells `ESCAPE '\\\\'` (declares the backslash escape char explicitly —
33
- * under `sql_mode=NO_BACKSLASH_ESCAPES` `\` is NOT the LIKE escape char by default, so leaving it
34
- * implicit would silently stop escaping and let a crafted sessionId's `_`/`%` over-match OTHER
35
- * sessions' rows); PG spells `ESCAPE '\'` (its default escape char already, kept for lock-step intent
36
- * with the TiDB twin rather than out of necessity).
37
- */
38
1
  import type { ToolResultStore, ToolResultSlice } from "@sema-agent/core";
39
2
  import type { Pool as MySqlPool } from "mysql2/promise";
40
3
  import type { Pool as PgPool, PoolClient as PgPoolClient } from "pg";
@@ -52,8 +15,13 @@ export declare class SqlToolResultStore implements ToolResultStore {
52
15
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
53
16
  private q;
54
17
  /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
55
- * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
56
- * (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
18
+ * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。
19
+ *
20
+ * 🔴 判定**单源**:直调 core 的 `assertSafeToolResultRef`(core 2.8.0 起入公共面)。
21
+ * 在此之前这里是那 6 个判别条件的**本地镜像** —— 我在 [2159] 主动交出过这条裂缝:镜像是第二真源,
22
+ * core 哪天收紧一格(加 Windows 保留名、长度上限……)我方不会跟着动,而**试剂盒会继续绿**
23
+ * (它测的是「拒不拒」,不是「按同一张表拒」)。core 按请求追加了导出,镜像随之删除,裂缝闭合。
24
+ * 抛错文案与 core 逐字相同,所以 wire 与既有钉都不变。 */
57
25
  private isUnsafeRef;
58
26
  private assertSafeRef;
59
27
  put(ref: string, content: string): Promise<void>;
@@ -1,3 +1,41 @@
1
+ /**
2
+ * Durable backing store for offloaded large tool results (core 1.47 offload / 1.49 durable contract) —
3
+ * SINGLE-FILE DUAL-DIALECT (design/158 A12 定型半场). ONE implementation, TWO dialects; the historical
4
+ * `TiDBToolResultStore` / `PgToolResultStore` class names survive as thin ctor subclasses so every consumer
5
+ * (store-backend.ts, the fake-pool unit suites, the real-DB integration suites) is untouched.
6
+ *
7
+ * Mirrors core's reference `TiDbToolResultStore` adapter. Without this, the Runner defaults to a
8
+ * task-scoped in-memory store: an async run that wakes on ANOTHER replica can't `read_tool_result` an
9
+ * offloaded result (the ref misses → the model is told it's unavailable; the preview still stands, so it
10
+ * degrades, never crashes). A durable store survives wake/resume across the stateless fleet.
11
+ *
12
+ * core namespaces the ref as `tr_<sessionId>_<toolCallId>` (1.49) → globally unique → usable directly as
13
+ * the PRIMARY KEY (no composite key needed). `put` is write-once (idempotent on replay/retry: a retry is
14
+ * a NEW toolCallId → new ref, so a given ref never changes content). Retention is anchored to a run's
15
+ * RECOVERABLE window via TTL (`reapOlderThan`): a resumable run may re-fetch an old ref after wake, but a
16
+ * completed run only needs the preview for audit/replay — so a TTL comfortably exceeding the run lifetime
17
+ * bounds storage without losing recoverable full-text.
18
+ *
19
+ * ── Dialect deltas, kept EXPLICIT ────────────────────────────────────────────────────────────────────
20
+ * - `?` placeholders vs `$n`
21
+ * - write-once insert = `INSERT IGNORE` vs `INSERT ... ON CONFLICT (ref) DO NOTHING`
22
+ * - affectedRows vs rowCount (via SqlDriver)
23
+ * - `SUBSTRING(content, ?, ?)` (1-indexed, comma form) vs the ANSI `SUBSTRING(content FROM $n::int FOR
24
+ * $m::int)` positional form WITH explicit `::int` casts — node-pg binds bare params as text, and
25
+ * `SUBSTRING(s FROM <text>)` is the POSIX-REGEX overload (it would treat $1 as a pattern); the cast
26
+ * forces the positional integer overload. `CHAR_LENGTH` is ANSI and shared.
27
+ * - content column: TiDB LONGTEXT vs PG TEXT (PG has no LONGTEXT; TEXT is unbounded), DATETIME(3) vs
28
+ * TIMESTAMPTZ(3) — schema only (TiDB DDL in tidb-pool.ts; PG DDL self-contained below, exported for the
29
+ * integration test + pg-pool.ts's central apply).
30
+ * - `put`'s UTF-16 sanitize is a GENUINE algorithm divergence, not just SQL text — see the dialect branch
31
+ * inline for why.
32
+ * - ESCAPE-clause literal: TiDB spells `ESCAPE '\\\\'` (declares the backslash escape char explicitly —
33
+ * under `sql_mode=NO_BACKSLASH_ESCAPES` `\` is NOT the LIKE escape char by default, so leaving it
34
+ * implicit would silently stop escaping and let a crafted sessionId's `_`/`%` over-match OTHER
35
+ * sessions' rows); PG spells `ESCAPE '\'` (its default escape char already, kept for lock-step intent
36
+ * with the TiDB twin rather than out of necessity).
37
+ */
38
+ import { assertSafeToolResultRef } from "@sema-agent/core";
1
39
  import { escapeLike } from "./sql-escape.js";
2
40
  import { pgSanitizeText } from "./pg-safe-json.js";
3
41
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
@@ -29,15 +67,25 @@ export class SqlToolResultStore {
29
67
  return this.db.dialect === "tidb" ? tidb : pg;
30
68
  }
31
69
  /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
32
- * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
33
- * (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
70
+ * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。
71
+ *
72
+ * 🔴 判定**单源**:直调 core 的 `assertSafeToolResultRef`(core 2.8.0 起入公共面)。
73
+ * 在此之前这里是那 6 个判别条件的**本地镜像** —— 我在 [2159] 主动交出过这条裂缝:镜像是第二真源,
74
+ * core 哪天收紧一格(加 Windows 保留名、长度上限……)我方不会跟着动,而**试剂盒会继续绿**
75
+ * (它测的是「拒不拒」,不是「按同一张表拒」)。core 按请求追加了导出,镜像随之删除,裂缝闭合。
76
+ * 抛错文案与 core 逐字相同,所以 wire 与既有钉都不变。 */
34
77
  isUnsafeRef(ref) {
35
- return ref === "" || ref === "." || ref === ".." || ref.includes("/") || ref.includes("\\") ||
36
- [...ref].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
78
+ // core 只给 assert (它就是判定本体);读面要的是谓词,由同一个 assert 派生 —— 仍是单源。
79
+ try {
80
+ assertSafeToolResultRef(ref);
81
+ return false;
82
+ }
83
+ catch {
84
+ return true;
85
+ }
37
86
  }
38
87
  assertSafeRef(ref) {
39
- if (this.isUnsafeRef(ref))
40
- throw new Error(`tool-result store: unsafe ref ${JSON.stringify(ref)}`);
88
+ assertSafeToolResultRef(ref);
41
89
  }
42
90
  async put(ref, content) {
43
91
  this.assertSafeRef(ref);
@@ -248,6 +248,9 @@ export declare function brainStatusEventData(ev: {
248
248
  phase: string;
249
249
  detail?: string;
250
250
  retryInSec?: number;
251
+ attempt?: number;
252
+ maxRetries?: number;
253
+ retryInMs?: number;
251
254
  eventId?: string;
252
255
  parentToolCallId?: string;
253
256
  }): Record<string, unknown>;
@@ -289,10 +289,14 @@ export function workspaceChangedEventData(ev) {
289
289
  * SERVICE's own observation record (why a turn stalled: rate_limited/retrying/reconnecting/circuit_open),
290
290
  * not a core event replay. Whitelist: the closed phase union + neutral bounded detail (redacted) + retryInSec. */
291
291
  export function brainStatusEventData(ev) {
292
+ const num = (v) => typeof v === "number" && Number.isFinite(v);
292
293
  return {
293
294
  phase: String(ev.phase),
294
295
  ...(typeof ev.detail === "string" && ev.detail.length > 0 ? { detail: redactSecrets(ev.detail).slice(0, 300) } : {}),
295
- ...(typeof ev.retryInSec === "number" && Number.isFinite(ev.retryInSec) ? { retryInSec: ev.retryInSec } : {}),
296
+ ...(num(ev.retryInSec) ? { retryInSec: ev.retryInSec } : {}),
297
+ ...(num(ev.attempt) ? { attempt: ev.attempt } : {}),
298
+ ...(num(ev.maxRetries) ? { maxRetries: ev.maxRetries } : {}),
299
+ ...(num(ev.retryInMs) ? { retryInMs: ev.retryInMs } : {}),
296
300
  ...identityFields(ev),
297
301
  };
298
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^2.5.0",
57
+ "@sema-agent/core": "^2.9.0",
58
58
  "@sema-agent/registry-core": "^0.11.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",