@sema-agent/server 3.9.0 → 3.10.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/dist/budget.js +23 -0
- package/dist/config-types.d.ts +7 -2
- package/dist/plugins/file-snapshot-store-sql.js +6 -0
- package/dist/plugins/mailbox-store-sql.js +13 -0
- package/dist/plugins/tool-result-store-sql.d.ts +5 -0
- package/dist/plugins/tool-result-store-sql.js +16 -0
- package/package.json +1 -1
package/dist/budget.js
CHANGED
|
@@ -178,6 +178,15 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
|
|
|
178
178
|
// 「下界 + costUnknown」——两层语义与本处的关系见 fleet-client.ts 的旁注(3.7.0 前这里曾写
|
|
179
179
|
// 「wire 表达不了缺席、center 只见低估」,已被该车推翻;doc-rot 修 2026-07-30)。
|
|
180
180
|
// └──────────────────────────────────────────────────────────────────────────────────────────────┘
|
|
181
|
+
// [2153]③ 侦察钉(core 2.6.0):`brain.call` 帧新增可选 `turn?: number`(trace.d.ts 确认;此前只有
|
|
182
|
+
// `turn.end`/`repetition.detected`/`task.degraded` 三帧带 turn)。**刻意不接**——`modelUsage.record`
|
|
183
|
+
// 喂的是 E8 durable `model_usage` echo,累加键=`taskId → model`(见 `ModelUsageTracker.byTask`,
|
|
184
|
+
// 上方 class 定义),drain 边界=**per-turn**(run lifecycle 在每个 turn 边界 drain 一次、append 一条
|
|
185
|
+
// durable 事件行)。也就是说 turn 粒度已经由「何时 drain」这个外层动作表达,一次 drain 窗口内本就
|
|
186
|
+
// 只可能有一个 turn 的 delta——帧上逐行再带一份 `turn` 是重复信息,对已经 per-turn 聚合的行没有
|
|
187
|
+
// 增量语义(不是「丢了信息」,是「信息已经在结构里」)。将来若变盘:若聚合粒度改粗(比如攒够 N
|
|
188
|
+
// turn 才 drain 一次,单行 delta 跨多个 turn),或 usage-analytics 想要「同一 drain 窗口内哪个 turn
|
|
189
|
+
// 花了多少」的细分,才值得把 `e.turn` 塞进 `ModelUsageDelta`(那时 byTask 需要再加一层 turn 键)。
|
|
181
190
|
// E8 (shell-host): per-task × per-model usage for the `TaskStats.modelUsage` echo. `record` is a NO-OP unless
|
|
182
191
|
// e.taskId is a REGISTERED top-level run (the leak fence — sub-task/throwaway taskIds are never registered, so
|
|
183
192
|
// they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
|
|
@@ -201,6 +210,20 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
|
|
|
201
210
|
metrics.observe("brain_call_latency_ms", e.latencyMs);
|
|
202
211
|
}
|
|
203
212
|
else if (e.kind === "tool.call") {
|
|
213
|
+
// [2153]③ 侦察钉(core 2.6.0):`tool.call` 帧新增可选 `toolCallId?: string` / `turn?: number`(trace.d.ts
|
|
214
|
+
// 确认)。**刻意不接进 `tool_calls_total` 的标签**:`toolCallId` 逐调用唯一 → 直接当标签会让这个
|
|
215
|
+
// 计数器的基数=调用总数(等于没聚合),是本文件 `cardinalityGuard` 存在的理由要防的那类爆炸,
|
|
216
|
+
// 连 guard 都救不了(不是「用户输入无界」那类可折叠 __other__ 的情形,是键本身设计上就唯一)。
|
|
217
|
+
// `turn` 本身是低基数(有界于单任务的 turn 数),但作为**跨任务**的 Prometheus 标签会随长任务/
|
|
218
|
+
// 多任务叠加成事实上无界的序列(同 model/tool_name 已用 guard 的理由,turn 目前没有等价上限)——
|
|
219
|
+
// 且当前没有「按 turn 分桶看 tool 调用量」的仪表盘/告警消费者在等这个切面,先不加标签。这两个键
|
|
220
|
+
// 已经在别的通道有名分:`toolCallId` 走 wire 的 `tool_start`/`tool_end` TaskEvent(trace/project.ts
|
|
221
|
+
// toolStartEventData/toolEndEventData,逐调用可关联、不进 metrics 基数);`turn` 走 `turn.end`/
|
|
222
|
+
// `task.end.turns`。本臂(createTracer→/metrics)只做计数,不是这两键的合适去处。将来若要接:
|
|
223
|
+
// (a) 出现「per-turn tool 调用密度」的真实消费者(如异常 turn 侦测),`turn` 分桶价值才成立——
|
|
224
|
+
// 用有界桶(如 `turn <= 20 ? String(turn) : "20+"`)而非原始整数,避免上面同款无界问题;
|
|
225
|
+
// (b) `toolCallId` 除非改造成非标签的关联字段(如结构化日志行,不是 Prometheus label),否则永远
|
|
226
|
+
// 不该进这个计数器。
|
|
204
227
|
metrics.inc("tool_calls_total", { name: toolName(e.name), ok: String(e.ok) });
|
|
205
228
|
}
|
|
206
229
|
else if (e.kind === "task.degraded") {
|
package/dist/config-types.d.ts
CHANGED
|
@@ -670,8 +670,13 @@ export interface ServiceConfigFlat {
|
|
|
670
670
|
* (default/auto/acceptEdits) gate every fs WRITE hand tool (Write/Edit/NotebookEdit) but never touched Bash —
|
|
671
671
|
* a model can `echo -n '…' > file` straight past the write-approval card. `MANUAL_MODE_SHELL_GATE` opts a
|
|
672
672
|
* deployment into ALSO tightening `TaskSpec.shellGate` on those same modes (core `bashReversibilityProbe`
|
|
673
|
-
* under `"classify"` only asks on constructively-irreversible commands — shell redirects etc
|
|
674
|
-
*
|
|
673
|
+
* under `"classify"` only asks on constructively-irreversible commands — shell redirects etc.).
|
|
674
|
+
* 🔴 **语义随 core 版本演进(2026-07-31 核)**:`"classify"` 下「纯读自动放行」这句自 core 2.7.0 起
|
|
675
|
+
* **不再无条件成立** —— RB-412 给 classifier 加了 root 边界扫描,core 内部自铸 boundary
|
|
676
|
+
* (`prepare-task.ts` 的 `shellReadBoundary`:roots=任务 root+additionalRoots,cwd=活 handsCwd),
|
|
677
|
+
* **读到 roots 之外的路径同样会 ask**(server 侧零接线即得:我们只传 shellGate,边界是 core 自算的)。
|
|
678
|
+
* 旧 core(<2.7.0)下仍是名字级判定=越界读放行,两者都能工作、后者更严 ⇒ floor 不因此抬。
|
|
679
|
+
* Absent/unset = `undefined` = **zero behavior change** (this is a tighten-ONLY opt-in, never a
|
|
675
680
|
* default — a deployment must explicitly choose it); `"always"` = CC-strict parity (every Bash asks). Any
|
|
676
681
|
* other value (incl. `"off"`) is treated as unset — this knob has no "off" value to opt BACK OUT with once a
|
|
677
682
|
* session's baseline already tightened elsewhere (tightenTaskSpec would reject that as a loosen anyway). */
|
|
@@ -205,6 +205,12 @@ export class SqlFileSnapshotStore {
|
|
|
205
205
|
// the manifest tx runs → fail-closed: no manifest row ⇒ has() false ⇒ never a half-snapshot a restore would
|
|
206
206
|
// silently truncate. Upsert REFRESHES created_at (incl dedup hits) → blobs touched=now, GC-grace-protected.
|
|
207
207
|
for (const hash of new Set(manifest.values())) {
|
|
208
|
+
// D2(试剂盒揪出,RB-361 同源):路径逃逸型 hash 在任何 fetch **之前**拒,文案逐字同 core
|
|
209
|
+
// File/InMemory 后端(消费方按文案分支不劈叉);SQL 侧 hash 虽只是字符串键,但 srcGetBlob
|
|
210
|
+
// 是跨后端回调(2c 传输面可能远端),可疑值不外递。
|
|
211
|
+
if (hash === "" || hash === "." || hash === ".." || !/^[A-Za-z0-9_.-]+$/.test(hash)) {
|
|
212
|
+
return { ok: false, error: { code: "read_failed", message: `unsafe blob hash ${JSON.stringify(hash)}` } };
|
|
213
|
+
}
|
|
208
214
|
let bytes;
|
|
209
215
|
try {
|
|
210
216
|
bytes = await srcGetBlob(hash);
|
|
@@ -10,6 +10,13 @@ function assertScope(scope) {
|
|
|
10
10
|
/** 键长守卫(复审 F5):TiDB `INSERT IGNORE` 对超 190 字节键**静默截断**(warning 降级)——截断键
|
|
11
11
|
* 与全长参数的字节比对恒 miss(seq 分配读空行 TypeError / 隔离键错位);PG 侧是干净 22001,同点
|
|
12
12
|
* 提前拒 = 双方言同文案 fail-loud。 */
|
|
13
|
+
/** D4(试剂盒揪出,RB-251 语义):身份键含 NUL/控制符——TiDB VARBINARY 能存但与文件头「两后端一致
|
|
14
|
+
* 拒绝」的声明不符(该声明此前是谎);写入面双方言同精神拒绝(PG 臂经 PgUnstorableError 已拒)。 */
|
|
15
|
+
function assertIdentityChars(what, v) {
|
|
16
|
+
if ([...v].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f)) {
|
|
17
|
+
throw new Error(`MailboxStore: ${what} contains control characters (refusing — identity keys must be byte-exact addressable)`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
13
20
|
function assertKeyBytes(what, v) {
|
|
14
21
|
if (Buffer.byteLength(v, "utf8") > 190) {
|
|
15
22
|
throw new Error(`MailboxStore: ${what} exceeds the 190-byte key column (refusing — a truncated key would desync from its byte-exact lookups)`);
|
|
@@ -95,6 +102,8 @@ export class TiDBMailboxStore {
|
|
|
95
102
|
}
|
|
96
103
|
async append(scope, handle, msg) {
|
|
97
104
|
assertScope(scope);
|
|
105
|
+
assertIdentityChars("scope", scope);
|
|
106
|
+
assertIdentityChars("handle", handle);
|
|
98
107
|
assertKeyBytes("scope", scope);
|
|
99
108
|
assertKeyBytes("handle", handle);
|
|
100
109
|
// ensure-first 形:先幂等 INSERT IGNORE 建盒(next_seq=1,首条/drop 后重建同臂)再 FOR UPDATE——
|
|
@@ -290,6 +299,10 @@ export class PgMailboxStore {
|
|
|
290
299
|
WHERE scope_key = $1 AND handle = $2 AND lease_owner = $3`, [scope, handle, owner]);
|
|
291
300
|
}
|
|
292
301
|
async peekCount(scope, handle) {
|
|
302
|
+
// D4(试剂盒揪出):unstorable 身份键在**读面降级**返回 0(该键在写入面就被拒 ⇒ 盒必空),
|
|
303
|
+
// 而不是把驱动层 22021 裸抛给调用方——与 tool-result 读面「degrades instead」同精神。
|
|
304
|
+
if (pgHasUnstorable(scope) || pgHasUnstorable(handle))
|
|
305
|
+
return 0;
|
|
293
306
|
const { rows } = await this.pool.query(`SELECT COUNT(*) AS n FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
|
|
294
307
|
return Number(rows[0].n);
|
|
295
308
|
}
|
|
@@ -51,6 +51,11 @@ export declare class SqlToolResultStore implements ToolResultStore {
|
|
|
51
51
|
constructor(db: SqlDriver);
|
|
52
52
|
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
53
53
|
private q;
|
|
54
|
+
/** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
|
|
55
|
+
* ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
|
|
56
|
+
* (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
|
|
57
|
+
private isUnsafeRef;
|
|
58
|
+
private assertSafeRef;
|
|
54
59
|
put(ref: string, content: string): Promise<void>;
|
|
55
60
|
get(ref: string, opts?: {
|
|
56
61
|
offset?: number;
|
|
@@ -28,7 +28,19 @@ export class SqlToolResultStore {
|
|
|
28
28
|
q(tidb, pg) {
|
|
29
29
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
30
30
|
}
|
|
31
|
+
/** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
|
|
32
|
+
* ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
|
|
33
|
+
* (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
|
|
34
|
+
isUnsafeRef(ref) {
|
|
35
|
+
return ref === "" || ref === "." || ref === ".." || ref.includes("/") || ref.includes("\\") ||
|
|
36
|
+
[...ref].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
|
|
37
|
+
}
|
|
38
|
+
assertSafeRef(ref) {
|
|
39
|
+
if (this.isUnsafeRef(ref))
|
|
40
|
+
throw new Error(`tool-result store: unsafe ref ${JSON.stringify(ref)}`);
|
|
41
|
+
}
|
|
31
42
|
async put(ref, content) {
|
|
43
|
+
this.assertSafeRef(ref);
|
|
32
44
|
// Sanitize invalid UTF-16 (lone/half surrogates — a JS string CAN hold them, e.g. a binary-ish tool
|
|
33
45
|
// output) to U+FFFD so the column accepts the FULL value via a Buffer utf8 round-trip (valid content is
|
|
34
46
|
// unchanged). Shared by both dialects.
|
|
@@ -45,6 +57,10 @@ export class SqlToolResultStore {
|
|
|
45
57
|
await this.db.query(this.q("INSERT IGNORE INTO tool_result (ref, content, created_at) VALUES (?,?,?)", "INSERT INTO tool_result (ref, content, created_at) VALUES ($1,$2,$3) ON CONFLICT (ref) DO NOTHING"), [ref, safe, new Date()]);
|
|
46
58
|
}
|
|
47
59
|
async get(ref, opts = {}) {
|
|
60
|
+
// D1 读面半条(RB-266「the read face degrades instead」):unsafe ref 在读面**降级**返回 undefined
|
|
61
|
+
// (该 ref 写入面就被拒 ⇒ 必不在库),不把驱动层错误(PG 22021 对 NUL)漏给调用方。
|
|
62
|
+
if (this.isUnsafeRef(ref))
|
|
63
|
+
return undefined;
|
|
48
64
|
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
|
49
65
|
const hasLimit = opts.limit !== undefined && Number.isFinite(opts.limit);
|
|
50
66
|
const limit = hasLimit ? Math.max(0, Math.floor(opts.limit)) : 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.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",
|