@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 +1 -1
- package/unify/engine.js +301 -0
- package/unify/tool-folding/exec-log.js +188 -0
- package/unify/tool-folding/fallback-stub.js +47 -0
- package/unify/tool-folding/index.js +123 -0
- package/unify/tool-folding/reflection-prompt.js +74 -0
- package/unify/tool-folding/t1-reflector.js +41 -0
- package/unify/tool-folding/t2-reflector.js +39 -0
- package/unify/web-bridge.js +18 -0
package/package.json
CHANGED
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
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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',
|