@sema-agent/client-core 0.60.0 → 0.62.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.
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * WIRE FACTS: with `forwardSubagentEvents: true` on the task request, the live stream carries the
12
12
  * subagent's text_delta / reasoning_delta / tool_start / tool_end stamped with EventIdentity
13
+ * —— 🔴 **durable 腿(重放)送的是同一段的聚合形** `text` / `reasoning`(整段全文,同样带
14
+ * EventIdentity)。两类都要收:只收增量的话,续听重放尾的宿主整条子代转录都是空的
15
+ * (0.62.0 补;两类的处置**不同**,见 {@link publishSubagentContentEvent} 的吸收规则)
13
16
  * (§E1 redact upstream; §E2 identity). 🔴 EventIdentity 的键是 `eventId` / `parentToolCallId`
14
17
  * (+ LIVE 白名单四臂上的 `sourceTaskId` / `bgAgentId`)—— **没有 `taskId`**(sdk `events.d.ts` 的
15
18
  * `interface EventIdentity` 直证;`taskId` 只长在 `meta` 首帧上)。所以内容帧到不了「自带引擎
@@ -89,7 +92,241 @@ export function coerceOutput(v) {
89
92
  const MAX_ITEMS_PER_TASK = 200;
90
93
  const MAX_TASKS = 32;
91
94
  const NOTIFY_COALESCE_MS = 250;
95
+ /** 不配时生效的两位(理由见 {@link SubagentContentStoreConfig} 顶注)。 */
96
+ export const SUBAGENT_CONTENT_STORE_DEFAULTS = Object.freeze({
97
+ maxBytesPerTask: 2 * 1024 * 1024,
98
+ maxBytesTotal: 16 * 1024 * 1024,
99
+ });
100
+ let storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
101
+ /**
102
+ * 配字节预算(宿主启动时调一次;两位可分开配,只给一位时另一位保持现值)。
103
+ *
104
+ * 🔴 **fail-loud**:0 / 负数 / 非有限数 / 非整数一律 `throw` —— 一个被**静默忽略**的预算配置正是
105
+ * 这本账本要消灭的那类失败(宿主以为配上了,而账本还在按缺省无声地丢或不丢)。
106
+ * 🔴 拒绝是**原子**的:任何一位不合法就整只拒,既有配置一个字节不动(半套配置比不配更坏)。
107
+ */
108
+ export function configureSubagentContentStore(next) {
109
+ const check = (name, v) => {
110
+ if (v === undefined)
111
+ return undefined;
112
+ if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) {
113
+ throw new TypeError(`configureSubagentContentStore: \`${name}\` must be a positive integer number of bytes (got ${String(v)})`);
114
+ }
115
+ return v;
116
+ };
117
+ const perTask = check('maxBytesPerTask', next.maxBytesPerTask);
118
+ const total = check('maxBytesTotal', next.maxBytesTotal);
119
+ // 🔴 两位的**关系**也要验(车内异源复审逼出:两位各自合法、合起来自相矛盾)。每子代帽大于总帽时,
120
+ // 单条子代可以合法地涨到每子代帽 —— 而总帽淘汰**不动当期那条**(它正在写),于是那一条就整只
121
+ // 越过了总帽,总帽从此形同虚设。判的是**落值之后**的那一对,不是这次传进来的那一位:只配一位
122
+ // 时另一位保持现值,矛盾同样可能是这次配出来的。
123
+ const nextPerTask = perTask ?? storeConfig.maxBytesPerTask;
124
+ const nextTotal = total ?? storeConfig.maxBytesTotal;
125
+ if (nextPerTask > nextTotal) {
126
+ throw new RangeError(`configureSubagentContentStore: \`maxBytesPerTask\` (${String(nextPerTask)}) must not exceed ` +
127
+ `\`maxBytesTotal\` (${String(nextTotal)}) — a per-task budget above the total is unreachable by ` +
128
+ 'construction, and the total stops bounding anything.');
129
+ }
130
+ // 两位都验完才落 —— 第二位不合法时第一位也不许生效。
131
+ storeConfig.maxBytesPerTask = nextPerTask;
132
+ storeConfig.maxBytesTotal = nextTotal;
133
+ }
134
+ /** 当前生效的两位(**快照**:改返回值不影响账本)。 */
135
+ export function subagentContentStoreConfig() {
136
+ return { ...storeConfig };
137
+ }
138
+ /** 测试钩:把预算复位到缺省。 */
139
+ export function __resetSubagentContentStoreConfigForTests() {
140
+ storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
141
+ }
92
142
  const tasks = new Map();
