@vellumai/assistant 0.8.8-dev.202606081143.f600053 → 0.8.9-staging.1

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": "@vellumai/assistant",
3
- "version": "0.8.8-dev.202606081143.f600053",
3
+ "version": "0.8.9-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -100,7 +100,7 @@ mock.module("../config/loader.js", () => ({
100
100
  // Can be a number (constant), a no-arg function, or a function that
101
101
  // receives the messages array for dynamic behavior based on content.
102
102
  // Both the calibrated entry point (`estimatePromptTokens`, which backs the
103
- // preflight overflow gate and the convergence path) and the raw entry point
103
+ // loop's budget gate and the convergence path) and the raw entry point
104
104
  // (`estimatePromptTokensRaw`, used by the pre-send calibration capture) are
105
105
  // stubbed so either call site can drive the test.
106
106
  let mockEstimateTokens: number | ((msgs?: Message[]) => number) = 1000;
@@ -113,7 +113,7 @@ mock.module("../context/token-estimator.js", () => ({
113
113
  typeof mockEstimateTokens === "function"
114
114
  ? mockEstimateTokens(msgs)
115
115
  : mockEstimateTokens,
116
- // The preflight overflow gate calls this calibrated wrapper directly, so it
116
+ // The loop's budget gate calls this calibrated wrapper directly, so it
117
117
  // must honor `mockEstimateTokens` too — otherwise the real implementation
118
118
  // (which sums tool tokens onto the real calibrated estimate) ignores the
119
119
  // per-test value and the overflow scenarios below never trigger.
@@ -795,9 +795,9 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
795
795
  const events: ServerMessage[] = [];
796
796
  let reducerCalled = false;
797
797
 
798
- // GIVEN the estimator reports 185k — under the 190k preflight budget
799
- // (200k * 0.95), so the turn proceeds to the provider rather than
800
- // compacting up front.
798
+ // GIVEN the estimator reports 185k and the context manager's compaction
799
+ // is a no-op, so the first call proceeds to the provider without any
800
+ // up-front reduction.
801
801
  mockEstimateTokens = 185_000;
802
802
 
803
803
  // AND the post-run convergence reducer successfully compacts
@@ -1364,14 +1364,9 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1364
1364
 
1365
1365
  // Budget = 200_000 * 0.95 = 190_000
1366
1366
  // Mid-loop threshold = 190_000 * 0.85 = 161_500
1367
- let estimateCallCount = 0;
1368
- mockEstimateTokens = () => {
1369
- estimateCallCount++;
1370
- // Preflight: below budget
1371
- if (estimateCallCount === 1) return 100_000;
1372
- // Every checkpoint call: above threshold — always triggers yield
1373
- return 170_000;
1374
- };
1367
+ // Every estimate is above the threshold, so the first-call gate compacts
1368
+ // before the first provider call and every checkpoint trips the yield.
1369
+ mockEstimateTokens = 170_000;
1375
1370
 
1376
1371
  // The convergence reducer reduces tokens enough for the rerun to recover.
1377
1372
  let convergenceReducerCalled = false;
@@ -1485,16 +1480,11 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1485
1480
 
1486
1481
  // Budget = 200_000 * 0.95 = 190_000
1487
1482
  // Mid-loop threshold = 190_000 * 0.85 = 161_500
1488
- let estimateCallCount = 0;
1489
- mockEstimateTokens = () => {
1490
- estimateCallCount++;
1491
- // Preflight: below budget.
1492
- if (estimateCallCount === 1) return 100_000;
1493
- // Every checkpoint estimate: above threshold — always trips the
1494
- // yield. Simulates a long turn where each tool call's result
1495
- // inflates the context past 85% even after a successful compaction.
1496
- return 170_000;
1497
- };
1483
+ // Every estimate is above the threshold: the first-call gate compacts
1484
+ // before the first provider call, and each subsequent checkpoint trips
1485
+ // the yield even after a successful compaction (each tool result inflates
1486
+ // the context back past 85%).
1487
+ mockEstimateTokens = 170_000;
1498
1488
 
1499
1489
  // A single tool round reaches one checkpoint; the in-loop budget gate
1500
1490
  // trips there and compaction runs in place. The loop continues the run
@@ -1687,125 +1677,6 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1687
1677
  );
1688
1678
  });
1689
1679
 
