Skip to content

Commit af268e9

Browse files
committed
fix(core): Fix hysteresis in async context management pipelines.
1 parent 1055911 commit af268e9

4 files changed

Lines changed: 278 additions & 74 deletions

File tree

packages/core/src/context/contextManager.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export class ContextManager {
3030
private readonly orchestrator: PipelineOrchestrator;
3131
private readonly historyObserver: HistoryObserver;
3232

33+
// Hysteresis tracking to prevent utility call churn
34+
private lastTriggeredDeficit = 0;
35+
3336
// Cache for Anomaly 3 (Redundant Renders)
3437
private lastRenderCache?: {
3538
nodesHash: string;
@@ -76,6 +79,7 @@ export class ContextManager {
7679
event.targets,
7780
event.returnedNodes,
7881
);
82+
this.evaluateTriggers(new Set());
7983
});
8084

8185
this.historyObserver.start();
@@ -144,11 +148,24 @@ export class ContextManager {
144148
const targetDeficit =
145149
currentTokens - this.sidecar.config.budget.retainedTokens;
146150

151+
// If the deficit has shrunk (e.g. after a consolidation), update the baseline
152+
// so we can track growth from this new, smaller deficit.
153+
if (targetDeficit < this.lastTriggeredDeficit) {
154+
this.lastTriggeredDeficit = targetDeficit;
155+
}
156+
147157
// Respect coalescing threshold for background work
148158
const threshold =
149159
this.sidecar.config.budget.coalescingThresholdTokens || 0;
150160

151-
if (targetDeficit >= threshold) {
161+
// Only trigger if deficit has grown significantly since last time
162+
const growthSinceLast = targetDeficit - this.lastTriggeredDeficit;
163+
164+
if (
165+
targetDeficit >= threshold &&
166+
(growthSinceLast >= threshold || this.lastTriggeredDeficit === 0)
167+
) {
168+
this.lastTriggeredDeficit = targetDeficit;
152169
this.env.tokenCalculator.garbageCollectCache(
153170
new Set(this.buffer.nodes.map((n) => n.id)),
154171
);
@@ -158,6 +175,9 @@ export class ContextManager {
158175
targetNodeIds: agedOutNodes,
159176
});
160177
}
178+
} else {
179+
// Budget is healthy, reset hysteresis
180+
this.lastTriggeredDeficit = 0;
161181
}
162182
}
163183
}

packages/core/src/context/pipeline/orchestrator.ts

Lines changed: 109 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class PipelineOrchestrator {
2323
private activeTimers: NodeJS.Timeout[] = [];
2424
private readonly pendingPipelines = new Map<string, Promise<void>>();
2525
private readonly pipelineMutex = new Map<string, Promise<void>>();
26+
private readonly pipelineScheduled = new Set<string>();
2627
private nodeProvider: (() => readonly ConcreteNode[]) | undefined;
2728

2829
constructor(
@@ -77,7 +78,7 @@ export class PipelineOrchestrator {
7778
nodes: readonly ConcreteNode[],
7879
targets: ReadonlySet<string>,
7980
protectedIds: ReadonlySet<string>,
80-
) => void,
81+
) => Promise<void>,
8182
) => {
8283
for (const pipeline of pipelines) {
8384
for (const trigger of pipeline.triggers) {
@@ -91,30 +92,62 @@ export class PipelineOrchestrator {
9192
trigger === 'nodes_aged_out'
9293
) {
9394
this.eventBus.onConsolidationNeeded((event) => {
94-
executeFn(pipeline, event.nodes, event.targetNodeIds, new Set());
95+
void executeFn(
96+
pipeline,
97+
event.nodes,
98+
event.targetNodeIds,
99+
new Set(),
100+
);
95101
});
96102
} else if (trigger === 'new_message' || trigger === 'nodes_added') {
97103
this.eventBus.onChunkReceived((event) => {
98-
executeFn(pipeline, event.nodes, event.targetNodeIds, new Set());
104+
void executeFn(
105+
pipeline,
106+
event.nodes,
107+
event.targetNodeIds,
108+
new Set(),
109+
);
99110
});
100111
}
101112
}
102113
}
103114
};
104115

