@sema-agent/client-core 0.61.0 → 0.62.1
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/CHANGELOG.md +160 -0
- package/README.md +8 -2
- package/dist/adapt/arms.js +76 -13
- package/dist/adapt/textStream.d.ts +31 -2
- package/dist/adapt/textStream.js +12 -1
- package/dist/adapter/runStream.js +16 -0
- package/dist/agentsWireCaps.d.ts +6 -2
- package/dist/engineWireSdk.d.ts +23 -2
- package/dist/engineWireSdk.js +71 -4
- package/dist/fleetTaskDesc.d.ts +19 -0
- package/dist/fleetTaskDesc.js +34 -0
- package/dist/interactiveHalt.d.ts +40 -17
- package/dist/interactiveHalt.js +32 -20
- package/dist/readFacePosture.js +1 -28
- package/dist/seam.d.ts +24 -1
- package/dist/seam.js +7 -0
- package/dist/seatContract.d.ts +84 -1
- package/dist/seatContract.js +37 -0
- package/dist/sqlEngineCapability.d.ts +2 -2
- package/dist/sqlEngineCapability.js +13 -14
- package/dist/subagentContentStore.d.ts +74 -1
- package/dist/subagentContentStore.js +539 -26
- package/dist/writeProtectionCapability.d.ts +4 -0
- package/dist/writeProtectionCapability.js +7 -3
- package/docs/INTEGRATION-CLIENTS.md +392 -7
- package/package.json +1 -1
|
@@ -10,6 +10,14 @@
|
|
|
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} 的吸收规则)
|
|
16
|
+
* 🔴 **重放的幂等闸覆盖四个臂**(`text` / `reasoning` / `tool_start` / `tool_end`):重连 = 新的一条
|
|
17
|
+
* 流,缝前那道事件序号去重只在同一条流内有效,所以「整轮重放」拦得住的只能是这本账。判据形相同
|
|
18
|
+
* (先问身份、再问账本),身份与账本各按臂取:散文两臂 = 聚合帧 `eventId` + 已上屏的整段正文;
|
|
19
|
+
* 工具两臂 = `toolCallId` + `items` 上那张卡,收口另带一本**自己额度**的收口身份账(同一次调用
|
|
20
|
+
* 会有**第二发**真收口 —— 门 park 之后恢复执行,见 {@link publishSubagentContentEvent})。
|
|
13
21
|
* (§E1 redact upstream; §E2 identity). 🔴 EventIdentity 的键是 `eventId` / `parentToolCallId`
|
|
14
22
|
* (+ LIVE 白名单四臂上的 `sourceTaskId` / `bgAgentId`)—— **没有 `taskId`**(sdk `events.d.ts` 的
|
|
15
23
|
* `interface EventIdentity` 直证;`taskId` 只长在 `meta` 首帧上)。所以内容帧到不了「自带引擎
|
|
@@ -89,7 +97,252 @@ export function coerceOutput(v) {
|
|
|
89
97
|
const MAX_ITEMS_PER_TASK = 200;
|
|
90
98
|
const MAX_TASKS = 32;
|
|
91
99
|
const NOTIFY_COALESCE_MS = 250;
|
|
100
|
+
/** 不配时生效的两位(理由见 {@link SubagentContentStoreConfig} 顶注)。 */
|
|
101
|
+
export const SUBAGENT_CONTENT_STORE_DEFAULTS = Object.freeze({
|
|
102
|
+
maxBytesPerTask: 2 * 1024 * 1024,
|
|
103
|
+
maxBytesTotal: 16 * 1024 * 1024,
|
|
104
|
+
});
|
|
105
|
+
let storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
|
|
106
|
+
/**
|
|
107
|
+
* 配字节预算(宿主启动时调一次;两位可分开配,只给一位时另一位保持现值)。
|
|
108
|
+
*
|
|
109
|
+
* 🔴 **fail-loud**:0 / 负数 / 非有限数 / 非整数一律 `throw` —— 一个被**静默忽略**的预算配置正是
|
|
110
|
+
* 这本账本要消灭的那类失败(宿主以为配上了,而账本还在按缺省无声地丢或不丢)。
|
|
111
|
+
* 🔴 拒绝是**原子**的:任何一位不合法就整只拒,既有配置一个字节不动(半套配置比不配更坏)。
|
|
112
|
+
*/
|
|
113
|
+
export function configureSubagentContentStore(next) {
|
|
114
|
+
const check = (name, v) => {
|
|
115
|
+
if (v === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) {
|
|
118
|
+
throw new TypeError(`configureSubagentContentStore: \`${name}\` must be a positive integer number of bytes (got ${String(v)})`);
|
|
119
|
+
}
|
|
120
|
+
return v;
|
|
121
|
+
};
|
|
122
|
+
const perTask = check('maxBytesPerTask', next.maxBytesPerTask);
|
|
123
|
+
const total = check('maxBytesTotal', next.maxBytesTotal);
|
|
124
|
+
// 🔴 两位的**关系**也要验(车内异源复审逼出:两位各自合法、合起来自相矛盾)。每子代帽大于总帽时,
|
|
125
|
+
// 单条子代可以合法地涨到每子代帽 —— 而总帽淘汰**不动当期那条**(它正在写),于是那一条就整只
|
|
126
|
+
// 越过了总帽,总帽从此形同虚设。判的是**落值之后**的那一对,不是这次传进来的那一位:只配一位
|
|
127
|
+
// 时另一位保持现值,矛盾同样可能是这次配出来的。
|
|
128
|
+
const nextPerTask = perTask ?? storeConfig.maxBytesPerTask;
|
|
129
|
+
const nextTotal = total ?? storeConfig.maxBytesTotal;
|
|
130
|
+
if (nextPerTask > nextTotal) {
|
|
131
|
+
throw new RangeError(`configureSubagentContentStore: \`maxBytesPerTask\` (${String(nextPerTask)}) must not exceed ` +
|
|
132
|
+
`\`maxBytesTotal\` (${String(nextTotal)}) — a per-task budget above the total is unreachable by ` +
|
|
133
|
+
'construction, and the total stops bounding anything.');
|
|
134
|
+
}
|
|
135
|
+
// 两位都验完才落 —— 第二位不合法时第一位也不许生效。
|
|
136
|
+
storeConfig.maxBytesPerTask = nextPerTask;
|
|
137
|
+
storeConfig.maxBytesTotal = nextTotal;
|
|
138
|
+
}
|
|
139
|
+
/** 当前生效的两位(**快照**:改返回值不影响账本)。 */
|
|
140
|
+
export function subagentContentStoreConfig() {
|
|
141
|
+
return { ...storeConfig };
|
|
142
|
+
}
|
|
143
|
+
/** 测试钩:把预算复位到缺省。 */
|
|
144
|
+
export function __resetSubagentContentStoreConfigForTests() {
|
|
145
|
+
storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
|
|
146
|
+
}
|
|
92
147
|
const tasks = new Map();
|
|
148
|
+
/** 整本账本当下保留的内容字节(逐次增删维护;总帽读它)。 */
|
|
149
|
+
let totalBytes = 0;
|
|
150
|
+
/**
|
|
151
|
+
* 一段文本的 **UTF-8 字节数**,不做编码分配。
|
|
152
|
+
*
|
|
153
|
+
* 🔴 为什么不用 `TextEncoder`:它每次都要**分配**一个字节数组,而这条路是逐 token 的热路径
|
|
154
|
+
* (实测 ~23 帧/s 的增量流)。算术形零分配、结果逐字节相同。
|
|
155
|
+
* 🔴 为什么不按 `str.length`(UTF-16 单元)算:账本里装的是引擎送来的文本,一段 CJK 的真实占用
|
|
156
|
+
* 是串长度的三倍 —— 按串长度算等于把预算对中文内容放宽两倍。
|
|
157
|
+
*/
|
|
158
|
+
function utf8Len(str) {
|
|
159
|
+
let n = 0;
|
|
160
|
+
for (let i = 0; i < str.length; i++) {
|
|
161
|
+
const c = str.charCodeAt(i);
|
|
162
|
+
if (c < 0x80)
|
|
163
|
+
n += 1;
|
|
164
|
+
else if (c < 0x800)
|
|
165
|
+
n += 2;
|
|
166
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
|
167
|
+
// 合法代理对 = 一个 4 字节码位(高位后面不是低位时按 3 字节的孤代理项算,与编码器同)
|
|
168
|
+
const next = str.charCodeAt(i + 1);
|
|
169
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
170
|
+
n += 4;
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
n += 3;
|
|
175
|
+
}
|
|
176
|
+
else
|
|
177
|
+
n += 3;
|
|
178
|
+
}
|
|
179
|
+
return n;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 把 `chunk` 追加到 `prev` 之后,**字节账真正增加了多少**。
|
|
183
|
+
*
|
|
184
|
+
* 🔴 不能直接用 `utf8Len(chunk)`(车内异源复审逼出的真病):一对代理对被上游拆成**两帧**送来时,
|
|
185
|
+
* 前一帧尾部那枚高位代理项按孤代理项算 3 字节、后一帧首部那枚低位代理项也算 3 字节,合计 6;
|
|
186
|
+
* 而拼起来之后它是一个 4 字节码位。每拆一次就虚高 2 字节,而段闭合时是按**拼接后的串**扣账
|
|
187
|
+
* —— 加 6 扣 4,差额永久留在账上。长流里这个漂移会一路把总账推高,最后把还在用的账本淘汰掉。
|
|
188
|
+
* ⇒ 只在**拼接边界**上修正一次(O(1)),不重扫整条缓冲(缓冲能有 MB 级,而这是逐 token 的热路径)。
|
|
189
|
+
*/
|
|
190
|
+
function appendBytes(prev, chunk) {
|
|
191
|
+
const add = utf8Len(chunk);
|
|
192
|
+
if (prev.length === 0 || chunk.length === 0)
|
|
193
|
+
return add;
|
|
194
|
+
const lastPrev = prev.charCodeAt(prev.length - 1);
|
|
195
|
+
const firstNew = chunk.charCodeAt(0);
|
|
196
|
+
const joinsPair = lastPrev >= 0xd800 && lastPrev <= 0xdbff && firstNew >= 0xdc00 && firstNew <= 0xdfff;
|
|
197
|
+
return joinsPair ? add - 2 : add;
|
|
198
|
+
}
|
|
199
|
+
/** 一条 item 的字节账(`truncated` 是关于预算的元信息,自身不计)。 */
|
|
200
|
+
function itemBytesOf(it) {
|
|
201
|
+
switch (it.kind) {
|
|
202
|
+
case 'text':
|
|
203
|
+
case 'thinking':
|
|
204
|
+
case 'echo':
|
|
205
|
+
return utf8Len(it.text);
|
|
206
|
+
case 'tool': {
|
|
207
|
+
let n = utf8Len(it.name) + utf8Len(it.output ?? '');
|
|
208
|
+
// 工具参数是**开集** `unknown`:序列化一次算体量(每张卡只算一次,不在增量热路径上)。
|
|
209
|
+
try {
|
|
210
|
+
if (it.input !== undefined)
|
|
211
|
+
n += utf8Len(JSON.stringify(it.input) ?? '');
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// 循环引用/不可序列化 ⇒ 这一位按 0 计(它进不了展示面,也就占不了这本账的预算)
|
|
215
|
+
}
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
case 'truncated':
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** 从串**头部**裁掉至少 `want` 字节;返回剩下的尾巴与真正裁掉的字节数(不劈开合法代理对)。 */
|
|
223
|
+
function trimHeadBytes(str, want) {
|
|
224
|
+
let removed = 0;
|
|
225
|
+
let i = 0;
|
|
226
|
+
while (i < str.length && removed < want) {
|
|
227
|
+
const c = str.charCodeAt(i);
|
|
228
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
|
229
|
+
const next = str.charCodeAt(i + 1);
|
|
230
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
231
|
+
removed += 4;
|
|
232
|
+
i += 2;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
removed += c < 0x80 ? 1 : c < 0x800 ? 2 : 3;
|
|
237
|
+
i += 1;
|
|
238
|
+
}
|
|
239
|
+
return { rest: str.slice(i), removed };
|
|
240
|
+
}
|
|
241
|
+
/** 字节账的唯一增减口(两本账同拍走,永不分叉)。 */
|
|
242
|
+
function addBytes(s, n) {
|
|
243
|
+
s.bytes += n;
|
|
244
|
+
totalBytes += n;
|
|
245
|
+
}
|
|
246
|
+
/** 记一次丢弃:更新/建立留痕记录(每条子代恒一条,恒排在最前)。 */
|
|
247
|
+
function noteDropped(s, dropped) {
|
|
248
|
+
if (dropped <= 0)
|
|
249
|
+
return;
|
|
250
|
+
s.droppedBytes += dropped;
|
|
251
|
+
const head = s.items[0];
|
|
252
|
+
if (head?.kind === 'truncated') {
|
|
253
|
+
head.droppedBytes = s.droppedBytes;
|
|
254
|
+
head.keptFrom = s.droppedBytes;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
s.items.unshift({ kind: 'truncated', droppedBytes: s.droppedBytes, keptFrom: s.droppedBytes });
|
|
258
|
+
s.itemBytes.unshift(0);
|
|
259
|
+
rebuildOpenTools(s);
|
|
260
|
+
}
|
|
261
|
+
/** items 被增删之后重建「开着的工具卡 → 下标」表(下标随 splice/unshift 整体漂移)。
|
|
262
|
+
* 🔴 同批把**卡已不在 items 上**的阶段水位清掉:水位是「这张卡现在承载的是哪一阶段」,卡没了它
|
|
263
|
+
* 就没有指涉对象,而这张表按 `toolCallId` 一条一条长 —— 不跟着卡清 = 一条长 turn 上的无界增长
|
|
264
|
+
* (本账本的立身之本就是有界)。守的是内存面,不是行为面,理由见 `toolPhase` 头注的那一笔。 */
|
|
265
|
+
function rebuildOpenTools(s) {
|
|
266
|
+
s.openTools.clear();
|
|
267
|
+
const live = new Set();
|
|
268
|
+
s.items.forEach((it, i) => {
|
|
269
|
+
if (it.kind === 'tool') {
|
|
270
|
+
live.add(it.id);
|
|
271
|
+
if (it.output === undefined)
|
|
272
|
+
s.openTools.set(it.id, i);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
for (const id of s.toolPhase.keys()) {
|
|
276
|
+
if (!live.has(id))
|
|
277
|
+
s.toolPhase.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 每子代帽:超出就**从最早的内容开始丢**,丢不动了再从缓冲头部裁。
|
|
282
|
+
*
|
|
283
|
+
* 🔴 顺序是「先 item 后缓冲」而不是反过来:缓冲是**活体尾巴**(用户正在看的那一段),item 是更早
|
|
284
|
+
* 的历史。反过来做会把用户眼前正在流的字丢掉,而屏上更早那一段完好无损 —— 那是错的一头。
|
|
285
|
+
* 🔴 留痕记录自身不参与丢弃(它是关于预算的元信息;丢掉它等于把「有东西被丢了」这件事也丢了)。
|
|
286
|
+
*/
|
|
287
|
+
function enforceTaskCap(s) {
|
|
288
|
+
const cap = storeConfig.maxBytesPerTask;
|
|
289
|
+
if (s.bytes <= cap)
|
|
290
|
+
return false;
|
|
291
|
+
let dropped = 0;
|
|
292
|
+
const first = () => (s.items[0]?.kind === 'truncated' ? 1 : 0);
|
|
293
|
+
while (s.bytes > cap && s.items.length > first()) {
|
|
294
|
+
const idx = first();
|
|
295
|
+
const b = s.itemBytes[idx] ?? 0;
|
|
296
|
+
s.items.splice(idx, 1);
|
|
297
|
+
s.itemBytes.splice(idx, 1);
|
|
298
|
+
addBytes(s, -b);
|
|
299
|
+
dropped += b;
|
|
300
|
+
}
|
|
301
|
+
if (s.bytes > cap) {
|
|
302
|
+
const cut = trimHeadBytes(s.thinkBuf, s.bytes - cap);
|
|
303
|
+
s.thinkBuf = cut.rest;
|
|
304
|
+
addBytes(s, -cut.removed);
|
|
305
|
+
dropped += cut.removed;
|
|
306
|
+
}
|
|
307
|
+
if (s.bytes > cap) {
|
|
308
|
+
const cut = trimHeadBytes(s.textBuf, s.bytes - cap);
|
|
309
|
+
s.textBuf = cut.rest;
|
|
310
|
+
addBytes(s, -cut.removed);
|
|
311
|
+
dropped += cut.removed;
|
|
312
|
+
}
|
|
313
|
+
if (dropped > 0) {
|
|
314
|
+
rebuildOpenTools(s);
|
|
315
|
+
noteDropped(s, dropped);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 总帽:整条**最久未用**的子代账本被清掉(`tasks` 的迭代序就是 LRU 序,活跃项每次访问都会重插)。
|
|
322
|
+
* 🔴 刻意不动**当期**那条(它正在写),也刻意不做「每条各裁一点」——半截的转录比一条整齐的缺席更难读。
|
|
323
|
+
*/
|
|
324
|
+
function enforceTotalCap(currentKey) {
|
|
325
|
+
while (totalBytes > storeConfig.maxBytesTotal) {
|
|
326
|
+
let victim;
|
|
327
|
+
for (const k of tasks.keys()) {
|
|
328
|
+
if (k !== currentKey) {
|
|
329
|
+
victim = k;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (victim === undefined)
|
|
334
|
+
return; // 只剩当期那条:它已被每子代帽按住,这是能做到的最好结果
|
|
335
|
+
dropTask(victim);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** 删一条子代账本并把它的字节从总账里扣掉(唯一删除口)。 */
|
|
339
|
+
function dropTask(key) {
|
|
340
|
+
const s = tasks.get(key);
|
|
341
|
+
if (!s)
|
|
342
|
+
return;
|
|
343
|
+
totalBytes -= s.bytes;
|
|
344
|
+
tasks.delete(key);
|
|
345
|
+
}
|
|
93
346
|
// parentToolCallId ↔ engine taskId aliasing: CONTENT events carry ONLY {eventId, parentToolCallId}
|
|
94
347
|
// (EventIdentity — no taskId on the wire), while the panel rows / 查看态 lookups key by the ENGINE task id
|
|
95
348
|
// (task_progress carries BOTH). Content arriving before the first tick parks under the parent key and is
|
|
@@ -115,9 +368,21 @@ function stateFor(taskId, parentToolCallId) {
|
|
|
115
368
|
if (tasks.size >= MAX_TASKS) {
|
|
116
369
|
const oldest = tasks.keys().next().value;
|
|
117
370
|
if (oldest !== undefined)
|
|
118
|
-
|
|
371
|
+
dropTask(oldest); // 条数淘汰同样要把字节从总账里扣掉
|
|
119
372
|
}
|
|
120
|
-
s = {
|
|
373
|
+
s = {
|
|
374
|
+
items: [],
|
|
375
|
+
itemBytes: [],
|
|
376
|
+
textBuf: '',
|
|
377
|
+
thinkBuf: '',
|
|
378
|
+
openTools: new Map(),
|
|
379
|
+
parentToolCallId,
|
|
380
|
+
bytes: 0,
|
|
381
|
+
droppedBytes: 0,
|
|
382
|
+
seenAggregateIds: new Set(),
|
|
383
|
+
recordedSegments: new Set(),
|
|
384
|
+
toolPhase: new Map(),
|
|
385
|
+
};
|
|
121
386
|
tasks.set(taskId, s);
|
|
122
387
|
}
|
|
123
388
|
else {
|
|
@@ -126,25 +391,57 @@ function stateFor(taskId, parentToolCallId) {
|
|
|
126
391
|
return s;
|
|
127
392
|
}
|
|
128
393
|
function pushItem(s, item) {
|
|
394
|
+
const b = itemBytesOf(item);
|
|
129
395
|
s.items.push(item);
|
|
396
|
+
s.itemBytes.push(b);
|
|
397
|
+
addBytes(s, b);
|
|
130
398
|
if (s.items.length > MAX_ITEMS_PER_TASK) {
|
|
131
|
-
|
|
399
|
+
const cut = s.items.length - MAX_ITEMS_PER_TASK;
|
|
400
|
+
let freed = 0;
|
|
401
|
+
for (let i = 0; i < cut; i++)
|
|
402
|
+
freed += s.itemBytes[i] ?? 0;
|
|
403
|
+
s.items.splice(0, cut);
|
|
404
|
+
s.itemBytes.splice(0, cut);
|
|
405
|
+
addBytes(s, -freed);
|
|
406
|
+
// 条数帽丢掉的同样是**内容**,同样要留痕(此前这条路是无声的)。
|
|
407
|
+
noteDropped(s, freed);
|
|
132
408
|
// open-tool indexes shifted — rebuild from the surviving items
|
|
133
|
-
s
|
|
134
|
-
s.items.forEach((it, i) => {
|
|
135
|
-
if (it.kind === 'tool' && it.output === undefined)
|
|
136
|
-
s.openTools.set(it.id, i);
|
|
137
|
-
});
|
|
409
|
+
rebuildOpenTools(s);
|
|
138
410
|
}
|
|
139
411
|
}
|
|
140
412
|
/** Close out streaming buffers into items (segment boundary: a tool starts, or the run settles). */
|
|
141
413
|
function flushBuffers(s) {
|
|
142
|
-
|
|
143
|
-
|
|
414
|
+
// 🔴 缓冲的字节此前已经计过账;搬进 item 时先扣掉缓冲那一份,再由 pushItem 计 item 那一份
|
|
415
|
+
// (纯空白段被丢弃时同样要扣 —— 少扣一次,总账就会一路虚高到把别人的账本淘汰掉)。
|
|
416
|
+
addBytes(s, -utf8Len(s.thinkBuf));
|
|
417
|
+
const think = s.thinkBuf;
|
|
144
418
|
s.thinkBuf = '';
|
|
145
|
-
if (
|
|
146
|
-
pushItem(s, { kind: '
|
|
419
|
+
if (think.trim()) {
|
|
420
|
+
pushItem(s, { kind: 'thinking', text: think });
|
|
421
|
+
rememberSegment(s, think);
|
|
422
|
+
}
|
|
423
|
+
addBytes(s, -utf8Len(s.textBuf));
|
|
424
|
+
const text = s.textBuf;
|
|
147
425
|
s.textBuf = '';
|
|
426
|
+
if (text.trim()) {
|
|
427
|
+
pushItem(s, { kind: 'text', text });
|
|
428
|
+
rememberSegment(s, text);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** 有界 FIFO 记号(两本小账共用;只用来做幂等判定,不参与预算)。 */
|
|
432
|
+
const MAX_REPLAY_KEYS = 64;
|
|
433
|
+
function remember(set, key) {
|
|
434
|
+
if (set.has(key))
|
|
435
|
+
return;
|
|
436
|
+
if (set.size >= MAX_REPLAY_KEYS) {
|
|
437
|
+
const oldest = set.values().next().value;
|
|
438
|
+
if (oldest !== undefined)
|
|
439
|
+
set.delete(oldest);
|
|
440
|
+
}
|
|
441
|
+
set.add(key);
|
|
442
|
+
}
|
|
443
|
+
function rememberSegment(s, text) {
|
|
444
|
+
remember(s.recordedSegments, text);
|
|
148
445
|
}
|
|
149
446
|
function scheduleNotify(taskId) {
|
|
150
447
|
pendingNotify.add(taskId);
|
|
@@ -196,6 +493,9 @@ function aliasContentKey(parentToolCallId, taskId) {
|
|
|
196
493
|
pendingNotify.add(taskId);
|
|
197
494
|
const parked = tasks.get(parentToolCallId);
|
|
198
495
|
if (parked && !tasks.has(taskId)) {
|
|
496
|
+
// 🔴 这是**换键**不是删除:同一本账换个键继续用,内容一个字节都没走 ⇒ 这里**不许**走
|
|
497
|
+
// {@link dropTask}(那一口会把它的字节从总账里扣掉,而字节还在)。扣了之后总账会一路虚低,
|
|
498
|
+
// 总帽从此形同虚设。删除只有三个口:clearSubagentContent / 条数淘汰 / 总帽淘汰。
|
|
199
499
|
tasks.delete(parentToolCallId);
|
|
200
500
|
tasks.set(taskId, parked);
|
|
201
501
|
scheduleNotify(taskId);
|
|
@@ -211,6 +511,101 @@ export function registerSubagentAlias(parentToolCallId, taskId) {
|
|
|
211
511
|
return;
|
|
212
512
|
taskToParent.set(taskId, parentToolCallId);
|
|
213
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* 这一条**聚合帧**是不是「已经收下过的那一条」(durable 重放的两道闸;命中 ⇒ 整帧丢弃)。
|
|
516
|
+
*
|
|
517
|
+
* 🔴 工具两臂**同一条判据形、不同的身份**:它们不进这一口(见 publish 的工具两臂),因为
|
|
518
|
+
* `toolCallId` 就是那次调用的身份、账本就是 `items` 自己 —— 比一本有界 FIFO 强,也不占它的额度。
|
|
519
|
+
*
|
|
520
|
+
* 为什么光靠缓冲前缀吸收不够(异源复审逼出的真病):吸收规则只认**当前缓冲**那一段。一条
|
|
521
|
+
* 跨过工具边界的 turn,早先那些段早就段闭合成 item、缓冲已空 —— 重放时它们的聚合帧再来一遍,
|
|
522
|
+
* `''` 是任何串的前缀,于是整段被当成新内容塞回缓冲,而且塞在**后面那段**之后(顺序也是错的)。
|
|
523
|
+
*
|
|
524
|
+
* 两道闸,顺序刻意如此:
|
|
525
|
+
* ① **事件身份**(`eventId`)—— 精确、无损:同一条账本帧重放多少次都只收一次。
|
|
526
|
+
* ② **整段内容**(`recordedSegments`)—— 兜底:上游没给身份、或本进程这条流是重连后新起的
|
|
527
|
+
* (身份没在这本账上留过),内容判据仍拦得住「这一段已经上过屏」。
|
|
528
|
+
* 📋 **如实留白**:闸 ② 的代价是——一条 turn 里出现**两段逐字节相同**的正文时,durable 腿上只会
|
|
529
|
+
* 留下一段。取舍是明写的:每次重连都把整轮正文再渲一遍是**必然**发生的用户可见损坏,而
|
|
530
|
+
* 「同一条 turn 里说了两遍一模一样的话」是罕见形,且活体腿上两段都在。
|
|
531
|
+
*/
|
|
532
|
+
function replaySeen(s, ev) {
|
|
533
|
+
const id = ev.eventId;
|
|
534
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
535
|
+
if (s.seenAggregateIds.has(id))
|
|
536
|
+
return true;
|
|
537
|
+
remember(s.seenAggregateIds, id);
|
|
538
|
+
}
|
|
539
|
+
const body = ev.text;
|
|
540
|
+
if (typeof body === 'string' && s.recordedSegments.has(body))
|
|
541
|
+
return true;
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 这一发工具帧比卡上现在承载的那一阶段**旧或一样**吗(旧 ⇒ 整帧丢弃)。
|
|
546
|
+
*
|
|
547
|
+
* 判据是 `eventId` 的字典序 —— 前提是它**时间可排序**(core 铸的 uuidv7,sdk `EventIdentity` 逐字)。
|
|
548
|
+
* 两侧任一缺席 ⇒ 判不出新旧,答 `false`(退回「后到的落账」:重放送的是同一份值,落账是空操作)。
|
|
549
|
+
* 逐字相等 ⇒ 同一条帧又来一遍 ⇒ 同样答 `true`(整帧丢弃,一次也不重复落账)。
|
|
550
|
+
*/
|
|
551
|
+
function notNewer(eventId, mark) {
|
|
552
|
+
if (typeof eventId !== 'string' || eventId.length === 0)
|
|
553
|
+
return false;
|
|
554
|
+
if (typeof mark !== 'string' || mark.length === 0)
|
|
555
|
+
return false;
|
|
556
|
+
return eventId <= mark;
|
|
557
|
+
}
|
|
558
|
+
/** 读一张卡的阶段水位(没有 ⇒ 这张卡还没落过对应那一半的帧)。 */
|
|
559
|
+
function phaseOf(s, id) {
|
|
560
|
+
return id === undefined ? undefined : s.toolPhase.get(id);
|
|
561
|
+
}
|
|
562
|
+
/** 抬一张卡的阶段水位(身份缺席 ⇒ 不抬:一个抬不动的水位比一个抬错的水位诚实)。 */
|
|
563
|
+
function markPhase(s, id, half, eventId) {
|
|
564
|
+
if (id === undefined || typeof eventId !== 'string' || eventId.length === 0)
|
|
565
|
+
return;
|
|
566
|
+
const cur = s.toolPhase.get(id);
|
|
567
|
+
if (cur === undefined) {
|
|
568
|
+
s.toolPhase.set(id, { [half]: eventId });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
cur[half] = eventId;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* 这条 `toolCallId` 在本账本里已经有卡了吗(有 ⇒ 返回下标,没有 ⇒ `-1`)。
|
|
575
|
+
*
|
|
576
|
+
* 🔴 为什么账本就是 `items` 自己、不另铸第三本小账:一次工具调用的卡**在不在屏上**只有 `items`
|
|
577
|
+
* 说了算。另开一本记号账就会有两处真源,而它们脱节的方式是可预期的 —— 条数帽/字节帽把卡丢掉
|
|
578
|
+
* 之后记号还在,重放于是被永久挡住,屏上那一格从此空着且无人知道。这里逐条问 `items`,
|
|
579
|
+
* 答案与屏上看到的恒等(上限 200 条,每帧一次线性扫,量级可忽略)。
|
|
580
|
+
* 🔴 从尾往前找:同一 id 的卡至多一张(本闸的作用就是维持这条),从尾找只是让常见形(刚落的那张)
|
|
581
|
+
* 第一下命中。
|
|
582
|
+
*/
|
|
583
|
+
function toolCardIndexOf(s, id) {
|
|
584
|
+
for (let i = s.items.length - 1; i >= 0; i--) {
|
|
585
|
+
const it = s.items[i];
|
|
586
|
+
if (it?.kind === 'tool' && it.id === id)
|
|
587
|
+
return i;
|
|
588
|
+
}
|
|
589
|
+
return -1;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 聚合帧对当前缓冲的**吸收判定**(两个聚合臂共用一份;`null` = 这一帧整只丢弃)。
|
|
593
|
+
*
|
|
594
|
+
* 三形,顺序刻意如此:
|
|
595
|
+
* ① 缓冲是全文的**前缀**(缓冲为空时同样成立)⇒ 换成全文 —— 活体已流过的那半段被这一份接管,
|
|
596
|
+
* 同一条帧重复送达因此**幂等**(全文以自己为前缀)。
|
|
597
|
+
* ② 缓冲是全文被**预算裁过**的尾巴(全文以缓冲结尾)⇒ **丢弃这一帧**。没有这一条,一段已经被
|
|
598
|
+
* 裁掉头部的正文,每重放一次就会被整只追加回来:内容重复、顺序错乱,而且截尾统计会一路虚涨
|
|
599
|
+
* (裁掉的字节被反复重算)——「丢了多少」那个数会变成一句假话。
|
|
600
|
+
* ③ 两者都不是 ⇒ 真分岔,退回**追加**、两段都留下(少渲一段比替引擎判定「哪一段才算数」更坏)。
|
|
601
|
+
*/
|
|
602
|
+
function absorbAggregate(buf, agg) {
|
|
603
|
+
if (agg.startsWith(buf))
|
|
604
|
+
return agg;
|
|
605
|
+
if (buf.length > 0 && agg.endsWith(buf))
|
|
606
|
+
return null;
|
|
607
|
+
return buf + agg;
|
|
608
|
+
}
|
|
214
609
|
function canonicalKey(ev) {
|
|
215
610
|
// the wire's content events carry no taskId — runStream passes parentToolCallId in both slots
|
|
216
611
|
if (ev.taskId !== ev.parentToolCallId)
|
|
@@ -222,44 +617,157 @@ export function publishSubagentContentEvent(ev) {
|
|
|
222
617
|
const s = stateFor(key, ev.parentToolCallId);
|
|
223
618
|
switch (ev.type) {
|
|
224
619
|
case 'text_delta':
|
|
225
|
-
if (typeof ev.delta === 'string')
|
|
620
|
+
if (typeof ev.delta === 'string') {
|
|
621
|
+
// 🔴 增量按**拼接边界**记账(见 appendBytes:代理对被拆成两帧时按 chunk 单算会永久虚高)。
|
|
622
|
+
addBytes(s, appendBytes(s.textBuf, ev.delta));
|
|
226
623
|
s.textBuf += ev.delta;
|
|
624
|
+
}
|
|
227
625
|
break;
|
|
228
626
|
case 'reasoning_delta':
|
|
229
|
-
if (typeof ev.delta === 'string')
|
|
627
|
+
if (typeof ev.delta === 'string') {
|
|
628
|
+
addBytes(s, appendBytes(s.thinkBuf, ev.delta));
|
|
230
629
|
s.thinkBuf += ev.delta;
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
// ── 聚合两臂(durable 重放腿:一段的权威全文)──────────────────────────────────────────
|
|
633
|
+
// 🔴 **吸收**而不是追加:活体已经流过的那一段是这一段全文的**前缀**,追加会让同一段内容
|
|
634
|
+
// 上屏两遍(缝前的事件序号去重管不到这一形——聚合帧与那串增量帧是不同的事件身份)。
|
|
635
|
+
// · 全文以当前缓冲为前缀 ⇒ 缓冲整体换成全文(活体流过 0 字节时同样成立,`''` 是任何串的前缀);
|
|
636
|
+
// 同一条聚合帧重复送达时因此是**幂等**的(全文以自己为前缀)。
|
|
637
|
+
// · 前缀对不上(理论上不该发生:同段的两次不同陈述)⇒ 退回**追加**的老语义并把两段都留下,
|
|
638
|
+
// 少渲一段比替引擎判定「哪一段才算数」更诚实。
|
|
639
|
+
// 🔴 不在这里段闭合:段边界仍由 tool_start / settle 给,与活体腿逐字同一条 —— 在这里 flush
|
|
640
|
+
// 会让「同一段的第二次重放」落成第二个 item(缓冲已空,吸收规则就没有前缀可比了)。
|
|
641
|
+
case 'text':
|
|
642
|
+
if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
|
|
643
|
+
const next = absorbAggregate(s.textBuf, ev.text);
|
|
644
|
+
if (next !== null) {
|
|
645
|
+
// 吸收/追加两形都可能改变缓冲长度 ⇒ 按**差额**记账(不是按帧长度加)。
|
|
646
|
+
const before = utf8Len(s.textBuf);
|
|
647
|
+
s.textBuf = next;
|
|
648
|
+
addBytes(s, utf8Len(s.textBuf) - before);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
break;
|
|
652
|
+
case 'reasoning':
|
|
653
|
+
if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
|
|
654
|
+
const next = absorbAggregate(s.thinkBuf, ev.text);
|
|
655
|
+
if (next !== null) {
|
|
656
|
+
const before = utf8Len(s.thinkBuf);
|
|
657
|
+
s.thinkBuf = next;
|
|
658
|
+
addBytes(s, utf8Len(s.thinkBuf) - before);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
231
661
|
break;
|
|
662
|
+
// ── 工具两臂(活体与 durable 重放送的是同一形)────────────────────────────────────────
|
|
663
|
+
// 🔴 与聚合两臂**同一条判据形**(先问身份、再问账本),但身份与账本都换成工具自己的那一套:
|
|
664
|
+
// 身份 = `toolCallId`(wire 给的那次调用的 id),账本 = `items` 自己(见 toolCardIndexOf)。
|
|
665
|
+
// 刻意**不写** `seenAggregateIds`:那本记号账是有界 FIFO,额度按「聚合帧只在重放腿上来」定的
|
|
666
|
+
// (活体走 `*_delta`,一个字都不写);工具帧**活体也来**,跟着写会把还在屏上的散文身份整窗
|
|
667
|
+
// 挤掉 —— 修一个臂的重放去重、弄坏另一个臂的,不是修。
|
|
668
|
+
// 🔴 顺序上「开着的卡恒收口」排在最前:一张在飞的卡如果被任何一道闸拦掉收口,它就**永远在飞**
|
|
669
|
+
// (屏上显示成还在跑),而那比多渲一次结果坏得多。
|
|
232
670
|
case 'tool_start': {
|
|
671
|
+
const startId = ev.toolCallId;
|
|
672
|
+
const seen = startId !== undefined && toolCardIndexOf(s, startId) >= 0;
|
|
673
|
+
if (seen && notNewer(ev.eventId, phaseOf(s, startId)?.start))
|
|
674
|
+
break;
|
|
675
|
+
// 段闭合可能触发条数裁剪(pushItem 从头 splice + unshift 留痕)⇒ 裁剪**前**取的下标与引用一律
|
|
676
|
+
// 作废:flush 之后按 toolCallId **重新**找卡。找不到 = 这张卡刚被裁掉 ⇒ 落到下面「新的一次」
|
|
677
|
+
// 建卡那一支(§26b 留白①),绝不对着已删的引用改名补参、也绝不把差额记到顶上那条留痕上。
|
|
678
|
+
if (seen && phaseOf(s, startId)?.start === undefined)
|
|
679
|
+
flushBuffers(s);
|
|
680
|
+
const at = startId !== undefined ? toolCardIndexOf(s, startId) : -1;
|
|
681
|
+
if (at >= 0) {
|
|
682
|
+
// 这次调用的卡已在账 ⇒ **不铸第二张**。剩下的两件按阶段水位判:
|
|
683
|
+
// 🔴 ① 这一发比卡上现在承载的那一阶段旧(或就是同一条帧又来一遍)⇒ 整帧丢弃。少了这一条,
|
|
684
|
+
// 门 park 那一阶段的 start 重放会把批复时**改过的执行参数**盖回原参数 —— 屏上就成了
|
|
685
|
+
// 「这个参数产生了那个结果」,而那个结果根本不是它跑出来的。
|
|
686
|
+
// 🔴 ② 这张卡有没有落过 `tool_start`:收口先到时下面那支会合成一张占位卡(名字 `Tool`、
|
|
687
|
+
// 参数 `{}`),那张卡**没有** start 水位 —— 迟到的这一发是它的**第一个段边界**,要补做
|
|
688
|
+
// 一次段闭合(不补的话工具前后两段散文会黏成一段、且一段都进不了已上屏正文那本记号账,
|
|
689
|
+
// 随后整轮重放时两段都拦不住)。反过来,水位已在 = 这一发是重放或下一阶段,**不**段闭合:
|
|
690
|
+
// 边界在活体那一轮已经给过,在这里切开会把还在流的那一段提前截断。
|
|
691
|
+
// 名字与参数就地补齐/更新(重放送来的是同一份值,补齐是空操作;字节按**差额**记账)。
|
|
692
|
+
const it = s.items[at];
|
|
693
|
+
const before = itemBytesOf(it);
|
|
694
|
+
if (ev.toolName !== undefined)
|
|
695
|
+
it.name = ev.toolName;
|
|
696
|
+
if (ev.args !== undefined)
|
|
697
|
+
it.input = ev.args;
|
|
698
|
+
const delta = itemBytesOf(it) - before;
|
|
699
|
+
s.itemBytes[at] = (s.itemBytes[at] ?? 0) + delta;
|
|
700
|
+
addBytes(s, delta);
|
|
701
|
+
markPhase(s, startId, 'start', ev.eventId);
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
233
704
|
flushBuffers(s);
|
|
234
|
-
const id =
|
|
705
|
+
const id = startId ?? `${ev.taskId}-tool-${s.items.length}`;
|
|
235
706
|
pushItem(s, { kind: 'tool', id, name: ev.toolName ?? 'Tool', input: ev.args ?? {} });
|
|
236
707
|
s.openTools.set(id, s.items.length - 1);
|
|
708
|
+
markPhase(s, id, 'start', ev.eventId);
|
|
237
709
|
break;
|
|
238
710
|
}
|
|
239
711
|
case 'tool_end': {
|
|
240
712
|
const id = ev.toolCallId;
|
|
241
713
|
const idx = id !== undefined ? s.openTools.get(id) : undefined;
|
|
242
714
|
if (idx !== undefined && s.items[idx]?.kind === 'tool') {
|
|
715
|
+
// 卡还开着 ⇒ 结果照落。这一支**排在所有闸之前**:一张在飞的卡被任何一道闸拦掉收口,
|
|
716
|
+
// 它就永远显示成还在跑,那比多渲一次结果坏得多。
|
|
243
717
|
const it = s.items[idx];
|
|
244
718
|
it.output = ev.output ?? '';
|
|
245
719
|
it.isError = ev.isError === true;
|
|
720
|
+
// 结果正文是**这一刻**才落到已在账的那条 item 上的 ⇒ 补记它的字节。
|
|
721
|
+
const add = utf8Len(it.output);
|
|
722
|
+
s.itemBytes[idx] = (s.itemBytes[idx] ?? 0) + add;
|
|
723
|
+
addBytes(s, add);
|
|
246
724
|
if (id !== undefined)
|
|
247
725
|
s.openTools.delete(id);
|
|
726
|
+
markPhase(s, id, 'end', ev.eventId);
|
|
727
|
+
break;
|
|
248
728
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
729
|
+
const settledAt = id !== undefined ? toolCardIndexOf(s, id) : -1;
|
|
730
|
+
if (settledAt >= 0) {
|
|
731
|
+
// 卡在账、却已不在「开着」表上 = 这次调用的结果**早就落过**。两种可能,判据是阶段水位:
|
|
732
|
+
// · 比卡上现在那一份**旧或就是同一条帧**(durable 重放 / 旧一阶段迟到)⇒ 整帧丢弃 ——
|
|
733
|
+
// 再落一次会把结果字节重复计进预算,走到下面那支还会另铸一张已完成的卡;更坏的是
|
|
734
|
+
// 旧一阶段会把屏上的真结果盖回去;
|
|
735
|
+
// · **更新的一发**收口(门 park 掉这次调用、批复后恢复执行,上游按同一个 `toolCallId`
|
|
736
|
+
// 再发一对 start/end)⇒ **后到的才是真结果**,就地替换。把「已 settled」当成永久完成
|
|
737
|
+
// 会把真结果吞掉,屏上停在那句「被门拦下」上。
|
|
738
|
+
// 🔴 身份缺席(判不出新旧)时走替换那一支:重放送的是同一份值,替换是空操作(差额恒 0)。
|
|
739
|
+
if (notNewer(ev.eventId, phaseOf(s, id)?.end))
|
|
740
|
+
break;
|
|
741
|
+
const it = s.items[settledAt];
|
|
742
|
+
const before = itemBytesOf(it);
|
|
743
|
+
it.output = ev.output ?? '';
|
|
744
|
+
it.isError = ev.isError === true;
|
|
745
|
+
const delta = itemBytesOf(it) - before;
|
|
746
|
+
s.itemBytes[settledAt] = (s.itemBytes[settledAt] ?? 0) + delta;
|
|
747
|
+
addBytes(s, delta);
|
|
748
|
+
markPhase(s, id, 'end', ev.eventId);
|
|
749
|
+
break;
|
|
259
750
|
}
|
|
751
|
+
// orphan close (start lost to the ring / arrived first) — synthesize a resolved card.
|
|
752
|
+
// 🔴 这里**不**段闭合:段边界由 `tool_start` 给(收口先到时那一发迟到的 start 会补做,见上)。
|
|
753
|
+
// 在这里闭合会把**还在流的**那一段提前切成 item —— 每子代帽配得很紧时,那一段随即变成
|
|
754
|
+
// 「最早的内容」而被帽整只丢掉,而它其实是用户眼前正在看的字。
|
|
755
|
+
const synthId = id ?? `${ev.taskId}-tool-${s.items.length}`;
|
|
756
|
+
pushItem(s, {
|
|
757
|
+
kind: 'tool',
|
|
758
|
+
id: synthId,
|
|
759
|
+
name: ev.toolName ?? 'Tool',
|
|
760
|
+
input: ev.args ?? {},
|
|
761
|
+
output: ev.output ?? '',
|
|
762
|
+
isError: ev.isError === true,
|
|
763
|
+
});
|
|
764
|
+
markPhase(s, synthId, 'end', ev.eventId);
|
|
260
765
|
break;
|
|
261
766
|
}
|
|
262
767
|
}
|
|
768
|
+
// 两道帽在**每一次写入之后**执行(不是定时扫):越限那一刻就丢,账本永远不会先胀起来再回落。
|
|
769
|
+
enforceTaskCap(s);
|
|
770
|
+
enforceTotalCap(key);
|
|
263
771
|
scheduleNotify(key);
|
|
264
772
|
}
|
|
265
773
|
/** 查看态 composer echo (C2 steer optimistic display) — segment-closes the buffers first so the echo
|
|
@@ -269,6 +777,8 @@ export function pushSubagentLocalEcho(taskId, text) {
|
|
|
269
777
|
const s = stateFor(taskId, tasks.get(taskId)?.parentToolCallId ?? '');
|
|
270
778
|
flushBuffers(s);
|
|
271
779
|
pushItem(s, { kind: 'echo', text });
|
|
780
|
+
enforceTaskCap(s);
|
|
781
|
+
enforceTotalCap(taskId);
|
|
272
782
|
scheduleNotify(taskId);
|
|
273
783
|
}
|
|
274
784
|
export function getSubagentContentSnapshot(taskId) {
|
|
@@ -310,6 +820,9 @@ export function planSubagentViewSlots(taskId) {
|
|
|
310
820
|
else if (it.kind === 'thinking') {
|
|
311
821
|
out.push({ kind: 'thinking', slot: `k${i}`, text: it.text });
|
|
312
822
|
}
|
|
823
|
+
else if (it.kind === 'truncated') {
|
|
824
|
+
out.push({ kind: 'truncated', slot: `x${i}`, droppedBytes: it.droppedBytes, keptFrom: it.keptFrom });
|
|
825
|
+
}
|
|
313
826
|
else {
|
|
314
827
|
out.push({ kind: 'echo', slot: `e${i}`, text: it.text });
|
|
315
828
|
}
|
|
@@ -336,7 +849,7 @@ export function settleSubagentContent(taskId) {
|
|
|
336
849
|
scheduleNotify(taskId);
|
|
337
850
|
}
|
|
338
851
|
export function clearSubagentContent(taskId) {
|
|
339
|
-
|
|
852
|
+
dropTask(taskId); // 唯一删除口:字节同拍从总账里扣掉
|
|
340
853
|
}
|
|
341
854
|
const bgFacts = new Map();
|
|
342
855
|
export function recordBgTerminalFacts(taskId, facts) {
|