@yeaft/webchat-agent 1.0.350 → 1.0.352

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.
Files changed (45) hide show
  1. package/connection/message-router.js +29 -1
  2. package/index.js +2 -0
  3. package/local-runtime/server/handlers/agent-sync.js +14 -0
  4. package/local-runtime/server/handlers/client-misc.js +21 -0
  5. package/local-runtime/version.json +1 -1
  6. package/local-runtime/web/app.bundle.js +110 -97
  7. package/local-runtime/web/app.bundle.js.gz +0 -0
  8. package/local-runtime/web/index.html +2 -2
  9. package/local-runtime/web/style.bundle.css +1 -1
  10. package/local-runtime/web/style.bundle.css.gz +0 -0
  11. package/package.json +1 -1
  12. package/yeaft/config-api.js +53 -1
  13. package/yeaft/config.js +54 -0
  14. package/yeaft/conversation/history-index-worker.js +13 -10
  15. package/yeaft/conversation/internal-control.js +1 -0
  16. package/yeaft/debug-trace.js +164 -47
  17. package/yeaft/engine.js +318 -28
  18. package/yeaft/llm/adapter.js +38 -0
  19. package/yeaft/llm/anthropic.js +11 -8
  20. package/yeaft/llm/openai-responses.js +11 -8
  21. package/yeaft/llm/router.js +1 -1
  22. package/yeaft/perf-trace.js +156 -24
  23. package/yeaft/session.js +7 -0
  24. package/yeaft/sessions/session-crud.js +19 -4
  25. package/yeaft/sub-agent/runner.js +4 -0
  26. package/yeaft/tools/agent.js +4 -0
  27. package/yeaft/tools/ask-user.js +1 -0
  28. package/yeaft/tools/bash.js +4 -0
  29. package/yeaft/tools/create-work-item.js +3 -0
  30. package/yeaft/tools/file-read.js +1 -0
  31. package/yeaft/tools/glob.js +1 -0
  32. package/yeaft/tools/grep.js +1 -0
  33. package/yeaft/tools/history-search.js +74 -20
  34. package/yeaft/tools/js-repl.js +1 -0
  35. package/yeaft/tools/list-agents.js +1 -0
  36. package/yeaft/tools/list-dir.js +1 -0
  37. package/yeaft/tools/list-tasks.js +1 -0
  38. package/yeaft/tools/read-task-log.js +1 -0
  39. package/yeaft/tools/route-forward.js +4 -0
  40. package/yeaft/tools/send-message.js +3 -0
  41. package/yeaft/tools/types.js +8 -0
  42. package/yeaft/tools/wait-agent.js +1 -0
  43. package/yeaft/utf8.js +44 -0
  44. package/yeaft/web-bridge.js +6 -0
  45. package/yeaft/work-center/runner.js +1 -0
package/yeaft/engine.js CHANGED
@@ -34,7 +34,7 @@ import { isVpForeign, readContent as readScopeContent } from './memory/store.js'
34
34
  import { ActiveMemorySet } from './memory/ams.js';
35
35
  import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
