@yeaft/webchat-agent 1.0.549 → 1.0.550

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.
@@ -1 +1 @@
1
- {"version":"1.0.549"}
1
+ {"version":"1.0.550"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.549",
3
+ "version": "1.0.550",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -353,7 +353,9 @@ function buildRequestSnapshot(info = {}) {
353
353
  systemPrompt: String(info.systemPrompt || ''),
354
354
  messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
355
355
  requestInputBreakdown: normalizeRequestInputBreakdown(info.requestInputBreakdown),
356
- rawRequest: info.rawRequest ?? null,
356
+ // Own the latest snapshot: adapters/callers may extend their message array
357
+ // after endTurn. Keeping that live reference would silently skip raw deltas.
358
+ rawRequest: cloneRawValue(info.rawRequest) ?? null,
357
359
  };
358
360
  }
359
361
 
@@ -501,6 +503,8 @@ function serializableTraceMeta(trace) {
501
503
  delete meta._lastSnapshot;
502
504
  delete meta._persistedFormat;
503
505
  delete meta._persistedRequestDir;
506
+ delete meta._payloadReleased;
507
+ delete meta._failedAppend;
504
508
  delete meta.baseRequest;
505
509
  delete meta.loops;
506
510
  delete meta.tools;
@@ -553,6 +557,8 @@ async function countRequestEvents(requestDir) {
553
557
  }
554
558
  let loopCount = 0;
555
559
  let toolCount = 0;
560
+ const loopIds = new Set();
561
+ const toolIds = new Set();
556
562
  const stream = createReadStream(requestEventsPath(requestDir), { encoding: 'utf8' });
557
563
  stream.on('error', () => {});
558
564
  try {
@@ -561,8 +567,13 @@ async function countRequestEvents(requestDir) {
561
567
  if (!line.trim()) continue;
562
568
  try {
563
569
  const event = JSON.parse(line);
564
- if (event?.type === 'loop' && event.record) loopCount += 1;
565
- else if (event?.type === 'tool' && event.record) toolCount += 1;
570
+ if (event?.type === 'loop' && event.record) {
571
+ const id = event.record.turnRowId || event.record.loopInstanceId || `${event.record.loopNumber || 0}`;
572
+ if (!loopIds.has(id)) { loopIds.add(id); loopCount += 1; }
573
+ } else if (event?.type === 'tool' && event.record) {
574
+ const id = event.record.id || `${event.record.turnRowId || ''}:${event.record.toolCallId || ''}`;
575
+ if (!toolIds.has(id)) { toolIds.add(id); toolCount += 1; }
576
+ }
566
577
  } catch { /* Ignore one torn final append. */ }
567
578
  }
568
579
  } catch { /* Missing/unreadable event file counts as empty. */ }
@@ -853,6 +864,28 @@ async function readHeaderDetail(rootDir, header) {
853
864
  return sameTraceIdentity(trace, header) ? trace : null;
854
865
  }
855
866
 
867
+ /** Detail reads combine durable payload with records whose append failed. The
868
+ * compact live rows must never overwrite full disk rows or hide unwritten data. */
869
+ async function readAvailableTrace(rootDir, trace) {
870
+ if (!trace?._payloadReleased) return trace;
871
+ const stored = await readHeaderDetail(rootDir, trace);
872
+ const loops = new Map((stored?.loops || []).map(loop => [loop.turnRowId || loop.loopInstanceId, loop]));
873
+ for (const loop of trace.loops || []) {
874
+ if (Object.hasOwn(loop, 'requestDelta')) loops.set(loop.turnRowId || loop.loopInstanceId, loop);
875
+ }
876
+ const tools = new Map((stored?.tools || []).map(tool => [tool.id, tool]));
877
+ for (const tool of trace.tools || []) {
878
+ if (Object.hasOwn(tool, 'toolOutput')) tools.set(tool.id, tool);
879
+ }
880
+ return {
881
+ ...trace,
882
+ baseRequest: stored?.baseRequest || null,
883
+ loops: [...loops.values()].sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0)
884
+ || String(a.turnRowId || '').localeCompare(String(b.turnRowId || ''))),
885
+ tools: [...tools.values()],
886
+ };
887
+ }
888
+
856
889
  async function countDirFiles(rootDir) {
857
890
  let files = 0;
858
891
  let bytes = 0;
@@ -986,10 +1019,10 @@ export class DebugTrace {
986
1019
  // it to a transport failure or another uncaptured attempt.
987
1020
  rawRequest: Object.prototype.hasOwnProperty.call(info, 'rawRequest')
988
1021
  ? info.rawRequest
989
- : (trace.baseRequest?.rawRequest ?? null),
1022
+ : (trace._lastSnapshot?.rawRequest ?? trace.baseRequest?.rawRequest ?? null),
990
1023
  });