105-
bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) => {
106-
// Fetch the tail of the current chain for this pipeline, or start a new one
116+
const handleSyncExecution = async (
117+
pipeline: PipelineDef,
118+
nodes: readonly ConcreteNode[],
119+
targets: ReadonlySet<string>,
120+
protectedIds: ReadonlySet<string>,
121+
) => {
122+
if (this.pipelineScheduled.has(pipeline.name)) {
123+
debugLogger.log(
124+
`[Orchestrator] Pipeline ${pipeline.name} already scheduled (sync), dropping.`,
125+
);
126+
return;
127+
}
128+
this.pipelineScheduled.add(pipeline.name);
129+
107130
const existing =
108131
this.pipelineMutex.get(pipeline.name) || Promise.resolve();
109132

110133
const nextPromise = (async () => {
111134
try {
112-
// Wait for the previous run of THIS pipeline to complete
113135
await existing;
136+
this.pipelineScheduled.delete(pipeline.name);
137+
138+
const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes;
139+
const latestTargets = latestNodes.filter((n) => targets.has(n.id));
140+
141+
debugLogger.log(
142+
`[Orchestrator] Executing sync pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`,
143+
);
114144

115-
// We re-fetch the LATEST nodes from the environment's live buffer
116-
// to ensure this sequential run isn't operating on stale data from the trigger event.
117-
const latestNodes = this.nodeProvider!();
145+
if (latestTargets.length === 0) {
146+
debugLogger.log(
147+
`[Orchestrator] No latest targets for sync pipeline ${pipeline.name}, returning.`,
148+
);
149+
return;
150+
}
118151

119152
await this.executePipelineAsync(
120153
pipeline,
@@ -123,41 +156,87 @@ export class PipelineOrchestrator {
123156
new Set(protectedIds),
124157
);
125158
} catch (e) {
126-
debugLogger.error(`Pipeline chain ${pipeline.name} failed:`, e);
159+
debugLogger.error(`Sync pipeline chain ${pipeline.name} failed:`, e);
127160
}
128161
})();
129162

130-
// Update the chain tail
131163
this.pipelineMutex.set(pipeline.name, nextPromise);
132-
133164
const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
134165
this.pendingPipelines.set(pipelineId, nextPromise);
135166
void nextPromise.finally(() => {
136167
this.pendingPipelines.delete(pipelineId);
137-
// Only clear the mutex if we are still the tail of the chain
138168
if (this.pipelineMutex.get(pipeline.name) === nextPromise) {
139169
this.pipelineMutex.delete(pipeline.name);
140170
}
141171
});
142-
});
172+
};
143173