36
36
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
37
- import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
37
+ import { boundRawExchange, perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
38
38
  // Default thread marker for legacy / non-group flows. Group VP runtime may
39
39
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
40
40
  const MAIN_THREAD_ID = 'main';
@@ -89,6 +89,39 @@ const MAX_PROMPT_MEMORY_ITEMS = 8;
89
89
  const MAX_RELATED_SESSION_MEMORY_ITEMS = 2;
90
90
  const MAX_MEMORY_ITEM_TOKENS = 1600;
91
91
 
92
+ function toolDefinitionFor(engine, name) {
93
+ return engine.getToolDefinition(name);
94
+ }
95
+
96
+ function isReadOnlyTool(engine, name, input) {
97
+ try {
98
+ return toolDefinitionFor(engine, name)?.isReadOnly?.(input) === true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function isCacheableTool(engine, name, input) {
105
+ try {
106
+ const tool = toolDefinitionFor(engine, name);
107
+ if (!tool || !isReadOnlyTool(engine, name, input)) return false;
108
+ return typeof tool.cacheWithinQuery === 'function'
109
+ ? tool.cacheWithinQuery(input) === true
110
+ : tool.cacheWithinQuery === true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function mayMutateWorkspaceAfterReturn(engine, name, input) {
117
+ try {
118
+ const value = toolDefinitionFor(engine, name)?.mayMutateWorkspaceAfterReturn;
119
+ return typeof value === 'function' ? value(input) === true : value === true;
120
+ } catch {
121
+ return true;
122
+ }
123
+ }
124
+
92
125
  // ─── LLM retry policy defaults ──────────────────────────────────
93
126
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
94
127
  // effective policy from `config.llmRetry` so users can dial these via
@@ -229,11 +262,9 @@ function sleepWithAbort(ms, signal) {
229
262
  * - `toolCalls` on assistant turns (the LLM's function_call requests)
230
263
  * - `toolCallId` + `isError` on tool turns (the paired tool_result)
231
264
  *
232
- * Content is passed through verbatim never truncated. Debug traces must
233
- * mirror exactly what we sent to the LLM; a truncated copy is misleading.
234
- * If the resulting payload is too large for the client debug store the
235
- * bound is per-loop-count (see `MAX_YEAFT_DEBUG_LOOPS` in
236
- * `web/stores/chat.js`), not per-payload mutilation here.
265
+ * Content is kept intact for the live protocol. The file-backed debug trace
266
+ * applies its own configured byte budget at persistence time so the model
267
+ * request path never pays an extra copy just for diagnostics.
237
268
  *
238
269
  * Pure function — no side effects on the input message.
239
270
  *
@@ -243,6 +274,7 @@ function sleepWithAbort(ms, signal) {
243
274
  export function mapDebugMessage(m) {
244
275
  const out = { role: m.role };
245
276
  out.content = m.content;
277
+ if (m.rawRequest != null) out.rawRequest = m.rawRequest;
246
278
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
247
279
  out.toolCalls = m.toolCalls.map(tc => ({
248
280
  id: tc.id,
@@ -649,6 +681,18 @@ export class Engine {
649
681
  /** @type {string} */
650
682
  #currentThreadId = MAIN_THREAD_ID;
651
683
 
684
+ /** Wire turn id of the active query, used by late async completion rows. */
685
+ #currentQueryTurnId = null;
686
+
687
+ /**
688
+ * Identity-bound hook into the active query's local read-only result cache.
689
+ * Async task terminal events can arrive while an adapter stream is already
690
+ * producing tool calls, so they must invalidate reuse immediately rather
691
+ * than waiting for the next queue-drain boundary.
692
+ * @type {{ owner: AbortController, invalidate: () => void }|null}
693
+ */
694
+ #activeReadOnlyToolReuse = null;
695
+
652
696
  /** @type {Array<{content:string|Array, preview:string}>} */
653
697
  #pendingUserMessages = [];
654
698
 
@@ -679,7 +723,7 @@ export class Engine {
679
723
  /**
680
724
  * Terminal async task results that should be appended to the original
681
725
  * tool_result message instead of injected as a synthetic user prompt.
682
- * @type {Array<{taskId:string, toolCallId:string, content:string|Array, preview:string}>}
726
+ * @type {Array<{taskId:string, toolCallId:string, toolName?:string, content:string|Array, preview:string}>}
683
727
  */
684
728
  #pendingTaskResultUpdates = [];
685
729
 
@@ -705,7 +749,7 @@ export class Engine {
705
749
  * Async task ownership metadata captured when a tool registers a
706
750
  * background task. Keyed by taskId so terminal events can update the
707
751
  * original tool_result instead of fabricating a separate turn.
708
- * @type {Map<string, { toolCallId?: string, toolName?: string, threadId?: string }>}
752
+ * @type {Map<string, { toolCallId?: string, toolName?: string, threadId?: string, sessionId?: string, vpId?: string, turnId?: string }>}
709
753
  */
710
754
  #asyncTaskToolMeta = new Map();
711
755
 
@@ -1467,6 +1511,8 @@ export class Engine {
1467
1511
  }
1468
1512
 
1469
1513
  #conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null, executionOrigin = null } = {}) {
1514
+ const effectiveTurnId = turnId || message.turnId || null;
1515
+ const effectiveVpId = message.speakerVpId || this.#vpId || null;
1470
1516
  const record = {
1471
1517
  role: message.role,
1472
1518
  content: typeof message.content === 'string'
@@ -1483,6 +1529,7 @@ export class Engine {
1483
1529
  if (message.isError) record.isError = true;
1484
1530
  if (message.imageAssetAnchor) record.imageAssetAnchor = true;
1485
1531
  if (message._reflection) record._reflection = true;
1532
+ if (message._asyncTaskCompletion === true) record._asyncTaskCompletion = true;
1486
1533
  if (message.role === 'user') record.userAuthored = message.userAuthored === true;
1487
1534
  if (message.internal === true) record.internal = true;
1488
1535
  if (message.responseKind === 'progress' || message.responseKind === 'result') {
@@ -1491,11 +1538,15 @@ export class Engine {
1491
1538
  if (Array.isArray(message.foldedMessageIds) && message.foldedMessageIds.length > 0) {
1492
1539
  record.foldedMessageIds = [...message.foldedMessageIds];
1493
1540
  }
1494
- if (turnId && (message.role === 'assistant' || message.role === 'tool')) record.turnId = turnId;
1541
+ if (effectiveTurnId && (message.role === 'assistant' || message.role === 'tool' || message.internal === true)) {
1542
+ record.turnId = effectiveTurnId;
1543
+ }
1495
1544
  if (executionOrigin === 'route_forward' && (message.role === 'assistant' || message.role === 'tool')) {
1496
1545
  record.executionOrigin = executionOrigin;
1497
1546
  }
1498
- if (this.#vpId && (message.role === 'assistant' || message.role === 'tool')) record.speakerVpId = this.#vpId;
1547
+ if (effectiveVpId && (message.role === 'assistant' || message.role === 'tool' || message.internal === true)) {
1548
+ record.speakerVpId = effectiveVpId;
1549
+ }
1499
1550
  if (incomplete) record.incomplete = true;
1500
1551
  if (stopReason) record.stopReason = stopReason;
1501
1552
  return record;
@@ -1744,22 +1795,53 @@ export class Engine {
1744
1795
  if (!update?.toolCallId) continue;
1745
1796
  const appendText = this.#formatTaskResultUpdateContent(update.content);
1746
1797
  if (!appendText.trim()) continue;
1747
- const toolMsg = [...conversationMessages].reverse().find((msg) => (
1748
- msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
1749
- ));
1798
+ const toolMsg = update.folded
1799
+ ? null
1800
+ : [...conversationMessages].reverse().find((msg) => (
1801
+ msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
1802
+ ));
1750
1803
  if (!toolMsg) {
1751
- this.#pendingTaskResultMessages.push({
1752
- content: update.content,
1753
- preview: update.preview,
1804
+ // T1/T2 may have folded the original tool row before this task
1805
+ // completed. The reflection is the canonical history, so a late
1806
+ // completion must not recreate the hidden tool arc on disk or in the
1807
+ // provider transcript. This is engine control context, not a fresh
1808
+ // user-authored message; ConversationStore keeps it off the visible
1809
+ // transcript while retaining it for the next provider boundary.
1810
+ const contextContent = truncateToolResultIfNeeded(appendText, {
1811
+ toolName: update.toolName || 'async task result',
1812
+ language: this.#config?.language,
1813
+ });
1814
+ const continuation = {
1815
+ role: 'user',
1816
+ content: `[system note] Async task completion for ${update.toolName || 'a folded tool call'}:\n${contextContent}`,
1754
1817
  internal: true,
1755
- taskId: update.taskId,
1818
+ _asyncTaskCompletion: true,
1819
+ turnId: update.turnId || null,
1820
+ speakerVpId: update.vpId || null,
1821
+ };
1822
+ const persistedContinuation = this.#persistConversationMessage(continuation, {
1823
+ sessionId: update.sessionId || this.#sessionId,
1824
+ turnId: update.turnId || null,
1756
1825
  });
1826
+ if (persistedContinuation?.id) continuation._persistedMessageId = persistedContinuation.id;
1827
+ conversationMessages.push(continuation);
1828
+ applied.push(update);
1829
+ if (this.#acceptedAsyncTaskResults.has(update.taskId)) {
1830
+ this.#pendingAsyncTaskConfirmIds.add(update.taskId);
1831
+ }
1757
1832
  continue;
1758
1833
  }
1759
1834
  const prior = typeof toolMsg.content === 'string'
1760
1835
  ? toolMsg.content
1761
1836
  : this.#formatTaskResultUpdateContent(toolMsg.content);
1762
- toolMsg.content = `${prior}\n\n${appendText}`;
1837
+ // Keep the durable row complete below, but apply the same per-tool
1838
+ // context cap used for the initial result before the next provider
1839
+ // request. Async completions otherwise bypassed the model-facing tool
1840
+ // result budget after their producing call had already returned.
1841
+ toolMsg.content = truncateToolResultIfNeeded(`${prior}\n\n${appendText}`, {
1842
+ toolName: update.toolName || 'async task result',
1843
+ language: this.#config?.language,
1844
+ });
1763
1845
  const persistedTool = this.#persistedToolMessages.get(update.toolCallId);
1764
1846
  if (persistedTool && typeof this.#conversationStore?.update === 'function') {
1765
1847
  const durablePrior = typeof persistedTool.content === 'string'
@@ -2027,10 +2109,16 @@ export class Engine {
2027
2109
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
2028
2110
  }
2029
2111
  this.retireAsyncTasks(abortCtrl.signal.aborted ? 'query_aborted' : 'query_closed');
2112
+ // The closure is local to this query. Do not let a late callback from a
2113
+ // retired run touch a later query's independent read cache.
2114
+ if (this.#activeReadOnlyToolReuse?.owner === abortCtrl) {
2115
+ this.#activeReadOnlyToolReuse = null;
2116
+ }
2030
2117
  // Clear current-run state so engine.isRunning flips back to false
2031
2118
  // and a subsequent query() starts with a clean slate.
2032
2119
  this.#currentAbortCtrl = null;
2033
2120
  this.#abortReason = null;
2121
+ this.#currentQueryTurnId = null;
2034
2122
  this.#currentThreadId = MAIN_THREAD_ID;
2035
2123
  this.#pendingUserMessages.length = 0;
2036
2124
  this.#externalUserWakePending = false;
@@ -2071,12 +2159,39 @@ export class Engine {
2071
2159
  ? 'route_forward'
2072
2160
  : null;
2073
2161
  const queryTurnId = randomUUID();
2162
+ this.#currentQueryTurnId = vpTurnId || queryTurnId;
2163
+ // Bind the live query scope before tools can register async work. The
2164
+ // constructor's Session id is only guaranteed for bridge-owned engines;
2165
+ // standalone/CLI callers pass it per query.
2166
+ this.#sessionId = runtimeSessionId || null;
2167
+ this.#currentThreadId = runtimeThreadId;
2074
2168
  const queryStartedAt = Date.now();
2075
2169
  const userQuestionPreview = String(prompt || '').slice(0, 200);
2076
2170
  const queryVpId = vpPersona && typeof vpPersona === 'object'
2077
2171
  && typeof vpPersona.vpId === 'string'
2078
2172
  ? vpPersona.vpId
2079
2173
  : (typeof senderVpId === 'string' ? senderVpId : null);
2174
+ // Exact read-only tool results are safe to reuse within one query only
2175
+ // when no intervening mutation can have changed the workspace. The map is
2176
+ // intentionally local to this query; cross-turn reuse belongs to the
2177
+ // persistent tool log and must not silently bypass new user work. A
2178
+ // detached operation can mutate after its tool call returns, so it disables
2179
+ // reuse for the remainder of this query rather than leaving a timing window
2180
+ // for stale entries to be repopulated.
2181
+ const readOnlyToolResults = new Map();
2182
+ let readOnlyToolReuseDisabled = false;
2183
+ const invalidateReadOnlyToolReuse = () => {
2184
+ readOnlyToolResults.clear();
2185
+ readOnlyToolReuseDisabled = true;
2186
+ };
2187
+ // Completion callbacks run outside this lexical loop. Publish an
2188
+ // identity-bound hook so an accepted completion can close the reuse window
2189
+ // even after the next provider stream has started.
2190
+ const readOnlyToolReuseOwner = this.#currentAbortCtrl;
2191
+ this.#activeReadOnlyToolReuse = {
2192
+ owner: readOnlyToolReuseOwner,
2193
+ invalidate: invalidateReadOnlyToolReuse,
2194
+ };
2080
2195
 
2081
2196
  // Durability boundary: a valid user turn must exist on disk before any
2082
2197
  // memory pre-flow or provider request can fail. The Web Session bridge
@@ -2485,6 +2600,12 @@ export class Engine {
2485
2600
  // control to other VPs cleanly. Reset to null at the top of every
2486
2601
  // outer-loop iteration so the flag never carries across turns.
2487
2602
  let endTurnRequested = null;
2603
+ // StartPlan is a control tool. If the model emits only the checklist after
2604
+ // it, there is no new workspace fact to interpret: persist the plan and
2605
+ // close the turn instead of spending another provider request on a
2606
+ // TodoWrite-only control round. A later user turn can continue the first
2607
+ // pending step; a batch that includes a real work tool always continues.
2608
+ let planBootstrapPending = false;
2488
2609
 
2489
2610
  // LLM retry bookkeeping (rate-limit / 5xx / transient network errors).
2490
2611
  // Counts CONSECUTIVE retryable failures on the same turn — reset to 0
@@ -2574,12 +2695,17 @@ export class Engine {
2574
2695
  const thinkingBlocks = []; // task-327d: collected from adapter for round-trip
2575
2696
  let stopReason = 'end_turn';
2576
2697
  const totalUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 };
2577
- // task-344: capture redacted raw request / raw response for debug panel.
2698
+ // task-344: capture bounded raw request / raw response for the debug
2699
+ // panel. Both exchanges obey the live telemetry budget before they
2700
+ // reach durable debug trace storage.
2578
2701
  let rawRequest = null;
2579
2702
  let rawResponse = null;
2703
+ const rawExchangeMaxBytes = Number.isFinite(Number(this.#config?.telemetry?.rawExchangeMaxBytes))
2704
+ ? Math.max(0, Number(this.#config.telemetry.rawExchangeMaxBytes))
2705
+ : 512 * 1024;
2580
2706
  const captureRawExchange = (exchange) => {
2581
- if (exchange?.rawRequest) rawRequest = exchange.rawRequest;
2582
- if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
2707
+ if (exchange?.rawRequest) rawRequest = boundRawExchange(exchange.rawRequest, rawExchangeMaxBytes);
2708
+ if (exchange?.rawResponse) rawResponse = boundRawExchange(exchange.rawResponse, rawExchangeMaxBytes);
2583
2709
  };
2584
2710
 
2585
2711
  // task-704b: resolve the live model's context window for this turn.
@@ -2611,6 +2737,12 @@ export class Engine {
2611
2737
  }
2612
2738
  }
2613
2739
  const taskResultUpdatesBeforeStream = this.#drainPendingTaskResultUpdates(conversationMessages);
2740
+ if (taskResultUpdatesBeforeStream.length > 0) {
2741
+ // A completed sub-agent can have mutated the shared workspace while
2742
+ // this query was parked. Its task-result update is the authoritative
2743
+ // synchronization point before the next provider loop.
2744
+ invalidateReadOnlyToolReuse();
2745
+ }
2614
2746
  if (taskResultUpdatesBeforeStream.length > 0) {
2615
2747
  for (const update of taskResultUpdatesBeforeStream) {
2616
2748
  yield {
@@ -2784,6 +2916,7 @@ export class Engine {
2784
2916
  effortSource: userEffort ? 'user' : 'auto',
2785
2917
  signal,
2786
2918
  onRawExchange: captureRawExchange,
2919
+ rawExchangeMaxBytes,
2787
2920
  onRequestStart: () => startProviderRequest?.(activeProviderRequest),
2788
2921
  })) {
2789
2922
  // task-325a (abort-stop fix): per-event abort short-circuit.
@@ -2809,7 +2942,13 @@ export class Engine {
2809
2942
  }
2810
2943
  switch (event.type) {
2811
2944
  case 'text_delta':
2812
- if (ttfbMs === null) ttfbMs = Date.now() - startTime;
2945
+ if (ttfbMs === null) {
2946
+ ttfbMs = Date.now() - startTime;
2947
+ traceRequest('llm.first_text', {
2948
+ durationMs: perfNowMs() - requestPerfStart,
2949
+ detail: { model: currentModel },
2950
+ });
2951
+ }
2813
2952
  responseText += event.text;
2814
2953
  yield event;
2815
2954
  break;
@@ -2833,6 +2972,12 @@ export class Engine {
2833
2972
  }
2834
2973
  break;
2835
2974
  case 'tool_call':
2975
+ if (toolCalls.length === 0) {
2976
+ traceRequest('llm.first_tool_call', {
2977
+ durationMs: perfNowMs() - requestPerfStart,
2978
+ detail: { name: event.name || null, model: currentModel },
2979
+ });
2980
+ }
2836
2981
  toolCalls.push(event);
2837
2982
  yield event;
2838
2983
  break;
@@ -2909,6 +3054,8 @@ export class Engine {
2909
3054
  stopReason,
2910
3055
  inputTokens: totalUsage.inputTokens,
2911
3056
  outputTokens: totalUsage.outputTokens,
3057
+ toolCallCount: toolCalls.length,
3058
+ responseTextBytes: Buffer.byteLength(responseText, 'utf8'),
2912
3059
  },
2913
3060
  });
2914
3061
  // Stream completed without throwing — reset the retry counter so
@@ -3270,7 +3417,7 @@ export class Engine {
3270
3417
  // yielding any post-stream diagnostics. A consumer may stop iterating at
3271
3418
  // any yield; persistence therefore cannot wait for turn_end or even the
3272
3419
  // debug `loop` event below.
3273
- const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress' };
3420
+ const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress', ...(rawRequest ? { rawRequest } : {}) };
3274
3421
  if (toolCalls.length > 0) {
3275
3422
  assistantMsg.toolCalls = toolCalls.map(tc => ({
3276
3423
  id: tc.id,
@@ -3483,6 +3630,10 @@ export class Engine {
3483
3630
  if (!signal?.aborted) {
3484
3631
  const taskResultUpdatesAfterAsyncWait = this.#drainPendingTaskResultUpdates(conversationMessages);
3485
3632
  if (taskResultUpdatesAfterAsyncWait.length > 0) {
3633
+ // This drain bypasses the next loop's pre-stream drain. A task
3634
+ // that completed while we waited may have changed the workspace,
3635
+ // so it is the same cache synchronization boundary.
3636
+ invalidateReadOnlyToolReuse();
3486
3637
  for (const update of taskResultUpdatesAfterAsyncWait) {
3487
3638
  yield {
3488
3639
  type: 'tool_result_update',
@@ -3737,8 +3888,12 @@ export class Engine {
3737
3888
  let output;
3738
3889
  let displayImages = [];
3739
3890
  let isError = false;
3891
+ let reusedReadOnlyResult = false;
3892
+ let reusedReadOnlyCallId = null;
3740
3893
  let toolErrorOutput = null;
3741
3894
  let fatalToolError = null;
3895
+ let cacheableTool = false;
3896
+ let duplicateKey = null;
3742
3897
  currentToolCallForAsyncTask = skipped
3743
3898
  ? null
3744
3899
  : {
@@ -3777,7 +3932,35 @@ export class Engine {
3777
3932
  isError = true;
3778
3933
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
3779
3934
  } else {
3780
- try {
3935
+ duplicateKey = `${tc.name}\u001f${argsHashOf(tc.input)}`;
3936
+ const readOnlyTool = isReadOnlyTool(this, tc.name, tc.input);
3937
+ cacheableTool = isCacheableTool(this, tc.name, tc.input);
3938
+ // The cache is only valid while the workspace has not changed. Clear
3939
+ // it before every potential mutation, including a tool that later
3940
+ // reports or throws an error after making a partial change. Detached
3941
+ // operations can still mutate after returning, so disable reuse for
3942
+ // the rest of this query instead of allowing stale entries to refill.
3943
+ if (!readOnlyTool) readOnlyToolResults.clear();
3944
+ const mayMutateAfterReturn = mayMutateWorkspaceAfterReturn(this, tc.name, tc.input);
3945
+ if (mayMutateAfterReturn) invalidateReadOnlyToolReuse();
3946
+ const cachedReadOnly = readOnlyToolResults.get(duplicateKey);
3947
+ if (!readOnlyToolReuseDisabled && cachedReadOnly && cacheableTool) {
3948
+ output = cachedReadOnly.output;
3949
+ isError = Boolean(cachedReadOnly.isError);
3950
+ reusedReadOnlyResult = true;
3951
+ reusedReadOnlyCallId = cachedReadOnly.callId || null;
3952
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId, reused: true };
3953
+ yield {
3954
+ type: 'tool_end',
3955
+ id: tc.id,
3956
+ name: tc.name,
3957
+ output,
3958
+ displayImages: [],
3959
+ isError: cachedReadOnly.isError,
3960
+ reused: true,
3961
+ threadId: this.currentThreadId,
3962
+ };
3963
+ } else try {
3781
3964
  yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
3782
3965
  if (this.#toolRegistry) {
3783
3966
  toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
@@ -3855,6 +4038,7 @@ export class Engine {
3855
4038
  durationMs: toolDurationMs,
3856
4039
  isError,
3857
4040
  toolOutput: output,
4041
+ ...(reusedReadOnlyResult ? { reused: true, reusedCallId: reusedReadOnlyCallId } : {}),
3858
4042
  ...(skipped ? { skipped: true } : {}),
3859
4043
  ...(displayImages.length > 0 ? { displayImageCount: displayImages.length } : {}),
3860
4044
  };
@@ -3882,8 +4066,21 @@ export class Engine {
3882
4066
  durationMs: toolDurationMs,
3883
4067
  isError,
3884
4068
  skipped,
4069
+ reused: reusedReadOnlyResult,
4070
+ reusedCallId: reusedReadOnlyCallId,
3885
4071
  });
3886
4072
 
4073
+ if (!skipped && !reusedReadOnlyResult && tc.name === 'StartPlan') {
4074
+ planBootstrapPending = true;
4075
+ }
4076
+ if (!skipped && !reusedReadOnlyResult && !readOnlyToolReuseDisabled && cacheableTool) {
4077
+ readOnlyToolResults.set(duplicateKey, {
4078
+ output,
4079
+ isError,
4080
+ callId: tc.id,
4081
+ });
4082
+ }
4083
+
3887
4084
  // Append only the bounded copy to the model message history. Raw
3888
4085
  // `output` is still used for debug traces, UI events, exec-log, and
3889
4086
  // persistence so large tool results are not lost outside context.
@@ -3939,6 +4136,36 @@ export class Engine {
3939
4136
  conversationMessages.push({ role: 'user', content: reminder });
3940
4137
  }
3941
4138
 
4139
+ // A plan bootstrap that produced only a TodoWrite has no executable work
4140
+ // to feed back to the provider. Close it here. This is intentionally
4141
+ // narrow: StartPlan + TodoWrite is a valid planning result, but a batch
4142
+ // containing any other tool must continue so the model can inspect its
4143
+ // result before deciding what to do next.
4144
+ const onlyPlanControls = planBootstrapPending
4145
+ && toolCalls.length > 0
4146
+ && toolCalls.every(call => call.name === 'StartPlan' || call.name === 'TodoWrite')
4147
+ && toolCalls.some(call => call.name === 'TodoWrite')
4148
+ && !toolBatchBarrier
4149
+ && !endTurnRequested
4150
+ && !abortedDuringTools
4151
+ && !signal?.aborted;
4152
+ if (onlyPlanControls) {
4153
+ const hasPendingStep = toolCalls
4154
+ .filter(call => call.name === 'TodoWrite')
4155
+ .some(call => Array.isArray(call.input?.todos)
4156
+ && call.input.todos.some(todo => todo?.status === 'pending' || todo?.status === 'in_progress'));
4157
+ yield {
4158
+ type: 'turn_end',
4159
+ turnNumber,
4160
+ stopReason: 'plan_recorded',
4161
+ detail: { nextStep: hasPendingStep ? 'pending_work_tools' : 'none', toolCount: toolCalls.length },
4162
+ threadId,
4163
+ terminal: true,
4164
+ };
4165
+ break;
4166
+ }
4167
+ planBootstrapPending = false;
4168
+
3942
4169
  // A batch barrier deliberately returns control to the provider. Any
3943
4170
  // handoff requested by an earlier call belongs to the invalidated plan
3944
4171
  // and must not leak into a later provider-generated batch.
@@ -4047,6 +4274,16 @@ export class Engine {
4047
4274
  if (durableRowsInRange && !persistedReflection) {
4048
4275
  throw new Error('T1 reflection could not publish its durable range replacement');
4049
4276
  }
4277
+ // Raw tool rows inside the replacement range are now tombstoned in
4278
+ // durable history. Late async completions must follow the folded
4279
+ // continuation path rather than appending to those stale rows.
4280
+ const foldedToolCallIds = conversationMessages
4281
+ .slice(batchStart, batchEnd + 1)
4282
+ .filter(message => message?.role === 'tool' && message.toolCallId)
4283
+ .map(message => message.toolCallId);
4284
+ for (const toolCallId of foldedToolCallIds) {
4285
+ this.#persistedToolMessages.delete(toolCallId);
4286
+ }
4050
4287
  conversationMessages.length = 0;
4051
4288
  for (const m of next) conversationMessages.push(m);
4052
4289
  // After collapse: the just-inserted reflection lives at
@@ -4164,6 +4401,16 @@ export class Engine {
4164
4401
  /** @returns {import('./tools/registry.js').ToolRegistry|null} */
4165
4402
  get toolRegistry() { return this.#toolRegistry; }
4166
4403
 
4404
+ /**
4405
+ * Return a registered tool definition for internal loop policy checks.
4406
+ * This keeps the duplicate-result fast path on the same registry metadata
4407
+ * used by normal execution, without exposing the private maps.
4408
+ */
4409
+ getToolDefinition(name) {
4410
+ if (this.#toolRegistry) return this.#toolRegistry.get(name);
4411
+ return this.#tools.get(name) || null;
4412
+ }
4413
+
4167
4414
  /** @returns {import('./skills.js').SkillManager|null} */
4168
4415
  get skillManager() { return this.#skillManager; }
4169
4416
 
@@ -4241,6 +4488,16 @@ export class Engine {
4241
4488
  },
4242
4489
  );
4243
4490
  if (durableRowsInRange && !persistedReflection) continue;
4491
+ // T2 replaces this arc in durable and in-memory history just like T1.
4492
+ // Forget its raw result handles before a late async task can append to a
4493
+ // tombstoned tool row at the next provider boundary.
4494
+ const foldedToolCallIds = conversationMessages
4495
+ .slice(startIdx, endIdx + 1)
4496
+ .filter(message => message?.role === 'tool' && message.toolCallId)
4497
+ .map(message => message.toolCallId);
4498
+ for (const toolCallId of foldedToolCallIds) {
4499
+ this.#persistedToolMessages.delete(toolCallId);
4500
+ }
4244
4501
  // Mutate in place so caller's reference stays valid.
4245
4502
  conversationMessages.length = 0;
4246
4503
  for (const m of next) conversationMessages.push(m);
@@ -4381,7 +4638,7 @@ export class Engine {
4381
4638
  *
4382
4639
  * @param {string} taskId
4383
4640
  * @param {string|Array} content — pre-formatted task result body
4384
- * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string }} [opts]
4641
+ * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string }} [opts]
4385
4642
  * @returns {boolean}
4386
4643
  */
4387
4644
  notifyAsyncTaskCompleted(taskId, content, opts = {}) {
@@ -4394,19 +4651,34 @@ export class Engine {
4394
4651
  // forward and bill for. Production callers (formatTaskResultForVp)
4395
4652
  // always emit a non-empty string today; this guards future refactors.
4396
4653
  if (Array.isArray(content) && content.length === 0) return false;
4654
+ // This is the actual workspace-synchronization boundary. The next
4655
+ // provider stream may already be yielding tool calls, so waiting until a
4656
+ // later queue drain leaves one stale-cache reuse window open.
4657
+ const activeReadOnlyReuse = this.#activeReadOnlyToolReuse;
4658
+ if (activeReadOnlyReuse?.owner === this.#currentAbortCtrl) {
4659
+ activeReadOnlyReuse.invalidate();
4660
+ }
4397
4661
  this.#pendingAsyncTaskIds.delete(taskId);
4398
4662
  const preview = typeof opts.preview === 'string'
4399
4663
  ? opts.preview
4400
4664
  : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
4401
4665
  const meta = this.#asyncTaskToolMeta.get(taskId) || {};
4666
+ // A completion that lands while its original tool arc is still present
4667
+ // can update that tool result in-place. Once T1/T2 folded the arc, the
4668
+ // provider must receive only a continuation note; otherwise we recreate
4669
+ // a raw tool row after the reflection and invalidate the fold.
4670
+ const hasLiveToolRow = typeof meta.toolCallId === 'string'
4671
+ && meta.toolCallId
4672
+ && Boolean(this.#persistedToolMessages.get(meta.toolCallId));
4402
4673
  const delivery = {
4403
4674
  content,
4404
4675
  preview,
4405
- sessionId: opts.sessionId,
4406
- vpId: opts.vpId,
4676
+ sessionId: opts.sessionId || meta.sessionId || this.#sessionId || null,
4677
+ vpId: opts.vpId || meta.vpId || this.#vpId || null,
4407
4678
  threadId: opts.threadId || meta.threadId,
4408
4679
  taskKind: opts.taskKind,
4409
4680
  taskStatus: opts.taskStatus,
4681
+ turnId: opts.turnId || meta.turnId || null,
4410
4682
  };
4411
4683
  this.#acceptedAsyncTaskResults.set(taskId, delivery);
4412
4684
  this.#asyncTaskToolMeta.delete(taskId);
@@ -4414,8 +4686,16 @@ export class Engine {
4414
4686
  this.#pendingTaskResultUpdates.push({
4415
4687
  taskId,
4416
4688
  toolCallId: meta.toolCallId,
4689
+ toolName: meta.toolName,
4417
4690
  content,
4418
4691
  preview,
4692
+ sessionId: delivery.sessionId,
4693
+ vpId: delivery.vpId,
4694
+ turnId: delivery.turnId,
4695
+ // #persistedToolMessages is cleared as soon as folding publishes its
4696
+ // durable reflection. Capture that boundary at completion time so a
4697
+ // later drain cannot infer stale liveness from an unrelated row.
4698
+ folded: !hasLiveToolRow,
4419
4699
  });
4420
4700
  } else {
4421
4701
  this.#pendingTaskResultMessages.push({
@@ -4433,7 +4713,7 @@ export class Engine {
4433
4713
  * Register a result-producing async task as belonging to the current query.
4434
4714
  * Called by tools such as SpawnAgent via `toolCtx.registerAsyncTask`.
4435
4715
  * @param {string} taskId
4436
- * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string }} [meta]
4716
+ * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string }} [meta]
4437
4717
  * @returns {void}
4438
4718
  */
4439
4719
  #registerAsyncTask(taskId, meta = {}) {
@@ -4447,6 +4727,16 @@ export class Engine {
4447
4727
  toolCallId,
4448
4728
  toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
4449
4729
  threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
4730
+ sessionId: typeof meta.sessionId === 'string' && meta.sessionId
4731
+ ? meta.sessionId
4732
+ : (this.#sessionId || null),
4733
+ vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
4734
+ // query() exposes the wire turn id while the task is registered.
4735
+ // Persist it with a late completion even after T1/T2 removed the
4736
+ // original tool row from the in-memory arc.
4737
+ turnId: typeof meta.turnId === 'string' && meta.turnId
4738
+ ? meta.turnId
4739
+ : (this.#currentQueryTurnId || null),
4450
4740
  });
4451
4741
  }
4452
4742
  try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
@@ -12,6 +12,8 @@
12
12
  * The engine sees only unified types — it never knows which API is underneath.
13
13
  */
14
14
 
15
+ import { utf8PrefixWithinBytes } from '../utf8.js';
16
+
15
17
  // ─── Unified Types ─────────────────────────────────────────────
16
18
 
17
19
  /**
@@ -461,6 +463,42 @@ export function classifyFetchError(err, opts = {}) {
461
463
  * @param {{ url: string, method: string, headers: object, body: any }} req
462
464
  * @returns {{ url: string, method: string, headers: object, body: any }}
463
465
  */
466
+
467
+ export function createBoundedTextAccumulator(maxBytes = 512 * 1024) {
468
+ const limit = Number.isFinite(Number(maxBytes)) ? Math.max(0, Math.floor(Number(maxBytes))) : 512 * 1024;
469
+ const chunks = [];
470
+ let retainedBytes = 0;
471
+ let totalBytes = 0;
472
+ let truncated = false;
473
+ return {
474
+ push(value) {
475
+ const text = String(value ?? '');
476
+ const bytes = Buffer.byteLength(text, 'utf8');
477
+ totalBytes += bytes;
478
+ if (retainedBytes >= limit || bytes === 0) {
479
+ if (bytes > 0) truncated = true;
480
+ return;
481
+ }
482
+ const available = limit - retainedBytes;
483
+ if (bytes <= available) {
484
+ chunks.push(text);
485
+ retainedBytes += bytes;
486
+ return;
487
+ }
488
+ // Walk UTF-16 code points once. The old slice(0, -1) loop repeatedly
489
+ // rescanned an oversized SSE chunk and went quadratic in its length.
490
+ const retained = utf8PrefixWithinBytes(text, available);
491
+ if (retained.text) chunks.push(retained.text);
492
+ retainedBytes += retained.bytes;
493
+ truncated = true;
494
+ },
495
+ text() { return chunks.join(''); },
496
+ get totalBytes() { return totalBytes; },
497
+ get truncated() { return truncated; },
498
+ get maxBytes() { return limit; },
499
+ };
500
+ }
501
+
464
502
  export function redactRawRequest(req) {
465
503
  if (!req || typeof req !== 'object') return req;
466
504
  const headers = { ...(req.headers || {}) };