@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 +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +13 -142
- package/src/__tests__/conversation-agent-loop.test.ts +17 -153
- package/src/daemon/conversation-agent-loop.ts +12 -146
- package/src/daemon/conversation-lifecycle.ts +6 -0
- package/src/__tests__/overflow-reduction-loop.test.ts +0 -384
- package/src/daemon/overflow-reduction-loop.ts +0 -197
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
799
|
-
//
|
|
800
|
-
//
|
|
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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
//
|
|
1563
|
-
// the turn
|
|
1564
|
-
|
|
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
|
|
3192
|
-
//
|
|
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
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
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
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
}
|