143
+ /** 整本账本当下保留的内容字节(逐次增删维护;总帽读它)。 */
144
+ let totalBytes = 0;
145
+ /**
146
+ * 一段文本的 **UTF-8 字节数**,不做编码分配。
147
+ *
148
+ * 🔴 为什么不用 `TextEncoder`:它每次都要**分配**一个字节数组,而这条路是逐 token 的热路径
149
+ * (实测 ~23 帧/s 的增量流)。算术形零分配、结果逐字节相同。
150
+ * 🔴 为什么不按 `str.length`(UTF-16 单元)算:账本里装的是引擎送来的文本,一段 CJK 的真实占用
151
+ * 是串长度的三倍 —— 按串长度算等于把预算对中文内容放宽两倍。
152
+ */
153
+ function utf8Len(str) {
154
+ let n = 0;
155
+ for (let i = 0; i < str.length; i++) {
156
+ const c = str.charCodeAt(i);
157
+ if (c < 0x80)
158
+ n += 1;
159
+ else if (c < 0x800)
160
+ n += 2;
161
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
162
+ // 合法代理对 = 一个 4 字节码位(高位后面不是低位时按 3 字节的孤代理项算,与编码器同)
163
+ const next = str.charCodeAt(i + 1);
164
+ if (next >= 0xdc00 && next <= 0xdfff) {
165
+ n += 4;
166
+ i++;
167
+ }
168
+ else
169
+ n += 3;
170
+ }
171
+ else
172
+ n += 3;
173
+ }
174
+ return n;
175
+ }
176
+ /**
177
+ * 把 `chunk` 追加到 `prev` 之后,**字节账真正增加了多少**。
178
+ *
179
+ * 🔴 不能直接用 `utf8Len(chunk)`(车内异源复审逼出的真病):一对代理对被上游拆成**两帧**送来时,
180
+ * 前一帧尾部那枚高位代理项按孤代理项算 3 字节、后一帧首部那枚低位代理项也算 3 字节,合计 6;
181
+ * 而拼起来之后它是一个 4 字节码位。每拆一次就虚高 2 字节,而段闭合时是按**拼接后的串**扣账
182
+ * —— 加 6 扣 4,差额永久留在账上。长流里这个漂移会一路把总账推高,最后把还在用的账本淘汰掉。
183
+ * ⇒ 只在**拼接边界**上修正一次(O(1)),不重扫整条缓冲(缓冲能有 MB 级,而这是逐 token 的热路径)。
184
+ */
185
+ function appendBytes(prev, chunk) {
186
+ const add = utf8Len(chunk);
187
+ if (prev.length === 0 || chunk.length === 0)
188
+ return add;
189
+ const lastPrev = prev.charCodeAt(prev.length - 1);
190
+ const firstNew = chunk.charCodeAt(0);
191
+ const joinsPair = lastPrev >= 0xd800 && lastPrev <= 0xdbff && firstNew >= 0xdc00 && firstNew <= 0xdfff;
192
+ return joinsPair ? add - 2 : add;
193
+ }
194
+ /** 一条 item 的字节账(`truncated` 是关于预算的元信息,自身不计)。 */
195
+ function itemBytesOf(it) {
196
+ switch (it.kind) {
197
+ case 'text':
198
+ case 'thinking':
199
+ case 'echo':
200
+ return utf8Len(it.text);
201
+ case 'tool': {
202
+ let n = utf8Len(it.name) + utf8Len(it.output ?? '');
203
+ // 工具参数是**开集** `unknown`:序列化一次算体量(每张卡只算一次,不在增量热路径上)。
204
+ try {
205
+ if (it.input !== undefined)
206
+ n += utf8Len(JSON.stringify(it.input) ?? '');
207
+ }
208
+ catch {
209
+ // 循环引用/不可序列化 ⇒ 这一位按 0 计(它进不了展示面,也就占不了这本账的预算)
210
+ }
211
+ return n;
212
+ }
213
+ case 'truncated':
214
+ return 0;
215
+ }
216
+ }
217
+ /** 从串**头部**裁掉至少 `want` 字节;返回剩下的尾巴与真正裁掉的字节数(不劈开合法代理对)。 */
218
+ function trimHeadBytes(str, want) {
219
+ let removed = 0;
220
+ let i = 0;
221
+ while (i < str.length && removed < want) {
222
+ const c = str.charCodeAt(i);
223
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
224
+ const next = str.charCodeAt(i + 1);
225
+ if (next >= 0xdc00 && next <= 0xdfff) {
226
+ removed += 4;
227
+ i += 2;
228
+ continue;
229
+ }
230
+ }
231
+ removed += c < 0x80 ? 1 : c < 0x800 ? 2 : 3;
232
+ i += 1;
233
+ }
234
+ return { rest: str.slice(i), removed };
235
+ }
236
+ /** 字节账的唯一增减口(两本账同拍走,永不分叉)。 */
237
+ function addBytes(s, n) {
238
+ s.bytes += n;
239
+ totalBytes += n;
240
+ }
241
+ /** 记一次丢弃:更新/建立留痕记录(每条子代恒一条,恒排在最前)。 */
242
+ function noteDropped(s, dropped) {
243
+ if (dropped <= 0)
244
+ return;
245
+ s.droppedBytes += dropped;
246
+ const head = s.items[0];
247
+ if (head?.kind === 'truncated') {
248
+ head.droppedBytes = s.droppedBytes;
249
+ head.keptFrom = s.droppedBytes;
250
+ return;
251
+ }
252
+ s.items.unshift({ kind: 'truncated', droppedBytes: s.droppedBytes, keptFrom: s.droppedBytes });
253
+ s.itemBytes.unshift(0);
254
+ rebuildOpenTools(s);
255
+ }
256
+ /** items 被增删之后重建「开着的工具卡 → 下标」表(下标随 splice/unshift 整体漂移)。 */
257
+ function rebuildOpenTools(s) {
258
+ s.openTools.clear();
259
+ s.items.forEach((it, i) => {
260
+ if (it.kind === 'tool' && it.output === undefined)
261
+ s.openTools.set(it.id, i);
262
+ });
263
+ }
264
+ /**
265
+ * 每子代帽:超出就**从最早的内容开始丢**,丢不动了再从缓冲头部裁。
266
+ *
267
+ * 🔴 顺序是「先 item 后缓冲」而不是反过来:缓冲是**活体尾巴**(用户正在看的那一段),item 是更早
268
+ * 的历史。反过来做会把用户眼前正在流的字丢掉,而屏上更早那一段完好无损 —— 那是错的一头。
269
+ * 🔴 留痕记录自身不参与丢弃(它是关于预算的元信息;丢掉它等于把「有东西被丢了」这件事也丢了)。
270
+ */
271
+ function enforceTaskCap(s) {
272
+ const cap = storeConfig.maxBytesPerTask;
273
+ if (s.bytes <= cap)
274
+ return false;
275
+ let dropped = 0;
276
+ const first = () => (s.items[0]?.kind === 'truncated' ? 1 : 0);
277
+ while (s.bytes > cap && s.items.length > first()) {
278
+ const idx = first();
279
+ const b = s.itemBytes[idx] ?? 0;
280
+ s.items.splice(idx, 1);
281
+ s.itemBytes.splice(idx, 1);
282
+ addBytes(s, -b);
283
+ dropped += b;
284
+ }
285
+ if (s.bytes > cap) {
286
+ const cut = trimHeadBytes(s.thinkBuf, s.bytes - cap);
287
+ s.thinkBuf = cut.rest;
288
+ addBytes(s, -cut.removed);
289
+ dropped += cut.removed;
290
+ }
291
+ if (s.bytes > cap) {
292
+ const cut = trimHeadBytes(s.textBuf, s.bytes - cap);
293
+ s.textBuf = cut.rest;
294
+ addBytes(s, -cut.removed);
295
+ dropped += cut.removed;
296
+ }
297
+ if (dropped > 0) {
298
+ rebuildOpenTools(s);
299
+ noteDropped(s, dropped);
300
+ return true;
301
+ }
302
+ return false;
303
+ }
304
+ /**
305
+ * 总帽:整条**最久未用**的子代账本被清掉(`tasks` 的迭代序就是 LRU 序,活跃项每次访问都会重插)。
306
+ * 🔴 刻意不动**当期**那条(它正在写),也刻意不做「每条各裁一点」——半截的转录比一条整齐的缺席更难读。
307
+ */
308
+ function enforceTotalCap(currentKey) {
309
+ while (totalBytes > storeConfig.maxBytesTotal) {
310
+ let victim;
311
+ for (const k of tasks.keys()) {
312
+ if (k !== currentKey) {
313
+ victim = k;
314
+ break;
315
+ }
316
+ }
317
+ if (victim === undefined)
318
+ return; // 只剩当期那条:它已被每子代帽按住,这是能做到的最好结果
319
+ dropTask(victim);
320
+ }
321
+ }
322
+ /** 删一条子代账本并把它的字节从总账里扣掉(唯一删除口)。 */
323
+ function dropTask(key) {
324
+ const s = tasks.get(key);
325
+ if (!s)
326
+ return;
327
+ totalBytes -= s.bytes;
328
+ tasks.delete(key);
329
+ }
93
330
  // parentToolCallId ↔ engine taskId aliasing: CONTENT events carry ONLY {eventId, parentToolCallId}
