@yeaft/webchat-agent 0.1.611 → 0.1.613

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.611",
3
+ "version": "0.1.613",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -36,6 +36,20 @@ import { pickEffort, parseEffortPrefix } from './effort.js';
36
36
  import { normalizeEffort } from './models.js';
37
37
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
38
38
  import { resolveThinking } from './router/thinking.js';
39
+ import {
40
+ TOOL_BATCH_SIZE,
41
+ TURN_SUMMARY_THRESHOLD,
42
+ DUP_TOOL_THRESHOLD,
43
+ ExecLog,
44
+ buildEntry as buildExecLogEntry,
45
+ argsHashOf,
46
+ runT1Reflection,
47
+ runT2Reflection,
48
+ buildFallbackStub,
49
+ collapseRangeToReflection,
50
+ buildDuplicateReminder,
51
+ extractToolPairsFromRange,
52
+ } from './tool-folding/index.js';
39
53
 
40
54
  /**
41
55
  * task-324 — Turn cap removed.
@@ -172,6 +186,26 @@ export class Engine {
172
186
  */
173
187
  #currentAbortCtrl = null;
174
188
 
189
+ /**
190
+ * PR-L — V7 Tool History Reflection state. Owned per Engine instance.
191
+ *
192
+ * • `#execLog` — append-only log of every tool execution, used for
193
+ * fallback-stub generation and duplicate-call detection. Persists
194
+ * to <yeaftDir>/tool-log/<traceId>/<turnIdx>.jsonl when yeaftDir
195
+ * is set; in-memory only otherwise.
196
+ * • `#pendingT2` — Map<turnNumber, { promise, loopRange, count, ... }>
197
+ * keyed by the turn number that triggered T2. The next query() call
198
+ * non-blocking-checks this map; if the promise has resolved, the
199
+ * prior turn's history is rewritten with the reflection. If still
200
+ * pending, the engine falls back to the exec-log stub.
201
+ * • `#reflectedTurns` — Set<turnNumber>; ensures T1 fires at most
202
+ * once per turn (when toolCount crosses TOOL_BATCH_SIZE).
203
+ */
204
+ #execLog = null;
205
+ #pendingT2 = new Map();
206
+ #reflectedTurns = new Set();
207
+ #__queryCounter = 0;
208
+
175
209
  /** @type {string|null} */
176
210
  #abortReason = null;
177
211
 
@@ -202,6 +236,14 @@ export class Engine {
202
236
  this.#mcpManager = mcpManager || null;
203
237
  this.#yeaftDir = yeaftDir || null;
204
238
 
239
+ // PR-L: tool history reflection log. Keyed by traceId so distinct
240
+ // engine instances don't stomp on each other's jsonl files. When
241
+ // yeaftDir is null the ExecLog still works — purely in-memory.
242
+ this.#execLog = new ExecLog({
243
+ yeaftDir: this.#yeaftDir,
244
+ conversationId: this.#traceId,
245
+ });
246
+
205
247
  // Build fast config: uses fastModelId for internal tasks (recall, consolidation, dream)
206
248
  // Falls back to primary model if no fastModel configured
207
249
  const fastModelId = config.fastModelId || config.model;
@@ -846,6 +888,22 @@ export class Engine {
846
888
  { role: 'user', content: prompt },
847
889
  ];
848
890
 
891
+ // PR-L: T2 carry-forward. If a previous query()'s end-of-turn
892
+ // reflection has resolved, rewrite that turn's range in
893
+ // `conversationMessages` to a single assistant reflection message.
894
+ // If still pending, fall back to the exec-log stub — non-blocking,
895
+ // never wait. This runs BEFORE the first adapter.stream so the
896
+ // upcoming call sees the rewritten history.
897
+ yield* this.#applyPendingT2Reflections(conversationMessages, prompt);
898
+
899
+ // PR-L: track this query()'s tool-arc for reflection.
900
+ // `turnStartIdx` is where the current user message lives; the arc
901
+ // we may collapse spans (turnStartIdx + 1 .. last assistant/tool).
902
+ const turnStartIdx = conversationMessages.length - 1;
903
+ let queryToolCount = 0;
904
+ let t1Fired = false;
905
+ const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
906
+
849
907
  const toolDefs = this.#getToolDefs();
850
908
  let turnNumber = 0;
851
909
  let continueTurns = 0; // auto-continue counter
@@ -1196,6 +1254,56 @@ export class Engine {
1196
1254
  }
1197
1255
  }
1198
1256
 
