@dreb/coding-agent 2.64.3 → 2.64.4

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.
@@ -95,6 +95,10 @@ export class AgentSession {
95
95
  // Compaction state
96
96
  _compactionAbortController = undefined;
97
97
  _autoCompactionAbortController = undefined;
98
+ /** The in-flight auto-compaction run (any start path), if any. */
99
+ _autoCompactionInFlight = undefined;
100
+ /** Serializes auto-compaction checks so two never run concurrently. */
101
+ _autoCompactionChain = Promise.resolve();
98
102
  _overflowRecoveryAttempted = false;
99
103
  // Branch summarization state
100
104
  _branchSummaryAbortController = undefined;
@@ -234,6 +238,7 @@ export class AgentSession {
234
238
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
235
239
  this._installAgentToolHooks();
236
240
  this._installBackgroundAgentGuardrails();
241
+ this._installMidTurnCompactionHook();
237
242
  this._buildRuntime({
238
243
  activeToolNames: this._initialActiveToolNames,
239
244
  includeAllExtensionTools: true,
@@ -546,6 +551,64 @@ export class AgentSession {
546
551
  return true;
547
552
  });
548
553
  }
554
+ /** Install the settled pre-request hook used for mid-turn compaction. */
555
+ _installMidTurnCompactionHook() {
556
+ this.agent.setBeforeLlmCall((context, signal) => this._prepareForLlmCall(context, signal));
557
+ }
558
+ /**
559
+ * Compact between tool-loop requests without starting a second Agent loop.
560
+ * Session persistence is asynchronous, so wait for prior message events before
561
+ * preparing a summary from the current branch.
562
+ */
563
+ async _prepareForLlmCall(context, signal) {
564
+ await this._agentEventQueue.catch(() => undefined);
565
+ if (signal?.aborted)
566
+ return undefined;
567
+ const loopTail = context.messages[context.messages.length - 1];
568
+ if (loopTail?.role !== "user" && loopTail?.role !== "toolResult") {
569
+ return undefined;
570
+ }
571
+ const messages = this.agent.state.messages;
572
+ const lastMessage = messages[messages.length - 1];
573
+ if (lastMessage?.role !== "user" && lastMessage?.role !== "toolResult") {
574
+ return undefined;
575
+ }
576
+ const settings = this.settingsManager.getCompactionSettings();
577
+ let contextWindow = this.model?.contextWindow ?? 0;
578
+ const estimate = estimateContextTokens(messages);
579
+ let contextTokens = estimate.tokens;
580
+ // A retained assistant message can still carry pre-compaction usage. For the
581
+ // first request after compaction, estimate the rebuilt context from message
582
+ // content instead of reusing that stale high-water mark.
583
+ const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
584
+ if (compactionEntry && estimate.lastUsageIndex !== null) {
585
+ const usageMessage = messages[estimate.lastUsageIndex];
586
+ if (usageMessage.role === "assistant" &&
587
+ usageMessage.timestamp <= new Date(compactionEntry.timestamp).getTime()) {
588
+ contextTokens = messages.reduce((total, message) => total + estimateTokens(message), 0);
589
+ }
590
+ }
591
+ // Preserve K3's tier behavior at the new mid-turn check. If the tier changes,
592
+ // the hook result refreshes the active loop model before its next request.
593
+ if (shouldUpgradeK3Tier(this.model, contextTokens)) {
594
+ const userCompactPoint = K3_256K_CONTEXT_WINDOW - settings.reserveTokens;
595
+ const userThresholdPreempts = settings.enabled && userCompactPoint < K3_UPGRADE_CUTOFF_TOKENS && contextTokens > userCompactPoint;
596
+ if (!userThresholdPreempts) {
597
+ this._tryUpgradeK3ContextTier();
598
+ contextWindow = this.model?.contextWindow ?? contextWindow;
599
+ }
600
+ }
601
+ if (!shouldCompact(contextTokens, contextWindow, settings)) {
602
+ return this.model ? { model: this.model } : undefined;
603
+ }
604
+ // `willRetry: true` tells event consumers that another request is imminent;
605
+ // `requestWillFollow` prevents a re-entrant agent.continue() call.
606
+ await this._trackAutoCompaction("threshold", true, true);
607
+ return {
608
+ messages: this.agent.state.messages,
609
+ ...(this.model ? { model: this.model } : {}),
610
+ };
611
+ }
549
612
  /**
550
613
  * Reset the background-agent guardrail counter and the pause-notified flag together.
551
614
  * These two fields are one logical unit — they must always reset in lockstep so a new
@@ -1039,8 +1102,9 @@ export class AgentSession {
1039
1102
  this._unsubscribeGuardrailSentinel = undefined;
1040
1103
  this._unsubscribeGuardrailCounter?.();
1041
1104
  this._unsubscribeGuardrailCounter = undefined;
1042
- // Clear the shouldContinue callback so the agent doesn't hold a reference to a disposed session
1105
+ // Clear callbacks so the agent doesn't hold a reference to a disconnected session.
1043
1106
  this.agent.setShouldContinue(undefined);
1107
+ this.agent.setBeforeLlmCall(undefined);
1044
1108
  }
1045
1109
  /**
1046
1110
  * Reconnect to agent events after _disconnectFromAgent().
@@ -1051,6 +1115,7 @@ export class AgentSession {
1051
1115
  return; // Already connected
1052
1116
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
1053
1117
  this._installBackgroundAgentGuardrails();
1118
+ this._installMidTurnCompactionHook();
1054
1119
  }
1055
1120
  /**
1056
1121
  * Remove all listeners and disconnect from agent.
@@ -1348,10 +1413,12 @@ export class AgentSession {
1348
1413
  throw new Error(`No API key found for ${this.model.provider}.\n\n` +
1349
1414
  `Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}`);
1350
1415
  }
1351
- // Check if we need to compact before sending (catches aborted responses)
1416
+ // Check if we need to compact before sending (catches aborted responses).
1417
+ // The prompt below is already the next request, so compaction must not
1418
+ // schedule a competing agent.continue().
1352
1419
  const lastAssistant = this._findLastAssistantMessage();
1353
1420
  if (lastAssistant) {
1354
- await this._checkCompaction(lastAssistant, false);
1421
+ await this._checkCompaction(lastAssistant, false, true);
1355
1422
  }
1356
1423
  // Build messages array (custom message if any, then user message)
1357
1424
  const messages = [];
@@ -2087,10 +2154,43 @@ export class AgentSession {
2087
2154
  * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
2088
2155
  * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
2089
2156
  *
2157
+ * Checks run on a serialization chain: a check that arrives while another
2158
+ * check's compaction is still in flight (e.g. a prompt submitted during an
2159
+ * agent_end compaction) waits for it, then evaluates instead of starting a
2160
+ * competing compaction. If an earlier queued check compacted or rebuilt
2161
+ * context first, the captured message is pre-compaction and the
2162
+ * pre-compaction staleness guard inside _doCheckCompaction skips it.
2163
+ *
2164
+ * @param assistantMessage The assistant message to check
2165
+ * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2166
+ * @param requestWillFollow Whether the caller will issue the next request without agent.continue().
2167
+ */
2168
+ _checkCompaction(assistantMessage, skipAbortedCheck = true, requestWillFollow = false) {
2169
+ const work = async () => {
2170
+ // A run started outside this chain (the mid-turn hook) may still
2171
+ // be in flight; wait for it before evaluating.
2172
+ if (this._autoCompactionInFlight) {
2173
+ await this._autoCompactionInFlight.catch(() => undefined);
2174
+ }
2175
+ await this._doCheckCompaction(assistantMessage, skipAbortedCheck, requestWillFollow);
2176
+ };
2177
+ const run = this._autoCompactionChain.then(work);
2178
+ this._autoCompactionChain = run.catch(() => undefined);
2179
+ return run;
2180
+ }
2181
+ /**
2182
+ * Evaluate compaction needs and run a tracked compaction if warranted.
2183
+ * Runs inside the _checkCompaction serialization chain.
2184
+ *
2185
+ * Two cases:
2186
+ * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
2187
+ * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
2188
+ *
2090
2189
  * @param assistantMessage The assistant message to check
2091
2190
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2191
+ * @param requestWillFollow Whether the caller will issue the next request without agent.continue().
2092
2192
  */
2093
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
2193
+ async _doCheckCompaction(assistantMessage, skipAbortedCheck = true, requestWillFollow = false) {
2094
2194
  const settings = this.settingsManager.getCompactionSettings();
2095
2195
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
2096
2196
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
@@ -2119,11 +2219,13 @@ export class AgentSession {
2119
2219
  // Remove the error message from agent state (it IS saved to session
2120
2220
  // for history, but we don't want it in context for the retry)
2121
2221
  this._removeLastAssistantMessage();
2122
- setTimeout(() => {
2123
- this.agent.continue().catch((err) => {
2124
- this.warnInSession(`Agent failed to continue after context window upgrade: ${err instanceof Error ? err.message : String(err)}`);
2125
- });
2126
- }, 100);
2222
+ if (!requestWillFollow) {
2223
+ setTimeout(() => {
2224
+ this.agent.continue().catch((err) => {
2225
+ this.warnInSession(`Agent failed to continue after context window upgrade: ${err instanceof Error ? err.message : String(err)}`);
2226
+ });
2227
+ }, 100);
2228
+ }
2127
2229
  return;
2128
2230
  }
2129
2231
  if (!settings.enabled)
@@ -2133,7 +2235,7 @@ export class AgentSession {
2133
2235
  type: "auto_compaction_end",
2134
2236
  result: undefined,
2135
2237
  aborted: false,
2136
- willRetry: false,
2238
+ willRetry: requestWillFollow,
2137
2239
  errorMessage: "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
2138
2240
  });
2139
2241
  return;
@@ -2142,7 +2244,7 @@ export class AgentSession {
2142
2244
  // Remove the error message from agent state (it IS saved to session for history,
2143
2245
  // but we don't want it in context for the retry)
2144
2246
  this._removeLastAssistantMessage();
2145
- await this._runAutoCompaction("overflow", true);
2247
+ await this._trackAutoCompaction("overflow", true, requestWillFollow);
2146
2248
  return;
2147
2249
  }
2148
2250
  // Case 2: Threshold - context is getting large
@@ -2188,7 +2290,7 @@ export class AgentSession {
2188
2290
  if (!settings.enabled)
2189
2291
  return;
2190
2292
  if (shouldCompact(contextTokens, contextWindow, settings)) {
2191
- await this._runAutoCompaction("threshold", false);
2293
+ await this._trackAutoCompaction("threshold", false, requestWillFollow);
2192
2294
  }
2193
2295
  }
2194
2296
  /**
@@ -2227,27 +2329,60 @@ export class AgentSession {
2227
2329
  });
2228
2330
  return true;
2229
2331
  }
2332
+ /**
2333
+ * Start an auto-compaction while tracking it, so a concurrent check that
2334
+ * arrives during the run can await it instead of starting a competing one.
2335
+ * The tracking flag clears when this specific run settles (even on error,
2336
+ * since _runAutoCompaction swallows its own failures).
2337
+ */
2338
+ _trackAutoCompaction(reason, willRetry, requestWillFollow = false) {
2339
+ const run = this._runAutoCompaction(reason, willRetry, requestWillFollow);
2340
+ this._autoCompactionInFlight = run;
2341
+ void run
2342
+ .catch(() => undefined)
2343
+ .finally(() => {
2344
+ if (this._autoCompactionInFlight === run) {
2345
+ this._autoCompactionInFlight = undefined;
2346
+ }
2347
+ });
2348
+ return run;
2349
+ }
2230
2350
  /**
2231
2351
  * Internal: Run auto-compaction with events.
2232
2352
  */
2233
- async _runAutoCompaction(reason, willRetry) {
2353
+ async _runAutoCompaction(reason, willRetry, requestWillFollow = false) {
2234
2354
  const settings = this.settingsManager.getCompactionSettings();
2235
2355
  this._emit({ type: "auto_compaction_start", reason });
2236
2356
  this._autoCompactionAbortController = new AbortController();
2237
2357
  try {
2238
2358
  if (!this.model) {
2239
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2359
+ this._emit({
2360
+ type: "auto_compaction_end",
2361
+ result: undefined,
2362
+ aborted: false,
2363
+ willRetry: requestWillFollow,
2364
+ });
2240
2365
  return;
2241
2366
  }
2242
2367
  const apiKey = await this._modelRegistry.getApiKey(this.model);
2243
2368
  if (!apiKey) {
2244
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2369
+ this._emit({
2370
+ type: "auto_compaction_end",
2371
+ result: undefined,
2372
+ aborted: false,
2373
+ willRetry: requestWillFollow,
2374
+ });
2245
2375
  return;
2246
2376
  }
2247
2377
  const pathEntries = this.sessionManager.getBranch();
2248
2378
  const preparation = prepareCompaction(pathEntries, settings);
2249
2379
  if (!preparation) {
2250
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2380
+ this._emit({
2381
+ type: "auto_compaction_end",
2382
+ result: undefined,
2383
+ aborted: false,
2384
+ willRetry: requestWillFollow,
2385
+ });
2251
2386
  return;
2252
2387
  }
2253
2388
  let extensionCompaction;
@@ -2261,7 +2396,12 @@ export class AgentSession {
2261
2396
  signal: this._autoCompactionAbortController.signal,
2262
2397
  }));
2263
2398
  if (extensionResult?.cancel) {
2264
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
2399
+ this._emit({
2400
+ type: "auto_compaction_end",
2401
+ result: undefined,
2402
+ aborted: true,
2403
+ willRetry: requestWillFollow,
2404
+ });
2265
2405
  return;
2266
2406
  }
2267
2407
  if (extensionResult?.compaction) {
@@ -2289,7 +2429,12 @@ export class AgentSession {
2289
2429
  details = compactResult.details;
2290
2430
  }
2291
2431
  if (this._autoCompactionAbortController.signal.aborted) {
2292
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
2432
+ this._emit({
2433
+ type: "auto_compaction_end",
2434
+ result: undefined,
2435
+ aborted: true,
2436
+ willRetry: requestWillFollow,
2437
+ });
2293
2438
  return;
2294
2439
  }
2295
2440
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
@@ -2316,17 +2461,28 @@ export class AgentSession {
2316
2461
  tokensBefore,
2317
2462
  details,
2318
2463
  };
2319
- this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry });
2320
- if (willRetry) {
2321
- const messages = this.agent.state.messages;
2322
- const lastMsg = messages[messages.length - 1];
2323
- if (lastMsg?.role === "assistant" && lastMsg.stopReason === "error") {
2324
- this.agent.replaceMessages(messages.slice(0, -1));
2325
- }
2464
+ // Rebuilding from persisted entries can reintroduce the assistant error
2465
+ // that triggered compaction. It is historical evidence, not valid retry
2466
+ // context, regardless of how the compaction was classified.
2467
+ let removedTrailingError = false;
2468
+ const rebuiltMessages = this.agent.state.messages;
2469
+ const rebuiltTail = rebuiltMessages[rebuiltMessages.length - 1];
2470
+ if (rebuiltTail?.role === "assistant" && rebuiltTail.stopReason === "error") {
2471
+ this.agent.replaceMessages(rebuiltMessages.slice(0, -1));
2472
+ removedTrailingError = true;
2326
2473
  }
2327
- // Check the explicit setting first so the continuation decision does not
2328
- // consult overflow-retry or queued-message state when it is enabled.
2329
- const shouldContinue = settings.continueAfterAutoCompaction || willRetry || this.agent.hasQueuedMessages();
2474
+ const messages = this.agent.state.messages;
2475
+ const tail = messages[messages.length - 1];
2476
+ const hasQueuedMessages = this.agent.hasQueuedMessages();
2477
+ const hasResumableTail = tail !== undefined && tail.role !== "assistant";
2478
+ const shouldContinue = !requestWillFollow &&
2479
+ messages.length > 0 &&
2480
+ (hasQueuedMessages ||
2481
+ (hasResumableTail && (removedTrailingError || willRetry || settings.continueAfterAutoCompaction)));
2482
+ const nextRequestWillFollow = requestWillFollow || shouldContinue;
2483
+ // Emit after deciding continuation so frontends can attach input queued
2484
+ // during compaction to the imminent request instead of starting a rival run.
2485
+ this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry: nextRequestWillFollow });
2330
2486
  if (shouldContinue) {
2331
2487
  setTimeout(() => {
2332
2488
  this.agent.continue().catch((err) => {
@@ -2342,7 +2498,7 @@ export class AgentSession {
2342
2498
  type: "auto_compaction_end",
2343
2499
  result: undefined,
2344
2500
  aborted: false,
2345
- willRetry: false,
2501
+ willRetry: requestWillFollow,
2346
2502
  errorMessage: reason === "overflow"
2347
2503
  ? `Context overflow recovery failed: ${errorMessage}`
2348
2504
  : `Auto-compaction failed: ${errorMessage}`,