1690
- // ── Test 8 ────────────────────────────────────────────────────────
1691
- // BUG: The preflight overflow reducer's budget check uses
1692
- // step.estimatedTokens (computed on bare ctx.messages) without
1693
- // accounting for tokens added by applyRuntimeInjections(). This
1694
- // causes the reducer to stop early when the bare estimate is under
1695
- // budget, even though post-injection tokens exceed it — leading to
1696
- // a wasted provider round-trip that gets rejected.
1697
- //
1698
- // After fix: the budget check re-estimates on runMessages (with
1699
- // injections) so the reducer continues to the next tier.
1700
- test("preflight reducer continues when post-injection tokens exceed budget", async () => {
1701
- const events: ServerMessage[] = [];
1702
-
1703
- // Injections add an extra message, bumping the token count.
1704
- const injectionMessage: Message = {
1705
- role: "user" as const,
1706
- content: [
1707
- {
1708
- type: "text" as const,
1709
- text: "injected context " + "x".repeat(500),
1710
- },
1711
- ],
1712
- };
1713
- mockApplyRuntimeInjections = (msgs) => [...msgs, injectionMessage];
1714
-
1715
- // Budget = 200_000 * 0.95 = 190_000
1716
- // The estimator returns different values based on whether the
1717
- // injection message is present:
1718
- // - bare history (no injection msg) → 195_000 (triggers preflight)
1719
- // - after tier 1 bare → 185_000 (under budget, would stop early without fix)
1720
- // - after tier 1 with injection → 195_000 (still over budget)
1721
- // - after tier 2 bare → 170_000
1722
- // - after tier 2 with injection → 175_000 (under budget, reducer stops)
1723
- let reducerCallCount = 0;
1724
- mockEstimateTokens = (msgs?: Message[]) => {
1725
- const hasInjection = msgs?.some(
1726
- (m) =>
1727
- m.role === "user" &&
1728
- Array.isArray(m.content) &&
1729
- m.content.some(
1730
- (b: { type: string; text?: string }) =>
1731
- b.type === "text" &&
1732
- typeof b.text === "string" &&
1733
- b.text.startsWith("injected context"),
1734
- ),
1735
- );
1736
- if (reducerCallCount === 0) {
1737
- // Before any reduction: preflight check on runMessages (with injection)
1738
- return 195_000;
1739
- }
1740
- if (reducerCallCount === 1) {
1741
- // After tier 1
1742
- return hasInjection ? 195_000 : 185_000;
1743
- }
1744
- // After tier 2
1745
- return hasInjection ? 175_000 : 170_000;
1746
- };
1747
-
1748
- mockReducerStepFn = (msgs: Message[]) => {
1749
- reducerCallCount++;
1750
- const tier =
1751
- reducerCallCount === 1 ? "forced_compaction" : "tool_result_truncation";
1752
- return {
1753
- messages: msgs,
1754
- tier,
1755
- state: {
1756
- appliedTiers:
1757
- reducerCallCount === 1
1758
- ? ["forced_compaction"]
1759
- : ["forced_compaction", "tool_result_truncation"],
1760
- injectionMode: "full" as const,
1761
- exhausted: reducerCallCount >= 2,
1762
- },
1763
- // Bare-history estimate (what the reducer sees on ctx.messages)
1764
- estimatedTokens: reducerCallCount === 1 ? 185_000 : 170_000,
1765
- compactionResult: {
1766
- compacted: true,
1767
- messages: msgs,
1768
- compactedPersistedMessages: 5,
1769
- summaryText: "Summary",
1770
- previousEstimatedInputTokens: 195_000,
1771
- estimatedInputTokens: reducerCallCount === 1 ? 185_000 : 170_000,
1772
- maxInputTokens: 200_000,
1773
- thresholdTokens: 160_000,
1774
- compactedMessages: 10,
1775
- summaryCalls: 1,
1776
- summaryInputTokens: 500,
1777
- summaryOutputTokens: 200,
1778
- summaryModel: "mock-model",
1779
- },
1780
- };
1781
- };
1782
-
1783
- // The preflight overflow reducer runs in the orchestrator before the loop,
1784
- // so a single successful provider turn is enough to drive the path.
1785
- const ctx = makeCtx({
1786
- providerResponses: [textResponse("done")],
1787
- contextWindowManager: {
1788
- updateConfig: () => {},
1789
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1790
- maybeCompact: async () => ({ compacted: false }),
1791
- } as unknown as Conversation["contextWindowManager"],
1792
- });
1793
-
1794
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1795
-
1796
- // The reducer must be called twice — the first tier's bare estimate
1797
- // (185k) is under budget (190k), but post-injection tokens (195k)
1798
- // still exceed it. Without the fix, the reducer would stop after
1799
- // tier 1 and the provider call would likely fail.
1800
- expect(reducerCallCount).toBe(2);
1801
-
1802
- // Should succeed without errors
1803
- const conversationError = events.find(
1804
- (e) => e.type === "conversation_error",
1805
- );
1806
- expect(conversationError).toBeUndefined();
1807
- });
1808
-
1809
1680
  // ── Test 9 ────────────────────────────────────────────────────────
1810
1681
  // When the `auto_compress_latest_turn` rerun (the last layer of the
1811
1682
  // overflow-recovery ladder) still yields at the mid-loop checkpoint,
@@ -1559,9 +1559,21 @@ describe("session-agent-loop", () => {
1559
1559
  },
1560
1560
  });
1561
1561
 
1562
- // After the orchestrator's preflight compaction runs, the loop completes
1563
- // the turn normally.
1564
- const ctx = makeCtx({ providerResponses: [textResponse("recovered")] });
1562
+ // The provider rejects the first call as too large; the convergence
1563
+ // reducer compacts and the rerun completes the turn, forwarding the
1564
+ // compaction's cache-aware usage to recordUsage.
1565
+ const { provider } = createMockProvider([
1566
+ new Error("context_length_exceeded"),
1567
+ textResponse("recovered"),
1568
+ ]);
1569
+ const ctx = makeCtx({
1570
+ loopProvider: provider,
1571
+ contextWindowManager: {
1572
+ updateConfig: () => {},
1573
+ shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1574
+ maybeCompact: async () => ({ compacted: false }),
1575
+ } as unknown as Conversation["contextWindowManager"],
1576
+ });
1565
1577
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1566
1578
 
1567
1579
  const compactorCall = recordUsageMock.mock.calls.find(
@@ -1973,49 +1985,6 @@ describe("session-agent-loop", () => {
1973
1985
  // maxAttempts is 3 — reducer should be called at most 3 times
1974
1986
  expect(reducerCalls).toBeLessThanOrEqual(3);
1975
1987
  });
1976
-
1977
- test("preflight budget evaluation invokes reducer before provider call", async () => {
1978
- const events: ServerMessage[] = [];
1979
- let reducerCalls = 0;
1980
-
1981
- // Set token estimate above budget (100000 * 0.95 = 95000)
1982
- mockEstimateTokens = 96000;
1983
-
1984
- mockReducerStepFn = (msgs: Message[]) => {
1985
- reducerCalls++;
1986
- return {
1987
- messages: msgs,
1988
- tier: "forced_compaction",
1989
- state: {
1990
- appliedTiers: ["forced_compaction"],
1991
- injectionMode: "full",
1992
- exhausted: true,
1993
- },
1994
- estimatedTokens: 50000,
1995
- };
1996
- };
1997
-
1998
- // After the preflight reducer brings the estimate under budget, the loop
1999
- // completes the turn in a single provider call.
2000
- const { provider, calls } = createMockProvider([textResponse("ok")]);
2001
- const ctx = makeCtx({
2002
- loopProvider: provider,
2003
- contextWindowManager: {
2004
- updateConfig: () => {},
2005
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
2006
- maybeCompact: async () => ({ compacted: false }),
2007
- } as unknown as Conversation["contextWindowManager"],
2008
- });
2009
-
2010
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2011
-
2012
- // Reducer should have been called during preflight
2013
- expect(reducerCalls).toBeGreaterThanOrEqual(1);
2014
- // Agent loop should still succeed in a single provider call
2015
- expect(calls.length).toBe(1);
2016
- const complete = events.find((e) => e.type === "message_complete");
2017
- expect(complete).toBeDefined();
2018
- });
2019
1988
  });
2020
1989
 