1257
+ // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
1258
+ // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
1259
+ // (5) AND T1 didn't already collapse the arc. Kicks off the
1260
+ // primary-model call without await; the next query()'s
1261
+ // `#applyPendingT2Reflections` carries the result forward.
1262
+ if (queryToolCount > TURN_SUMMARY_THRESHOLD && !t1Fired) {
1263
+ const arcStart = turnStartIdx + 1;
1264
+ const arcEnd = conversationMessages.length - 1;
1265
+ if (arcEnd > arcStart) {
1266
+ const { pairs, assistantText } = extractToolPairsFromRange(
1267
+ conversationMessages, arcStart, arcEnd,
1268
+ );
1269
+ yield {
1270
+ type: 'reflection',
1271
+ trigger: 't2',
1272
+ status: 'pending',
1273
+ loopRange: [arcStart, arcEnd],
1274
+ toolCount: pairs.length,
1275
+ };
1276
+ const promise = runT2Reflection({
1277
+ adapter: this.#adapter,
1278
+ model: this.#config.model,
1279
+ originalUserMsg: prompt,
1280
+ toolPairs: pairs,
1281
+ assistantText,
1282
+ signal,
1283
+ });
1284
+ // Detach: never await. The promise outlives this query() and
1285
+ // the next call will pick it up (or use the fallback stub if
1286
+ // it hasn't resolved by then).
1287
+ // PR-L follow-up: latch a synchronously-readable ready flag
1288
+ // and result on the info record so `#applyPendingT2Reflections`
1289
+ // can decide ready-vs-pending without racing microtasks.
1290
+ const info = {
1291
+ promise,
1292
+ loopRange: [arcStart, arcEnd],
1293
+ count: pairs.length,
1294
+ originalUserMsg: prompt,
1295
+ ready: false,
1296
+ result: null,
1297
+ error: null,
1298
+ };
1299
+ promise.then(
1300
+ (v) => { info.ready = true; info.result = v; },
1301
+ (err) => { info.ready = true; info.error = err; },
1302
+ );
1303
+ this.#pendingT2.set(queryNumber, info);
1304
+ }
1305
+ }
1306
+
1199
1307
  break;
1200
1308
  }
1201
1309
 
@@ -1206,6 +1314,8 @@ export class Engine {
1206
1314
  // break out of the outer while-loop cleanly once the current
1207
1315
  // tool batch finishes reporting.
1208
1316
  let abortedDuringTools = false;
1317
+ /** @type {string[]} */
1318
+ const pendingDupReminders = [];
1209
1319
 
1210
1320
  for (const tc of toolCalls) {
1211
1321
  // task-325a: honour abort between tools. We don't cancel a tool
@@ -1219,6 +1329,35 @@ export class Engine {
1219
1329
 
1220
1330
  const toolStartTime = Date.now();
1221
1331
 
1332
+ // PR-L: duplicate-call detection. If this exact (toolName,
1333
+ // argsHash) pair has already been executed DUP_TOOL_THRESHOLD
1334
+ // (3) times within the current turn + last 2 turns, queue a
1335
+ // system reminder. We push the reminder AFTER the tool batch
1336
+ // completes (not now) so the
1337
+ // assistant(tool_use) → user(tool_result, …) pairing demanded
1338
+ // by the Anthropic / OpenAI Responses APIs stays intact. We
1339
+ // don't block the call — the LLM still decides.
1340
+ const dupHash = argsHashOf(tc.input);
1341
+ // PR-L follow-up: lookback is by user-conversation turn
1342
+ // (`queryNumber`), NOT by inner adapter loop iteration. Each call
1343
+ // to query() bumps queryNumber once, so "last 2 turns" means the
1344
+ // current user turn + the previous two user turns — the natural
1345
+ // semantic for "the model is stuck in a loop across the
1346
+ // conversation."
1347
+ const dupInfo = this.#execLog.dupInfo({
1348
+ toolName: tc.name,
1349
+ argsHash: dupHash,
1350
+ currentTurn: queryNumber,
1351
+ lookbackTurns: 2,
1352
+ });
1353
+ if (dupInfo.count + 1 >= DUP_TOOL_THRESHOLD) {
1354
+ pendingDupReminders.push(buildDuplicateReminder({
1355
+ toolName: tc.name,
1356
+ count: dupInfo.count + 1,
1357
+ lastResultBrief: dupInfo.lastResultBrief,
1358
+ }));
1359
+ }
1360
+
1222
1361
  let output;
1223
1362
  let isError = false;
1224
1363
 
@@ -1266,6 +1405,92 @@ export class Engine {
1266
1405
  content: output,
1267
1406
  isError,
1268
1407
  });
