@yeaft/webchat-agent 1.0.640 → 1.0.641
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +61 -556
- package/yeaft/history-window.js +132 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.641"}
|
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Reference: yeaft-yeaft-implementation-plan.md §3.1, §4 (Phase 2)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { randomUUID
|
|
20
|
+
import { randomUUID } from 'crypto';
|
|
21
21
|
import { QueryProgress, progressReminder } from './query-progress.js';
|
|
22
22
|
import { createWebResearchState } from './web-research.js';
|
|
23
23
|
import { promises as fsp } from 'fs';
|
|
@@ -55,12 +55,6 @@ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
|
55
55
|
// pass a real threadId per (sessionId, vpId, threadId) engine instance.
|
|
56
56
|
const MAIN_THREAD_ID = 'main';
|
|
57
57
|
|
|
58
|
-
function foldingMessageKey(message) {
|
|
59
|
-
return message?._persistedMessageId || message?.id || createHash('sha256').update(JSON.stringify([
|
|
60
|
-
message?.role, message?.toolCallId, message?.toolCalls, message?.content,
|
|
61
|
-
])).digest('hex');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
58
|
import { pickEffort, parseEffortPrefix, snapshotEffortDecision } from './effort.js';
|
|
65
59
|
import { bindProviderState } from './llm/provider-state.js';
|
|
66
60
|
import {
|
|
@@ -71,8 +65,7 @@ import {
|
|
|
71
65
|
removePostCompactIfSource,
|
|
72
66
|
savePostCompact,
|
|
73
67
|
} from './post-compact.js';
|
|
74
|
-
import {
|
|
75
|
-
import { lookupModelLimitSync } from './llm/models-dev.js';
|
|
68
|
+
import { getModelInfo, normalizeEffort, parseModelRef, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
|
|
76
69
|
import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
|
|
77
70
|
import { resolveThinking } from './router/thinking.js';
|
|
78
71
|
import { approxTokens, computeBudget } from './memory/budget.js';
|
|
@@ -84,18 +77,11 @@ import { createPluginSkillManager } from './plugins.js';
|
|
|
84
77
|
import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
|
|
85
78
|
import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
|
|
86
79
|
import {
|
|
87
|
-
TOOL_LOOP_REFLECTION_INTERVAL,
|
|
88
|
-
TURN_SUMMARY_THRESHOLD,
|
|
89
80
|
DUP_TOOL_THRESHOLD,
|
|
90
81
|
ExecLog,
|
|
91
82
|
buildEntry as buildExecLogEntry,
|
|
92
83
|
argsHashOf,
|
|
93
|
-
runT1Reflection,
|
|
94
|
-
runT2Reflection,
|
|
95
|
-
buildFallbackStub,
|
|
96
|
-
collapseRangeToReflection,
|
|
97
84
|
buildDuplicateReminder,
|
|
98
|
-
extractToolPairsFromRange,
|
|
99
85
|
} from './tool-folding/index.js';
|
|
100
86
|
|
|
101
87
|
/**
|
|
@@ -465,47 +451,6 @@ export function estimateProviderInputBreakdown({
|
|
|
465
451
|
};
|
|
466
452
|
}
|
|
467
453
|
|
|
468
|
-
export const GROUP_CONTEXT_PRESSURE_RATIO = 0.8;
|
|
469
|
-
|
|
470
|
-
export function shouldAllowGroupReflection({
|
|
471
|
-
system = '',
|
|
472
|
-
messages = [],
|
|
473
|
-
model = null,
|
|
474
|
-
config = {},
|
|
475
|
-
sessionId = null,
|
|
476
|
-
} = {}) {
|
|
477
|
-
if (!sessionId) {
|
|
478
|
-
return {
|
|
479
|
-
allowed: true,
|
|
480
|
-
tokenEstimate: estimateMessagesTokens(system, messages),
|
|
481
|
-
threshold: 0,
|
|
482
|
-
contextWindow: null,
|
|
483
|
-
ratio: GROUP_CONTEXT_PRESSURE_RATIO,
|
|
484
|
-
usedFallbackContextWindow: false,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
const contextWindow = resolveContextWindow(model, config);
|
|
488
|
-
// Telemetry: did the resolver hit either of its top non-default rungs?
|
|
489
|
-
// Used by `usedFallbackContextWindow` below — if neither models.dev nor
|
|
490
|
-
// the global config provided a number, we fell through to DEFAULT and
|
|
491
|
-
// callers may want to surface that to the user.
|
|
492
|
-
const hasModelsDevContext = !!lookupModelLimitSync(model, resolveModel(model)?.provider || null)?.context;
|
|
493
|
-
const hasConfigContext = Number.isFinite(config?.maxContextTokens) && config.maxContextTokens > 0;
|
|
494
|
-
const threshold = Math.floor(contextWindow * GROUP_CONTEXT_PRESSURE_RATIO);
|
|
495
|
-
const tokenEstimate = estimateMessagesTokens(system, messages);
|
|
496
|
-
const overThreshold = tokenEstimate >= threshold;
|
|
497
|
-
return {
|
|
498
|
-
// Group send defaults to no reflection. Trust the model until context
|
|
499
|
-
// pressure says we are near the model window.
|
|
500
|
-
allowed: overThreshold,
|
|
501
|
-
tokenEstimate,
|
|
502
|
-
threshold,
|
|
503
|
-
contextWindow,
|
|
504
|
-
ratio: GROUP_CONTEXT_PRESSURE_RATIO,
|
|
505
|
-
usedFallbackContextWindow: !hasModelsDevContext && !hasConfigContext && contextWindow === DEFAULT_CONTEXT_WINDOW,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
454
|
// ─── Engine Events (superset of adapter events) ──────────────────
|
|
510
455
|
|
|
511
456
|
/**
|
|
@@ -769,24 +714,8 @@ export class Engine {
|
|
|
769
714
|
*/
|
|
770
715
|
#currentAbortCtrl = null;
|
|
771
716
|
|
|
772
|
-
/**
|
|
773
|
-
* PR-L — V7 Tool History Reflection state. Owned per Engine instance.
|
|
774
|
-
*
|
|
775
|
-
* • `#execLog` — append-only log of every tool execution, used for
|
|
776
|
-
* fallback-stub generation and duplicate-call detection. Persists
|
|
777
|
-
* to <yeaftDir>/tool-log/<traceId>/<turnIdx>.jsonl when yeaftDir
|
|
778
|
-
* is set; in-memory only otherwise.
|
|
779
|
-
* • `#pendingT2` — Map<turnNumber, { promise, loopRange, count, ... }>
|
|
780
|
-
* keyed by the turn number that triggered T2. The next query() call
|
|
781
|
-
* non-blocking-checks this map; if the promise has resolved, the
|
|
782
|
-
* prior turn's history is rewritten with the reflection. If still
|
|
783
|
-
* pending, the engine falls back to the exec-log stub.
|
|
784
|
-
* • `#reflectedTurns` — Set<turnNumber>; ensures T1 fires at most
|
|
785
|
-
* once per reflection interval measured in tool loops.
|
|
786
|
-
*/
|
|
717
|
+
/** Append-only tool execution diagnostics and query-local duplicate lookup. */
|
|
787
718
|
#execLog = null;
|
|
788
|
-
#pendingT2 = new Map();
|
|
789
|
-
#reflectedTurns = new Set();
|
|
790
719
|
#__queryCounter = 0;
|
|
791
720
|
|
|
792
721
|
/** Derived post-response summaries, keyed by Session/VP/thread scope. */
|
|
@@ -1683,17 +1612,6 @@ export class Engine {
|
|
|
1683
1612
|
return this.#conversationStore.append(this.#conversationRecord(message, context), { strict: context.strict === true });
|
|
1684
1613
|
}
|
|
1685
1614
|
|
|
1686
|
-
#persistFoldedRange(foldedMessages, reflection, context = {}) {
|
|
1687
|
-
if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
|
|
1688
|
-
const persistedRows = (foldedMessages || [])
|
|
1689
|
-
.map(message => message?._persistedMessageId || message?.id)
|
|
1690
|
-
.filter(id => typeof id === 'string' && id)
|
|
1691
|
-
.map(id => ({ id }));
|
|
1692
|
-
if (persistedRows.length === 0) return null;
|
|
1693
|
-
const record = this.#conversationRecord(reflection, context);
|
|
1694
|
-
return this.#conversationStore.foldMessages(persistedRows, record);
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
1615
|
#formatTaskResultUpdateContent(content) {
|
|
1698
1616
|
if (typeof content === 'string') return content;
|
|
1699
1617
|
try { return JSON.stringify(content); } catch { return String(content); }
|
|
@@ -1713,10 +1631,9 @@ export class Engine {
|
|
|
1713
1631
|
msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
|
|
1714
1632
|
));
|
|
1715
1633
|
if (!toolMsg) {
|
|
1716
|
-
//
|
|
1717
|
-
// completed.
|
|
1718
|
-
//
|
|
1719
|
-
// provider transcript. This is engine control context, not a fresh
|
|
1634
|
+
// Legacy histories may have folded the original tool row before this
|
|
1635
|
+
// task completed. A late completion must not recreate the hidden tool arc
|
|
1636
|
+
// on disk or in the provider transcript. This is engine control context, not a fresh
|
|
1720
1637
|
// user-authored message; ConversationStore keeps it off the visible
|
|
1721
1638
|
// transcript while retaining it for the next provider boundary.
|
|
1722
1639
|
const contextContent = truncateToolResultIfNeeded(appendText, {
|
|
@@ -2535,64 +2452,10 @@ export class Engine {
|
|
|
2535
2452
|
},
|
|
2536
2453
|
];
|
|
2537
2454
|
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
messages: conversationMessages,
|
|
2541
|
-
model: this.#config.model,
|
|
2542
|
-
config: this.#config,
|
|
2543
|
-
sessionId,
|
|
2544
|
-
});
|
|
2545
|
-
const groupReflectionAllowed = groupReflectionGate.allowed === true;
|
|
2546
|
-
if (sessionId && groupReflectionGate?.usedFallbackContextWindow) {
|
|
2547
|
-
this.#trace.log?.('group_context_window_fallback', {
|
|
2548
|
-
sessionId,
|
|
2549
|
-
model: this.#config.model,
|
|
2550
|
-
contextWindow: groupReflectionGate.contextWindow,
|
|
2551
|
-
threshold: groupReflectionGate.threshold,
|
|
2552
|
-
});
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
// PR-L: T2 carry-forward. If a previous query()'s end-of-turn
|
|
2556
|
-
// reflection has resolved, rewrite that turn's range in
|
|
2557
|
-
// `conversationMessages` to a single assistant reflection message.
|
|
2558
|
-
// If still pending, fall back to the exec-log stub — non-blocking,
|
|
2559
|
-
// never wait. This runs BEFORE the first adapter.stream so the
|
|
2560
|
-
// upcoming call sees the rewritten history. Group send defaults to no
|
|
2561
|
-
// reflection; only high context pressure (>=80% of model window)
|
|
2562
|
-
// enables the carry-forward rewrite.
|
|
2563
|
-
if (groupReflectionAllowed) {
|
|
2564
|
-
yield* this.#applyPendingT2Reflections(conversationMessages, prompt, {
|
|
2565
|
-
sessionId: runtimeSessionId,
|
|
2566
|
-
model: this.#config.model,
|
|
2567
|
-
});
|
|
2568
|
-
}
|
|
2569
|
-
|
|
2570
|
-
// PR-L: track this query()'s tool-arc for reflection.
|
|
2571
|
-
// `turnStartIdx` is where the current user message lives; the arc
|
|
2572
|
-
// we may collapse spans (arcStartIdx .. last assistant/tool).
|
|
2573
|
-
//
|
|
2574
|
-
// Periodic T1 fires every 30 tool loops, not every 30 calls. A single
|
|
2575
|
-
// provider batch can contain many parallel calls but is still one loop.
|
|
2576
|
-
// track:
|
|
2577
|
-
// • `lastT1AtLoopCount` — tool-loop snapshot at the last T1
|
|
2578
|
-
// ATTEMPT (success OR error). Trigger when
|
|
2579
|
-
// `completedToolLoops - lastT1AtLoopCount >= interval`.
|
|
2580
|
-
// • `arcStartIdx` — first index of the current (uncollapsed)
|
|
2581
|
-
// tool arc. Initialised to turnStartIdx + 1; reset after each
|
|
2582
|
-
// successful T1 collapse to `conversationMessages.length`
|
|
2583
|
-
// (i.e. the slot the next assistant message will land in).
|
|
2584
|
-
// • `t1CollapsesDone` — count of T1 firings that ACTUALLY
|
|
2585
|
-
// rewrote history. Distinct from `lastT1AtLoopCount` because
|
|
2586
|
-
// the catch block bumps the latter to back off after a
|
|
2587
|
-
// transient reflector error WITHOUT having collapsed
|
|
2588
|
-
// anything. The T2 schedule check below is gated on this
|
|
2589
|
-
// counter (==0 means "no T1 ever rewrote the arc, T2 may
|
|
2590
|
-
// fall back at end_turn").
|
|
2455
|
+
// Current-turn protocol stays on the live tape. Only the disposable
|
|
2456
|
+
// provider copy is compacted when its actual model-window budget is full.
|
|
2591
2457
|
const turnStartIdx = conversationMessages.length - 1;
|
|
2592
2458
|
let queryToolCount = 0;
|
|
2593
|
-
let lastT1AtLoopCount = 0;
|
|
2594
|
-
let arcStartIdx = turnStartIdx + 1;
|
|
2595
|
-
let t1CollapsesDone = 0;
|
|
2596
2459
|
// Duplicate policy is scoped to one user query. Only successful, real
|
|
2597
2460
|
// executions increment these counters; errors and cache reuse do not.
|
|
2598
2461
|
const queryDuplicateCounts = new Map();
|
|
@@ -3063,7 +2926,7 @@ export class Engine {
|
|
|
3063
2926
|
));
|
|
3064
2927
|
const toolSchemaTokens = toolDefs.length > 0
|
|
3065
2928
|
? approxTokens(JSON.stringify(toolDefs)) : 0;
|
|
3066
|
-
const
|
|
2929
|
+
const fitOptions = {
|
|
3067
2930
|
contextWindow: Math.max(1, Math.floor(currentContextWindow * providerContextScale)),
|
|
3068
2931
|
systemTokens: estimateMessagesTokens(systemPrompt, []),
|
|
3069
2932
|
toolSchemaTokens,
|
|
@@ -3071,9 +2934,50 @@ export class Engine {
|
|
|
3071
2934
|
historyMessageCount,
|
|
3072
2935
|
historyTokenBudget: historyBudget,
|
|
3073
2936
|
language: requestConfig.language,
|
|
2937
|
+
};
|
|
2938
|
+
let fittedRequest = fitProviderRequestToContext(wireMessages, fitOptions);
|
|
2939
|
+
// A task can complete on an old tool row. Sampling or evicting that row
|
|
2940
|
+
// must not acknowledge unseen evidence. Re-inject only missing pending
|
|
2941
|
+
// deliveries as fresh provider-only notes, then check the final copy.
|
|
2942
|
+
const taskDeliveries = Array.from(this.#pendingAsyncTaskConfirmIds).flatMap(taskId => {
|
|
2943
|
+
const delivery = this.#acceptedAsyncTaskResults.get(taskId);
|
|
2944
|
+
if (!delivery) return [];
|
|
2945
|
+
const text = truncateToolResultIfNeeded(this.#formatTaskResultUpdateContent(delivery.content), {
|
|
2946
|
+
toolName: 'async task result', language: requestConfig.language,
|
|
2947
|
+
});
|
|
2948
|
+
return [{ taskId, text }];
|
|
3074
2949
|
});
|
|
2950
|
+
const includesDelivery = (messages, text) => messages.some(message => (
|
|
2951
|
+
this.#formatTaskResultUpdateContent(message.content).includes(text)
|
|
2952
|
+
));
|
|
2953
|
+
const missingDeliveries = taskDeliveries.filter(({ text }) => !includesDelivery(fittedRequest.messages, text));
|
|
2954
|
+
if (missingDeliveries.length > 0) {
|
|
2955
|
+
fittedRequest = fitProviderRequestToContext([...wireMessages, ...missingDeliveries.map(({ text }) => ({
|
|
2956
|
+
role: 'user', content: `[system note] Async task completion:\n${text}`,
|
|
2957
|
+
}))], fitOptions);
|
|
2958
|
+
}
|
|
3075
2959
|
wireMessages = fittedRequest.messages;
|
|
2960
|
+
const deliveredTaskIds = taskDeliveries.filter(({ text }) => includesDelivery(wireMessages, text))
|
|
2961
|
+
.map(({ taskId }) => taskId);
|
|
3076
2962
|
historyMessageCount = fittedRequest.meta.historyMessagesAfter;
|
|
2963
|
+
if (fittedRequest.meta.compactedToolResults > 0) {
|
|
2964
|
+
// Compaction invalidates file-read hints, not execution/reuse safety
|
|
2965
|
+
// ledgers. The complete result remains in transcript/debug storage.
|
|
2966
|
+
fileReadObservations.clear();
|
|
2967
|
+
recordAgentPerfTrace(requestConfig, {
|
|
2968
|
+
traceId: perfTraceId || queryTurnId,
|
|
2969
|
+
phase: 'tool_result_compact',
|
|
2970
|
+
sessionId: runtimeSessionId,
|
|
2971
|
+
vpId: queryVpId,
|
|
2972
|
+
turnId: queryTurnId,
|
|
2973
|
+
threadId: runtimeThreadId,
|
|
2974
|
+
detail: {
|
|
2975
|
+
model: currentModel,
|
|
2976
|
+
recoveryAttempt: contextOverflowRecoveryAttempts,
|
|
2977
|
+
...fittedRequest.meta,
|
|
2978
|
+
},
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
3077
2981
|
if (fittedRequest.meta.droppedHistoryMessages > 0
|
|
3078
2982
|
|| fittedRequest.meta.droppedCurrentMessages > 0
|
|
3079
2983
|
|| providerContextScale < 1) {
|
|
@@ -3086,49 +2990,6 @@ export class Engine {
|
|
|
3086
2990
|
});
|
|
3087
2991
|
}
|
|
3088
2992
|
|
|
3089
|
-
// task-704b: pre-flight total-token guard. Even with the per-tool
|
|
3090
|
-
// cap (registry.js: 10% of contextWindow per result), N tool
|
|
3091
|
-
// results plus history can still breach the wire limit before we
|
|
3092
|
-
// ever call adapter.stream(). Estimate the total token cost; if
|
|
3093
|
-
// it exceeds PREFLIGHT_RATIO of the live context window, run an
|
|
3094
|
-
// emergency archive sweep with `turnAgeMin: 0` so even
|
|
3095
|
-
// current-turn-but-not-this-call bulky results get stubbed. The
|
|
3096
|
-
// normal sweep above only stubs results older than 5 user turns
|
|
3097
|
-
// — that's the wrong cadence when the *current* turn already has
|
|
3098
|
-
// 4 large grep results.
|
|
3099
|
-
//
|
|
3100
|
-
// PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
|
|
3101
|
-
// own output tokens + tools metadata + light future history.
|
|
3102
|
-
// The estimator (`estimateMessagesTokens`) is approxTokens
|
|
3103
|
-
// (char/4 with CJK weighting) — good enough for a guard rail; a
|
|
3104
|
-
// real tokenizer would be exact but adds a heavy dep.
|
|
3105
|
-
if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
3106
|
-
const PREFLIGHT_RATIO = 0.85;
|
|
3107
|
-
const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
|
|
3108
|
-
const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
|
|
3109
|
-
if (estimate > threshold) {
|
|
3110
|
-
try {
|
|
3111
|
-
const sweep = await archiveToolResults({
|
|
3112
|
-
root: `${this.#yeaftDir}/memory`,
|
|
3113
|
-
scopeDir: 'user',
|
|
3114
|
-
messages: wireMessages,
|
|
3115
|
-
turnAgeMin: 0,
|
|
3116
|
-
lengthMin: this.#config?.archive?.lengthMin ?? 2000,
|
|
3117
|
-
});
|
|
3118
|
-
wireMessages = sweep.nextMessages;
|
|
3119
|
-
if (sweep.archivedCount > 0) {
|
|
3120
|
-
this.#trace.log?.('preflight_sweep', {
|
|
3121
|
-
archivedCount: sweep.archivedCount,
|
|
3122
|
-
archivedBytes: sweep.archivedBytes,
|
|
3123
|
-
estimateBefore: estimate,
|
|
3124
|
-
threshold,
|
|
3125
|
-
contextWindow: currentContextWindow,
|
|
3126
|
-
});
|
|
3127
|
-
}
|
|
3128
|
-
} catch { /* best-effort */ }
|
|
3129
|
-
}
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
2993
|
// Freeze diagnostics only after every provider-only trim/sweep has
|
|
3133
2994
|
// completed, so debug describes the exact request copy rather than the
|
|
3134
2995
|
// durable transcript or an earlier preflight candidate.
|
|
@@ -3224,7 +3085,7 @@ export class Engine {
|
|
|
3224
3085
|
// is not delivery: fetch may remain pending and then be aborted before
|
|
3225
3086
|
// the provider processes anything. Ack only after a normal stream end
|
|
3226
3087
|
// that included the provider's terminal stop event.
|
|
3227
|
-
const requestAsyncTaskIds =
|
|
3088
|
+
const requestAsyncTaskIds = deliveredTaskIds;
|
|
3228
3089
|
let sawProviderStop = false;
|
|
3229
3090
|
for await (const event of providerStream) {
|
|
3230
3091
|
// task-325a (abort-stop fix): per-event abort short-circuit.
|
|
@@ -4210,80 +4071,8 @@ export class Engine {
|
|
|
4210
4071
|
yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
|
|
4211
4072
|
|
|
4212
4073
|
// Message durability is handled incrementally before this terminal
|
|
4213
|
-
// branch.
|
|
4214
|
-
|
|
4215
|
-
// PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
|
|
4216
|
-
// total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
|
|
4217
|
-
// (8) AND no T1 has actually rewritten the arc yet. Kicks off the
|
|
4218
|
-
// primary-model call without await; the next query()'s
|
|
4219
|
-
// `#applyPendingT2Reflections` carries the result forward.
|
|
4220
|
-
//
|
|
4221
|
-
// Periodic-T1 fix: gate on `t1CollapsesDone === 0`, NOT
|
|
4222
|
-
// `lastT1AtLoopCount === 0`. The catch block of T1 advances
|
|
4223
|
-
// `lastT1AtLoopCount` after a reflector error to avoid
|
|
4224
|
-
// tight-loop retries — but no collapse happened, so T2 should
|
|
4225
|
-
// still be allowed to fall back at end_turn. Fowler-review
|
|
4226
|
-
// critical finding.
|
|
4227
|
-
if (groupReflectionAllowed && queryToolCount > TURN_SUMMARY_THRESHOLD && t1CollapsesDone === 0) {
|
|
4228
|
-
const arcStart = turnStartIdx + 1;
|
|
4229
|
-
const arcEnd = conversationMessages.length - 1;
|
|
4230
|
-
if (arcEnd > arcStart) {
|
|
4231
|
-
const { pairs, assistantText } = extractToolPairsFromRange(
|
|
4232
|
-
conversationMessages, arcStart, arcEnd,
|
|
4233
|
-
);
|
|
4234
|
-
yield {
|
|
4235
|
-
type: 'reflection',
|
|
4236
|
-
turnId: queryTurnId,
|
|
4237
|
-
loopNumber: turnNumber,
|
|
4238
|
-
trigger: 't2',
|
|
4239
|
-
status: 'pending',
|
|
4240
|
-
loopRange: [arcStart, arcEnd],
|
|
4241
|
-
toolCount: pairs.length,
|
|
4242
|
-
};
|
|
4243
|
-
const promise = runT2Reflection({
|
|
4244
|
-
adapter: this.#adapter,
|
|
4245
|
-
model: this.#config.model,
|
|
4246
|
-
originalUserMsg: prompt,
|
|
4247
|
-
toolPairs: pairs,
|
|
4248
|
-
assistantText,
|
|
4249
|
-
language: this.#config.language,
|
|
4250
|
-
signal,
|
|
4251
|
-
onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
|
|
4252
|
-
sessionId: runtimeSessionId, turnId: queryTurnId,
|
|
4253
|
-
trigger: 't2', model: this.#config.model, ...diagnostic,
|
|
4254
|
-
}),
|
|
4255
|
-
});
|
|
4256
|
-
// Detach: never await. The promise outlives this query() and
|
|
4257
|
-
// the next call will pick it up (or use the fallback stub if
|
|
4258
|
-
// it hasn't resolved by then).
|
|
4259
|
-
// PR-L follow-up: latch a synchronously-readable ready flag
|
|
4260
|
-
// and result on the info record so `#applyPendingT2Reflections`
|
|
4261
|
-
// can decide ready-vs-pending without racing microtasks.
|
|
4262
|
-
const info = {
|
|
4263
|
-
promise,
|
|
4264
|
-
loopRange: [arcStart, arcEnd],
|
|
4265
|
-
// Absolute indexes do not survive history-window reconstruction.
|
|
4266
|
-
arcKeys: conversationMessages.slice(arcStart, arcEnd + 1).map(foldingMessageKey),
|
|
4267
|
-
arcRows: conversationMessages.slice(arcStart, arcEnd + 1).map(message => ({
|
|
4268
|
-
role: message.role, id: message.id, _persistedMessageId: message._persistedMessageId,
|
|
4269
|
-
toolCallId: message.toolCallId, toolCalls: message.toolCalls?.map(call => ({ id: call.id })),
|
|
4270
|
-
})),
|
|
4271
|
-
count: pairs.length,
|
|
4272
|
-
originalUserMsg: prompt,
|
|
4273
|
-
originatingTurnId: queryTurnId,
|
|
4274
|
-
causalRootId,
|
|
4275
|
-
executionOrigin,
|
|
4276
|
-
ready: false,
|
|
4277
|
-
result: null,
|
|
4278
|
-
error: null,
|
|
4279
|
-
};
|
|
4280
|
-
promise.then(
|
|
4281
|
-
(v) => { info.ready = true; info.result = v; },
|
|
4282
|
-
(err) => { info.ready = true; info.error = err; },
|
|
4283
|
-
);
|
|
4284
|
-
this.#pendingT2.set(queryNumber, info);
|
|
4285
|
-
}
|
|
4286
|
-
}
|
|
4074
|
+
// branch. No reflection or durable history fold runs here; the separate
|
|
4075
|
+
// post-response compact is scheduled only after turn_close delivery.
|
|
4287
4076
|
|
|
4288
4077
|
break;
|
|
4289
4078
|
}
|
|
@@ -4413,7 +4202,6 @@ export class Engine {
|
|
|
4413
4202
|
// break out of the outer while-loop cleanly once the current
|
|
4414
4203
|
// tool batch finishes reporting.
|
|
4415
4204
|
let abortedDuringTools = false;
|
|
4416
|
-
let batchHasUnseenImages = false;
|
|
4417
4205
|
/** @type {string[]} */
|
|
4418
4206
|
const pendingDupReminders = [];
|
|
4419
4207
|
let terminateAfterDuplicateBatch = false;
|
|
@@ -4946,7 +4734,6 @@ export class Engine {
|
|
|
4946
4734
|
toolName: tc.name,
|
|
4947
4735
|
language: this.#config?.language,
|
|
4948
4736
|
});
|
|
4949
|
-
if (modelImages.length > 0) batchHasUnseenImages = true;
|
|
4950
4737
|
const toolMessage = {
|
|
4951
4738
|
role: 'tool',
|
|
4952
4739
|
toolCallId: tc.id,
|
|
@@ -5088,10 +4875,8 @@ export class Engine {
|
|
|
5088
4875
|
// clean wire shape. Used by `route_forward` to hand off control
|
|
5089
4876
|
// to other VPs without continuing to generate.
|
|
5090
4877
|
//
|
|
5091
|
-
//
|
|
5092
|
-
//
|
|
5093
|
-
// multi-iteration tool loops) and BEFORE the abortedDuringTools
|
|
5094
|
-
// check (so a clean handoff doesn't get reported as 'aborted').
|
|
4878
|
+
// A clean handoff must precede the abortedDuringTools check so it
|
|
4879
|
+
// does not get reported as 'aborted'.
|
|
5095
4880
|
if (endTurnRequested && !toolBatchBarrier) {
|
|
5096
4881
|
if (pendingSubAgentNotifs.length > 0) {
|
|
5097
4882
|
acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
|
|
@@ -5110,180 +4895,6 @@ export class Engine {
|
|
|
5110
4895
|
break;
|
|
5111
4896
|
}
|
|
5112
4897
|
|
|
5113
|
-
// PR-L: T1 in-turn (synchronous) reflection. Fires every 30 completed
|
|
5114
|
-
// tool loops. A provider response containing many parallel tool calls is
|
|
5115
|
-
// one loop, not many. Generates a markdown reflection
|
|
5116
|
-
// over the assistant+tool arc since the last T1 firing (or
|
|
5117
|
-
// since the user prompt for the first batch) and rewrites that
|
|
5118
|
-
// range to a SINGLE synthetic user message before the next
|
|
5119
|
-
// adapter.stream() runs.
|
|
5120
|
-
//
|
|
5121
|
-
// Loop semantics:
|
|
5122
|
-
// - First interval: arcStartIdx = turnStartIdx + 1.
|
|
5123
|
-
// - Each subsequent interval: arcStartIdx is updated to the slot
|
|
5124
|
-
// right after the just-inserted reflection message; fires
|
|
5125
|
-
// again whenever 30 more tool loops have completed.
|
|
5126
|
-
// - The dedup Set key includes the loop count so each interval
|
|
5127
|
-
// within the same query gets a distinct entry — without
|
|
5128
|
-
// this the second batch would be silently skipped.
|
|
5129
|
-
const completedToolLoops = toolLoopTurns + 1;
|
|
5130
|
-
const t1BatchDue = completedToolLoops - lastT1AtLoopCount
|
|
5131
|
-
>= TOOL_LOOP_REFLECTION_INTERVAL;
|
|
5132
|
-
// Text-only reflection cannot consume visual input. Defer folding until
|
|
5133
|
-
// the primary provider has received this image batch at least once.
|
|
5134
|
-
if (t1BatchDue && !batchHasUnseenImages && !toolBatchBarrier
|
|
5135
|
-
&& !abortedDuringTools && !signal?.aborted) {
|
|
5136
|
-
const t1DedupKey = `${queryNumber}:t1-loop:${completedToolLoops}`;
|
|
5137
|
-
if (this.#reflectedTurns.has(t1DedupKey)) {
|
|
5138
|
-
// Defensive: should never hit since t1BatchDue gates re-entry
|
|
5139
|
-
// and queryNumber namespaces queries. Kept as belt-and-
|
|
5140
|
-
// suspenders against any future external mutation of the
|
|
5141
|
-
// cursor (or a re-entrant query() that this code doesn't
|
|
5142
|
-
// anticipate).
|
|
5143
|
-
} else {
|
|
5144
|
-
this.#reflectedTurns.add(t1DedupKey);
|
|
5145
|
-
const batchStart = arcStartIdx;
|
|
5146
|
-
const batchEnd = conversationMessages.length - 1;
|
|
5147
|
-
try {
|
|
5148
|
-
const { pairs, assistantText } = extractToolPairsFromRange(
|
|
5149
|
-
conversationMessages, batchStart, batchEnd,
|
|
5150
|
-
);
|
|
5151
|
-
yield {
|
|
5152
|
-
type: 'reflection',
|
|
5153
|
-
turnId: queryTurnId,
|
|
5154
|
-
threadId,
|
|
5155
|
-
loopNumber: turnNumber,
|
|
5156
|
-
trigger: 't1',
|
|
5157
|
-
status: 'pending',
|
|
5158
|
-
loopRange: [batchStart, batchEnd],
|
|
5159
|
-
toolCount: pairs.length,
|
|
5160
|
-
};
|
|
5161
|
-
// Observe only this synchronous query-owned call. Do not tap the
|
|
5162
|
-
// shared accounting adapter (also used by children), or feed these
|
|
5163
|
-
// costs into the main loop's token/dispatch budget. Capture raw usage
|
|
5164
|
-
// before reflection diagnostics normalize away cache-inclusion flags.
|
|
5165
|
-
const reflectionAdapter = typeof this.#adapter.call === 'function' ? {
|
|
5166
|
-
call: async params => {
|
|
5167
|
-
let dispatches = 0;
|
|
5168
|
-
let result;
|
|
5169
|
-
let failure;
|
|
5170
|
-
try {
|
|
5171
|
-
result = await this.#adapter.call({
|
|
5172
|
-
...params,
|
|
5173
|
-
onRequestStart: (...args) => {
|
|
5174
|
-
dispatches++;
|
|
5175
|
-
queryProgress.requestStarted();
|
|
5176
|
-
params.onRequestStart?.(...args);
|
|
5177
|
-
},
|
|
5178
|
-
});
|
|
5179
|
-
return result;
|
|
5180
|
-
} catch (error) {
|
|
5181
|
-
failure = error;
|
|
5182
|
-
throw error;
|
|
5183
|
-
} finally {
|
|
5184
|
-
// Native adapters report every dispatch, including a refreshed
|
|
5185
|
-
// credential retry. Legacy adapters without the hook count once.
|
|
5186
|
-
if (dispatches === 0) queryProgress.requestStarted();
|
|
5187
|
-
const usage = result?.usage || failure?.usage;
|
|
5188
|
-
if (usage) queryProgress.usageObserved(usage);
|
|
5189
|
-
}
|
|
5190
|
-
},
|
|
5191
|
-
} : this.#adapter;
|
|
5192
|
-
const { content, durationMs } = await runT1Reflection({
|
|
5193
|
-
adapter: reflectionAdapter,
|
|
5194
|
-
model: this.#config.model,
|
|
5195
|
-
originalUserMsg: prompt,
|
|
5196
|
-
toolPairs: pairs,
|
|
5197
|
-
assistantText,
|
|
5198
|
-
language: this.#config.language,
|
|
5199
|
-
signal,
|
|
5200
|
-
onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
|
|
5201
|
-
sessionId: runtimeSessionId, turnId: queryTurnId,
|
|
5202
|
-
trigger: 't1', model: this.#config.model, ...diagnostic,
|
|
5203
|
-
}),
|
|
5204
|
-
});
|
|
5205
|
-
const { messages: next, reflection: reflectionMessage, foldedMessages } = collapseRangeToReflection(
|
|
5206
|
-
conversationMessages, batchStart, batchEnd, content,
|
|
5207
|
-
);
|
|
5208
|
-
const durableRowsInRange = foldedMessages
|
|
5209
|
-
.some(message => message?._persistedMessageId || message?.id);
|
|
5210
|
-
const persistedReflection = this.#persistFoldedRange(
|
|
5211
|
-
foldedMessages,
|
|
5212
|
-
reflectionMessage,
|
|
5213
|
-
{ sessionId: runtimeSessionId, model: currentModel, executionOrigin },
|
|
5214
|
-
);
|
|
5215
|
-
if (durableRowsInRange && !persistedReflection) {
|
|
5216
|
-
throw new Error('T1 reflection could not publish its durable range replacement');
|
|
5217
|
-
}
|
|
5218
|
-
if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
|
|
5219
|
-
// Raw tool rows inside the replacement range are now tombstoned in
|
|
5220
|
-
// durable history. Late async completions must follow the folded
|
|
5221
|
-
// continuation path rather than appending to those stale rows.
|
|
5222
|
-
const foldedToolCallIds = conversationMessages
|
|
5223
|
-
.slice(batchStart, batchEnd + 1)
|
|
5224
|
-
.filter(message => message?.role === 'tool' && message.toolCallId)
|
|
5225
|
-
.map(message => message.toolCallId);
|
|
5226
|
-
for (const toolCallId of foldedToolCallIds) {
|
|
5227
|
-
this.#persistedToolMessages.delete(toolCallId);
|
|
5228
|
-
}
|
|
5229
|
-
conversationMessages.length = 0;
|
|
5230
|
-
for (const m of next) conversationMessages.push(m);
|
|
5231
|
-
// The preserved users precede the reflection. The next arc starts
|
|
5232
|
-
// immediately after it, i.e. at conversationMessages.length
|
|
5233
|
-
// (the next assistant message will land here).
|
|
5234
|
-
arcStartIdx = conversationMessages.length;
|
|
5235
|
-
// Read hints must not imply raw ranges remain in model context.
|
|
5236
|
-
fileReadObservations.clear();
|
|
5237
|
-
lastT1AtLoopCount = completedToolLoops;
|
|
5238
|
-
// Bump the success counter — used by the T2 schedule check
|
|
5239
|
-
// to decide whether T2 still has work to do at end_turn.
|
|
5240
|
-
// Distinct from lastT1AtLoopCount which the catch block
|
|
5241
|
-
// also bumps (but without rewriting history).
|
|
5242
|
-
t1CollapsesDone += 1;
|
|
5243
|
-
yield {
|
|
5244
|
-
type: 'reflection',
|
|
5245
|
-
turnId: queryTurnId,
|
|
5246
|
-
threadId,
|
|
5247
|
-
loopNumber: turnNumber,
|
|
5248
|
-
trigger: 't1',
|
|
5249
|
-
// PR-L bug fix: keep the same loopRange as the `pending` event
|
|
5250
|
-
// so the frontend key stays stable across pending → ready and
|
|
5251
|
-
// the spinner card is replaced in place (no orphan).
|
|
5252
|
-
status: 'ready',
|
|
5253
|
-
loopRange: [batchStart, batchEnd],
|
|
5254
|
-
toolCount: pairs.length,
|
|
5255
|
-
content,
|
|
5256
|
-
durationMs,
|
|
5257
|
-
};
|
|
5258
|
-
} catch (err) {
|
|
5259
|
-
// Best-effort. On failure leave history unchanged so the loop
|
|
5260
|
-
// continues normally — never block the turn.
|
|
5261
|
-
yield {
|
|
5262
|
-
type: 'reflection',
|
|
5263
|
-
turnId: queryTurnId,
|
|
5264
|
-
threadId,
|
|
5265
|
-
loopNumber: turnNumber,
|
|
5266
|
-
trigger: 't1',
|
|
5267
|
-
status: 'error',
|
|
5268
|
-
error: err && err.message || String(err),
|
|
5269
|
-
};
|
|
5270
|
-
// Advance lastT1AtLoopCount past this interval so we don't
|
|
5271
|
-
// tight-loop on a hiccuping reflector. The next attempt is
|
|
5272
|
-
// another 30 tool loops from now, not immediately. arcStartIdx is
|
|
5273
|
-
// left alone because history wasn't rewritten — the tail still
|
|
5274
|
-
// begins where it did. The trade-off: the next batch's
|
|
5275
|
-
// reflection will cover the tools that just failed too,
|
|
5276
|
-
// which is fine (they're still in conversationMessages).
|
|
5277
|
-
//
|
|
5278
|
-
// We do NOT bump t1CollapsesDone — see the variable's
|
|
5279
|
-
// declaration comment. This keeps the T2 fallback path live
|
|
5280
|
-
// when every T1 attempt has errored.
|
|
5281
|
-
lastT1AtLoopCount = completedToolLoops;
|
|
5282
|
-
}
|
|
5283
|
-
yield queryProgress.snapshot(progressIdentity);
|
|
5284
|
-
}
|
|
5285
|
-
}
|
|
5286
|
-
|
|
5287
4898
|
// task-325a: if abort fired between tools, converge now — emit
|
|
5288
4899
|
// the typed `aborted` event + a final turn_end with stopReason
|
|
5289
4900
|
// 'aborted' instead of looping back to a new adapter call.
|
|
@@ -5455,109 +5066,6 @@ export class Engine {
|
|
|
5455
5066
|
/** @returns {import('./mcp.js').MCPManager|null} */
|
|
5456
5067
|
get mcpManager() { return this.#mcpManager; }
|
|
5457
5068
|
|
|
5458
|
-
/**
|
|
5459
|
-
* PR-L — V7 Tool History Reflection helpers.
|
|
5460
|
-
*
|
|
5461
|
-
* `#applyPendingT2Reflections` is called at the start of every
|
|
5462
|
-
* `#runQuery` to carry forward any prior turn's async reflection. It is
|
|
5463
|
-
* a generator so it can yield reflection events to the engine consumer.
|
|
5464
|
-
* Non-blocking: never awaits a pending promise.
|
|
5465
|
-
*
|
|
5466
|
-
* @param {Array} conversationMessages
|
|
5467
|
-
* @param {string} originalUserMsg
|
|
5468
|
-
* @param {{sessionId?: string, model?: string}} context
|
|
5469
|
-
*/
|
|
5470
|
-
async *#applyPendingT2Reflections(conversationMessages, originalUserMsg, context = {}) {
|
|
5471
|
-
if (this.#pendingT2.size === 0) return;
|
|
5472
|
-
// Drain in insertion order (Map preserves it). We process all entries
|
|
5473
|
-
// because the user could send multiple prompts back-to-back before
|
|
5474
|
-
// the engine resumes — each historical turn gets its rewrite.
|
|
5475
|
-
const drained = [...this.#pendingT2.entries()];
|
|
5476
|
-
this.#pendingT2.clear();
|
|
5477
|
-
|
|
5478
|
-
for (const [turnNumber, info] of drained) {
|
|
5479
|
-
const keys = info.arcKeys;
|
|
5480
|
-
if (!Array.isArray(keys) || keys.length === 0) continue;
|
|
5481
|
-
const currentKeys = conversationMessages.map(foldingMessageKey);
|
|
5482
|
-
const startIdx = currentKeys.findIndex((key, index) => key === keys[0]
|
|
5483
|
-
&& keys.every((expected, offset) => currentKeys[index + offset] === expected));
|
|
5484
|
-
const endIdx = startIdx + keys.length - 1;
|
|
5485
|
-
const rangePresent = startIdx >= 0;
|
|
5486
|
-
|
|
5487
|
-
// PR-L follow-up: deterministic readiness check. The info record
|
|
5488
|
-
// carries `ready / result / error` flags that are flipped from the
|
|
5489
|
-
// promise's then/catch handler; reading them here is purely
|
|
5490
|
-
// synchronous bookkeeping — no microtask race.
|
|
5491
|
-
let content;
|
|
5492
|
-
let trigger;
|
|
5493
|
-
let durationMs = 0;
|
|
5494
|
-
if (!info.ready) {
|
|
5495
|
-
// Still in flight — fall back to the exec-log stub. Detach the
|
|
5496
|
-
// unresolved promise so we don't leak it (handlers above already
|
|
5497
|
-
// swallow rejection by routing into info.error).
|
|
5498
|
-
const entries = this.#execLog ? this.#execLog.readTurn(turnNumber) : [];
|
|
5499
|
-
content = buildFallbackStub({ execLogEntries: entries, originalUserMsg: info.originalUserMsg || originalUserMsg });
|
|
5500
|
-
trigger = 't2-fallback';
|
|
5501
|
-
} else if (info.error) {
|
|
5502
|
-
// Promise rejected — leave history unchanged, no event.
|
|
5503
|
-
continue;
|
|
5504
|
-
} else if (info.result && typeof info.result.content === 'string' && info.result.content) {
|
|
5505
|
-
content = info.result.content;
|
|
5506
|
-
trigger = 't2';
|
|
5507
|
-
durationMs = info.result.durationMs || 0;
|
|
5508
|
-
} else {
|
|
5509
|
-
// Resolved but with no usable content — defensively skip.
|
|
5510
|
-
continue;
|
|
5511
|
-
}
|
|
5512
|
-
|
|
5513
|
-
// Rewrite history and publish the same logical replacement to disk.
|
|
5514
|
-
const { messages: next, reflection: reflectionMessage, foldedMessages } = rangePresent
|
|
5515
|
-
? collapseRangeToReflection(conversationMessages, startIdx, endIdx, content)
|
|
5516
|
-
: collapseRangeToReflection(info.arcRows, 0, info.arcRows.length - 1, content);
|
|
5517
|
-
const durableRowsInRange = foldedMessages
|
|
5518
|
-
.some(message => message?._persistedMessageId || message?.id);
|
|
5519
|
-
const persistedReflection = this.#persistFoldedRange(
|
|
5520
|
-
foldedMessages,
|
|
5521
|
-
reflectionMessage,
|
|
5522
|
-
{
|
|
5523
|
-
...context,
|
|
5524
|
-
causalRootId: info.causalRootId || null,
|
|
5525
|
-
executionOrigin: info.executionOrigin === 'route_forward' ? 'route_forward' : null,
|
|
5526
|
-
},
|
|
5527
|
-
);
|
|
5528
|
-
if (durableRowsInRange && !persistedReflection) continue;
|
|
5529
|
-
if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
|
|
5530
|
-
// T2 replaces this arc in durable and in-memory history just like T1.
|
|
5531
|
-
// Forget its raw result handles before a late async task can append to a
|
|
5532
|
-
// tombstoned tool row at the next provider boundary.
|
|
5533
|
-
const foldedToolCallIds = foldedMessages
|
|
5534
|
-
.filter(message => message?.role === 'tool' && message.toolCallId)
|
|
5535
|
-
.map(message => message.toolCallId);
|
|
5536
|
-
for (const toolCallId of foldedToolCallIds) {
|
|
5537
|
-
this.#persistedToolMessages.delete(toolCallId);
|
|
5538
|
-
}
|
|
5539
|
-
// History may have evicted the old arc. Publish only its original
|
|
5540
|
-
// durable row IDs; never replace unrelated rows at stale indexes.
|
|
5541
|
-
if (rangePresent) {
|
|
5542
|
-
conversationMessages.length = 0;
|
|
5543
|
-
for (const m of next) conversationMessages.push(m);
|
|
5544
|
-
}
|
|
5545
|
-
|
|
5546
|
-
yield {
|
|
5547
|
-
type: 'reflection',
|
|
5548
|
-
turnId: info.originatingTurnId || null,
|
|
5549
|
-
trigger,
|
|
5550
|
-
status: 'ready',
|
|
5551
|
-
// Wire identity must match the original pending card, even if the
|
|
5552
|
-
// history window shifted or evicted its internal replacement range.
|
|
5553
|
-
loopRange: info.loopRange,
|
|
5554
|
-
toolCount: info.count || 0,
|
|
5555
|
-
content,
|
|
5556
|
-
durationMs,
|
|
5557
|
-
};
|
|
5558
|
-
}
|
|
5559
|
-
}
|
|
5560
|
-
|
|
5561
5069
|
/**
|
|
5562
5070
|
* PR-L — read-only accessor for tests.
|
|
5563
5071
|
*/
|
|
@@ -5712,10 +5220,9 @@ export class Engine {
|
|
|
5712
5220
|
? opts.preview
|
|
5713
5221
|
: (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
|
|
5714
5222
|
const meta = this.#asyncTaskToolMeta.get(taskId) || {};
|
|
5715
|
-
// A completion
|
|
5716
|
-
//
|
|
5717
|
-
//
|
|
5718
|
-
// a raw tool row after the reflection and invalidate the fold.
|
|
5223
|
+
// A completion updates its original tool row when available. Legacy folded
|
|
5224
|
+
// histories instead receive a continuation note; provider-only compaction
|
|
5225
|
+
// never clears this durable handle or changes result ownership.
|
|
5719
5226
|
const hasLiveToolRow = typeof meta.toolCallId === 'string'
|
|
5720
5227
|
&& meta.toolCallId
|
|
5721
5228
|
&& Boolean(this.#persistedToolMessages.get(meta.toolCallId));
|
|
@@ -5741,9 +5248,8 @@ export class Engine {
|
|
|
5741
5248
|
sessionId: delivery.sessionId,
|
|
5742
5249
|
vpId: delivery.vpId,
|
|
5743
5250
|
turnId: delivery.turnId,
|
|
5744
|
-
//
|
|
5745
|
-
//
|
|
5746
|
-
// later drain cannot infer stale liveness from an unrelated row.
|
|
5251
|
+
// Capture durable row ownership at completion time, independently of
|
|
5252
|
+
// any provider-only compacted request copy.
|
|
5747
5253
|
folded: !hasLiveToolRow,
|
|
5748
5254
|
});
|
|
5749
5255
|
} else {
|
|
@@ -5781,8 +5287,7 @@ export class Engine {
|
|
|
5781
5287
|
: (this.#sessionId || null),
|
|
5782
5288
|
vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
|
|
5783
5289
|
// query() exposes the wire turn id while the task is registered.
|
|
5784
|
-
//
|
|
5785
|
-
// original tool row from the in-memory arc.
|
|
5290
|
+
// Keep the originating turn even for legacy missing/folded tool rows.
|
|
5786
5291
|
turnId: typeof meta.turnId === 'string' && meta.turnId
|
|
5787
5292
|
? meta.turnId
|
|
5788
5293
|
: (this.#currentQueryTurnId || null),
|
package/yeaft/history-window.js
CHANGED
|
@@ -565,6 +565,130 @@ function truncateToolResultsForModel(messages, options = {}) {
|
|
|
565
565
|
});
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
const TOOL_COMPACT_MARKER = '\n[tool result compacted for context budget; middle omitted]\n';
|
|
569
|
+
const MIN_TOOL_SAMPLE_BYTES = 64;
|
|
570
|
+
const TOOL_ERROR_FIELDS = new Set(['error', 'code', 'errorEffect', 'failureType', 'status']);
|
|
571
|
+
const BINARY_PART_TYPES = new Set(['image', 'input_image', 'document', 'input_file']);
|
|
572
|
+
|
|
573
|
+
function sampleToolText(text, retainedBytes) {
|
|
574
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
575
|
+
if (bytes.length <= retainedBytes + Buffer.byteLength(TOOL_COMPACT_MARKER)) return text;
|
|
576
|
+
let headEnd = Math.floor(retainedBytes / 2);
|
|
577
|
+
let tailStart = bytes.length - (retainedBytes - headEnd);
|
|
578
|
+
// UTF-8 boundaries also keep CJK and astral characters intact. The marker is
|
|
579
|
+
// charged together with both samples by estimateContentTokens below.
|
|
580
|
+
while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
|
|
581
|
+
while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
|
|
582
|
+
return bytes.subarray(0, headEnd).toString('utf8') + TOOL_COMPACT_MARKER
|
|
583
|
+
+ bytes.subarray(tailStart).toString('utf8');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sampleToolJson(value, retainedBytes) {
|
|
587
|
+
if (typeof value === 'string') return sampleToolText(value, retainedBytes);
|
|
588
|
+
if (!value || typeof value !== 'object' || BINARY_PART_TYPES.has(value.type)) return value;
|
|
589
|
+
if (Array.isArray(value)) return value.map(item => sampleToolJson(item, retainedBytes));
|
|
590
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
591
|
+
key, TOOL_ERROR_FIELDS.has(key) ? item : sampleToolJson(item, retainedBytes),
|
|
592
|
+
]));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function toolTextSampler(text) {
|
|
596
|
+
// Keep structured output parseable, including JSON inside text content parts
|
|
597
|
+
// and JSON string scalars. Only string leaves are sampled; flags, exit codes
|
|
598
|
+
// and Bash error markers stay whole. Unsafe numeric round trips and deeply
|
|
599
|
+
// nested structures stay intact for the existing protocol-unit budget fitter.
|
|
600
|
+
if (/^\s*[\[{"]/.test(text)) {
|
|
601
|
+
try {
|
|
602
|
+
const value = JSON.parse(text, (_key, item, context) => {
|
|
603
|
+
if (typeof item === 'number' && context?.source !== JSON.stringify(item)) {
|
|
604
|
+
throw new RangeError('JSON numeric representation would change');
|
|
605
|
+
}
|
|
606
|
+
return item;
|
|
607
|
+
});
|
|
608
|
+
const pending = [{ value, depth: 0 }];
|
|
609
|
+
while (pending.length > 0) {
|
|
610
|
+
const next = pending.pop();
|
|
611
|
+
if (!next.value || typeof next.value !== 'object') continue;
|
|
612
|
+
if (next.depth >= 64) return () => text;
|
|
613
|
+
for (const item of Object.values(next.value)) pending.push({ value: item, depth: next.depth + 1 });
|
|
614
|
+
}
|
|
615
|
+
return bytes => JSON.stringify(sampleToolJson(value, bytes));
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// A valid but unsafe/deep JSON value must not crash sampling or silently
|
|
618
|
+
// change facts. Only invalid JSON falls through to ordinary text sampling.
|
|
619
|
+
if (!(error instanceof SyntaxError)) return () => text;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return bytes => sampleToolText(text, bytes);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toolContentSampler(content) {
|
|
626
|
+
// Content parts keep their ordering, metadata and binary payloads. In
|
|
627
|
+
// particular, do not collapse mixed text/image/document output into JSON.
|
|
628
|
+
if (Array.isArray(content) && content.some(part => part && typeof part === 'object'
|
|
629
|
+
&& (['text', 'input_text', 'output_text'].includes(part.type) || BINARY_PART_TYPES.has(part.type)))) {
|
|
630
|
+
const samplers = content.map(part => {
|
|
631
|
+
if (typeof part === 'string') return toolTextSampler(part);
|
|
632
|
+
if (part && ['text', 'input_text', 'output_text'].includes(part.type)
|
|
633
|
+
&& typeof part.text === 'string') {
|
|
634
|
+
const sample = toolTextSampler(part.text);
|
|
635
|
+
return bytes => ({ ...part, text: sample(bytes) });
|
|
636
|
+
}
|
|
637
|
+
return () => part;
|
|
638
|
+
});
|
|
639
|
+
return bytes => samplers.map(sample => sample(bytes));
|
|
640
|
+
}
|
|
641
|
+
return toolTextSampler(serializeJsonValue(content));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function compactToolContentToBudget(content, tokenBudget) {
|
|
645
|
+
const sample = toolContentSampler(content);
|
|
646
|
+
const minimum = sample(MIN_TOOL_SAMPLE_BYTES);
|
|
647
|
+
if (estimateContentTokens(minimum) > tokenBudget) return minimum;
|
|
648
|
+
// Search measured content cost, not tokenBudget * chars/bytes. JSON escaping,
|
|
649
|
+
// CJK and multipart framing otherwise make small budgets easy to overshoot.
|
|
650
|
+
let low = MIN_TOOL_SAMPLE_BYTES;
|
|
651
|
+
let high = Math.max(low, Buffer.byteLength(serializeJsonValue(content), 'utf8'));
|
|
652
|
+
let fitted = minimum;
|
|
653
|
+
while (low < high) {
|
|
654
|
+
const mid = Math.ceil((low + high) / 2);
|
|
655
|
+
const candidate = sample(mid);
|
|
656
|
+
if (estimateContentTokens(candidate) <= tokenBudget) {
|
|
657
|
+
low = mid;
|
|
658
|
+
fitted = candidate;
|
|
659
|
+
} else {
|
|
660
|
+
high = mid - 1;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return fitted;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compactCurrentToolResults(messages, messageBudget) {
|
|
667
|
+
const out = messages.slice();
|
|
668
|
+
let excess = estimateMessagesTokens(out) - messageBudget;
|
|
669
|
+
let compactedToolResults = 0;
|
|
670
|
+
let compactedToolResultTokensSaved = 0;
|
|
671
|
+
// Protect the complete newest tool batch, not merely its last result. All
|
|
672
|
+
// parallel results are evidence for the next assistant continuation.
|
|
673
|
+
let latestEnd = out.length - 1;
|
|
674
|
+
while (latestEnd >= 0 && out[latestEnd]?.role !== 'tool') latestEnd -= 1;
|
|
675
|
+
let latestStart = latestEnd;
|
|
676
|
+
while (latestStart > 0 && out[latestStart - 1]?.role === 'tool') latestStart -= 1;
|
|
677
|
+
for (let index = 0; index < latestStart && excess > 0; index += 1) {
|
|
678
|
+
const message = out[index];
|
|
679
|
+
if (message?.role !== 'tool') continue;
|
|
680
|
+
const before = estimateContentTokens(message.content);
|
|
681
|
+
const content = compactToolContentToBudget(message.content, Math.max(0, before - excess));
|
|
682
|
+
const saved = before - estimateContentTokens(content);
|
|
683
|
+
if (saved <= 0) continue;
|
|
684
|
+
out[index] = { ...message, content };
|
|
685
|
+
excess -= saved;
|
|
686
|
+
compactedToolResults += 1;
|
|
687
|
+
compactedToolResultTokensSaved += saved;
|
|
688
|
+
}
|
|
689
|
+
return { messages: out, compactedToolResults, compactedToolResultTokensSaved };
|
|
690
|
+
}
|
|
691
|
+
|
|
568
692
|
const HISTORY_SOURCE_INDEX = Symbol('historySourceIndex');
|
|
569
693
|
|
|
570
694
|
function withHistorySourceIndexes(messages, offset = 0) {
|
|
@@ -1139,8 +1263,9 @@ export function buildHistoryBuckets(snapshot, options = {}) {
|
|
|
1139
1263
|
* Fit one provider-request copy to the actual model window. The caller tells
|
|
1140
1264
|
* us where current-turn rows begin; only the prefix is subject to the history
|
|
1141
1265
|
* budget. If the complete request is still too large, old history disappears
|
|
1142
|
-
* first
|
|
1143
|
-
*
|
|
1266
|
+
* first. Under current-turn pressure, sample old tool-result text before
|
|
1267
|
+
* evicting disposable protocol units; the latest tool batch stays lossless
|
|
1268
|
+
* until the final budget fallback. The source and transcript are never mutated.
|
|
1144
1269
|
*
|
|
1145
1270
|
* @param {Array<object>} messages
|
|
1146
1271
|
* @param {{ contextWindow:number, systemTokens?:number, toolSchemaTokens?:number,
|
|
@@ -1167,9 +1292,9 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1167
1292
|
const historySource = source.slice(0, split);
|
|
1168
1293
|
const currentSource = source.slice(split);
|
|
1169
1294
|
|
|
1170
|
-
let current = pairSanitize(
|
|
1171
|
-
|
|
1172
|
-
|
|
1295
|
+
let current = pairSanitize(currentSource.map(message => ({ ...message })));
|
|
1296
|
+
const compact = compactCurrentToolResults(current, messageBudget);
|
|
1297
|
+
current = compact.messages;
|
|
1173
1298
|
if (estimateMessagesTokens(current) > messageBudget || current.length > messageCap) {
|
|
1174
1299
|
const fitted = [];
|
|
1175
1300
|
if (current.length > 0 && messageBudget >= 2 && messageCap > 0) {
|
|
@@ -1222,6 +1347,8 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1222
1347
|
currentMessagesAfter: current.length,
|
|
1223
1348
|
droppedHistoryMessages: historySource.length - history.length,
|
|
1224
1349
|
droppedCurrentMessages: currentSource.length - current.length,
|
|
1350
|
+
compactedToolResults: compact.compactedToolResults,
|
|
1351
|
+
compactedToolResultTokensSaved: compact.compactedToolResultTokensSaved,
|
|
1225
1352
|
},
|
|
1226
1353
|
};
|
|
1227
1354
|
}
|