2021
1990
  describe("provider ordering error retry", () => {
@@ -3188,9 +3157,8 @@ describe("session-agent-loop", () => {
3188
3157
  };
3189
3158
  const maybeCompactInputs: Message[][] = [];
3190
3159
 
3191
- // Sits above the loop's mid-loop threshold (~80.75k) but below the
3192
- // preflight-overflow budget (95k), so the loop's first-call gate — not
3193
- // the orchestrator — owns the turn-start compaction.
3160
+ // Sits above the loop's first-call gate threshold (~80.75k), so the
3161
+ // loop's first-call gate owns the turn-start compaction.
3194
3162
  mockEstimateTokens = 90_000;
3195
3163
 
3196
3164
  const ctx = makeCtx({
@@ -3260,110 +3228,6 @@ describe("session-agent-loop", () => {
3260
3228
  ).not.toHaveBeenCalled();
3261
3229
  });
3262
3230
 
3263
- test("overflow reducer Slack compaction persists watermark from rendered context", async () => {
3264
- const renderedSlackMessages: Message[] = [
3265
- {
3266
- role: "user",
3267
- content: [{ type: "text", text: "first rendered Slack row" }],
3268
- },
3269
- {
3270
- role: "user",
3271
- content: [{ type: "text", text: "second rendered Slack row" }],
3272
- },
3273
- {
3274
- role: "user",
3275
- content: [{ type: "text", text: "retained Slack row" }],
3276
- },
3277
- ];
3278
- mockSlackChronologicalContext = {
3279
- messages: renderedSlackMessages,
3280
- renderedMessages: renderedSlackMessages.map((message, index) => ({
3281
- message,
3282
- sourceChannelTs: [
3283
- "1700000010.000000",
3284
- "1700000020.000000",
3285
- "1700000030.000000",
3286
- ][index]!,
3287
- tagLineProvenance: "none",
3288
- })),
3289
- compactableStartIndex: 0,
3290
- };
3291
- mockEstimateTokens = 120_000;
3292
- mockReducerStepFn = (_msgs: Message[]) => ({
3293
- messages: [
3294
- {
3295
- role: "user",
3296
- content: [{ type: "text", text: "summary" }],
3297
- },
3298
- renderedSlackMessages[2]!,
3299
- ],
3300
- tier: "forced_compaction",
3301
- state: {
3302
- appliedTiers: ["forced_compaction"],
3303
- injectionMode: "full",
3304
- exhausted: false,
3305
- },
3306
- estimatedTokens: 5_000,
3307
- compactionResult: {
3308
- compacted: true,
3309
- messages: [
3310
- {
3311
- role: "user",
3312
- content: [{ type: "text", text: "summary" }],
3313
- },
3314
- renderedSlackMessages[2]!,
3315
- ],
3316
- compactedPersistedMessages: 2,
3317
- previousEstimatedInputTokens: 120_000,
3318
- estimatedInputTokens: 5_000,
3319
- maxInputTokens: 100_000,
3320
- thresholdTokens: 80_000,
3321
- compactedMessages: 2,
3322
- summaryCalls: 1,
3323
- summaryInputTokens: 100,
3324
- summaryOutputTokens: 20,
3325
- summaryModel: "mock-model",
3326
- summaryText: "summary",
3327
- summaryFailed: false,
3328
- },
3329
- });
3330
-
3331
- const ctx = makeCtx({
3332
- channelCapabilities: {
3333
- channel: "slack",
3334
- dashboardCapable: false,
3335
- supportsDynamicUi: false,
3336
- supportsVoiceInput: false,
3337
- chatType: "channel",
3338
- },
3339
- trustContext: {
3340
- sourceChannel: "slack",
3341
- trustClass: "guardian",
3342
- } as Conversation["trustContext"],
3343
- getTurnChannelContext: () => ({
3344
- userMessageChannel: "slack" as const,
3345
- assistantMessageChannel: "slack" as const,
3346
- }),
3347
- contextWindowManager: {
3348
- updateConfig: () => {},
3349
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3350
- maybeCompact: async () => ({ compacted: false }),
3351
- } as unknown as Conversation["contextWindowManager"],
3352
- });
3353
-
3354
- await runAgentLoopImpl(ctx, "next reply", "user-msg-overflow", () => {});
3355
-
3356
- expect(getSlackCompactionWatermarkForPrefixMock).toHaveBeenCalledWith(
3357
- mockSlackChronologicalContext,
3358
- 2,
3359
- );
3360
- expect(updateConversationSlackContextWatermarkMock).toHaveBeenCalledWith(
3361
- "test-conv",
3362
- "1700000020.000000",
3363
- expect.any(Number),
3364
- );
3365
- });
3366
-
3367
3231
  test("mid-loop Slack compaction does not persist watermark from mismatched loaded context", async () => {
3368
3232
  const renderedSlackMessages: Message[] = [
3369
3233
  {
@@ -41,7 +41,6 @@ import {
41
41
  } from "../context/post-turn-tool-result-truncation.js";
42
42
  import {
43
43
  estimatePromptTokens,
44
- estimatePromptTokensWithTools,
45
44
  getCalibrationProviderKey,
46
45
  } from "../context/token-estimator.js";
47
46
  import type { ContextWindowCompactOptions } from "../context/window-manager.js";
@@ -147,10 +146,6 @@ import type {
147
146
  SurfaceType,
148
147
  UsageStats,
149
148
  } from "./message-protocol.js";
150
- import {
151
- type OverflowReduceArgs,
152
- runOverflowReductionLoop,
153
- } from "./overflow-reduction-loop.js";
154
149
  import { parseActualTokensFromError } from "./parse-actual-tokens-from-error.js";
155
150
  import {
156
151
  persistUnsendableImageDowngrades,
@@ -874,12 +869,11 @@ export async function runAgentLoopImpl(
874
869
  // outputs, persists the retrieval's own side effects (injected-block
875
870
  // metadata, recall log, `memory_recalled` event), and assembles the turn's
876
871
  // runtime-injection blocks onto the history (persisting those blocks too).
877
- // Runs at the early "prompt submitted, before context assembly" moment
878
- // because its output feeds the overflow-reduction transform below. It is
879
- // shaped as the `user-prompt-submit-temp` hook handler but invoked directly
880
- // for now: it must run early, while the canonical late `user-prompt-submit`
881
- // hook (history repair, title) runs after that transform, so the two cannot
882
- // share a fire site until compaction is cleared from the gap between them.
872
+ // Runs at the early "prompt submitted, before context assembly" moment so
873
+ // its injected output is what the agent loop receives. It is shaped as the
874
+ // `user-prompt-submit-temp` hook handler but invoked directly for now,
875
+ // separate from the canonical late `user-prompt-submit` hook (history
876
+ // repair, title) that fires just before the loop.
883
877
  // The injection inputs (`mode`, `isNonInteractive`, `modelProfile`,
884
878
  // `actorContext`) are resolved once at turn start and threaded in so
885
879
  // post-compaction re-injection reuses the same snapshot rather than live
@@ -915,144 +909,16 @@ export async function runAgentLoopImpl(
915
909
 
916
910
  // The `remember` tool handles scratchpad-style memory writes directly to the graph.
917
911
 
918
- // ── Preflight budget evaluation ──────────────────────────────
919
- // After runtime injections are applied, estimate the prompt token count
920
- // and proactively invoke the reducer if already above budget. This avoids
921
- // a wasted provider round-trip that would just fail with context_too_large.
922
- const initialContextBudget = resolveCurrentContextBudget();
923
- const overflowRecovery = initialContextBudget.overflowRecovery;
924
- const preflightBudget = initialContextBudget.preflightBudget;
912
+ // Reducer state, tool-token budget, and calibration provider key consumed
913
+ // by the post-rejection convergence loop further down. The tool-token
914
+ // budget is resolved once per turn (the resolved tool set is stable across
915
+ // the turn); the calibration key matches the key recorded by `handleUsage`
916
+ // for wrapper providers (OpenRouter routing to Anthropic → key is
917
+ // `"anthropic"`).
925
918
  let reducerState: ReducerState | undefined;
926
-
927
919
  const toolTokenBudget = ctx.agentLoop.getToolTokenBudget(runMessages);
928
- // Canonical calibration key — used by the preflight estimate, the
929
- // overflow reducer config, and the convergence-path `estimatePromptTokens`
930
- // call. Matches the key recorded by `handleUsage` for wrapper providers
931
- // (OpenRouter routing to Anthropic → key is `"anthropic"`).
932
920
  const estimationProviderName = getCalibrationProviderKey(ctx.provider);
933
921
 
934
- const preflightTokens = estimatePromptTokensWithTools(
935
- runMessages,
936
- ctx.systemPrompt,
937
- ctx.agentLoop.getResolvedTools(runMessages),
938
- estimationProviderName,
939
- );
940
-
941
- if (overflowRecovery.enabled && preflightTokens > preflightBudget) {
942
- rlog.warn(
943
- {
944
- phase: "preflight",
945
- estimatedTokens: preflightTokens,
946
- budget: preflightBudget,
947
- },
948
- "Preflight budget exceeded — running overflow reducer before provider call",
949
- );
950
-
951
- // `runOverflowReductionLoop` drives the tier loop — forced compaction →
952
- // tool-result truncation → media stubbing → injection downgrade — plus
953
- // the re-inject/re-estimate convergence check. The callbacks below are
954
- // the orchestrator-specific side effects it coordinates per iteration
955
- // (activity emission, compaction application, runtime injection
956
- // reassembly, token re-estimation).
957
- const messagesForPreflightOverflowReduction =
958
- slackChronologicalContext?.messages ?? ctx.messages;
959
- const overflowArgs: OverflowReduceArgs = {
960
- messages: messagesForPreflightOverflowReduction,
961
- runMessages,
962
- systemPrompt: ctx.systemPrompt,
963
- providerName: estimationProviderName,
964
- contextWindow: resolveCurrentContextWindowConfig(),
965
- preflightBudget,
966
- toolTokenBudget,
967
- maxAttempts: resolveCurrentContextBudget().overflowRecovery.maxAttempts,
968
- abortSignal: abortController.signal,
969
- compactFn: async (msgs, signal, opts) => {
970
- // Delegate the reducer's forced-compaction tier to the default
971
- // compaction plugin, overlaying the turn's resolved inference
972
- // profile and actor trust class onto the reducer-supplied options.
973
- const reducerOptions = (opts ?? {}) as ContextWindowCompactOptions;
974
- return defaultCompact({
975
- manager: ctx.contextWindowManager,
976
- messages: msgs,
977
- signal,
978
- ...reducerOptions,
979
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
980
- actorTrustClass: resolveTurnActorTrustClass(ctx),
981
- });
982
- },
983
- emitActivityState: () => {
984
- ctx.emitActivityState("thinking", "context_compacting", {
985
- requestId: reqId,
986
- });
987
- },
988
- onCompactionResult: async (result, compactedBasis) => {
989
- // Track circuit-breaker state whenever the reducer invoked
990
- // compaction. The reducer's forced_compaction tier uses
991
- // force:true, so it bypasses the open-circuit check, but we
992
- // still want failure tracking to detect a run of broken
993
- // summaries and clear the counter on success. Only track when
994
- // the summary LLM actually ran — `summaryFailed === undefined`
995
- // indicates an early return (no eligible messages,
996
- // truncation-only path, etc.) that shouldn't influence the
997
- // breaker.
998
- if (result.summaryFailed !== undefined) {
999
- await ctx.agentLoop.compactionCircuit.recordOutcome(
1000
- result.summaryFailed,
1001
- onEvent,
1002
- );
1003
- }
1004
- if (result.compacted) {
1005
- await applySuccessfulCompaction(result, compactedBasis);
1006
- }
1007
- },
1008
- reinjectForMode: async (reducedMessages, mode) => {
1009
- // `ctx.messages` must track the reducer's latest output before
1010
- // re-injection runs: the injectors' message-presence scans and the
1011
- // self-resolved Slack chronological transcript read live conversation
1012
- // state, and `applyCompactionResult` only updates `ctx.messages` on a
1013
- // compaction tier. Assigning here keeps non-compaction tiers
1014
- // (tool-result truncation, media stubbing, injection downgrade)
1015
- // observable to downstream injection assembly on the same turn.
1016
- ctx.messages = reducedMessages;
1017
-
1018
- // When THIS iteration compacted, it stripped the existing
1019
- // memory-static block — so we re-inject current content. A later
1020
- // iteration that only truncates or downgrades must NOT re-force it,
1021
- // or each round would grow the token count.
1022
- // Gate: only the iteration that actually compacted re-injects.
1023
- // (The `<knowledge_base>`, NOW.md, and v2 static `<info>` blocks
1024
- // self-gate inside their injectors on whether they are already
1025
- // present in `reducedMessages`.)
1026
- const injection = await applyRuntimeInjections(reducedMessages, {
1027
- isNonInteractive,
1028
- modelProfile: modelProfileStr,
1029
- actorContext,
1030
- mode,
1031
- requestId: reqId,
1032
- conversationId: ctx.conversationId,
1033
- });
1034
- let next = injection.messages;
1035
- if (isTrustedActor && mode !== "minimal") {
1036
- const memResult = ctx.graphMemory.reinjectCachedMemory(next);
1037
- next = memResult.runMessages;
1038
- }
1039
- return next;
1040
- },
1041
- estimatePostInjection: (runMsgs) =>
1042
- estimatePromptTokens(runMsgs, ctx.systemPrompt, {
1043
- providerName: estimationProviderName,
1044
- toolTokenBudget,
1045
- }),
1046
- };
1047
-
1048
- const overflowResult = await runOverflowReductionLoop(overflowArgs);
1049
-
1050
- ctx.messages = overflowResult.messages;
1051
- runMessages = overflowResult.runMessages;
1052
- currentInjectionMode = overflowResult.injectionMode;
1053
- reducerState = overflowResult.reducerState;
1054
- }
1055
-
1056
922
  // Replace historical web_search_tool_result blocks with text summaries.
1057
923
  // The opaque `encrypted_content` tokens Anthropic attaches to each result
1058
924
  // expire / are route-scoped; replaying a stale token is rejected with
@@ -1557,7 +1423,7 @@ export async function runAgentLoopImpl(
1557
1423
  // through to the final graceful-error fallback below.
1558
1424
  if (state.contextTooLargeDetected) {
1559
1425
  const action = resolveOverflowAction({
1560
- overflowRecovery,
1426
+ overflowRecovery: convergenceBudget.overflowRecovery,
1561
1427
  isInteractive: isInteractiveResolved,
1562
1428
  });
1563
1429
 
@@ -242,5 +242,11 @@ export function disposeConversation(ctx: DisposeContext): void {
242
242
  ctx.accumulatedSurfaceState.clear();
243
243
  ctx.lastSurfaceAction.clear();
244
244
  ctx.workspaceTopLevelContext = null;
245
+ // The compaction module owns the per-conversation ContextWindowManager, so
246
+ // teardown releases it directly. Moving this behind a compaction-plugin hook
247
+ // would let the module own disposal end-to-end, but the per-turn `stop` hook
248
+ // would first require relocating the manager's only
249
+ // cross-turn state — `nonPersistedPrefixCount` — off the manager so a
250
+ // per-turn dispose/rebuild stays correct.
245
251
  disposeContextWindowManager(ctx.conversationId);
246
252
  }
@@ -1,384 +0,0 @@
1
- /**
2
- * Unit tests for `runOverflowReductionLoop` — the direct-call overflow
3
- * reducer driver.
4
- *
5
- * The default loop produces results **identical** to the historical inline
6
- * tier loop for a golden set of over-budget histories. We exercise this by
7
- * running the same inputs through two paths — `runOverflowReductionLoop` and
8
- * a faithful re-implementation of the original inline loop — and asserting
9
- * the final `(messages, runMessages, injectionMode, reducerState,
10
- * attempts)` tuple matches byte-for-byte. Additional cases
11
- * cover the two abort gates.
12
- */
13
-
14
- import { describe, expect, test } from "bun:test";
15
-
16
- import { estimatePromptTokens } from "../context/token-estimator.js";
17
- import type {
18
- ContextWindowCompactOptions,
19
- ContextWindowResult,
20
- } from "../context/window-manager.js";
21
- import { createContextSummaryMessage } from "../context/window-manager.js";
22
- import {
23
- createInitialReducerState,
24
- reduceContextOverflow,
25
- type ReducerState,
26
- } from "../daemon/context-overflow-reducer.js";
27
- import type { InjectionMode } from "../daemon/conversation-runtime-assembly.js";
28
- import {
29
- type OverflowReduceArgs,
30
- runOverflowReductionLoop,
31
- } from "../daemon/overflow-reduction-loop.js";
32
- import type { Message } from "../providers/types.js";
33
-
34
- // ── Fixtures ────────────────────────────────────────────────────────────────
35
-
36
- function msg(role: "user" | "assistant", text: string): Message {
37
- return { role, content: [{ type: "text", text }] };
38
- }
39
-
40
- function toolUseMsg(id: string, name: string): Message {
41
- return {
42
- role: "assistant",
43
- content: [{ type: "tool_use", id, name, input: { path: "/tmp/test" } }],
44
- };
45
- }
46
-
47
- function toolResultMsg(toolUseId: string, content: string): Message {
48
- return {
49
- role: "user",
50
- content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
51
- };
52
- }
53
-
54
- const SYSTEM_PROMPT = "You are a helpful assistant.";
55
-
56
- const CONTEXT_WINDOW = {
57
- enabled: true,
58
- maxInputTokens: 2000,
59
- targetBudgetRatio: 0.65,
60
- compactThreshold: 0.6,
61
- summaryBudgetRatio: 0.05,
62
- overflowRecovery: {
63
- enabled: true,
64
- safetyMarginRatio: 0.05,
65
- maxAttempts: 3,
66
- interactiveLatestTurnCompression: "summarize" as const,
67
- nonInteractiveLatestTurnCompression: "truncate" as const,
68
- },
69
- };
70
-
71
- /**
72
- * Minimal compaction stub — always compacts to a one-message summary so the
73
- * reducer's forced-compaction tier succeeds. Mirrors `makeCompactFn` from
74
- * `context-overflow-reducer.test.ts` so the two test suites exercise the
75
- * reducer under comparable conditions.
76
- */
77
- function makeCompactFn(
78
- summaryText = "## Goals\n- compacted summary",
79
- ): (
80
- messages: Message[],
81
- signal: AbortSignal | undefined,
82
- options: ContextWindowCompactOptions,
83
- ) => Promise<ContextWindowResult> {
84
- return async (messages, _signal, _options) => {
85
- const summaryMsg = createContextSummaryMessage(summaryText);
86
- const compactedMessages = [summaryMsg];
87
- const estimatedInputTokens = estimatePromptTokens(
88
- compactedMessages,
89
- SYSTEM_PROMPT,
90
- { providerName: "mock" },
91
- );
92
- return {
93
- messages: compactedMessages,
94
- compacted: true,
95
- previousEstimatedInputTokens: estimatePromptTokens(
96
- messages,
97
- SYSTEM_PROMPT,
98
- { providerName: "mock" },
99
- ),
100
- estimatedInputTokens,
101
- maxInputTokens: 2000,
102
- thresholdTokens: 1200,
103
- compactedMessages: messages.length,
104
- compactedPersistedMessages: messages.length,
105
- summaryCalls: 1,
106
- summaryInputTokens: 100,
107
- summaryOutputTokens: 50,
108
- summaryModel: "mock-model",
109
- summaryText,
110
- };
111
- };
112
- }
113
-
114
- /**
115
- * Faithful re-implementation of the original inline tier loop — lives in
116
- * this test file rather than the production module so we have an immutable
117
- * baseline `runOverflowReductionLoop` can be diffed against. If either
118
- * implementation drifts, the golden-output cases below fail.
119
- *
120
- * The function intentionally avoids any side effects on external state — no
121
- * circuit-breaker tracking, no activity emission, no `applyCompactionResult`.
122
- * The production orchestrator still runs those through callbacks; this
123
- * baseline only needs the *message mutation* behavior so we can compare
124
- * reducer output.
125
- */
126
- async function runInlineBaseline(args: {
127
- readonly messages: Message[];
128
- readonly runMessages: Message[];
129
- readonly systemPrompt: string;
130
- readonly providerName: string;
131
- readonly preflightBudget: number;
132
- readonly toolTokenBudget?: number;
133
- readonly maxAttempts: number;
134
- readonly abortSignal?: AbortSignal;
135
- readonly compactFn: (
136
- messages: Message[],
137
- signal: AbortSignal | undefined,
138
- options: ContextWindowCompactOptions,
139
- ) => Promise<ContextWindowResult>;
140
- readonly contextWindow: typeof CONTEXT_WINDOW;
141
- readonly reinjectForMode: (
142
- reducedMessages: Message[],
143
- mode: InjectionMode,
144
- ) => Promise<Message[]>;
145
- readonly estimatePostInjection: (runMsgs: Message[]) => number;
146
- }): Promise<{
147
- messages: Message[];
148
- runMessages: Message[];
149
- injectionMode: InjectionMode;
150
- reducerState: ReducerState;
151
- attempts: number;
152
- }> {
153
- let messages = args.messages;
154
- let runMessages = args.runMessages;
155
- let injectionMode: InjectionMode = "full";
156
- let reducerState: ReducerState = createInitialReducerState();
157
- let attempts = 0;
158
-
159
- while (attempts < args.maxAttempts && !reducerState.exhausted) {
160
- args.abortSignal?.throwIfAborted();
161
- attempts++;
162
- const step = await reduceContextOverflow(
163
- messages,
164
- {
165
- providerName: args.providerName,
166
- systemPrompt: args.systemPrompt,
167
- contextWindow: args.contextWindow,
168
- targetTokens: args.preflightBudget,
169
- toolTokenBudget: args.toolTokenBudget,
170
- },
171
- reducerState,
172
- args.compactFn,
173
- args.abortSignal,
174
- );
175
-
176
- reducerState = step.state;
177
- messages = step.messages;
178
- injectionMode = step.state.injectionMode;
179
-
180
- args.abortSignal?.throwIfAborted();
181
-
182
- runMessages = await args.reinjectForMode(messages, injectionMode);
183
-
184
- const postInjectionTokens = args.estimatePostInjection(runMessages);
185
- if (postInjectionTokens <= args.preflightBudget) break;
186
- }
187
-
188
- return {
189
- messages,
190
- runMessages,
191
- injectionMode,
192
- reducerState,
193
- attempts,
194
- };
195
- }
196
-
197
- function buildArgs(messages: Message[]): {
198
- args: OverflowReduceArgs;
199
- reinjectCalls: Array<{ mode: InjectionMode }>;
200
- compactionResults: ContextWindowResult[];
201
- rawCompactFn: (
202
- messages: Message[],
203
- signal: AbortSignal | undefined,
204
- options: ContextWindowCompactOptions,
205
- ) => Promise<ContextWindowResult>;
206
- } {
207
- const reinjectCalls: Array<{ mode: InjectionMode }> = [];
208
- const compactionResults: ContextWindowResult[] = [];
209
- const compactFn = makeCompactFn();
210
-
211
- // Identity reinject: the test harness does not exercise the full
212
- // `applyRuntimeInjections` pipeline; it simply tracks how many times the
213
- // orchestrator would have been asked to rebuild `runMessages`. Returns the
214
- // reducer's latest `messages` untouched — real orchestrator code re-injects
215
- // runtime blocks.
216
- const reinjectForMode = async (
217
- reducedMessages: Message[],
218
- mode: InjectionMode,
219
- ): Promise<Message[]> => {
220
- reinjectCalls.push({ mode });
221
- return reducedMessages;
222
- };
223
-
224
- const estimatePostInjection = (runMsgs: Message[]): number =>
225
- estimatePromptTokens(runMsgs, SYSTEM_PROMPT, {
226
- providerName: "mock",
227
- });
228
-
229
- const args: OverflowReduceArgs = {
230
- messages,
231
- runMessages: messages,
232
- systemPrompt: SYSTEM_PROMPT,
233
- providerName: "mock",
234
- contextWindow: CONTEXT_WINDOW,
235
- preflightBudget: 1000,
236
- toolTokenBudget: 0,
237
- maxAttempts: CONTEXT_WINDOW.overflowRecovery.maxAttempts,
238
- // `OverflowReduceArgs.compactFn` types `options` as `unknown` to avoid
239
- // leaking the `ContextWindowCompactOptions` shape into the loop's args
240
- // surface. The test helper produces a real `ContextWindowCompactOptions`
241
- // signature, so we trampoline through a widened wrapper.
242
- compactFn: (msgs, signal, opts) =>
243
- compactFn(msgs, signal, opts as ContextWindowCompactOptions),
244
- emitActivityState: () => {
245
- /* no-op — the orchestrator owns activity emission */
246
- },
247
- onCompactionResult: (result) => {
248
- compactionResults.push(result);
249
- },
250
- reinjectForMode,
251
- estimatePostInjection,
252
- };
253
-
254
- return { args, reinjectCalls, compactionResults, rawCompactFn: compactFn };
255
- }
256
-
257
- // ── Test suite ──────────────────────────────────────────────────────────────
258
-
259
- describe("runOverflowReductionLoop", () => {
260
- describe("matches historical inline loop", () => {
261
- test("large tool-result history — identical reduced output", async () => {
262
- // GIVEN an over-budget history dominated by a large tool result.
263
- const longToolResult = "r".repeat(8000);
264
- const goldenHistory: Message[] = [
265
- msg("user", "Start"),
266
- toolUseMsg("tu_1", "read_file"),
267
- toolResultMsg("tu_1", longToolResult),
268
- msg("assistant", "Result"),
269
- msg("user", "Next"),
270
- ];
271
-
272
- // AND two independently-built arg sets over the SAME fixture so the
273
- // direct call and the inline baseline never share a `compactFn`.
274
- const directBuild = buildArgs(goldenHistory);
275
- const inlineBuild = buildArgs(goldenHistory);
276
-
277
- // WHEN we reduce via the direct loop and the inline baseline.
278
- const directResult = await runOverflowReductionLoop(directBuild.args);
279
- const inlineResult = await runInlineBaseline({
280
- messages: goldenHistory,
281
- runMessages: goldenHistory,
282
- systemPrompt: SYSTEM_PROMPT,
283
- providerName: "mock",
284
- preflightBudget: inlineBuild.args.preflightBudget,
285
- toolTokenBudget: inlineBuild.args.toolTokenBudget,
286
- maxAttempts: inlineBuild.args.maxAttempts,
287
- compactFn: inlineBuild.rawCompactFn,
288
- contextWindow: CONTEXT_WINDOW,
289
- reinjectForMode: inlineBuild.args.reinjectForMode,
290
- estimatePostInjection: inlineBuild.args.estimatePostInjection,
291
- });
292
-
293
- // THEN every field the orchestrator relies on matches byte-for-byte.
294
- expect(directResult.messages).toEqual(inlineResult.messages);
295
- expect(directResult.runMessages).toEqual(inlineResult.runMessages);
296
- expect(directResult.injectionMode).toBe(inlineResult.injectionMode);
297
- expect(directResult.reducerState).toEqual(inlineResult.reducerState);
298
- expect(directResult.attempts).toBe(inlineResult.attempts);
299
- });
300
-
301
- test("small conversation that fits after first reduction — single attempt", async () => {
302
- // GIVEN a history that the first forced compaction brings under budget.
303
- const smallHistory: Message[] = [
304
- msg("user", "Hello"),
305
- msg("assistant", "Hi there — how can I help?"),
306
- ];
307
-
308
- const directBuild = buildArgs(smallHistory);
309
- const inlineBuild = buildArgs(smallHistory);
310
-
311
- // WHEN we reduce via the direct loop and the inline baseline.
312
- const directResult = await runOverflowReductionLoop(directBuild.args);
313
- const inlineResult = await runInlineBaseline({
314
- messages: smallHistory,
315
- runMessages: smallHistory,
316
- systemPrompt: SYSTEM_PROMPT,
317
- providerName: "mock",
318
- preflightBudget: inlineBuild.args.preflightBudget,
319
- toolTokenBudget: inlineBuild.args.toolTokenBudget,
320
- maxAttempts: inlineBuild.args.maxAttempts,
321
- compactFn: inlineBuild.rawCompactFn,
322
- contextWindow: CONTEXT_WINDOW,
323
- reinjectForMode: inlineBuild.args.reinjectForMode,
324
- estimatePostInjection: inlineBuild.args.estimatePostInjection,
325
- });
326
-
327
- // THEN both paths converge in the same single attempt with equal output.
328
- expect(directResult.attempts).toBe(inlineResult.attempts);
329
- expect(directResult.attempts).toBeGreaterThanOrEqual(1);
330
- expect(directResult.messages).toEqual(inlineResult.messages);
331
- });
332
- });
333
-
334
- describe("abort signal propagation", () => {
335
- test("bails between iterations when abortSignal fires", async () => {
336
- // GIVEN a history that won't converge in one step (multiple iterations).
337
- const longToolResult = "r".repeat(8000);
338
- const history: Message[] = [
339
- msg("user", "Start"),
340
- toolUseMsg("tu_1", "read_file"),
341
- toolResultMsg("tu_1", longToolResult),
342
- msg("user", "Next"),
343
- ];
344
-
345
- const controller = new AbortController();
346
- const build = buildArgs(history);
347
- // AND an estimator that aborts on its first call while reporting
348
- // over-budget — so without the abort gate another iteration would run.
349
- let estimateCalls = 0;
350
- const aborting: OverflowReduceArgs = {
351
- ...build.args,
352
- abortSignal: controller.signal,
353
- estimatePostInjection: () => {
354
- estimateCalls++;
355
- if (estimateCalls === 1) controller.abort();
356
- return build.args.preflightBudget + 1_000_000;
357
- },
358
- };
359
-
360
- // WHEN the loop runs THEN it throws on the post-side-effect abort gate.
361
- await expect(runOverflowReductionLoop(aborting)).rejects.toThrow();
362
- // AND exactly one iteration ran; the gate stopped the next round.
363
- expect(estimateCalls).toBe(1);
364
- });
365
-
366
- test("refuses to start when abortSignal is already aborted", async () => {
367
- // GIVEN an already-aborted signal.
368
- const history: Message[] = [msg("user", "Hi")];
369
- const controller = new AbortController();
370
- controller.abort();
371
- const build = buildArgs(history);
372
- const args: OverflowReduceArgs = {
373
- ...build.args,
374
- abortSignal: controller.signal,
375
- };
376
-
377
- // WHEN the loop runs THEN it throws before the reducer ever runs.
378
- await expect(runOverflowReductionLoop(args)).rejects.toThrow();
379
- // AND no compaction or reinject callbacks were observed.
380
- expect(build.compactionResults).toHaveLength(0);
381
- expect(build.reinjectCalls).toHaveLength(0);
382
- });
383
- });
384
- });
@@ -1,197 +0,0 @@
1
- import type { ContextWindowConfig } from "../config/schemas/inference.js";
2
- import type {
3
- ContextWindowCompactOptions,
4
- ContextWindowResult,
5
- } from "../context/window-manager.js";
6
- import type { Message } from "../providers/types.js";
7
- import {
8
- createInitialReducerState,
9
- reduceContextOverflow,
10
- type ReducerState,
11
- } from "./context-overflow-reducer.js";
12
- import type { InjectionMode } from "./conversation-runtime-assembly.js";
13
-
14
- /**
15
- * Input to the overflow-reduction loop. Captures everything the reducer
16
- * tier loop needs, including the message history, reducer configuration,
17
- * and side-effect callbacks that bridge the loop back to the orchestrator's
18
- * mutable per-turn state (context-window manager, activity emitter, runtime
19
- * injection reassembly, memory reinjection).
20
- *
21
- * The callbacks are supplied by the orchestrator because the reducer loop
22
- * needs to coordinate with state that lives on the `Conversation`
23
- * (message mutation, compaction event emission, circuit breaker tracking,
24
- * injection block reassembly). Keeping them as explicit callbacks keeps the
25
- * loop free of any dependency on the agent-loop context object.
26
- */
27
- export interface OverflowReduceArgs {
28
- /** Bare persisted message history (the reducer applies results to a copy
29
- * of this array). */
30
- readonly messages: Message[];
31
- /** Current run-time message array with runtime injections applied. */
32
- readonly runMessages: Message[];
33
- /** System prompt used for post-step token estimation. */
34
- readonly systemPrompt: string;
35
- /** Provider name used for token estimation (calibration provider key). */
36
- readonly providerName: string;
37
- /** Context window config (drives compaction behavior). */
38
- readonly contextWindow: ContextWindowConfig;
39
- /** Token budget the reducer must get below (preflight budget). */
40
- readonly preflightBudget: number;
41
- /** Tool-token overhead included in every estimation call. */
42
- readonly toolTokenBudget?: number;
43
- /** Maximum reducer iterations before the loop exits unconditionally. */
44
- readonly maxAttempts: number;
45
- /** Abort signal threaded through compaction calls. */
46
- readonly abortSignal?: AbortSignal;
47
- /**
48
- * Compaction callback — the loop never owns the ContextWindowManager
49
- * instance. The orchestrator supplies this closure so the loop can
50
- * delegate the forced-compaction tier without crossing the infra
51
- * boundary on its own.
52
- */
53
- readonly compactFn: (
54
- messages: Message[],
55
- signal: AbortSignal | undefined,
56
- options: unknown,
57
- ) => Promise<ContextWindowResult>;
58
- /**
59
- * Invoked before each reducer iteration to emit the `context_compacting`
60
- * activity state. The orchestrator owns activity emission because the
61
- * signal is trust/channel aware.
62
- */
63
- readonly emitActivityState: () => void;
64
- /**
65
- * Invoked after each reducer step that produced a successful compaction.
66
- * Handles circuit-breaker tracking, event emission, and context mutation.
67
- */
68
- readonly onCompactionResult: (
69
- result: ContextWindowResult,
70
- compactedBasis?: Message[],
71
- ) => void | Promise<void>;
72
- /**
73
- * Invoked after each step to rebuild `runMessages` from the step's
74
- * reduced history with the requested injection mode. The orchestrator
75
- * owns this helper so the full per-turn injection options object doesn't
76
- * leak into the loop. The current reduced messages array is passed
77
- * explicitly so the orchestrator doesn't need to read mutable shared
78
- * state. Returns the new `runMessages`.
79
- *
80
- * Re-injection self-resolves every per-turn block (including the Slack
81
- * chronological transcript, which it loads scoped by the conversation's
82
- * current compaction boundary), so the loop's compaction signals don't
83
- * need to be threaded in.
84
- */
85
- readonly reinjectForMode: (
86
- messages: Message[],
87
- mode: InjectionMode,
88
- ) => Promise<Message[]>;
89
- /**
90
- * Invoked after each step to post-estimate the rebuilt `runMessages`.
91
- * Pulled out so the orchestrator controls how estimation is performed
92
- * (and which fields feed it) without the loop reimplementing it.
93
- */
94
- readonly estimatePostInjection: (runMessages: Message[]) => number;
95
- }
96
-
97
- /** Output of the overflow-reduction loop. */
98
- export interface OverflowReduceResult {
99
- /** Final reduced `ctx.messages` value. */
100
- readonly messages: Message[];
101
- /** Final `runMessages` with re-applied runtime injections. */
102
- readonly runMessages: Message[];
103
- /** Final injection mode (may be `"minimal"` if the downgrade tier fired). */
104
- readonly injectionMode: InjectionMode;
105
- /** Accumulated reducer state at exit. */
106
- readonly reducerState: ReducerState;
107
- /** How many iterations of the tier loop executed. */
108
- readonly attempts: number;
109
- }
110
-
111
- /**
112
- * Run the context-overflow reducer tier loop — forced compaction, tool-result
113
- * truncation, media stubbing, injection downgrade — plus the post-step
114
- * re-injection / re-estimation convergence check.
115
- *
116
- * The forced-compaction tier is delegated through `args.compactFn`; the other
117
- * tiers mutate the message array directly. After each step the orchestrator
118
- * rebuilds `runMessages` via `args.reinjectForMode` and the loop re-estimates
119
- * the post-injection token count, exiting once it fits the preflight budget,
120
- * the reducer is exhausted, or `maxAttempts` is reached.
121
- */
122
- export async function runOverflowReductionLoop(
123
- args: OverflowReduceArgs,
124
- ): Promise<OverflowReduceResult> {
125
- let messages = args.messages;
126
- let runMessages = args.runMessages;
127
- let injectionMode: "full" | "minimal" = "full";
128
- let reducerState: ReducerState = createInitialReducerState();
129
- let attempts = 0;
130
-
131
- while (attempts < args.maxAttempts && !reducerState.exhausted) {
132
- // Abort check at the top of every iteration. When the caller aborts
133
- // externally, this check lets us bail out BETWEEN iterations rather
134
- // than letting another round of compaction / re-injection mutate
135
- // `ctx.messages` after the turn has already failed. Individual
136
- // `reduceContextOverflow` calls also honor the signal, but without this
137
- // gate a fresh iteration could still start after the signal fires,
138
- // since the previous one returned normally before the abort propagated.
139
- args.abortSignal?.throwIfAborted();
140
-
141
- attempts++;
142
- args.emitActivityState();
143
-
144
- const basisMessages = messages;
145
- const step = await reduceContextOverflow(
146
- basisMessages,
147
- {
148
- providerName: args.providerName,
149
- systemPrompt: args.systemPrompt,
150
- contextWindow: args.contextWindow,
151
- targetTokens: args.preflightBudget,
152
- toolTokenBudget: args.toolTokenBudget,
153
- },
154
- reducerState,
155
- (msgs, signal, opts: ContextWindowCompactOptions) =>
156
- args.compactFn(msgs, signal, opts),
157
- args.abortSignal,
158
- );
159
-
160
- reducerState = step.state;
161
- messages = step.messages;
162
- injectionMode = step.state.injectionMode;
163
-
164
- // Let the orchestrator apply compaction side effects (circuit-breaker
165
- // tracking, event emission, ctx mutation) before we re-inject.
166
- if (step.compactionResult) {
167
- await args.onCompactionResult(step.compactionResult, basisMessages);
168
- }
169
-
170
- // Second abort gate — if the side effects or the step itself took us
171
- // past the deadline, don't rebuild runMessages or iterate again.
172
- args.abortSignal?.throwIfAborted();
173
-
174
- // Rebuild runMessages via the orchestrator-supplied helper (which
175
- // re-runs `applyRuntimeInjections` with potentially downgraded mode
176
- // and freshly re-hydrated PKB/NOW blocks after compaction). We pass
177
- // the current reduced `messages` explicitly so the orchestrator never
178
- // has to read from mutable shared state to rebuild runMessages — a
179
- // tier that doesn't trigger compaction (tool-result truncation, media
180
- // stubbing) won't update `ctx.messages` on its own.
181
- runMessages = await args.reinjectForMode(messages, injectionMode);
182
-
183
- // Re-estimate with injections included — `step.estimatedTokens` was
184
- // computed on bare history and doesn't account for tokens added by
185
- // runtime injections.
186
- const postInjectionTokens = args.estimatePostInjection(runMessages);
187
- if (postInjectionTokens <= args.preflightBudget) break;
188
- }
189
-
190
- return {
191
- messages,
192
- runMessages,
193
- injectionMode,
194
- reducerState,
195
- attempts,
196
- };
197
- }