@yeaft/webchat-agent 1.0.634 → 1.0.636

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.634",
3
+ "version": "1.0.636",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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: msg.sessionId || source.sessionId || null,
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: invalidateReadOnlyToolReuse,
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
- if (executionPolicy?.finalize) toolDefs = [];
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
- if (executionPolicy?.finalize) {
3918
- yield { type: 'turn_end', turnNumber, stopReason: 'budget_report', threadId, terminal: true };
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 contextOutput = truncateToolResultIfNeeded(output, {
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,