@yeaft/webchat-agent 1.0.634 → 1.0.635
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/local-runtime/web/app.bundle.js +132 -119
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/search.js +25 -9
- package/yeaft/engine.js +116 -14
- package/yeaft/query-progress.js +201 -0
- package/yeaft/sub-agent/prompt-queue.js +3 -0
- package/yeaft/sub-agent/result-reference.js +13 -0
- package/yeaft/sub-agent/runner.js +11 -6
- package/yeaft/sub-agent/task-identity.js +46 -0
- package/yeaft/tools/agent.js +73 -2
- package/yeaft/tools/history-search.js +28 -8
- package/yeaft/tools/types.js +2 -0
- package/yeaft/tools/wait-agent.js +21 -1
- package/yeaft/tools/web-fetch.js +9 -0
- package/yeaft/web-bridge.js +25 -2
- package/yeaft/work-center/runner.js +1 -1
|
Binary file
|
package/package.json
CHANGED
|
@@ -38,8 +38,14 @@ function searchableContent(msg) {
|
|
|
38
38
|
return '';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function matchesMessage(msg, terms) {
|
|
41
|
+
function matchesMessage(msg, terms, source, boundary) {
|
|
42
42
|
if (!msg || msg.role === 'tool' || !isVisibleConversationRow(msg)) return false;
|
|
43
|
+
// Sequence numbers belong to the source Session, not to a sibling transcript
|
|
44
|
+
// (or a row's optional sessionId). Filter before counting toward any limit.
|
|
45
|
+
if (boundary && source.sessionId === boundary.sessionId) {
|
|
46
|
+
const seq = Number.isFinite(msg.seq) ? msg.seq : parseSeqFromId(msg.id);
|
|
47
|
+
if (Number.isFinite(seq) && seq >= boundary.beforeSeq) return false;
|
|
48
|
+
}
|
|
43
49
|
const content = searchableContent(msg).toLocaleLowerCase();
|
|
44
50
|
return content.length > 0 && terms.every(term => content.includes(term));
|
|
45
51
|
}
|
|
@@ -59,7 +65,7 @@ function withSource(msg, source) {
|
|
|
59
65
|
return {
|
|
60
66
|
...publicMessage,
|
|
61
67
|
content: searchableContent(msg),
|
|
62
|
-
sessionId:
|
|
68
|
+
sessionId: source.sessionId || msg.sessionId || null,
|
|
63
69
|
historySource: source.kind,
|
|
64
70
|
};
|
|
65
71
|
}
|
|
@@ -67,7 +73,7 @@ function withSource(msg, source) {
|
|
|
67
73
|
/**
|
|
68
74
|
* Search Markdown messages in one directory, newest first.
|
|
69
75
|
*/
|
|
70
|
-
function searchMarkdownDir(dir, terms, limit, source, telemetry) {
|
|
76
|
+
function searchMarkdownDir(dir, terms, limit, source, telemetry, boundary) {
|
|
71
77
|
if (!existsSync(dir)) return [];
|
|
72
78
|
|
|
73
79
|
const files = readdirSync(dir)
|
|
@@ -84,12 +90,12 @@ function searchMarkdownDir(dir, terms, limit, source, telemetry) {
|
|
|
84
90
|
const lowerRaw = raw.toLocaleLowerCase();
|
|
85
91
|
if (!terms.every(term => lowerRaw.includes(term))) continue;
|
|
86
92
|
const msg = parseMessage(raw);
|
|
87
|
-
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
93
|
+
if (matchesMessage(msg, terms, source, boundary)) results.push(withSource(msg, source));
|
|
88
94
|
}
|
|
89
95
|
return results;
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
function searchSegmentDir(dir, terms, limit, source, telemetry) {
|
|
98
|
+
function searchSegmentDir(dir, terms, limit, source, telemetry, boundary) {
|
|
93
99
|
if (!existsSync(dir)) return [];
|
|
94
100
|
const files = readdirSync(dir)
|
|
95
101
|
.filter(f => f.endsWith('.jsonl'))
|
|
@@ -109,7 +115,7 @@ function searchSegmentDir(dir, terms, limit, source, telemetry) {
|
|
|
109
115
|
if (!lines[i]?.trim()) continue;
|
|
110
116
|
recordMessageScan(telemetry);
|
|
111
117
|
const msg = parseJsonLine(lines[i]);
|
|
112
|
-
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
118
|
+
if (matchesMessage(msg, terms, source, boundary)) results.push(withSource(msg, source));
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
return results;
|
|
@@ -154,7 +160,10 @@ function sessionConversationDirs(dir) {
|
|
|
154
160
|
* @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
|
|
155
161
|
* @param {string} keyword — search terms
|
|
156
162
|
* @param {number} [limit=10] — max results
|
|
157
|
-
* @param {{telemetry?: {scannedFiles?: number, scannedBytes?: number, scannedMessages?: number}, sessionIds?: string[]}} [options]
|
|
163
|
+
* @param {{telemetry?: {scannedFiles?: number, scannedBytes?: number, scannedMessages?: number}, sessionIds?: string[], boundarySessionId?: string, beforeSeq?: number, beforeMessageId?: string}} [options]
|
|
164
|
+
* A trusted exclusive boundary applies only to boundarySessionId's transcript.
|
|
165
|
+
* Finite beforeSeq takes precedence over a monotonic beforeMessageId. Without
|
|
166
|
+
* a valid boundary, legacy search behavior is preserved.
|
|
158
167
|
* @returns {object[]} — matching messages, newest first
|
|
159
168
|
*/
|
|
160
169
|
export function searchMessages(dir, keyword, limit = 10, options = {}) {
|
|
@@ -169,6 +178,13 @@ export function searchMessages(dir, keyword, limit = 10, options = {}) {
|
|
|
169
178
|
telemetry.scannedMessages = 0;
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
const beforeSeq = Number.isFinite(options.beforeSeq)
|
|
182
|
+
? options.beforeSeq
|
|
183
|
+
: parseSeqFromId(options.beforeMessageId);
|
|
184
|
+
const boundary = typeof options.boundarySessionId === 'string' && options.boundarySessionId && Number.isFinite(beforeSeq)
|
|
185
|
+
? { sessionId: options.boundarySessionId, beforeSeq }
|
|
186
|
+
: null;
|
|
187
|
+
|
|
172
188
|
const requestedSessionIds = Array.isArray(options.sessionIds)
|
|
173
189
|
? new Set(options.sessionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))
|
|
174
190
|
: null;
|
|
@@ -191,8 +207,8 @@ export function searchMessages(dir, keyword, limit = 10, options = {}) {
|
|
|
191
207
|
const segmentDirs = conversationDirs.map(source => ({ ...source, dir: join(source.dir, 'segments') }));
|
|
192
208
|
|
|
193
209
|
const results = [
|
|
194
|
-
...segmentDirs.flatMap(source => searchSegmentDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
195
|
-
...markdownDirs.flatMap(source => searchMarkdownDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
210
|
+
...segmentDirs.flatMap(source => searchSegmentDir(source.dir, terms, resultLimit, source, telemetry, boundary)),
|
|
211
|
+
...markdownDirs.flatMap(source => searchMarkdownDir(source.dir, terms, resultLimit, source, telemetry, boundary)),
|
|
196
212
|
]
|
|
197
213
|
.sort(compareNewest)
|
|
198
214
|
.slice(0, resultLimit);
|
package/yeaft/engine.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { randomUUID, createHash } from 'crypto';
|
|
21
|
+
import { QueryProgress, progressReminder } from './query-progress.js';
|
|
21
22
|
import { promises as fsp } from 'fs';
|
|
22
23
|
import { join, resolve as resolvePath } from 'path';
|
|
23
24
|
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
@@ -1442,6 +1443,8 @@ export class Engine {
|
|
|
1442
1443
|
signal,
|
|
1443
1444
|
effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
|
|
1444
1445
|
requestIdentity: vpCtx?.requestIdentity,
|
|
1446
|
+
queryTurnId: vpCtx?.queryTurnId,
|
|
1447
|
+
historyBeforeSeq: vpCtx?.historyBeforeSeq,
|
|
1445
1448
|
yeaftDir: this.#yeaftDir,
|
|
1446
1449
|
managedCliReady: this.#managedCliReady,
|
|
1447
1450
|
runtimePlatform: getRuntimePlatformInfo(),
|
|
@@ -2122,6 +2125,16 @@ export class Engine {
|
|
|
2122
2125
|
const effortConstraint = isSubAgent || scenario === 'sub_agent' || vpPersona?.subAgent
|
|
2123
2126
|
? Object.freeze({ parentDecision: snapshotEffortDecision(parentEffortDecision) }) : null;
|
|
2124
2127
|
const queryStartedAt = Date.now();
|
|
2128
|
+
const queryProgress = new QueryProgress();
|
|
2129
|
+
const progressIdentity = { turnId: queryTurnId, threadId: runtimeThreadId, sessionId: runtimeSessionId, vpId: requestIdentity.vpId };
|
|
2130
|
+
let progressReportPending = false;
|
|
2131
|
+
const resetProgressEvidence = () => {
|
|
2132
|
+
queryProgress.resetEvidence();
|
|
2133
|
+
progressReportPending = false;
|
|
2134
|
+
};
|
|
2135
|
+
const syncProgressInput = (items) => {
|
|
2136
|
+
if (items.some(item => !item.internal || item.taskId)) resetProgressEvidence();
|
|
2137
|
+
};
|
|
2125
2138
|
const userQuestionPreview = String(prompt || '').slice(0, 200);
|
|
2126
2139
|
const queryVpId = vpPersona && typeof vpPersona === 'object'
|
|
2127
2140
|
&& typeof vpPersona.vpId === 'string'
|
|
@@ -2152,7 +2165,10 @@ export class Engine {
|
|
|
2152
2165
|
const readOnlyToolReuseOwner = this.#currentAbortCtrl;
|
|
2153
2166
|
this.#activeReadOnlyToolReuse = {
|
|
2154
2167
|
owner: readOnlyToolReuseOwner,
|
|
2155
|
-
invalidate:
|
|
2168
|
+
invalidate: () => {
|
|
2169
|
+
invalidateReadOnlyToolReuse();
|
|
2170
|
+
resetProgressEvidence();
|
|
2171
|
+
},
|
|
2156
2172
|
};
|
|
2157
2173
|
|
|
2158
2174
|
// Durability boundary: a valid user turn must exist on disk before any
|
|
@@ -2833,6 +2849,7 @@ export class Engine {
|
|
|
2833
2849
|
const currentContextWindow = resolveContextWindow(currentModel, requestConfig);
|
|
2834
2850
|
|
|
2835
2851
|
const appendedBeforeStream = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
2852
|
+
syncProgressInput(appendedBeforeStream);
|
|
2836
2853
|
if (appendedBeforeStream.length > 0) {
|
|
2837
2854
|
for (const item of appendedBeforeStream) {
|
|
2838
2855
|
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
@@ -2853,6 +2870,7 @@ export class Engine {
|
|
|
2853
2870
|
// this query was parked. Its task-result update is the authoritative
|
|
2854
2871
|
// synchronization point before the next provider loop.
|
|
2855
2872
|
invalidateReadOnlyToolReuse();
|
|
2873
|
+
resetProgressEvidence();
|
|
2856
2874
|
}
|
|
2857
2875
|
if (taskResultUpdatesBeforeStream.length > 0) {
|
|
2858
2876
|
for (const update of taskResultUpdatesBeforeStream) {
|
|
@@ -2887,7 +2905,8 @@ export class Engine {
|
|
|
2887
2905
|
// tool-free response for a useful handoff, under the existing signal.
|
|
2888
2906
|
const executionPolicy = isSubAgent
|
|
2889
2907
|
? this.#toolRegistry?.prepareProviderRequest?.() : null;
|
|
2890
|
-
|
|
2908
|
+
const progressReporting = progressReportPending;
|
|
2909
|
+
if (executionPolicy?.finalize || progressReporting) toolDefs = [];
|
|
2891
2910
|
({ resolvedSkillContent, resolvedSkills, skillResolutionError } = resolveSkillPromptState({
|
|
2892
2911
|
skillManager: this.#skillManager,
|
|
2893
2912
|
prompt,
|
|
@@ -2906,6 +2925,7 @@ export class Engine {
|
|
|
2906
2925
|
reportedSkillError = skillResolutionError;
|
|
2907
2926
|
systemPrompt = buildCurrentSystemPrompt();
|
|
2908
2927
|
if (executionPolicy?.prompt) systemPrompt += `\n\n${executionPolicy.prompt}`;
|
|
2928
|
+
if (progressReporting) systemPrompt += `\n\n${progressReminder('report', queryProgress.snapshot().reason)}`;
|
|
2909
2929
|
let requestDebugMessages = [];
|
|
2910
2930
|
let requestInputBreakdown = null;
|
|
2911
2931
|
|
|
@@ -3140,7 +3160,12 @@ export class Engine {
|
|
|
3140
3160
|
continuationCommitted = true;
|
|
3141
3161
|
};
|
|
3142
3162
|
let childDispatchReserved = false;
|
|
3163
|
+
let progressRequestCounted = false;
|
|
3143
3164
|
const commitDispatch = () => {
|
|
3165
|
+
if (!progressRequestCounted) {
|
|
3166
|
+
queryProgress.requestStarted();
|
|
3167
|
+
progressRequestCounted = true;
|
|
3168
|
+
}
|
|
3144
3169
|
if (isSubAgent && !childDispatchReserved) {
|
|
3145
3170
|
this.#toolRegistry?.reserveProviderRequest?.({ reporting: !!executionPolicy?.finalize });
|
|
3146
3171
|
childDispatchReserved = true;
|
|
@@ -3260,7 +3285,7 @@ export class Engine {
|
|
|
3260
3285
|
case 'tool_call':
|
|
3261
3286
|
// No dispatch or orphan protocol rows during the reserved report,
|
|
3262
3287
|
// even if an adapter ignores the absence of tool definitions.
|
|
3263
|
-
if (executionPolicy?.finalize) break;
|
|
3288
|
+
if (executionPolicy?.finalize || progressReporting) break;
|
|
3264
3289
|
if (toolCalls.length === 0) {
|
|
3265
3290
|
traceRequest('llm.first_tool_call', {
|
|
3266
3291
|
durationMs: perfNowMs() - requestPerfStart,
|
|
@@ -3505,7 +3530,7 @@ export class Engine {
|
|
|
3505
3530
|
// the caller. Replaying that request would publish a duplicate call and
|
|
3506
3531
|
// leave ambiguous execution ownership, so only pre-tool failures are
|
|
3507
3532
|
// eligible for transparent retry or model fallback.
|
|
3508
|
-
const canReplayProviderRequest = toolCalls.length === 0 && !executionPolicy?.finalize;
|
|
3533
|
+
const canReplayProviderRequest = toolCalls.length === 0 && !executionPolicy?.finalize && !progressReporting;
|
|
3509
3534
|
if (earlyIsContextOverflow && canReplayProviderRequest
|
|
3510
3535
|
&& contextOverflowRecoveryAttempts < 3) {
|
|
3511
3536
|
contextOverflowRecoveryAttempts += 1;
|
|
@@ -3914,8 +3939,54 @@ export class Engine {
|
|
|
3914
3939
|
// The reporting allowance is exactly one provider response, not another
|
|
3915
3940
|
// investigation loop. Ignore unsolicited calls even from a noncompliant
|
|
3916
3941
|
// adapter, and never auto-continue a truncated reporting response.
|
|
3917
|
-
|
|
3918
|
-
|
|
3942
|
+
yield queryProgress.snapshot(progressIdentity);
|
|
3943
|
+
// Normal tool calls have already been persisted. On abort let that batch
|
|
3944
|
+
// reach the skipped-result path so every call remains durably paired.
|
|
3945
|
+
if (signal?.aborted && (toolCalls.length === 0 || executionPolicy?.finalize || progressReporting)) {
|
|
3946
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
3947
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
|
|
3948
|
+
break;
|
|
3949
|
+
}
|
|
3950
|
+
if (executionPolicy?.finalize || progressReporting) {
|
|
3951
|
+
if (progressReporting) {
|
|
3952
|
+
// An owned completion accepted during this tool-free stream resets
|
|
3953
|
+
// progress. Resume at the normal pre-stream drains so both updated
|
|
3954
|
+
// tool rows and folded/message deliveries reach the next request.
|
|
3955
|
+
if (!progressReportPending) {
|
|
3956
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'async_task_continue', threadId };
|
|
3957
|
+
continue;
|
|
3958
|
+
}
|
|
3959
|
+
// The reporting allowance cannot discard input accepted during its
|
|
3960
|
+
// stream. Honor the same atomic input-close fence as normal endings.
|
|
3961
|
+
let appended = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
3962
|
+
if (appended.length === 0 && typeof closePendingUserInput === 'function' && !closePendingUserInput()) {
|
|
3963
|
+
appended = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
3964
|
+
if (appended.length === 0) throw new Error('Could not close pending user input for progress report');
|
|
3965
|
+
}
|
|
3966
|
+
if (appended.length > 0) {
|
|
3967
|
+
syncProgressInput(appended);
|
|
3968
|
+
for (const item of appended) {
|
|
3969
|
+
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
3970
|
+
conversationMessages.push({ role: 'user', content: item.content });
|
|
3971
|
+
yield { type: 'user_append', turnId: queryTurnId, loopNumber: turnNumber, threadId,
|
|
3972
|
+
preview: String(item.preview || '').slice(0, 200), internal: Boolean(item.internal) };
|
|
3973
|
+
}
|
|
3974
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'user_append_continue', threadId };
|
|
3975
|
+
continue;
|
|
3976
|
+
}
|
|
3977
|
+
// No more same-turn deliveries after this decision. Later task
|
|
3978
|
+
// completions must use the coordinator rescue path, not be accepted
|
|
3979
|
+
// between the final report/error yields and terminal turn_end.
|
|
3980
|
+
this.retireAsyncTasks('no_progress');
|
|
3981
|
+
if (!responseText.trim()) {
|
|
3982
|
+
const report = 'Execution paused after repeated tool results or failures without observed progress. The requested task is not confirmed complete. Review the preceding tool results and resolve the blocker before resuming.';
|
|
3983
|
+
this.#persistConversationMessage({ role: 'assistant', content: report, responseKind: 'progress' }, { sessionId: runtimeSessionId, turnId: queryTurnId, model: currentModel, stopReason: 'no_progress', executionOrigin });
|
|
3984
|
+
yield { type: 'text_delta', text: report, threadId };
|
|
3985
|
+
}
|
|
3986
|
+
if (!signal?.aborted) yield { type: 'error', error: 'Execution paused after repeated non-progress batches; task completion was not verified.', code: 'no_progress', retryable: false };
|
|
3987
|
+
}
|
|
3988
|
+
if (signal?.aborted) yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
3989
|
+
yield { type: 'turn_end', turnNumber, stopReason: signal?.aborted ? 'aborted' : progressReporting ? 'no_progress' : 'budget_report', threadId, terminal: true };
|
|
3919
3990
|
break;
|
|
3920
3991
|
}
|
|
3921
3992
|
|
|
@@ -3958,6 +4029,7 @@ export class Engine {
|
|
|
3958
4029
|
if (toolCalls.length === 0) {
|
|
3959
4030
|
const appendedAfterAssistant = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
3960
4031
|
if (appendedAfterAssistant.length > 0) {
|
|
4032
|
+
syncProgressInput(appendedAfterAssistant);
|
|
3961
4033
|
for (const item of appendedAfterAssistant) {
|
|
3962
4034
|
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
3963
4035
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
@@ -3987,7 +4059,8 @@ export class Engine {
|
|
|
3987
4059
|
// tell the user the outcome or continue the work. Do not wait
|
|
3988
4060
|
// for another user turn."
|
|
3989
4061
|
if ((stopReason !== 'tool_use' || toolCalls.length === 0)
|
|
3990
|
-
&& this.#pendingAsyncTaskIds.size > 0
|
|
4062
|
+
&& (this.#pendingAsyncTaskIds.size > 0
|
|
4063
|
+
|| this.#pendingTaskResultUpdates.length > 0 || this.#pendingTaskResultMessages.length > 0)
|
|
3991
4064
|
&& !signal?.aborted) {
|
|
3992
4065
|
const asyncTaskWaitTimeoutMs = this.#asyncTaskWaitTimeoutMs();
|
|
3993
4066
|
const deferredTaskIds = [];
|
|
@@ -4047,6 +4120,7 @@ export class Engine {
|
|
|
4047
4120
|
// that completed while we waited may have changed the workspace,
|
|
4048
4121
|
// so it is the same cache synchronization boundary.
|
|
4049
4122
|
invalidateReadOnlyToolReuse();
|
|
4123
|
+
resetProgressEvidence();
|
|
4050
4124
|
for (const update of taskResultUpdatesAfterAsyncWait) {
|
|
4051
4125
|
yield {
|
|
4052
4126
|
type: 'tool_result_update',
|
|
@@ -4064,6 +4138,7 @@ export class Engine {
|
|
|
4064
4138
|
}
|
|
4065
4139
|
const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
4066
4140
|
if (appendedAfterAsyncWait.length > 0) {
|
|
4141
|
+
syncProgressInput(appendedAfterAsyncWait);
|
|
4067
4142
|
for (const item of appendedAfterAsyncWait) {
|
|
4068
4143
|
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
4069
4144
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
@@ -4095,6 +4170,7 @@ export class Engine {
|
|
|
4095
4170
|
if (appendedBeforeClose.length === 0) {
|
|
4096
4171
|
throw new Error('Could not close pending user input for terminal completion');
|
|
4097
4172
|
}
|
|
4173
|
+
syncProgressInput(appendedBeforeClose);
|
|
4098
4174
|
for (const item of appendedBeforeClose) {
|
|
4099
4175
|
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
4100
4176
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
@@ -4216,6 +4292,8 @@ export class Engine {
|
|
|
4216
4292
|
const toolCtx = this.#buildToolContext(signal, {
|
|
4217
4293
|
effortDecision: snapshotEffortDecision(requestEffortDecision),
|
|
4218
4294
|
requestIdentity,
|
|
4295
|
+
queryTurnId,
|
|
4296
|
+
historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
|
|
4219
4297
|
router,
|
|
4220
4298
|
senderVpId,
|
|
4221
4299
|
sessionId: runtimeSessionId,
|
|
@@ -4401,6 +4479,7 @@ export class Engine {
|
|
|
4401
4479
|
if (signal?.aborted) abortedDuringTools = true;
|
|
4402
4480
|
|
|
4403
4481
|
if (!preparedParallelExecution && !toolBatchBarrier && !signal?.aborted
|
|
4482
|
+
&& !queryProgress.blocked(tc)
|
|
4404
4483
|
&& toolAllowedForRequest(tc) && isConcurrencySafeTool(this, tc.name, tc.input)
|
|
4405
4484
|
&& duplicatePolicyForCall(tc) !== 'suppress'
|
|
4406
4485
|
&& !mayMutateWorkspaceAfterReturn(this, tc.name, tc.input)) {
|
|
@@ -4409,7 +4488,7 @@ export class Engine {
|
|
|
4409
4488
|
candidateIndex < toolCalls.length;
|
|
4410
4489
|
candidateIndex += 1) {
|
|
4411
4490
|
const candidate = toolCalls[candidateIndex];
|
|
4412
|
-
if (!toolAllowedForRequest(candidate)
|
|
4491
|
+
if (queryProgress.blocked(candidate) || !toolAllowedForRequest(candidate)
|
|
4413
4492
|
|| !isConcurrencySafeTool(this, candidate.name, candidate.input)
|
|
4414
4493
|
|| duplicatePolicyForCall(candidate) === 'suppress'
|
|
4415
4494
|
|| mayMutateWorkspaceAfterReturn(this, candidate.name, candidate.input)) break;
|
|
@@ -4491,6 +4570,7 @@ export class Engine {
|
|
|
4491
4570
|
&& duplicateCallPolicy === 'suppress'
|
|
4492
4571
|
&& successfulDuplicateCount >= DUP_TOOL_THRESHOLD - 1;
|
|
4493
4572
|
let suppressionCount = queryDuplicateSuppressions.get(duplicateCallKey) || 0;
|
|
4573
|
+
const failureBlock = !readyParallelExecution && !skipped ? queryProgress.blocked(tc) : null;
|
|
4494
4574
|
|
|
4495
4575
|
let output;
|
|
4496
4576
|
let displayImages = [];
|
|
@@ -4570,6 +4650,11 @@ export class Engine {
|
|
|
4570
4650
|
: `Error: unknown tool "${tc.name}"`;
|
|
4571
4651
|
isError = true;
|
|
4572
4652
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
|
|
4653
|
+
} else if (failureBlock) {
|
|
4654
|
+
output = failureBlock;
|
|
4655
|
+
isError = true;
|
|
4656
|
+
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
|
|
4657
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, suppressed: true, threadId: this.currentThreadId };
|
|
4573
4658
|
} else if (needsProjectDocReload) {
|
|
4574
4659
|
projectDocSource = freshProjectDocSource;
|
|
4575
4660
|
projectDocLoadedPathHints = [...new Set([
|
|
@@ -4738,7 +4823,7 @@ export class Engine {
|
|
|
4738
4823
|
])].slice(-128);
|
|
4739
4824
|
}
|
|
4740
4825
|
|
|
4741
|
-
if (!skipped && !duplicateCallSuppressed && !isError && !reusedReadOnlyResult
|
|
4826
|
+
if (!skipped && !duplicateCallSuppressed && !failureBlock && !isError && !reusedReadOnlyResult
|
|
4742
4827
|
&& duplicateCallPolicy !== 'allow') {
|
|
4743
4828
|
const nextDuplicateCount = successfulDuplicateCount + 1;
|
|
4744
4829
|
queryDuplicateCounts.set(duplicateCallKey, nextDuplicateCount);
|
|
@@ -4765,6 +4850,13 @@ export class Engine {
|
|
|
4765
4850
|
`[system note] ${tc.name} rejected the same arguments twice before execution: ${validationError.slice(0, 300)}. Correct the arguments using its schema/error hint or choose a different tool. No operation was performed; repeating unchanged arguments will not help.`,
|
|
4766
4851
|
);
|
|
4767
4852
|
}
|
|
4853
|
+
const repeatedResultCallId = queryProgress.observe({
|
|
4854
|
+
call: tc, output, isError, readOnly: readOnlyTool,
|
|
4855
|
+
executed: !skipped && !duplicateCallSuppressed && !failureBlock && !reusedReadOnlyResult && !readyParallelExecution?.shared && hasTool && !needsProjectDocReload,
|
|
4856
|
+
reused: reusedReadOnlyResult || Boolean(readyParallelExecution?.shared),
|
|
4857
|
+
skipped: skipped || needsProjectDocReload,
|
|
4858
|
+
policy: duplicateCallPolicy,
|
|
4859
|
+
});
|
|
4768
4860
|
const toolDurationMs = readyParallelExecution?.durationMs ?? (Date.now() - toolStartTime);
|
|
4769
4861
|
|
|
4770
4862
|
// feat-6af5f9f1 PR B: emit a structured `tool_exec` event for the
|
|
@@ -4779,7 +4871,7 @@ export class Engine {
|
|
|
4779
4871
|
name: tc.name,
|
|
4780
4872
|
durationMs: toolDurationMs,
|
|
4781
4873
|
isError,
|
|
4782
|
-
suppressed: duplicateCallSuppressed,
|
|
4874
|
+
suppressed: duplicateCallSuppressed || Boolean(failureBlock),
|
|
4783
4875
|
toolOutput: output,
|
|
4784
4876
|
...(reusedReadOnlyResult ? { reused: true, reusedCallId: reusedReadOnlyCallId } : {}),
|
|
4785
4877
|
...(skipped ? { skipped: true } : {}),
|
|
@@ -4789,7 +4881,7 @@ export class Engine {
|
|
|
4789
4881
|
// 2026-05-13: feed the per-tool counters. Stays best-effort — a
|
|
4790
4882
|
// stats sink that throws shouldn't crash the engine. `record`
|
|
4791
4883
|
// already swallows internal write errors.
|
|
4792
|
-
if (!skipped && !duplicateCallSuppressed
|
|
4884
|
+
if (!skipped && !duplicateCallSuppressed && !failureBlock && !reusedReadOnlyResult && !readyParallelExecution?.shared
|
|
4793
4885
|
&& this.#toolStats && typeof this.#toolStats.record === 'function') {
|
|
4794
4886
|
try {
|
|
4795
4887
|
this.#toolStats.record({
|
|
@@ -4815,7 +4907,7 @@ export class Engine {
|
|
|
4815
4907
|
reusedCallId: reusedReadOnlyCallId,
|
|
4816
4908
|
});
|
|
4817
4909
|
|
|
4818
|
-
if (!skipped && !duplicateCallSuppressed && !reusedReadOnlyResult
|
|
4910
|
+
if (!skipped && !duplicateCallSuppressed && !failureBlock && !reusedReadOnlyResult && !isError
|
|
4819
4911
|
&& !readOnlyToolReuseDisabled && cacheableTool) {
|
|
4820
4912
|
readOnlyToolResults.set(duplicateKey, {
|
|
4821
4913
|
output,
|
|
@@ -4828,7 +4920,10 @@ export class Engine {
|
|
|
4828
4920
|
// Append only the bounded copy to the model message history. Raw
|
|
4829
4921
|
// `output` is still used for debug traces, UI events, exec-log, and
|
|
4830
4922
|
// persistence so large tool results are not lost outside context.
|
|
4831
|
-
const
|
|
4923
|
+
const modelOutput = repeatedResultCallId && !isError && modelImages.length === 0
|
|
4924
|
+
? `[Unchanged read result: already returned by call ${repeatedResultCallId}; reuse it instead of reading again.]\n${output}`
|
|
4925
|
+
: output;
|
|
4926
|
+
const contextOutput = truncateToolResultIfNeeded(modelOutput, {
|
|
4832
4927
|
toolName: tc.name,
|
|
4833
4928
|
language: this.#config?.language,
|
|
4834
4929
|
});
|
|
@@ -4862,7 +4957,7 @@ export class Engine {
|
|
|
4862
4957
|
// user-conversation turn), not the inner loop's turnNumber.
|
|
4863
4958
|
// Aligns exec-log layout with dup detection lookback and the
|
|
4864
4959
|
// T2 fallback-stub readTurn() call below.
|
|
4865
|
-
if (!skipped && !duplicateCallSuppressed) {
|
|
4960
|
+
if (!skipped && !duplicateCallSuppressed && !failureBlock && !reusedReadOnlyResult && !readyParallelExecution?.shared) {
|
|
4866
4961
|
this.#execLog.append(queryNumber, buildExecLogEntry({
|
|
4867
4962
|
loopIdx: queryToolCount,
|
|
4868
4963
|
toolName: tc.name,
|
|
@@ -4935,6 +5030,13 @@ export class Engine {
|
|
|
4935
5030
|
conversationMessages.push({ role: 'user', content: reminder });
|
|
4936
5031
|
}
|
|
4937
5032
|
if (pendingDupReminders.length > 0) duplicateReminderAwaitingResponse = true;
|
|
5033
|
+
const progressAction = queryProgress.finishBatch();
|
|
5034
|
+
yield queryProgress.snapshot(progressIdentity);
|
|
5035
|
+
if (progressAction && !signal?.aborted && !endTurnRequested) {
|
|
5036
|
+
const reason = queryProgress.snapshot().reason;
|
|
5037
|
+
conversationMessages.push({ role: 'user', content: progressReminder(progressAction, reason) });
|
|
5038
|
+
if (progressAction === 'report') progressReportPending = true;
|
|
5039
|
+
}
|
|
4938
5040
|
if (terminateAfterDuplicateBatch) {
|
|
4939
5041
|
yield {
|
|
4940
5042
|
type: 'error',
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-local loop evidence. Activity is not completion: only changed observations
|
|
3
|
+
* (including new negative reads) count as evidence. No transcript, arguments or
|
|
4
|
+
* result bodies escape this object. Polling/waiting is neutral, not stagnation or progress.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { argsHashOf } from './tool-folding/exec-log.js';
|
|
8
|
+
|
|
9
|
+
const MAX_ENTRIES = 1024;
|
|
10
|
+
const WARNING_BATCHES = 4;
|
|
11
|
+
const RECOVERY_BATCHES = 8;
|
|
12
|
+
const REPORT_BATCHES = 12;
|
|
13
|
+
const FAILURE_LIMIT = 3;
|
|
14
|
+
const NEUTRAL_TOOLS = new Set([
|
|
15
|
+
'AskUser', 'WaitAgent', 'ListAgents', 'WaitTask', 'ListTasks', 'ReadTaskLog',
|
|
16
|
+
'PromptAgent', 'UpdateAgent', 'CloseAgent', 'PresentResponse',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function boundedSet(map, key, value) {
|
|
20
|
+
map.delete(key);
|
|
21
|
+
map.set(key, value);
|
|
22
|
+
if (map.size > MAX_ENTRIES) map.delete(map.keys().next().value);
|
|
23
|
+
}
|
|
24
|
+
function resultHash(output) {
|
|
25
|
+
return createHash('sha256').update(typeof output === 'string' ? output : JSON.stringify(output) || '').digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function failureKind(output, call, readOnly) {
|
|
29
|
+
const text = typeof output === 'string' ? output : JSON.stringify(output) || '';
|
|
30
|
+
const errorText = text.replace(/^Error:\s*/, '');
|
|
31
|
+
let contract;
|
|
32
|
+
// MCP and legacy tools throw JSON envelopes; Engine prefixes their messages
|
|
33
|
+
// with "Error: ". Preserve that contract for classification without changing
|
|
34
|
+
// the raw persisted/UI tool output.
|
|
35
|
+
try { contract = typeof output === 'string' ? JSON.parse(errorText) : output; } catch { /* legacy text errors */ }
|
|
36
|
+
// Confirmed nonzero exit is not rollback evidence. It is, however, a finished
|
|
37
|
+
// command that can be revalidated after an edit or new input, unlike a timeout
|
|
38
|
+
// or an operation whose lifecycle is still unknown.
|
|
39
|
+
if (call.name === 'Bash' && contract?.code === 'bash_exit_nonzero'
|
|
40
|
+
&& contract.failureType === 'exit_nonzero' && contract.terminationConfirmed === true
|
|
41
|
+
&& contract.timedOut === false && Number.isInteger(contract.exitCode)) return 'terminated_command';
|
|
42
|
+
if (contract?.errorEffect === 'unknown' || contract?.replaySafe === false) return 'uncertain';
|
|
43
|
+
// Match the registry's explicit pre-execution validation contract, not words
|
|
44
|
+
// in arbitrary messages or file paths such as /schema/validation/*.json.
|
|
45
|
+
if (contract?.code === 'invalid_arguments' && contract.errorEffect === 'none') return 'validation';
|
|
46
|
+
// A dispatched mutation without an explicit no-effect contract is unsafe to
|
|
47
|
+
// replay regardless of whether its message happens to mention a timeout.
|
|
48
|
+
if (!readOnly && contract?.errorEffect !== 'none') return 'uncertain';
|
|
49
|
+
const message = typeof contract?.error === 'string' ? contract.error : errorText;
|
|
50
|
+
if (['EPERM', 'EACCES'].includes(contract?.code)
|
|
51
|
+
|| /^(?:Failed to read file:\s*)?(?:EPERM\b|EACCES\b|permission denied\b|access denied\b|forbidden\b)/i.test(message)) return 'permission';
|
|
52
|
+
// Known negative filesystem observations contain paths, not lifecycle data.
|
|
53
|
+
// Names such as /timeout/schema/ are domain evidence, never timeout signals.
|
|
54
|
+
if (readOnly && (/^(?:File not found:|ENOENT\b|Path is a directory:|Cannot read binary file:|File too large:)/i.test(message)
|
|
55
|
+
|| contract?.code === 'ENOENT')) return resultHash(output);
|
|
56
|
+
if (/timed?\s*out|timeout|ECONNRESET|disconnect|socket hang up|connection closed/i.test(message)) return 'uncertain';
|
|
57
|
+
return resultHash(output);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class QueryProgress {
|
|
61
|
+
#results = new Map();
|
|
62
|
+
#mutations = new Map();
|
|
63
|
+
#failures = new Map();
|
|
64
|
+
#batch = { evidence: false, repeated: false, failed: false };
|
|
65
|
+
#revision = 0;
|
|
66
|
+
#state = {
|
|
67
|
+
modelRequests: 0, toolExecutions: 0, reusedCalls: 0, repeatedCalls: 0,
|
|
68
|
+
failedCalls: 0, stage: 'running', reason: null, lastEvidenceAt: null, stagnantBatches: 0,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
requestStarted() { this.#state.modelRequests += 1; }
|
|
72
|
+
|
|
73
|
+
/** Caller feedback/new owned task results may invalidate prior observations. */
|
|
74
|
+
resetEvidence() {
|
|
75
|
+
this.#results.clear();
|
|
76
|
+
this.#mutations.clear();
|
|
77
|
+
// New instructions/results do not prove a timed-out mutation was rolled back.
|
|
78
|
+
for (const [key, failure] of this.#failures) {
|
|
79
|
+
if (failure.readOnly || failure.kind !== 'uncertain') this.#failures.delete(key);
|
|
80
|
+
}
|
|
81
|
+
this.#state.stagnantBatches = 0;
|
|
82
|
+
this.#state.stage = 'running';
|
|
83
|
+
this.#state.reason = null;
|
|
84
|
+
this.#batch = { evidence: false, repeated: false, failed: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Limit unchanged failures; never automatically replay an uncertain mutation. */
|
|
88
|
+
blocked(call) {
|
|
89
|
+
const failure = this.#failures.get(`${call.name}:${argsHashOf(call.input)}`);
|
|
90
|
+
// A possibly committed mutation must be verified before even the first replay.
|
|
91
|
+
if (!failure || (failure.count < FAILURE_LIMIT
|
|
92
|
+
&& !(['uncertain', 'terminated_command'].includes(failure.kind) && !failure.readOnly))) return null;
|
|
93
|
+
return `Blocked unchanged failed call to ${call.name} after ${failure.count} equivalent failures. This call was NOT executed. Use the earlier error to correct the arguments or choose a different supported path. If an earlier operation timed out or disconnected, its side effects may already have happened: verify the target state before retrying. Otherwise report the concrete blocker. Previous failed call: ${failure.callId}.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
observe({ call, output, isError = false, readOnly = false, executed = true, reused = false, skipped = false, policy = 'warn' }) {
|
|
97
|
+
if (skipped) return;
|
|
98
|
+
if (executed) this.#state.toolExecutions += 1;
|
|
99
|
+
if (reused) this.#state.reusedCalls += 1;
|
|
100
|
+
if (isError) this.#state.failedCalls += 1;
|
|
101
|
+
const key = `${call.name}:${argsHashOf(call.input)}`;
|
|
102
|
+
const hash = resultHash(output);
|
|
103
|
+
if (isError) {
|
|
104
|
+
const prior = this.#failures.get(key);
|
|
105
|
+
const kind = failureKind(output, call, readOnly);
|
|
106
|
+
if (executed && !readOnly) {
|
|
107
|
+
// A partial mutation may have changed even a previously unreadable
|
|
108
|
+
// resource. Permit verification, never a replay of the mutation itself.
|
|
109
|
+
this.#results.clear();
|
|
110
|
+
for (const [failedKey, failure] of this.#failures) {
|
|
111
|
+
if (failure.readOnly) this.#failures.delete(failedKey);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// A blocked result is synthetic; do not replace the original failure.
|
|
115
|
+
if (executed) boundedSet(this.#failures, key, {
|
|
116
|
+
kind, readOnly, count: prior?.kind === kind ? prior.count + 1 : 1, callId: call.id,
|
|
117
|
+
});
|
|
118
|
+
const repeated = !executed || prior?.kind === kind;
|
|
119
|
+
// A first negative observation for a distinct read narrows the search.
|
|
120
|
+
// Validation/permission failures are blockers, not domain evidence.
|
|
121
|
+
if (readOnly && !repeated && !['validation', 'permission', 'uncertain'].includes(kind)) {
|
|
122
|
+
this.#batch.evidence = true;
|
|
123
|
+
this.#state.lastEvidenceAt = Date.now();
|
|
124
|
+
} else {
|
|
125
|
+
this.#batch.failed = true;
|
|
126
|
+
}
|
|
127
|
+
if (repeated) {
|
|
128
|
+
this.#batch.repeated = true;
|
|
129
|
+
this.#state.repeatedCalls += 1;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.#failures.delete(key);
|
|
134
|
+
if (NEUTRAL_TOOLS.has(call.name) || policy === 'allow') return;
|
|
135
|
+
if (!readOnly && executed && !reused) {
|
|
136
|
+
// New mutations invalidate read observations, so edit/verify loops remain
|
|
137
|
+
// useful. An identical mutation/acknowledgement is not itself evidence
|
|
138
|
+
// of another change; never cache or replay it, merely count repetition.
|
|
139
|
+
if (this.#mutations.get(key) === hash) {
|
|
140
|
+
this.#batch.repeated = true;
|
|
141
|
+
this.#state.repeatedCalls += 1;
|
|
142
|
+
} else {
|
|
143
|
+
boundedSet(this.#mutations, key, hash);
|
|
144
|
+
this.#results.clear();
|
|
145
|
+
// Reads and confirmed, finished commands may now succeed after an
|
|
146
|
+
// edit. Unknown/timeout side effects still require explicit verification.
|
|
147
|
+
for (const [failedKey, failure] of this.#failures) {
|
|
148
|
+
if (failure.readOnly || failure.kind === 'terminated_command') this.#failures.delete(failedKey);
|
|
149
|
+
}
|
|
150
|
+
this.#batch.evidence = true;
|
|
151
|
+
this.#state.lastEvidenceAt = Date.now();
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Different ranges/pages are independent observations. A changed result for
|
|
156
|
+
// the same call is fresh evidence even when it returns to an older value.
|
|
157
|
+
const prior = this.#results.get(key);
|
|
158
|
+
if (prior?.hash === hash || reused || !executed) {
|
|
159
|
+
this.#state.repeatedCalls += 1;
|
|
160
|
+
this.#batch.repeated = true;
|
|
161
|
+
return readOnly && prior?.hash === hash ? prior.callId : null;
|
|
162
|
+
} else {
|
|
163
|
+
boundedSet(this.#results, key, { hash, callId: call.id });
|
|
164
|
+
this.#batch.evidence = true;
|
|
165
|
+
this.#state.lastEvidenceAt = Date.now();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Called only AFTER every tool result is paired and persisted. */
|
|
170
|
+
finishBatch() {
|
|
171
|
+
const previousStage = this.#state.stage;
|
|
172
|
+
if (this.#batch.evidence) {
|
|
173
|
+
this.#state.stagnantBatches = 0;
|
|
174
|
+
this.#state.stage = 'running';
|
|
175
|
+
this.#state.reason = null;
|
|
176
|
+
} else if (this.#batch.repeated || this.#batch.failed) {
|
|
177
|
+
this.#state.stagnantBatches += 1;
|
|
178
|
+
this.#state.reason = this.#batch.failed ? 'repeated_failure' : 'repeated_results';
|
|
179
|
+
const count = this.#state.stagnantBatches;
|
|
180
|
+
this.#state.stage = count >= REPORT_BATCHES ? 'stopped'
|
|
181
|
+
: count >= RECOVERY_BATCHES ? 'recovering'
|
|
182
|
+
: count >= WARNING_BATCHES ? 'warning' : 'running';
|
|
183
|
+
}
|
|
184
|
+
this.#batch = { evidence: false, repeated: false, failed: false };
|
|
185
|
+
if (previousStage === this.#state.stage || this.#state.stage === 'running') return null;
|
|
186
|
+
if (this.#state.stage === 'stopped') return 'report';
|
|
187
|
+
return this.#state.stage;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
snapshot(identity = {}) {
|
|
191
|
+
return { type: 'query_progress', ...identity, revision: ++this.#revision, ...this.#state };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function progressReminder(action, reason) {
|
|
196
|
+
const failureHint = reason === 'repeated_failure'
|
|
197
|
+
? 'The same execution paths are failing. A timeout does not prove an operation did not happen; verify state before retrying any side effect.'
|
|
198
|
+
: 'Recent tool batches repeat results already available; re-reading or restating a plan is not new evidence.';
|
|
199
|
+
if (action === 'report') return `[system note] Investigation stopped after repeated non-progress batches despite recovery guidance. ${failureHint} Do not claim the user task is complete. Using only existing evidence, give a concise handoff: completed artifacts, unfinished requirements, concrete blocker, and a safe next step. No further tools are available for this one reporting response.`;
|
|
200
|
+
return `[system note] Query progress ${action === 'recovering' ? 'recovery required' : 'warning'}: ${failureHint} Reuse prior results. Execute a concrete next step toward the requested deliverable, obtain genuinely missing evidence, or explain the blocker. Do not spawn another review of the same baseline or search for your own promises. Long-running useful work and legitimate waits remain allowed.`;
|
|
201
|
+
}
|
|
@@ -11,6 +11,9 @@ import { snapshotEffortDecision } from '../effort.js';
|
|
|
11
11
|
* @param {{ projectSessionIds?: string[], projectLabel?: string, projectInstruction?: string }} projectContext
|
|
12
12
|
*/
|
|
13
13
|
export function enqueueSubAgentPrompt(agent, prompt, projectContext = {}) {
|
|
14
|
+
// Reserve the original delegation identity, but stop reusing its mutable
|
|
15
|
+
// evidence as soon as follow-up work is accepted (even before it runs).
|
|
16
|
+
agent.delegationSuperseded = true;
|
|
14
17
|
if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
|
|
15
18
|
agent.pendingPrompts.push({
|
|
16
19
|
prompt,
|