144-
bindTriggers(this.asyncPipelines, (pipeline, nodes, targetIds) => {
145-
const inboxSnapshot = new InboxSnapshotImpl(
146-
this.env.inbox.getMessages() || [],
147-
);
148-
const targets = nodes.filter((n) => targetIds.has(n.id));
149-
for (const processor of pipeline.processors) {
150-
processor
151-
.process({
152-
targets,
153-
inbox: inboxSnapshot,
154-
buffer: ContextWorkingBufferImpl.initialize(nodes),
155-
})
156-
.catch((e: unknown) =>
157-
debugLogger.error(`AsyncProcessor ${processor.name} failed:`, e),
158-
);
174+
const handleAsyncExecution = async (
175+
pipeline: AsyncPipelineDef,
176+
nodes: readonly ConcreteNode[],
177+
targets: ReadonlySet<string>,
178+
) => {
179+
if (this.pipelineScheduled.has(pipeline.name)) {
180+
debugLogger.log(
181+
`[Orchestrator] Pipeline ${pipeline.name} already scheduled (async), dropping.`,
182+
);
183+
return;
159184
}
160-
});
185+
this.pipelineScheduled.add(pipeline.name);
186+
187+
const existing =
188+
this.pipelineMutex.get(pipeline.name) || Promise.resolve();
189+
190+
const nextPromise = (async () => {
191+
try {
192+
await existing;
193+
this.pipelineScheduled.delete(pipeline.name);
194+
195+
const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes;
196+
const latestTargets = latestNodes.filter((n) => targets.has(n.id));
197+
198+
debugLogger.log(
199+
`[Orchestrator] Executing async pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`,
200+
);
201+
202+
const inboxSnapshot = new InboxSnapshotImpl(
203+
this.env.inbox.getMessages() || [],
204+
);
205+
206+
for (const processor of pipeline.processors) {
207+
debugLogger.log(
208+
`[Orchestrator] Running async processor ${processor.id}`,
209+
);
210+
await processor.process({
211+
targets: latestTargets,
212+
inbox: inboxSnapshot,
213+
buffer: ContextWorkingBufferImpl.initialize(latestNodes),
214+
});
215+
}
216+
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
217+
} catch (e) {
218+
debugLogger.error(`Async pipeline chain ${pipeline.name} failed:`, e);
219+
}
220+
})();
221+
222+
this.pipelineMutex.set(pipeline.name, nextPromise);
223+
const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
224+
this.pendingPipelines.set(pipelineId, nextPromise);
225+
void nextPromise.finally(() => {
226+
this.pendingPipelines.delete(pipelineId);
227+
if (this.pipelineMutex.get(pipeline.name) === nextPromise) {
228+
this.pipelineMutex.delete(pipeline.name);
229+
}
230+
});
231+
};
232+
233+
bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) =>
234+
handleSyncExecution(pipeline, nodes, targets, protectedIds),
235+
);
236+
237+
bindTriggers(this.asyncPipelines, (pipeline, nodes, targets) =>
238+
handleAsyncExecution(pipeline, nodes, targets),
239+
);
161240
}
162241