94
331
  // (EventIdentity — no taskId on the wire), while the panel rows / 查看态 lookups key by the ENGINE task id
95
332
  // (task_progress carries BOTH). Content arriving before the first tick parks under the parent key and is
@@ -115,9 +352,20 @@ function stateFor(taskId, parentToolCallId) {
115
352
  if (tasks.size >= MAX_TASKS) {
116
353
  const oldest = tasks.keys().next().value;
117
354
  if (oldest !== undefined)
118
- tasks.delete(oldest);
355
+ dropTask(oldest); // 条数淘汰同样要把字节从总账里扣掉
119
356
  }
120
- s = { items: [], textBuf: '', thinkBuf: '', openTools: new Map(), parentToolCallId };
357
+ s = {
358
+ items: [],
359
+ itemBytes: [],
360
+ textBuf: '',
361
+ thinkBuf: '',
362
+ openTools: new Map(),
363
+ parentToolCallId,
364
+ bytes: 0,
365
+ droppedBytes: 0,
366
+ seenAggregateIds: new Set(),
367
+ recordedSegments: new Set(),
368
+ };
121
369
  tasks.set(taskId, s);
122
370
  }
123
371
  else {
@@ -126,25 +374,57 @@ function stateFor(taskId, parentToolCallId) {
126
374
  return s;
127
375
  }
128
376
  function pushItem(s, item) {
377
+ const b = itemBytesOf(item);
129
378
  s.items.push(item);
379
+ s.itemBytes.push(b);
380
+ addBytes(s, b);
130
381
  if (s.items.length > MAX_ITEMS_PER_TASK) {
131
- s.items.splice(0, s.items.length - MAX_ITEMS_PER_TASK);
382
+ const cut = s.items.length - MAX_ITEMS_PER_TASK;
383
+ let freed = 0;
384
+ for (let i = 0; i < cut; i++)
385
+ freed += s.itemBytes[i] ?? 0;
386
+ s.items.splice(0, cut);
387
+ s.itemBytes.splice(0, cut);
388
+ addBytes(s, -freed);
389
+ // 条数帽丢掉的同样是**内容**,同样要留痕(此前这条路是无声的)。
390
+ noteDropped(s, freed);
132
391
  // open-tool indexes shifted — rebuild from the surviving items
133
- s.openTools.clear();
134
- s.items.forEach((it, i) => {
135
- if (it.kind === 'tool' && it.output === undefined)
136
- s.openTools.set(it.id, i);
137
- });
392
+ rebuildOpenTools(s);
138
393
  }
139
394
  }
140
395
  /** Close out streaming buffers into items (segment boundary: a tool starts, or the run settles). */
141
396
  function flushBuffers(s) {
142
- if (s.thinkBuf.trim())
143
- pushItem(s, { kind: 'thinking', text: s.thinkBuf });
397
+ // 🔴 缓冲的字节此前已经计过账;搬进 item 时先扣掉缓冲那一份,再由 pushItem 计 item 那一份
398
+ // (纯空白段被丢弃时同样要扣 —— 少扣一次,总账就会一路虚高到把别人的账本淘汰掉)
399
+ addBytes(s, -utf8Len(s.thinkBuf));
400
+ const think = s.thinkBuf;
144
401
  s.thinkBuf = '';
145
- if (s.textBuf.trim())
146
- pushItem(s, { kind: 'text', text: s.textBuf });
402
+ if (think.trim()) {
403
+ pushItem(s, { kind: 'thinking', text: think });
404
+ rememberSegment(s, think);
405
+ }
406
+ addBytes(s, -utf8Len(s.textBuf));
407
+ const text = s.textBuf;
147
408
  s.textBuf = '';
409
+ if (text.trim()) {
410
+ pushItem(s, { kind: 'text', text });
411
+ rememberSegment(s, text);
412
+ }
413
+ }
414
+ /** 有界 FIFO 记号(两本小账共用;只用来做幂等判定,不参与预算)。 */
415
+ const MAX_REPLAY_KEYS = 64;
416
+ function remember(set, key) {
417
+ if (set.has(key))
418
+ return;
419
+ if (set.size >= MAX_REPLAY_KEYS) {
420
+ const oldest = set.values().next().value;
421
+ if (oldest !== undefined)
422
+ set.delete(oldest);
423
+ }
424
+ set.add(key);
425
+ }
426
+ function rememberSegment(s, text) {
427
+ remember(s.recordedSegments, text);
148
428
  }
149
429
  function scheduleNotify(taskId) {
150
430
  pendingNotify.add(taskId);
@@ -196,6 +476,9 @@ function aliasContentKey(parentToolCallId, taskId) {
196
476
  pendingNotify.add(taskId);
197
477
  const parked = tasks.get(parentToolCallId);
198
478
  if (parked && !tasks.has(taskId)) {
479
+ // 🔴 这是**换键**不是删除:同一本账换个键继续用,内容一个字节都没走 ⇒ 这里**不许**走
480
+ // {@link dropTask}(那一口会把它的字节从总账里扣掉,而字节还在)。扣了之后总账会一路虚低,
481
+ // 总帽从此形同虚设。删除只有三个口:clearSubagentContent / 条数淘汰 / 总帽淘汰。
199
482
  tasks.delete(parentToolCallId);
200
483
  tasks.set(taskId, parked);
201
484
  scheduleNotify(taskId);
@@ -211,6 +494,51 @@ export function registerSubagentAlias(parentToolCallId, taskId) {
211
494
  return;
212
495
  taskToParent.set(taskId, parentToolCallId);
213
496
  }
497
+ /**
498
+ * 这一条**聚合帧**是不是「已经收下过的那一条」(durable 重放的两道闸;命中 ⇒ 整帧丢弃)。
499
+ *
500
+ * 为什么光靠缓冲前缀吸收不够(车内异源复审逼出的真病):吸收规则只认**当前缓冲**那一段。一条
501
+ * 跨过工具边界的 turn,早先那些段早就段闭合成 item、缓冲已空 —— 重放时它们的聚合帧再来一遍,
502
+ * `''` 是任何串的前缀,于是整段被当成新内容塞回缓冲,而且塞在**后面那段**之后(顺序也是错的)。
503
+ *
504
+ * 两道闸,顺序刻意如此:
505
+ * ① **事件身份**(`eventId`)—— 精确、无损:同一条账本帧重放多少次都只收一次。
506
+ * ② **整段内容**(`recordedSegments`)—— 兜底:上游没给身份、或本进程这条流是重连后新起的
507
+ * (身份没在这本账上留过),内容判据仍拦得住「这一段已经上过屏」。
508
+ * 📋 **如实留白**:闸 ② 的代价是——一条 turn 里出现**两段逐字节相同**的正文时,durable 腿上只会
509
+ * 留下一段。取舍是明写的:每次重连都把整轮正文再渲一遍是**必然**发生的用户可见损坏,而
510
+ * 「同一条 turn 里说了两遍一模一样的话」是罕见形,且活体腿上两段都在。
511
+ */
512
+ function replaySeen(s, ev) {
513
+ const id = ev.eventId;
514
+ if (typeof id === 'string' && id.length > 0) {
515
+ if (s.seenAggregateIds.has(id))
516
+ return true;
517
+ remember(s.seenAggregateIds, id);
518
+ }
519
+ const body = ev.text;
520
+ if (typeof body === 'string' && s.recordedSegments.has(body))
521
+ return true;
522
+ return false;
523
+ }
524
+ /**
525
+ * 聚合帧对当前缓冲的**吸收判定**(两个聚合臂共用一份;`null` = 这一帧整只丢弃)。
526
+ *
527
+ * 三形,顺序刻意如此:
528
+ * ① 缓冲是全文的**前缀**(缓冲为空时同样成立)⇒ 换成全文 —— 活体已流过的那半段被这一份接管,
529
+ * 同一条帧重复送达因此**幂等**(全文以自己为前缀)。
530
+ * ② 缓冲是全文被**预算裁过**的尾巴(全文以缓冲结尾)⇒ **丢弃这一帧**。没有这一条,一段已经被
531
+ * 裁掉头部的正文,每重放一次就会被整只追加回来:内容重复、顺序错乱,而且截尾统计会一路虚涨
532
+ * (裁掉的字节被反复重算)——「丢了多少」那个数会变成一句假话。
533
+ * ③ 两者都不是 ⇒ 真分岔,退回**追加**、两段都留下(少渲一段比替引擎判定「哪一段才算数」更坏)。
534
+ */
535
+ function absorbAggregate(buf, agg) {
536
+ if (agg.startsWith(buf))
537
+ return agg;
538
+ if (buf.length > 0 && agg.endsWith(buf))
539
+ return null;
540
+ return buf + agg;
541
+ }
214
542
  function canonicalKey(ev) {
215
543
  // the wire's content events carry no taskId — runStream passes parentToolCallId in both slots
216
544
  if (ev.taskId !== ev.parentToolCallId)
@@ -222,12 +550,47 @@ export function publishSubagentContentEvent(ev) {
222
550
  const s = stateFor(key, ev.parentToolCallId);
223
551
  switch (ev.type) {
224
552
  case 'text_delta':
225
- if (typeof ev.delta === 'string')
553
+ if (typeof ev.delta === 'string') {
554
+ // 🔴 增量按**拼接边界**记账(见 appendBytes:代理对被拆成两帧时按 chunk 单算会永久虚高)。
555
+ addBytes(s, appendBytes(s.textBuf, ev.delta));
226
556
  s.textBuf += ev.delta;
557
+ }
227
558
  break;
228
559
  case 'reasoning_delta':
229
- if (typeof ev.delta === 'string')
560
+ if (typeof ev.delta === 'string') {
561
+ addBytes(s, appendBytes(s.thinkBuf, ev.delta));
230
562
  s.thinkBuf += ev.delta;
563
+ }
564
+ break;
565
+ // ── 聚合两臂(durable 重放腿:一段的权威全文)──────────────────────────────────────────
566
+ // 🔴 **吸收**而不是追加:活体已经流过的那一段是这一段全文的**前缀**,追加会让同一段内容
567
+ // 上屏两遍(缝前的事件序号去重管不到这一形——聚合帧与那串增量帧是不同的事件身份)。
568
+ // · 全文以当前缓冲为前缀 ⇒ 缓冲整体换成全文(活体流过 0 字节时同样成立,`''` 是任何串的前缀);
569
+ // 同一条聚合帧重复送达时因此是**幂等**的(全文以自己为前缀)。
570
+ // · 前缀对不上(理论上不该发生:同段的两次不同陈述)⇒ 退回**追加**的老语义并把两段都留下,
571
+ // 少渲一段比替引擎判定「哪一段才算数」更诚实。
572
+ // 🔴 不在这里段闭合:段边界仍由 tool_start / settle 给,与活体腿逐字同一条 —— 在这里 flush
573
+ // 会让「同一段的第二次重放」落成第二个 item(缓冲已空,吸收规则就没有前缀可比了)。
574
+ case 'text':
575
+ if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
576
+ const next = absorbAggregate(s.textBuf, ev.text);
577
+ if (next !== null) {
578
+ // 吸收/追加两形都可能改变缓冲长度 ⇒ 按**差额**记账(不是按帧长度加)。
579
+ const before = utf8Len(s.textBuf);
580
+ s.textBuf = next;
581
+ addBytes(s, utf8Len(s.textBuf) - before);
582
+ }
583
+ }
584
+ break;
585
+ case 'reasoning':
586
+ if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
587
+ const next = absorbAggregate(s.thinkBuf, ev.text);
588
+ if (next !== null) {
589
+ const before = utf8Len(s.thinkBuf);
590
+ s.thinkBuf = next;
591
+ addBytes(s, utf8Len(s.thinkBuf) - before);
592
+ }
593
+ }
231
594
  break;
232
595
  case 'tool_start': {
233
596
  flushBuffers(s);
@@ -243,6 +606,10 @@ export function publishSubagentContentEvent(ev) {
243
606
  const it = s.items[idx];
244
607
  it.output = ev.output ?? '';
245
608
  it.isError = ev.isError === true;
609
+ // 结果正文是**这一刻**才落到已在账的那条 item 上的 ⇒ 补记它的字节。
610
+ const add = utf8Len(it.output);
611
+ s.itemBytes[idx] = (s.itemBytes[idx] ?? 0) + add;
612
+ addBytes(s, add);
246
613
  if (id !== undefined)
247
614
  s.openTools.delete(id);
248
615
  }
@@ -260,6 +627,9 @@ export function publishSubagentContentEvent(ev) {
260
627
  break;
261
628
  }
262
629
  }
630
+ // 两道帽在**每一次写入之后**执行(不是定时扫):越限那一刻就丢,账本永远不会先胀起来再回落。
631
+ enforceTaskCap(s);
632
+ enforceTotalCap(key);
263
633
  scheduleNotify(key);
264
634
  }
265
635
  /** 查看态 composer echo (C2 steer optimistic display) — segment-closes the buffers first so the echo
@@ -269,6 +639,8 @@ export function pushSubagentLocalEcho(taskId, text) {
269
639
  const s = stateFor(taskId, tasks.get(taskId)?.parentToolCallId ?? '');
270
640
  flushBuffers(s);
271
641
  pushItem(s, { kind: 'echo', text });
642
+ enforceTaskCap(s);
643
+ enforceTotalCap(taskId);
272
644
  scheduleNotify(taskId);
273
645
  }
274
646
  export function getSubagentContentSnapshot(taskId) {
@@ -310,6 +682,9 @@ export function planSubagentViewSlots(taskId) {
310
682
  else if (it.kind === 'thinking') {
311
683
  out.push({ kind: 'thinking', slot: `k${i}`, text: it.text });
312
684
  }
685
+ else if (it.kind === 'truncated') {
686
+ out.push({ kind: 'truncated', slot: `x${i}`, droppedBytes: it.droppedBytes, keptFrom: it.keptFrom });
687
+ }
313
688
  else {
314
689
  out.push({ kind: 'echo', slot: `e${i}`, text: it.text });
315
690
  }
@@ -336,7 +711,7 @@ export function settleSubagentContent(taskId) {
336
711
  scheduleNotify(taskId);
337
712
  }
338
713
  export function clearSubagentContent(taskId) {
339
- tasks.delete(taskId);
714
+ dropTask(taskId); // 唯一删除口:字节同拍从总账里扣掉
340
715
  }
341
716
  const bgFacts = new Map();
342
717
  export function recordBgTerminalFacts(taskId, facts) {
@@ -123,6 +123,10 @@ export declare function writeProtectionDoctorDetail(reading: WriteProtectionRead
123
123
  /**
124
124
  * operator 面读数 → 一行 detail(行名是 UNTRUSTED 自由文本 ⇒ 呈前**逐条**消毒 + 封长)。
125
125
  *
126
+ * 🔴 消毒与封长的顺序 = **先转义、后按转义结果封长**({@link capForDisplay},0.62.0 起本包该族的
127
+ * 唯一实现)。此前这三处是反的(先按原文 `slice` 再转义),一段纯控制字符的 40 字符原文转义后
128
+ * 能占 240 个显示字符 —— 承诺的列宽预算成了名义值的六倍。同形存量另有 SQL 姿态行一处,同批同改。
129
+ *
126
130
  * 🔴 只给**行数与来源**加上「头几行的名字」,不整表倾泻:一行诊断不是一个表格,而 operator 面的
127
131
  * 真表读法是那个端点本身。
128
132
  * 🔴 `rows: []` 渲的是**显式无表**这句正面事实,不是「读不出」。
@@ -29,7 +29,7 @@
29
29
  * 🔴 与**模式型** write deny(`SENSITIVE_WRITE_PATTERNS`)是**并列机制**,两个读面分开报、
30
30
  * 绝不合成一位:两者的解法不同(一套改模式,一套改名表)。
31
31
  */
32
- import { escapeDisplayControlChars } from './fleetTaskDesc.js';
32
+ import { capForDisplay } from './fleetTaskDesc.js';
33
33
  import { engineWireTarget } from './engineWireTarget.js';
34
34
  import { engineCapsGeneration } from './engineCapsCache.js';
35
35
  /**
@@ -186,17 +186,21 @@ export function writeProtectionDoctorDetail(reading) {
186
186
  /**
187
187
  * operator 面读数 → 一行 detail(行名是 UNTRUSTED 自由文本 ⇒ 呈前**逐条**消毒 + 封长)。
188
188
  *
189
+ * 🔴 消毒与封长的顺序 = **先转义、后按转义结果封长**({@link capForDisplay},0.62.0 起本包该族的
190
+ * 唯一实现)。此前这三处是反的(先按原文 `slice` 再转义),一段纯控制字符的 40 字符原文转义后
191
+ * 能占 240 个显示字符 —— 承诺的列宽预算成了名义值的六倍。同形存量另有 SQL 姿态行一处,同批同改。
192
+ *
189
193
  * 🔴 只给**行数与来源**加上「头几行的名字」,不整表倾泻:一行诊断不是一个表格,而 operator 面的
190
194
  * 真表读法是那个端点本身。
191
195
  * 🔴 `rows: []` 渲的是**显式无表**这句正面事实,不是「读不出」。
192
196
  */
193
197
  export function writeProtectionPostureDetail(posture) {
194
- const source = escapeDisplayControlChars(posture.source.slice(0, WP_DETAIL_MAX));
198
+ const source = capForDisplay(posture.source, WP_DETAIL_MAX);
195
199
  if (posture.rows.length === 0)
196
200
  return `source ${source} · no protected names in effect`;
197
201
  const shown = posture.rows
198
202
  .slice(0, 3)
199
- .map((r) => `${escapeDisplayControlChars(r.name.slice(0, WP_DETAIL_MAX))}(${escapeDisplayControlChars(r.kind.slice(0, WP_DETAIL_MAX))})`)
203
+ .map((r) => `${capForDisplay(r.name, WP_DETAIL_MAX)}(${capForDisplay(r.kind, WP_DETAIL_MAX)})`)
200
204
  .join(', ');
201
205
  const more = posture.rows.length > 3 ? `, +${String(posture.rows.length - 3)} more` : '';
202
206
  const dropped = posture.droppedDefaultRows !== undefined && posture.droppedDefaultRows.length > 0