1408
+
1409
+ // PR-L: persist this execution to the exec-log for fallback-stub
1410
+ // and duplicate-call detection. Best-effort — disk failures are
1411
+ // swallowed inside ExecLog.append.
1412
+ // PR-L follow-up: persist under `queryNumber` (one entry-key per
1413
+ // user-conversation turn), not the inner loop's turnNumber.
1414
+ // Aligns exec-log layout with dup detection lookback and the
1415
+ // T2 fallback-stub readTurn() call below.
1416
+ this.#execLog.append(queryNumber, buildExecLogEntry({
1417
+ loopIdx: queryToolCount,
1418
+ toolName: tc.name,
1419
+ args: tc.input,
1420
+ output,
1421
+ isError,
1422
+ }));
1423
+ queryToolCount += 1;
1424
+ }
1425
+
1426
+ // PR-L: flush any duplicate-call reminders queued during the batch.
1427
+ // Pushed AFTER the for-loop so the tool_use → tool_result pairing
1428
+ // is intact; the next adapter.stream() will see the reminder as a
1429
+ // user message immediately after the last tool result.
1430
+ for (const reminder of pendingDupReminders) {
1431
+ conversationMessages.push({ role: 'user', content: reminder });
1432
+ }
1433
+
1434
+ // PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
1435
+ // query() lifetime, the moment queryToolCount crosses
1436
+ // TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
1437
+ // assistant+tool arc since the user prompt and rewrites the history
1438
+ // in place — collapsing it to a SINGLE assistant message — before
1439
+ // the next adapter.stream() runs.
1440
+ if (!t1Fired
1441
+ && queryToolCount >= TOOL_BATCH_SIZE
1442
+ && !this.#reflectedTurns.has(`${queryNumber}:t1`)
1443
+ && !abortedDuringTools && !signal?.aborted) {
1444
+ t1Fired = true;
1445
+ this.#reflectedTurns.add(`${queryNumber}:t1`);
1446
+ try {
1447
+ const arcStart = turnStartIdx + 1;
1448
+ const arcEnd = conversationMessages.length - 1;
1449
+ const { pairs, assistantText } = extractToolPairsFromRange(
1450
+ conversationMessages, arcStart, arcEnd,
1451
+ );
1452
+ yield {
1453
+ type: 'reflection',
1454
+ trigger: 't1',
1455
+ status: 'pending',
1456
+ loopRange: [arcStart, arcEnd],
1457
+ toolCount: pairs.length,
1458
+ };
1459
+ const { content, durationMs } = await runT1Reflection({
1460
+ adapter: this.#adapter,
1461
+ model: this.#config.model,
1462
+ originalUserMsg: prompt,
1463
+ toolPairs: pairs,
1464
+ assistantText,
1465
+ signal,
1466
+ });
1467
+ const next = collapseRangeToReflection(
1468
+ conversationMessages, arcStart, arcEnd, content,
1469
+ );
1470
+ conversationMessages.length = 0;
1471
+ for (const m of next) conversationMessages.push(m);
1472
+ yield {
1473
+ type: 'reflection',
1474
+ trigger: 't1',
1475
+ // PR-L bug fix: keep the same loopRange as the `pending` event
1476
+ // so the frontend key stays stable across pending → ready and
1477
+ // the spinner card is replaced in place (no orphan).
1478
+ status: 'ready',
1479
+ loopRange: [arcStart, arcEnd],
1480
+ toolCount: pairs.length,
1481
+ content,
1482
+ durationMs,
1483
+ };
1484
+ } catch (err) {
1485
+ // Best-effort. On failure leave history unchanged so the loop
1486
+ // continues normally — never block the turn.
1487
+ yield {
1488
+ type: 'reflection',
1489
+ trigger: 't1',
1490
+ status: 'error',
1491
+ error: err && err.message || String(err),
1492
+ };
1493
+ }
1269
1494
  }
1270
1495
 
1271
1496
  // task-325a: if abort fired between tools, converge now — emit