163242
shutdown() {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { SimulationHarness } from './simulationHarness.js';
9+
import { createMockLlmClient } from '../testing/contextTestUtils.js';
10+
import type { ContextProfile } from '../config/profiles.js';
11+
import { generalistProfile } from '../config/profiles.js';
12+
13+
describe('Context Manager Hysteresis Tests', () => {
14+
const mockLlmClient = createMockLlmClient(['<SNAPSHOT>']);
15+
16+
const getHysteresisConfig = (threshold: number): ContextProfile => ({
17+
...generalistProfile,
18+
name: 'Hysteresis Stress Test',
19+
config: {
20+
budget: {
21+
maxTokens: 5000,
22+
retainedTokens: 1000,
23+
coalescingThresholdTokens: threshold,
24+
},
25+
},
26+
});
27+
28+
it('should block consolidation when deficit is below coalescing threshold', async () => {
29+
const threshold = 1500;
30+
const harness = await SimulationHarness.create(
31+
getHysteresisConfig(threshold),
32+
mockLlmClient,
33+
);
34+
35+
// Turn 0: INIT
36+
await harness.simulateTurn([{ role: 'user', parts: [{ text: 'INIT' }] }]);
37+
38+
// Turn 1: Add 1500 chars (~500 tokens). Total ~500. Under retained (1000).
39+
await harness.simulateTurn([
40+
{ role: 'user', parts: [{ text: 'A'.repeat(1500) }] },
41+
]);
42+
43+
// Turn 2: Add 3000 chars (~1000 tokens). Total ~1500. Deficit ~500 < 1500.
44+
await harness.simulateTurn([
45+
{ role: 'user', parts: [{ text: 'B'.repeat(3000) }] },
46+
]);
47+
48+
await new Promise((resolve) => setTimeout(resolve, 100));
49+
let state = await harness.getGoldenState();
50+
// No snapshot because maxTokens (5000) not exceeded, and deficit < threshold.
51+
expect(
52+
state.finalProjection.some((c) =>
53+
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
54+
),
55+
).toBe(false);
56+
57+
// Turn 3: Add 9000 chars (~3000 tokens). Total ~4500.
58+
// Deficit ~3500 > 1500. TRIGGER!
59+
await harness.simulateTurn([
60+
{ role: 'user', parts: [{ text: 'C'.repeat(9000) }] },
61+
]);
62+
63+
// Give it a moment for the async task to finish
64+
await new Promise((resolve) => setTimeout(resolve, 500));
65+
66+
// Exceed maxTokens to force a render that shows the snapshot
67+
// Add 3000 more tokens (9000 chars). Total ~7500 > 5000.
68+
await harness.simulateTurn([
69+
{ role: 'user', parts: [{ text: 'D'.repeat(9000) }] },
70+
]);
71+
72+
state = await harness.getGoldenState();
73+
expect(
74+
state.finalProjection.some((c) =>
75+
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
76+
),
77+
).toBe(true);
78+
});
79+
80+
it('should track growth from the new baseline after consolidation', async () => {
81+
const threshold = 1000;
82+
const harness = await SimulationHarness.create(
83+
getHysteresisConfig(threshold),
84+
mockLlmClient,
85+
);
86+
87+
// 1. Trigger first consolidation
88+
// Add ~9000 chars (~3000 tokens). Total ~3000. Deficit ~2000 > 1000.
89+
await harness.simulateTurn([
90+
{ role: 'user', parts: [{ text: 'A'.repeat(9000) }] },
91+
]);
92+
await harness.simulateTurn([{ role: 'user', parts: [{ text: 'B' }] }]); // Make eligible
93+
94+
await new Promise((resolve) => setTimeout(resolve, 500));
95+
// Exceed maxTokens (5000) to see it
96+
await harness.simulateTurn([
97+
{ role: 'user', parts: [{ text: 'X'.repeat(9000) }] },
98+
]);
99+
100+
let state = await harness.getGoldenState();
101+
expect(
102+
state.finalProjection.some((c) =>
103+
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
104+
),
105+
).toBe(true);
106+
107+
// Get baseline tokens
108+
const baselineTokens =
109+
harness.env.tokenCalculator.calculateConcreteListTokens(
110+
harness.contextManager.getNodes(),
111+
);
112+
113+
// 2. Add nodes again, staying below threshold growth
114+
// Add 1500 chars (~500 tokens). Growth ~500 < 1000.
115+
await harness.simulateTurn([
116+
{ role: 'user', parts: [{ text: 'C'.repeat(1500) }] },
117+
]);
118+
await harness.simulateTurn([{ role: 'user', parts: [{ text: 'D' }] }]); // Make eligible
119+
120+
await new Promise((resolve) => setTimeout(resolve, 200));
121+
const currentTokens =
122+
harness.env.tokenCalculator.calculateConcreteListTokens(
123+
harness.contextManager.getNodes(),
124+
);
125+
// Should not have shrunk further (except for D's small addition)
126+
expect(currentTokens).toBeGreaterThanOrEqual(baselineTokens);
127+
128+
// 3. Exceed threshold growth
129+
// Add 6000 chars (~2000 tokens). Growth = ~500 + ~2000 = ~2500 > 1000.
130+
await harness.simulateTurn([
131+
{ role: 'user', parts: [{ text: 'E'.repeat(6000) }] },
132+
]);
133+
await harness.simulateTurn([{ role: 'user', parts: [{ text: 'F' }] }]); // Make eligible
134+
135+
await new Promise((resolve) => setTimeout(resolve, 500));
136+
const finalTokens = harness.env.tokenCalculator.calculateConcreteListTokens(
137+
harness.contextManager.getNodes(),
138+
);
139+
// Now it should have consolidated again (E should be replaced by a snapshot eventually)
140+
expect(finalTokens).toBeLessThan(currentTokens + 2000);
141+
});
142+
});

0 commit comments

Comments
 (0)