991
1024
  const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
992
- if (!trace.baseRequest) {
1025
+ if (!previousSnapshot) {
993
1026
  trace.baseRequest = {
994
1027
  systemPrompt: snapshot.systemPrompt,
995
1028
  messages: Array.isArray(snapshot.messages) ? cloneJsonValue(snapshot.messages) : [],
@@ -1065,7 +1098,7 @@ export class DebugTrace {
1065
1098
  };
1066
1099
  trace.tools.push(tool);
1067
1100
  trace.updatedAt = tool.createdAt;
1068
- this.#appendTraceRecord(trace, 'tool', tool, { writeMeta: false });
1101
+ this.#appendTraceRecord(trace, 'tool', tool, { writeMeta: trace.active === false });
1069
1102
  return id;
1070
1103
  }
1071
1104
 
@@ -1142,6 +1175,7 @@ export class DebugTrace {
1142
1175
  item?.sessionId === requestedSessionId
1143
1176
  && (item.requestId === requestedTurnId || item.traceId === requestedTurnId)
1144
1177
  )) || null;
1178
+ if (trace) trace = await readAvailableTrace(this.#rootDir, trace);
1145
1179
  if (!trace) {
1146
1180
  const locator = await readJson(turnLocatorPath(this.#rootDir, requestedSessionId, requestedTurnId));
1147
1181
  if (locator?.requestKey && locator.sessionId === requestedSessionId && locator.requestId === requestedTurnId) {
@@ -1218,8 +1252,8 @@ export class DebugTrace {
1218
1252
  await this.#drainWrites();
1219
1253
  const tools = [];
1220
1254
  for (const { trace: header } of this.#traceSummaries().slice().reverse()) {
1221
- const trace = this.#requestCache.get(header.requestKey)
1222
- || await readHeaderDetail(this.#rootDir, header);
1255
+ const live = this.#requestCache.get(header.requestKey);
1256
+ const trace = live ? await readAvailableTrace(this.#rootDir, live) : await readHeaderDetail(this.#rootDir, header);
1223
1257
  if (!trace) continue;
1224
1258
  for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
1225
1259
  const row = traceToolToLegacy(trace, tool);
@@ -1240,8 +1274,8 @@ export class DebugTrace {
1240
1274
  if (!needle) return [];
1241
1275
  const results = [];
1242
1276
  for (const { trace: header } of this.#traceSummaries().slice().reverse()) {
1243
- const trace = this.#requestCache.get(header.requestKey)
1244
- || await readHeaderDetail(this.#rootDir, header);
1277
+ const live = this.#requestCache.get(header.requestKey);
1278
+ const trace = live ? await readAvailableTrace(this.#rootDir, live) : await readHeaderDetail(this.#rootDir, header);
1245
1279
  if (!trace || !JSON.stringify(trace).toLowerCase().includes(needle)) continue;
1246
1280
  results.push(...traceToLegacyRows(trace));
1247
1281
  if (results.length >= 50) break;
@@ -1319,6 +1353,23 @@ export class DebugTrace {
1319
1353
  async close() {
1320
1354
  this.#acceptingWrites = false;
1321
1355
  await this.#drainWrites();
1356
+ // One final bounded retry, also for a failure with no later loop/finalize.
1357
+ // Persistent disk errors retain diagnostics rather than silently evicting.
1358
+ for (const trace of this.#requestCache.values()) {
1359
+ const failed = trace._failedAppend;
1360
+ if (!failed) continue;
1361
+ delete trace._failedAppend;
1362
+ for (const entry of failed.records) {
1363
+ this.#pendingWrites.push({ trace, ...entry, initialize: true,
1364
+ writeMeta: failed.writeMeta, evictAfterWrite: failed.evictAfterWrite });
1365
+ }
1366
+ }
1367
+ await this.#drainWrites();
1368
+ if ([...this.#requestCache.values()].some(trace => trace._failedAppend)) return;
1369
+ this.#requestCache.clear();
1370
+ this.#turnIndex.clear();
1371
+ this.#retentionIndex.clear();
1372
+ this.#reconciledRetentionSessions.clear();
1322
1373
  }
1323
1374
 
1324
1375
  /**
@@ -1410,6 +1461,12 @@ export class DebugTrace {
1410
1461
  .find(item => traceMatchesIdentity(item, sessionId, turnId)) || null;
1411
1462
  }
1412
1463
  if (!trace) return false;
1464
+ // Legacy traces must keep their source payload until format migration has
1465
+ // durably copied it. Event traces already own their history on disk.
1466
+ if (trace._persistedFormat === 'events') {
1467
+ trace._lastSnapshot = this.#reconstructLastSnapshot(trace);
1468
+ this.#releasePersistedPayload(trace, [...trace.loops, ...trace.tools]);
1469
+ }
1413
1470
  this.#requestCache.set(trace.requestKey, trace);
1414
1471
  return true;
1415
1472
  }
@@ -1477,6 +1534,25 @@ export class DebugTrace {
1477
1534
  return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
1478
1535
  }
1479
1536
 
1537
+ /** Release only exact records known to be durable; newer replacements and
1538
+ * in-flight records keep their payload. Lightweight rows preserve identity,
1539
+ * counts and usage without keeping tool output/response/request bodies. */
1540
+ #releasePersistedPayload(trace, records) {
1541
+ const persisted = new Set(records);
1542
+ trace.loops = (trace.loops || []).map(loop => {
1543
+ if (!persisted.has(loop)) return loop;
1544
+ const { response, toolCalls, rawRequest, rawResponse, requestDelta, ...meta } = loop;
1545
+ return meta;
1546
+ });
1547
+ trace.tools = (trace.tools || []).map(tool => {
1548
+ if (!persisted.has(tool)) return tool;
1549
+ const { toolInput, toolOutput, ...meta } = tool;
1550
+ return meta;
1551
+ });
1552
+ trace.baseRequest = null;
1553
+ trace._payloadReleased = true;
1554
+ }
1555
+
1480
1556
  #appendTraceRecord(trace, type, record, { writeMeta = false, evictAfterWrite = false } = {}) {
1481
1557
  if (!this.#acceptingWrites || !trace?.requestKey || !record) return;
1482
1558
  this.#requestCache.set(trace.requestKey, trace);
@@ -1485,7 +1561,9 @@ export class DebugTrace {
1485
1561
  this.#pendingWrites.push({
1486
1562
  trace,
1487
1563
  type,
1488
- record: cloneJsonValue(record),
1564
+ // Records are private immutable snapshots, already detached from caller
1565
+ // input by endTurn/logTool. Do not serialize+parse a second full copy.
1566
+ record,
1489
1567
  initialize,
1490
1568
  writeMeta: !!writeMeta,
1491
1569
  evictAfterWrite: !!evictAfterWrite,
@@ -1528,17 +1606,29 @@ export class DebugTrace {
1528
1606
  for (const entry of entries) {
1529
1607
  if (!this.#requestCache.has(entry.trace.requestKey)) continue;
1530
1608
  const requestDir = requestDirFor(this.#rootDir, entry.trace.sessionId || null, entry.trace.requestKey);
1531
- const batch = batches.get(requestDir) || { trace: entry.trace, initialize: false, writeMeta: false, evictAfterWrite: false, lines: [] };
1609
+ const batch = batches.get(requestDir) || { trace: entry.trace, initialize: false, writeMeta: false, evictAfterWrite: false, records: [] };
1532
1610
  batch.trace = entry.trace;
1533
1611
  batch.initialize ||= entry.initialize;
1534
1612
  batch.writeMeta ||= entry.writeMeta;
1535
1613
  batch.evictAfterWrite ||= entry.evictAfterWrite;
1536
- batch.lines.push(`${JSON.stringify({ type: entry.type, record: entry.record })}\n`);
1614
+ batch.records.push({ type: entry.type, record: entry.record });
1537
1615
  batches.set(requestDir, batch);
1538
1616
  }
1539
1617
  for (const [requestDir, batch] of batches) {
1540
- const { trace, initialize, writeMeta, evictAfterWrite } = batch;
1541
- const lines = [...batch.lines];
1618
+ const { trace } = batch;
1619
+ // A failed append may contain the base/delta for every later loop.
1620
+ // Retry it before new records (including finalize); do not run a timer
1621
+ // retry loop on a full disk. Record identity makes partial replay safe.
1622
+ if (trace._failedAppend) {
1623
+ const failed = trace._failedAppend;
1624
+ batch.records.unshift(...failed.records);
1625
+ batch.initialize = true; // recheck metadata and repair any partial tail
1626
+ batch.writeMeta ||= failed.writeMeta;
1627
+ batch.evictAfterWrite ||= failed.evictAfterWrite;
1628
+ }
1629
+ const { initialize, writeMeta, evictAfterWrite } = batch;
1630
+ const records = [...batch.records];
1631
+ const durableRecords = batch.records.map(entry => entry.record);
1542
1632
  const legacyRequestDir = trace._persistedFormat === 'legacy'
1543
1633
  ? trace._persistedRequestDir || null
1544
1634
  : null;
@@ -1548,14 +1638,16 @@ export class DebugTrace {
1548
1638
  const meta = serializableTraceMeta(trace);
1549
1639
  if (legacyRequestDir) {
1550
1640
  meta.legacyBaseRequest = cloneJsonValue(trace.baseRequest);
1551
- const legacyLines = [];
1641
+ const legacyRecords = [];
1552
1642
  for (const loop of Array.isArray(trace.loops) ? trace.loops : []) {
1553
- legacyLines.push(`${JSON.stringify({ type: 'loop', record: loop })}\n`);
1643
+ legacyRecords.push({ type: 'loop', record: loop });
1644
+ durableRecords.push(loop);
1554
1645
  }
1555
1646
  for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
1556
- legacyLines.push(`${JSON.stringify({ type: 'tool', record: tool })}\n`);
1647
+ legacyRecords.push({ type: 'tool', record: tool });
1648
+ durableRecords.push(tool);
1557
1649
  }
1558
- lines.unshift(...legacyLines);
1650
+ records.unshift(...legacyRecords);
1559
1651
  }
1560
1652
  await atomicWriteText(metaPath, JSON.stringify(meta));
1561
1653
  await atomicWriteText(
@@ -1566,18 +1658,29 @@ export class DebugTrace {
1566
1658
  await ensureDir(requestDir);
1567
1659
  const eventPath = requestEventsPath(requestDir);
1568
1660
  if (initialize) await prepareJsonlAppend(eventPath);
1569
- await fsp.appendFile(eventPath, lines.join(''), 'utf8');
1661
+ // Serialize/write one record at a time, not all batch strings plus a
1662
+ // second joined string. Raw diagnostics remain lossless on disk.
1663
+ const handle = await fsp.open(eventPath, 'a');
1664
+ try {
1665
+ for (const record of records) await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
1666
+ } finally { await handle.close(); }
1570
1667
  if (writeMeta) {
1571
1668
  const meta = serializableTraceMeta(trace);
1572
1669
  await atomicWriteText(metaPath, JSON.stringify(meta));
1573
1670
  this.#diskHeaders.set(trace.requestKey, meta);
1574
1671
  }
1672
+ delete trace._failedAppend;
1673
+ this.#releasePersistedPayload(trace, durableRecords);
1575
1674
  trace._persistedFormat = 'events';
1576
1675
  trace._persistedRequestDir = requestDir;
1577
1676
  if (legacyRequestDir && legacyRequestDir !== requestDir) {
1578
1677
  await removeRequestDirIfIdentityMatches(legacyRequestDir, trace);
1579
1678
  }
1580
1679
  if (evictAfterWrite) {
1680
+ // Retention must not keep a second strong reference to finalized
1681
+ // _lastSnapshot after the primary cache has evicted the request.
1682
+ const retained = this.#retentionIndex.get(trace.sessionId || '')?.get(trace.requestKey);
1683
+ if (retained) retained.trace = serializableTraceMeta(trace);
1581
1684
  this.#requestCache.delete(trace.requestKey);
1582
1685
  this.#initializedRequestKeys.delete(trace.requestKey);
1583
1686
  for (const [turnId, ctx] of this.#turnIndex) {
@@ -1585,6 +1688,9 @@ export class DebugTrace {
1585
1688
  }
1586
1689
  }
1587
1690
  } catch (err) {
1691
+ // Keep immutable entries until a later append/close can durably retry
1692
+ // them. In particular, finalize must not evict a failed base/delta.
1693
+ trace._failedAppend = { records: batch.records, writeMeta, evictAfterWrite };
1588
1694
  console.warn('[Yeaft] debug trace append failed:', err?.message || err);
1589
1695
  }
1590
1696
  }
package/yeaft/engine.js CHANGED
@@ -336,9 +336,10 @@ function sleepWithAbort(ms, signal) {
336
336
  * - `toolCalls` on assistant turns (the LLM's function_call requests)
337
337
  * - `toolCallId` + `isError` on tool turns (the paired tool_result)
338
338
  *
339
- * Content is kept intact for the live protocol. The file-backed debug trace
340
- * applies its own configured byte budget at persistence time so the model
341
- * request path never pays an extra copy just for diagnostics.
339
+ * Content is kept intact for the live protocol. Raw provider exchanges belong
340
+ * to the loop-level file-backed trace, never to its message snapshots: copying
341
+ * historical rawRequest values here multiplies retained request bodies on every
342
+ * tool loop. Do not even traverse diagnostic fields on incoming messages.
342
343
  *
343
344
  * Pure function — no side effects on the input message.
344
345
  *
@@ -348,7 +349,6 @@ function sleepWithAbort(ms, signal) {
348
349
  export function mapDebugMessage(m) {
349
350
  const out = { role: m.role };
350
351
  out.content = m.content;
351
- if (m.rawRequest != null) out.rawRequest = m.rawRequest;
352
352
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
353
353
  out.toolCalls = m.toolCalls.map(tc => ({
354
354
  id: tc.id,
@@ -3741,7 +3741,10 @@ export class Engine {
3741
3741
  // yielding any post-stream diagnostics. A consumer may stop iterating at
3742
3742
  // any yield; persistence therefore cannot wait for turn_end or even the
3743
3743
  // debug `loop` event below.
3744
- const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress', ...(rawRequest ? { rawRequest } : {}) };
3744
+ // The complete exchange is already owned by the per-loop debug trace.
3745
+ // Keeping it on model history retains every previous request and makes
3746
+ // subsequent debug snapshots grow with cumulative request bodies.
3747
+ const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress' };
3745
3748
  if (toolCalls.length > 0) {
3746
3749
  assistantMsg.toolCalls = toolCalls.map(tc => ({
3747
3750
  id: tc.id,