@@ -1335,6 +1560,82 @@ export class Engine {
1335
1560
  /** @returns {import('./mcp.js').MCPManager|null} */
1336
1561
  get mcpManager() { return this.#mcpManager; }
1337
1562
 
1563
+ /**
1564
+ * PR-L — V7 Tool History Reflection helpers.
1565
+ *
1566
+ * `#applyPendingT2Reflections` is called at the start of every
1567
+ * `#runQuery` to carry forward any prior turn's async reflection. It is
1568
+ * a generator so it can yield reflection events to the engine consumer.
1569
+ * Non-blocking: never awaits a pending promise.
1570
+ *
1571
+ * @param {Array} conversationMessages
1572
+ * @param {string} originalUserMsg
1573
+ */
1574
+ async *#applyPendingT2Reflections(conversationMessages, originalUserMsg) {
1575
+ if (this.#pendingT2.size === 0) return;
1576
+ // Drain in insertion order (Map preserves it). We process all entries
1577
+ // because the user could send multiple prompts back-to-back before
1578
+ // the engine resumes — each historical turn gets its rewrite.
1579
+ const drained = [...this.#pendingT2.entries()];
1580
+ this.#pendingT2.clear();
1581
+
1582
+ for (const [turnNumber, info] of drained) {
1583
+ const range = info.loopRange;
1584
+ if (!Array.isArray(range) || range.length !== 2) continue;
1585
+ const [startIdx, endIdx] = range;
1586
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= conversationMessages.length) {
1587
+ continue;
1588
+ }
1589
+
1590
+ // PR-L follow-up: deterministic readiness check. The info record
1591
+ // carries `ready / result / error` flags that are flipped from the
1592
+ // promise's then/catch handler; reading them here is purely
1593
+ // synchronous bookkeeping — no microtask race.
1594
+ let content;
1595
+ let trigger;
1596
+ let durationMs = 0;
1597
+ if (!info.ready) {
1598
+ // Still in flight — fall back to the exec-log stub. Detach the
1599
+ // unresolved promise so we don't leak it (handlers above already
1600
+ // swallow rejection by routing into info.error).
1601
+ const entries = this.#execLog ? this.#execLog.readTurn(turnNumber) : [];
1602
+ content = buildFallbackStub({ execLogEntries: entries, originalUserMsg: info.originalUserMsg || originalUserMsg });
1603
+ trigger = 't2-fallback';
1604
+ } else if (info.error) {
1605
+ // Promise rejected — leave history unchanged, no event.
1606
+ continue;
1607
+ } else if (info.result && typeof info.result.content === 'string' && info.result.content) {
1608
+ content = info.result.content;
1609
+ trigger = 't2';
1610
+ durationMs = info.result.durationMs || 0;
1611
+ } else {
1612
+ // Resolved but with no usable content — defensively skip.
1613
+ continue;
1614
+ }
1615
+
1616
+ // Rewrite history.
1617
+ const next = collapseRangeToReflection(conversationMessages, startIdx, endIdx, content);
1618
+ // Mutate in place so caller's reference stays valid.
1619
+ conversationMessages.length = 0;
1620
+ for (const m of next) conversationMessages.push(m);
1621
+
1622
+ yield {
1623
+ type: 'reflection',
1624
+ trigger,
1625
+ status: 'ready',
1626
+ loopRange: [startIdx, endIdx],
1627
+ toolCount: info.count || 0,
1628
+ content,
1629
+ durationMs,
1630
+ };
1631
+ }
1632
+ }
1633
+
1634
+ /**
1635
+ * PR-L — read-only accessor for tests.
1636
+ */
1637
+ get _execLog() { return this.#execLog; }
1638
+
1338
1639
  /**
1339
1640
  * task-299 Phase 1: the engine's current thread marker.
1340
1641
  * Defaults to 'main' if the thread store is unreachable for any reason.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * exec-log.js — Persistent tool execution log used by the reflection
3
+ * subsystem (PR-L).
4
+ *
5
+ * Two purposes:
6
+ * 1. Source of fallback-stub when T2 reflection isn't ready in time.
7
+ * 2. Duplicate-call detection — count how many times the SAME
8
+ * (toolName, argsHash) pair has been executed in the current turn
9
+ * plus the last two turns, so the engine can inject a reminder when
10
+ * the model is stuck in a loop.
11
+ *
12
+ * Storage layout (only when yeaftDir is configured):
13
+ * <yeaftDir>/tool-log/<conversationId>/<turnIdx>.jsonl
14
+ *
15
+ * One JSON object per line:
16
+ * { loopIdx, toolName, argsHash, argsBrief, resultBrief,
17
+ * resultBytes, resultStatus, timestamp }
18
+ *
19
+ * If yeaftDir is absent, the log is held in memory only.
20
+ */
21
+
22
+ import { createHash } from 'crypto';
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+
26
+ const ARGS_BRIEF_CAP = 200;
27
+ const RESULT_BRIEF_CAP = 500;
28
+
29
+ /**
30
+ * Canonical-JSON-stringify and hash. Stable across key ordering so identical
31
+ * args produce identical hashes regardless of how the LLM serialised them.
32
+ *
33
+ * @param {any} args
34
+ * @returns {string} 16 hex chars
35
+ */
36
+ export function argsHashOf(args) {
37
+ let canon;
38
+ try {
39
+ canon = canonicalStringify(args);
40
+ } catch {
41
+ canon = String(args);
42
+ }
43
+ return createHash('sha256').update(canon).digest('hex').slice(0, 16);
44
+ }
45
+
46
+ function canonicalStringify(value) {
47
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
48
+ if (Array.isArray(value)) {
49
+ return '[' + value.map(canonicalStringify).join(',') + ']';
50
+ }
51
+ const keys = Object.keys(value).sort();
52
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k])).join(',') + '}';
53
+ }
54
+
55
+ function brief(value, cap) {
56
+ let s;
57
+ if (typeof value === 'string') s = value;
58
+ else {
59
+ try { s = JSON.stringify(value); } catch { s = String(value); }
60
+ }
61
+ if (s == null) s = '';
62
+ if (s.length <= cap) return s;
63
+ return s.slice(0, cap) + '…';
64
+ }
65
+
66
+ /**
67
+ * Build a single exec-log entry from a tool execution result.
68
+ *
69
+ * @param {{ loopIdx: number, toolName: string, args: any, output: any, isError: boolean }} p
70
+ * @returns {object}
71
+ */
72
+ export function buildEntry({ loopIdx, toolName, args, output, isError }) {
73
+ const argsBrief = brief(args, ARGS_BRIEF_CAP);
74
+ const resultBrief = brief(output, RESULT_BRIEF_CAP);
75
+ const resultBytes = typeof output === 'string'
76
+ ? Buffer.byteLength(output, 'utf8')
77
+ : Buffer.byteLength(brief(output, 1_000_000), 'utf8');
78
+ return {
79
+ loopIdx,
80
+ toolName,
81
+ argsHash: argsHashOf(args),
82
+ argsBrief,
83
+ resultBrief,
84
+ resultBytes,
85
+ resultStatus: isError ? 'error' : 'ok',
86
+ timestamp: Date.now(),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * ExecLog — manages append + read for one Engine instance. Tracks turns in
92
+ * memory and (if yeaftDir is configured) mirrors them to disk as JSONL.
93
+ */
94
+ export class ExecLog {
95
+ /**
96
+ * @param {{ yeaftDir?: string|null, conversationId?: string|null }} opts
97
+ */
98
+ constructor({ yeaftDir = null, conversationId = null } = {}) {
99
+ this.yeaftDir = yeaftDir || null;
100
+ this.conversationId = conversationId || 'default';
101
+ /** @type {Map<number, object[]>} */
102
+ this.turns = new Map();
103
+ }
104
+
105
+ /** Path for a turn's jsonl file (or null if persistence is disabled). */
106
+ pathFor(turnIdx) {
107
+ if (!this.yeaftDir) return null;
108
+ return path.join(this.yeaftDir, 'tool-log', this.conversationId, `${turnIdx}.jsonl`);
109
+ }
110
+
111
+ /** Append an entry to a given turn's log. */
112
+ append(turnIdx, entry) {
113
+ let arr = this.turns.get(turnIdx);
114
+ if (!arr) { arr = []; this.turns.set(turnIdx, arr); }
115
+ arr.push(entry);
116
+ const p = this.pathFor(turnIdx);
117
+ if (p) {
118
+ try {
119
+ fs.mkdirSync(path.dirname(p), { recursive: true });
120
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf8');
121
+ } catch {
122
+ // Best-effort persistence — never break the engine on disk failure.
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Read all entries for a turn (memory first; disk on cold-start). */
128
+ readTurn(turnIdx) {
129
+ const mem = this.turns.get(turnIdx);
130
+ if (mem) return mem.slice();
131
+ const p = this.pathFor(turnIdx);
132
+ if (!p) return [];
133
+ try {
134
+ const txt = fs.readFileSync(p, 'utf8');
135
+ const out = [];
136
+ for (const line of txt.split('\n')) {
137
+ const t = line.trim();
138
+ if (!t) continue;
139
+ try { out.push(JSON.parse(t)); } catch { /* skip bad line */ }
140
+ }
141
+ this.turns.set(turnIdx, out.slice());
142
+ return out;
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Count exact duplicates of (toolName, argsHash) across the current turn
150
+ * plus the previous N turns.
151
+ *
152
+ * @param {{ toolName: string, argsHash: string, currentTurn: number, lookbackTurns?: number }} q
153
+ * @returns {number}
154
+ */
155
+ dupCount({ toolName, argsHash, currentTurn, lookbackTurns = 2 }) {
156
+ let count = 0;
157
+ let lastBrief = '';
158
+ for (let t = Math.max(0, currentTurn - lookbackTurns); t <= currentTurn; t += 1) {
159
+ for (const e of this.readTurn(t)) {
160
+ if (e.toolName === toolName && e.argsHash === argsHash) {
161
+ count += 1;
162
+ lastBrief = e.resultBrief || lastBrief;
163
+ }
164
+ }
165
+ }
166
+ return count;
167
+ }
168
+
169
+ /**
170
+ * Like dupCount but also returns the most-recent matching resultBrief
171
+ * (used to compose the duplicate reminder).
172
+ *
173
+ * @returns {{ count: number, lastResultBrief: string }}
174
+ */
175
+ dupInfo({ toolName, argsHash, currentTurn, lookbackTurns = 2 }) {
176
+ let count = 0;
177
+ let lastBrief = '';
178
+ for (let t = Math.max(0, currentTurn - lookbackTurns); t <= currentTurn; t += 1) {
179
+ for (const e of this.readTurn(t)) {
180
+ if (e.toolName === toolName && e.argsHash === argsHash) {
181
+ count += 1;
182
+ lastBrief = e.resultBrief || lastBrief;
183
+ }
184
+ }
185
+ }
186
+ return { count, lastResultBrief: lastBrief };
187
+ }
188
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * fallback-stub.js — Generated when T2 reflection isn't ready in time
3
+ * (PR-L). Built synchronously from exec-log entries — no LLM call.
4
+ */
5
+
6
+ /**
7
+ * @param {{ execLogEntries: Array<object>, originalUserMsg?: string }} p
8
+ * @returns {string}
9
+ */
10
+ export function buildFallbackStub({ execLogEntries, originalUserMsg }) {
11
+ const entries = Array.isArray(execLogEntries) ? execLogEntries : [];
12
+ const N = entries.length;
13
+ const counts = new Map();
14
+ for (const e of entries) {
15
+ counts.set(e.toolName, (counts.get(e.toolName) || 0) + 1);
16
+ }
17
+ const tally = [...counts.entries()]
18
+ .sort((a, b) => b[1] - a[1])
19
+ .map(([name, n]) => `- ${name} × ${n}`)
20
+ .join('\n');
21
+
22
+ const errors = entries.filter(e => e.resultStatus === 'error');
23
+ const findings = entries
24
+ .slice(0, 10)
25
+ .map(e => `- \`${e.toolName}(${e.argsBrief})\` → ${e.resultStatus}: ${e.resultBrief}`)
26
+ .join('\n');
27
+
28
+ const head = originalUserMsg
29
+ ? `_(Original request: ${String(originalUserMsg).slice(0, 200)})_\n\n`
30
+ : '';
31
+
32
+ return `${head}## What was attempted
33
+ A previous turn executed ${N} tool call${N === 1 ? '' : 's'}; reflection wasn't generated in time, so this is a mechanical summary.
34
+
35
+ ## Key findings
36
+ ${findings || '_(no entries)_'}
37
+
38
+ ## Direction check
39
+ - ${errors.length} tool call${errors.length === 1 ? '' : 's'} failed.
40
+ - This is a fallback stub; no semantic analysis was performed.
41
+
42
+ ## Suggested next direction
43
+ Continue per the user's original request; do not assume the previous turn made progress beyond the raw findings above.
44
+
45
+ ## Tool execution log
46
+ ${tally || '_(empty)_'}`;
47
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * tool-folding/index.js — V7 reflection subsystem entry (PR-L).
3
+ *
4
+ * Exposes:
5
+ * - Constants TOOL_BATCH_SIZE, TURN_SUMMARY_THRESHOLD, DUP_TOOL_THRESHOLD
6
+ * - Reflector helpers (T1 sync, T2 async, fallback stub)
7
+ * - Helpers for collapsing message ranges into a single assistant
8
+ * reflection message
9
+ * - Duplicate-reminder text formatter
10
+ *
11
+ * The constants are NOT config-driven — V7 design freezes them in code.
12
+ */
13
+
14
+ export const TOOL_BATCH_SIZE = 13;
15
+ export const TURN_SUMMARY_THRESHOLD = 5;
16
+ export const DUP_TOOL_THRESHOLD = 3;
17
+
18
+ export { ExecLog, buildEntry, argsHashOf } from './exec-log.js';
19
+ export { buildReflectionPrompt, REFLECTION_TEMPLATE } from './reflection-prompt.js';
20
+ export { runT1Reflection } from './t1-reflector.js';
21
+ export { runT2Reflection } from './t2-reflector.js';
22
+ export { buildFallbackStub } from './fallback-stub.js';
23
+
24
+ /**
25
+ * Collapse messages[startIdx..endIdx] (inclusive) into a single
26
+ * `{ role: 'assistant', content }` message. Returns a NEW array; does not
27
+ * mutate the input.
28
+ *
29
+ * The original assistant+tool sequence (the action arc) is replaced by the
30
+ * reflection; user messages within that range stay put (defensive — the
31
+ * caller normally passes a range that contains only assistant+tool).
32
+ *
33
+ * @param {Array} messages
34
+ * @param {number} startIdx
35
+ * @param {number} endIdx
36
+ * @param {string} reflectionContent
37
+ * @returns {Array}
38
+ */
39
+ export function collapseRangeToReflection(messages, startIdx, endIdx, reflectionContent) {
40
+ if (!Array.isArray(messages)) return messages;
41
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= messages.length) return messages;
42
+ const before = messages.slice(0, startIdx);
43
+ const collapsed = messages.slice(startIdx, endIdx + 1);
44
+ const after = messages.slice(endIdx + 1);
45
+
46
+ // Preserve any user messages that happened to appear inside the range
47
+ // (not expected per V7 spec, but defensive). Everything else (assistant +
48
+ // tool) is replaced by ONE assistant reflection message.
49
+ const preservedUsers = collapsed.filter(m => m && m.role === 'user');
50
+ const reflectionMsg = {
51
+ role: 'assistant',
52
+ content: reflectionContent,
53
+ _reflection: true,
54
+ };
55
+ return [...before, ...preservedUsers, reflectionMsg, ...after];
56
+ }
57
+
58
+ /**
59
+ * Build the (toolName, argsHash) → 3rd-time reminder text used by the
60
+ * duplicate-call detector.
61
+ *
62
+ * @param {{ toolName: string, count: number, lastResultBrief: string }} p
63
+ * @returns {string}
64
+ */
65
+ export function buildDuplicateReminder({ toolName, count, lastResultBrief }) {
66
+ return `[system note] You have called ${toolName} with the same arguments ${count} times. `
67
+ + `Previous result: ${(lastResultBrief || '').trim()}. `
68
+ + `Consider whether re-running this tool is necessary or if you should try a different approach.`;
69
+ }
70
+
71
+ /**
72
+ * Convert assistant.toolCalls + matching tool results into the
73
+ * { name, input, output, isError } pairs the reflector prompt expects.
74
+ *
75
+ * Walks `messages[startIdx..endIdx]` and pairs each tool_use with its
76
+ * tool_result by toolCallId. Unpaired tool_use entries are dropped.
77
+ *
78
+ * @param {Array} messages
79
+ * @param {number} startIdx
80
+ * @param {number} endIdx
81
+ * @returns {{ pairs: Array, assistantText: string }}
82
+ */
83
+ export function extractToolPairsFromRange(messages, startIdx, endIdx) {
84
+ const pairs = [];
85
+ const byId = new Map();
86
+ let assistantText = '';
87
+ const lo = Math.max(0, startIdx);
88
+ const hi = Math.min(messages.length - 1, endIdx);
89
+ for (let i = lo; i <= hi; i += 1) {
90
+ const m = messages[i];
91
+ if (!m) continue;
92
+ if (m.role === 'assistant') {
93
+ if (typeof m.content === 'string' && m.content) {
94
+ assistantText += (assistantText ? '\n' : '') + m.content;
95
+ }
96
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
97
+ for (const tc of calls) {
98
+ const ent = { name: tc.name, input: tc.input, output: '', isError: false, _id: tc.id };
99
+ pairs.push(ent);
100
+ if (tc.id) byId.set(tc.id, ent);
101
+ }
102
+ } else if (m.role === 'tool') {
103
+ const ent = m.toolCallId ? byId.get(m.toolCallId) : null;
104
+ if (ent) {
105
+ ent.output = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
106
+ ent.isError = !!m.isError;
107
+ } else {
108
+ // Orphan tool result — still include for completeness.
109
+ pairs.push({
110
+ name: '(orphan)',
111
+ input: {},
112
+ output: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
113
+ isError: !!m.isError,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ // Strip internal _id field before returning.
119
+ return {
120
+ pairs: pairs.map(({ name, input, output, isError }) => ({ name, input, output, isError })),
121
+ assistantText,
122
+ };
123
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * reflection-prompt.js — V7 reflection prompt builder (PR-L).
3
+ *
4
+ * One template, used by both T1 (in-turn) and T2 (end-of-turn) reflectors.
5
+ * The primary model is asked to REFLECT on a sequence of tool calls — not
6
+ * just summarise. The output is markdown with five fixed sections so the
7
+ * frontend ReflectionCard can render each independently.
8
+ */
9
+
10
+ const TEMPLATE = `You are reviewing a sequence of {N} tool calls executed by an AI agent.
11
+ Your job is NOT just to summarize, but to REFLECT.
12
+
13
+ Output as markdown with these exact sections:
14
+
15
+ ## What was attempted
16
+ 2-3 sentences on the goal and action arc.
17
+
18
+ ## Key findings
19
+ Concrete facts: paths, line numbers, IDs, error codes (preserve verbatim).
20
+
21
+ ## Direction check
22
+ - Is the trajectory still aligned with the user's original request?
23
+ - Any drift / scope creep?
24
+ - Any tool calls that look redundant?
25
+ - Any signs of unproductive loops?
26
+
27
+ ## Suggested next direction
28
+ What should the next loop focus on? What should it AVOID?
29
+
30
+ ## Tool execution log
31
+ Compact list: <tool_name> × <count> (with notable args).
32
+
33
+ CRITICAL: Preserve all identifiers, paths, URLs, line numbers, and error
34
+ messages literally. Do NOT paraphrase data values.
35
+
36
+ User original request:
37
+ {originalUserMessage}
38
+
39
+ Tool execution sequence:
40
+ {toolCallsAndResults}`;
41
+
42
+ /**
43
+ * Render the prompt.
44
+ *
45
+ * @param {{ originalUserMsg: string, toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>, assistantText?: string }} p
46
+ * @returns {string}
47
+ */
48
+ export function buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText }) {
49
+ const N = toolPairs.length;
50
+ const seq = toolPairs.map((p, i) => formatPair(i + 1, p)).join('\n\n');
51
+ const head = assistantText && assistantText.trim()
52
+ ? `Assistant text emitted during this batch:\n${assistantText.trim()}\n\n`
53
+ : '';
54
+ return TEMPLATE
55
+ .replace('{N}', String(N))
56
+ .replace('{originalUserMessage}', String(originalUserMsg || '').slice(0, 4000))
57
+ .replace('{toolCallsAndResults}', head + seq);
58
+ }
59
+
60
+ function formatPair(idx, p) {
61
+ let inputStr;
62
+ try { inputStr = JSON.stringify(p.input); } catch { inputStr = String(p.input); }
63
+ if (typeof inputStr === 'string' && inputStr.length > 1000) {
64
+ inputStr = inputStr.slice(0, 1000) + '…';
65
+ }
66
+ let outputStr = typeof p.output === 'string' ? p.output : (() => {
67
+ try { return JSON.stringify(p.output); } catch { return String(p.output); }
68
+ })();
69
+ if (outputStr.length > 2000) outputStr = outputStr.slice(0, 2000) + '…';
70
+ const status = p.isError ? ' [ERROR]' : '';
71
+ return `[${idx}] ${p.name}${status}\n args: ${inputStr}\n result: ${outputStr}`;
72
+ }
73
+
74
+ export const REFLECTION_TEMPLATE = TEMPLATE;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * t1-reflector.js — V7 in-turn (synchronous) reflection (PR-L).
3
+ *
4
+ * Triggered when the current turn has accumulated TOOL_BATCH_SIZE (13) tool
5
+ * results and the engine is about to loop back into adapter.stream(). Calls
6
+ * the PRIMARY model — never the fast model — to generate a markdown
7
+ * reflection over the batch.
8
+ *
9
+ * On success: returns { content, durationMs }.
10
+ * On failure: throws (engine catches and leaves history unchanged).
11
+ */
12
+
13
+ import { buildReflectionPrompt } from './reflection-prompt.js';
14
+
15
+ /**
16
+ * @param {{
17
+ * adapter: { call: Function },
18
+ * model: string,
19
+ * originalUserMsg: string,
20
+ * toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>,
21
+ * assistantText?: string,
22
+ * signal?: AbortSignal,
23
+ * }} p
24
+ * @returns {Promise<{ content: string, durationMs: number }>}
25
+ */
26
+ export async function runT1Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, signal }) {
27
+ const t0 = Date.now();
28
+ const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText });
29
+ const result = await adapter.call({
30
+ model,
31
+ system: prompt,
32
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
33
+ maxTokens: 2048,
34
+ signal,
35
+ });
36
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
37
+ if (!content) {
38
+ throw new Error('T1 reflection returned empty content');
39
+ }
40
+ return { content, durationMs: Date.now() - t0 };
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * t2-reflector.js — V7 end-of-turn (asynchronous) reflection (PR-L).
3
+ *
4
+ * Same prompt and primary-model call as T1, but kicked off without await.
5
+ * The engine stores the resulting promise on the instance; on the next
6
+ * query() it checks (non-blocking) whether the promise has resolved and
7
+ * either rewrites history with the reflection or falls back to the
8
+ * exec-log stub.
9
+ */
10
+
11
+ import { buildReflectionPrompt } from './reflection-prompt.js';
12
+
13
+ /**
14
+ * @param {{
15
+ * adapter: { call: Function },
16
+ * model: string,
17
+ * originalUserMsg: string,
18
+ * toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>,
19
+ * assistantText?: string,
20
+ * signal?: AbortSignal,
21
+ * }} p
22
+ * @returns {Promise<{ content: string, durationMs: number }>}
23
+ */
24
+ export async function runT2Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, signal }) {
25
+ const t0 = Date.now();
26
+ const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText });
27
+ const result = await adapter.call({
28
+ model,
29
+ system: prompt,
30
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
31
+ maxTokens: 2048,
32
+ signal,
33
+ });
34
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
35
+ if (!content) {
36
+ throw new Error('T2 reflection returned empty content');
37
+ }
38
+ return { content, durationMs: Date.now() - t0 };
39
+ }
@@ -1006,6 +1006,24 @@ function handleEngineEvent(event, threadId, hctx) {
1006
1006
  }, gid);
1007
1007
  break;
1008
1008
 
1009
+ case 'reflection':
1010
+ // PR-L: V7 tool-history reflection event. Two phases per occurrence:
1011
+ // status: 'pending' — generation kicked off
1012
+ // status: 'ready' — markdown content + durationMs
1013
+ // status: 'error' — generation failed (history left unchanged)
1014
+ sendUnifyEvent({
1015
+ type: 'reflection',
1016
+ trigger: event.trigger,
1017
+ status: event.status,
1018
+ loopRange: event.loopRange,
1019
+ toolCount: event.toolCount,
1020
+ content: event.content,
1021
+ durationMs: event.durationMs,
1022
+ error: event.error,
1023
+ threadId,
1024
+ }, gid);
1025
+ break;
1026
+
1009
1027
  case 'debug_turn':
1010
1028
  sendUnifyEvent({
1011
1029
  type: 'debug_turn',