praxis-agent 0.24.0 → 0.25.0
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/dist/application/session-memory.d.ts +11 -1
- package/dist/application/session-memory.js +49 -26
- package/dist/application/session-service.d.ts +3 -0
- package/dist/application/session-service.js +140 -55
- package/dist/application/subagent-service.js +7 -0
- package/dist/cli/interactive.d.ts +1 -0
- package/dist/cli/interactive.js +52 -25
- package/dist/cli-runtime.js +1 -0
- package/dist/compatibility/claude/fork.js +3 -1
- package/dist/compatibility/claude/history.js +47 -1
- package/dist/core/context-budget.d.ts +28 -1
- package/dist/core/context-budget.js +70 -2
- package/package.json +1 -1
|
@@ -85,11 +85,21 @@ export declare class SessionMemoryController {
|
|
|
85
85
|
* The persisted counters only advance once an extraction succeeds.
|
|
86
86
|
*/
|
|
87
87
|
observeDelta(inputTokens: number, toolCalls: number, messageId: string, messages?: readonly ModelMessage[]): Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Records the observed totals and starts an extraction when the fixed
|
|
90
|
+
* eligibility contract is met. Returns true when an extraction is running or
|
|
91
|
+
* was just scheduled; callers on normal turns never await the extraction.
|
|
92
|
+
*/
|
|
93
|
+
private scheduleExtraction;
|
|
88
94
|
/** Safe snapshot of the loaded durable summary; empty when none exists. */
|
|
89
95
|
summary(): Promise<string>;
|
|
90
96
|
/** Safe snapshot of the loaded session memory state. */
|
|
91
97
|
state(): Promise<SessionMemoryState>;
|
|
92
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Resolves when no extraction is running; rejects on extraction failure.
|
|
100
|
+
* Compact callers wait softly: if extraction outlives the bounded
|
|
101
|
+
* `waitTimeoutMs`, this resolves so compaction can proceed anyway.
|
|
102
|
+
*/
|
|
93
103
|
waitForIdle(): Promise<void>;
|
|
94
104
|
clear(): Promise<void>;
|
|
95
105
|
private ensureLoaded;
|
|
@@ -2,6 +2,8 @@ import { readFile, rm } from 'node:fs/promises';
|
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { isClaudeSessionId } from '../compatibility/claude/paths.js';
|
|
4
4
|
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
5
|
+
/** A persisted extraction this old is recovered as stale and safely re-extracted. */
|
|
6
|
+
const STALE_EXTRACTION_THRESHOLD_MS = 60_000;
|
|
5
7
|
export class SessionMemoryStateError extends Error {
|
|
6
8
|
name = 'SessionMemoryStateError';
|
|
7
9
|
constructor(message) {
|
|
@@ -170,8 +172,8 @@ export class SessionMemoryController {
|
|
|
170
172
|
this.options = options;
|
|
171
173
|
this.initTokens = options.initTokens ?? 10_000;
|
|
172
174
|
this.updateTokens = options.updateTokens ?? 5_000;
|
|
173
|
-
this.updateToolCalls = options.updateToolCalls ??
|
|
174
|
-
this.waitTimeoutMs = options.waitTimeoutMs ??
|
|
175
|
+
this.updateToolCalls = options.updateToolCalls ?? 3;
|
|
176
|
+
this.waitTimeoutMs = options.waitTimeoutMs ?? 15_000;
|
|
175
177
|
for (const [name, value] of [
|
|
176
178
|
['initTokens', this.initTokens],
|
|
177
179
|
['updateTokens', this.updateTokens],
|
|
@@ -204,22 +206,10 @@ export class SessionMemoryController {
|
|
|
204
206
|
toolCalls < state.lastObservedToolCalls) {
|
|
205
207
|
throw new SessionMemoryStateError(`Session memory observed counters regressed (tokens ${tokens} < ${state.lastObservedTokens}, toolCalls ${toolCalls} < ${state.lastObservedToolCalls})`);
|
|
206
208
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
if (this.inFlight === null) {
|
|
213
|
-
const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages);
|
|
214
|
-
this.inFlight = extraction;
|
|
215
|
-
extraction
|
|
216
|
-
.catch(() => undefined)
|
|
217
|
-
.finally(() => {
|
|
218
|
-
if (this.inFlight === extraction)
|
|
219
|
-
this.inFlight = null;
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
return true;
|
|
209
|
+
// A direct absolute observation reports a natural break when no tool calls
|
|
210
|
+
// have accumulated since the last successful extraction.
|
|
211
|
+
const naturalBreak = toolCalls === state.lastObservedToolCalls;
|
|
212
|
+
return this.scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak);
|
|
223
213
|
}
|
|
224
214
|
/**
|
|
225
215
|
* Adds non-negative deltas to the current cumulative observed totals and
|
|
@@ -241,7 +231,32 @@ export class SessionMemoryController {
|
|
|
241
231
|
}
|
|
242
232
|
this.observedTokens += inputTokens;
|
|
243
233
|
this.observedToolCalls += toolCalls;
|
|
244
|
-
|
|
234
|
+
// A zero-tool-call turn is a natural break: the last assistant turn made
|
|
235
|
+
// no tool calls.
|
|
236
|
+
return this.scheduleExtraction(this.observedTokens, this.observedToolCalls, messageId, messages, toolCalls === 0);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Records the observed totals and starts an extraction when the fixed
|
|
240
|
+
* eligibility contract is met. Returns true when an extraction is running or
|
|
241
|
+
* was just scheduled; callers on normal turns never await the extraction.
|
|
242
|
+
*/
|
|
243
|
+
scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak) {
|
|
244
|
+
this.observedTokens = Math.max(this.observedTokens, tokens);
|
|
245
|
+
this.observedToolCalls = Math.max(this.observedToolCalls, toolCalls);
|
|
246
|
+
if (!this.isExtractionDue(this.observedTokens, this.observedToolCalls, naturalBreak)) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
if (this.inFlight === null) {
|
|
250
|
+
const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages);
|
|
251
|
+
this.inFlight = extraction;
|
|
252
|
+
extraction
|
|
253
|
+
.catch(() => undefined)
|
|
254
|
+
.finally(() => {
|
|
255
|
+
if (this.inFlight === extraction)
|
|
256
|
+
this.inFlight = null;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
245
260
|
}
|
|
246
261
|
/** Safe snapshot of the loaded durable summary; empty when none exists. */
|
|
247
262
|
async summary() {
|
|
@@ -256,15 +271,19 @@ export class SessionMemoryController {
|
|
|
256
271
|
}
|
|
257
272
|
return { ...this.stateValue };
|
|
258
273
|
}
|
|
259
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Resolves when no extraction is running; rejects on extraction failure.
|
|
276
|
+
* Compact callers wait softly: if extraction outlives the bounded
|
|
277
|
+
* `waitTimeoutMs`, this resolves so compaction can proceed anyway.
|
|
278
|
+
*/
|
|
260
279
|
async waitForIdle() {
|
|
261
280
|
await this.ensureLoaded();
|
|
262
281
|
const extraction = this.inFlight;
|
|
263
282
|
if (extraction === null)
|
|
264
283
|
return;
|
|
265
284
|
let timer;
|
|
266
|
-
const timeout = new Promise((
|
|
267
|
-
timer = setTimeout(
|
|
285
|
+
const timeout = new Promise((resolve) => {
|
|
286
|
+
timer = setTimeout(resolve, this.waitTimeoutMs);
|
|
268
287
|
});
|
|
269
288
|
try {
|
|
270
289
|
await Promise.race([extraction, timeout]);
|
|
@@ -307,7 +326,7 @@ export class SessionMemoryController {
|
|
|
307
326
|
...state,
|
|
308
327
|
extractionStartedAt: null,
|
|
309
328
|
extractionCompletedAt: null,
|
|
310
|
-
extractionError: elapsed >=
|
|
329
|
+
extractionError: elapsed >= STALE_EXTRACTION_THRESHOLD_MS
|
|
311
330
|
? `Session memory extraction is stale after ${elapsed}ms`
|
|
312
331
|
: 'Session memory extraction was interrupted',
|
|
313
332
|
};
|
|
@@ -321,14 +340,18 @@ export class SessionMemoryController {
|
|
|
321
340
|
this.observedToolCalls = this.stateValue.lastObservedToolCalls;
|
|
322
341
|
this.summaryValue = summary;
|
|
323
342
|
}
|
|
324
|
-
isExtractionDue(tokens, toolCalls) {
|
|
343
|
+
isExtractionDue(tokens, toolCalls, naturalBreak) {
|
|
325
344
|
const state = this.stateValue;
|
|
326
345
|
if (state === null)
|
|
327
346
|
return false;
|
|
328
347
|
if (!state.initialized)
|
|
329
348
|
return tokens >= this.initTokens;
|
|
330
|
-
|
|
331
|
-
|
|
349
|
+
// Unchanged context must not retrigger; tool-call growth alone is never
|
|
350
|
+
// enough without at least the update-token growth.
|
|
351
|
+
if (tokens - state.lastObservedTokens < this.updateTokens)
|
|
352
|
+
return false;
|
|
353
|
+
return (toolCalls - state.lastObservedToolCalls >= this.updateToolCalls ||
|
|
354
|
+
naturalBreak);
|
|
332
355
|
}
|
|
333
356
|
async runExtraction(tokens, toolCalls, messageId, messages) {
|
|
334
357
|
const state = this.stateValue;
|
|
@@ -161,6 +161,7 @@ export interface RewindPoint {
|
|
|
161
161
|
fileRestoreAvailable: boolean;
|
|
162
162
|
}
|
|
163
163
|
export declare function workflowTokenTarget(prompt: string): number | null;
|
|
164
|
+
export type HookSessionEndReason = 'clear' | 'resume' | 'other';
|
|
164
165
|
export declare class ClaudeSessionService {
|
|
165
166
|
private readonly options;
|
|
166
167
|
private readonly schema;
|
|
@@ -182,6 +183,7 @@ export declare class ClaudeSessionService {
|
|
|
182
183
|
private activeCostSessionId;
|
|
183
184
|
private closeCostSavePromise;
|
|
184
185
|
private readonly sessionMemoryControllers;
|
|
186
|
+
private readonly hookLifecycle;
|
|
185
187
|
private runtimeCwd;
|
|
186
188
|
constructor(options: ClaudeSessionServiceOptions);
|
|
187
189
|
nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
|
|
@@ -198,6 +200,7 @@ export declare class ClaudeSessionService {
|
|
|
198
200
|
stopTask(sessionId: string, taskId: string): Promise<void>;
|
|
199
201
|
private applyPermissionUpdates;
|
|
200
202
|
close(): Promise<void>;
|
|
203
|
+
transitionHookSession(sessionId: string, reason: Exclude<HookSessionEndReason, 'other'>): Promise<void>;
|
|
201
204
|
createHostedToolRegistry(sessionId: string): ToolRegistry;
|
|
202
205
|
run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
|
|
203
206
|
runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
|
|
@@ -319,6 +319,116 @@ function validSessionName(value) {
|
|
|
319
319
|
? name.toLocaleLowerCase()
|
|
320
320
|
: null;
|
|
321
321
|
}
|
|
322
|
+
const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
|
|
323
|
+
class HookLifecycle {
|
|
324
|
+
hooks;
|
|
325
|
+
eventSink;
|
|
326
|
+
sessions = new Map();
|
|
327
|
+
pendingSource;
|
|
328
|
+
constructor(hooks, eventSink) {
|
|
329
|
+
this.hooks = hooks;
|
|
330
|
+
this.eventSink = eventSink;
|
|
331
|
+
}
|
|
332
|
+
async start(sessionId, input, fallbackSource, signal) {
|
|
333
|
+
let state = this.sessions.get(sessionId);
|
|
334
|
+
if (state?.started)
|
|
335
|
+
return undefined;
|
|
336
|
+
if (state?.starting)
|
|
337
|
+
return state.starting;
|
|
338
|
+
state = {
|
|
339
|
+
input,
|
|
340
|
+
started: false,
|
|
341
|
+
starting: undefined,
|
|
342
|
+
ending: undefined,
|
|
343
|
+
};
|
|
344
|
+
this.sessions.set(sessionId, state);
|
|
345
|
+
const source = this.pendingSource ?? fallbackSource;
|
|
346
|
+
this.pendingSource = undefined;
|
|
347
|
+
state.starting = this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source }, source, signal);
|
|
348
|
+
try {
|
|
349
|
+
const outcome = await state.starting;
|
|
350
|
+
state.started = true;
|
|
351
|
+
return outcome;
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
this.sessions.delete(sessionId);
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
state.starting = undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async refresh(sessionId, input, signal) {
|
|
362
|
+
const state = this.sessions.get(sessionId);
|
|
363
|
+
if (state)
|
|
364
|
+
state.input = input;
|
|
365
|
+
return this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source: 'compact' }, 'compact', signal);
|
|
366
|
+
}
|
|
367
|
+
async transition(sessionId, reason) {
|
|
368
|
+
await this.end(sessionId, reason);
|
|
369
|
+
this.pendingSource = reason;
|
|
370
|
+
}
|
|
371
|
+
async end(sessionId, reason) {
|
|
372
|
+
const state = this.sessions.get(sessionId);
|
|
373
|
+
if (!state)
|
|
374
|
+
return;
|
|
375
|
+
if (state.starting)
|
|
376
|
+
await state.starting;
|
|
377
|
+
if (!state.started)
|
|
378
|
+
return;
|
|
379
|
+
if (state.ending)
|
|
380
|
+
return state.ending;
|
|
381
|
+
state.ending = this.runEnd(state.input, reason).finally(() => {
|
|
382
|
+
this.sessions.delete(sessionId);
|
|
383
|
+
});
|
|
384
|
+
await state.ending;
|
|
385
|
+
}
|
|
386
|
+
async close() {
|
|
387
|
+
await Promise.all([...this.sessions.keys()].map((sessionId) => this.end(sessionId, 'other')));
|
|
388
|
+
}
|
|
389
|
+
async runEnd(input, reason) {
|
|
390
|
+
if (!this.hooks)
|
|
391
|
+
return;
|
|
392
|
+
const controller = new AbortController();
|
|
393
|
+
let timer;
|
|
394
|
+
try {
|
|
395
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
396
|
+
timer = setTimeout(() => {
|
|
397
|
+
controller.abort();
|
|
398
|
+
reject(new Error(`timed out after ${SESSION_END_HOOK_TIMEOUT_MS}ms`));
|
|
399
|
+
}, SESSION_END_HOOK_TIMEOUT_MS);
|
|
400
|
+
});
|
|
401
|
+
const outcome = await Promise.race([
|
|
402
|
+
this.hooks.run({ ...input, hook_event_name: 'SessionEnd', reason }, reason, controller.signal),
|
|
403
|
+
timeout,
|
|
404
|
+
]);
|
|
405
|
+
for (const execution of outcome.executions) {
|
|
406
|
+
if (execution.exitCode === 0)
|
|
407
|
+
continue;
|
|
408
|
+
const detail = execution.stderr.trim() ||
|
|
409
|
+
execution.stdout.trim() ||
|
|
410
|
+
`exit code ${execution.exitCode}`;
|
|
411
|
+
this.warn(detail);
|
|
412
|
+
}
|
|
413
|
+
if (outcome.blockedReason && outcome.executions.at(-1)?.exitCode === 0) {
|
|
414
|
+
this.warn(outcome.blockedReason);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
this.warn(error instanceof Error ? error.message : String(error));
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
if (timer !== undefined)
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
warn(detail) {
|
|
426
|
+
this.eventSink?.({
|
|
427
|
+
type: 'warning',
|
|
428
|
+
message: `SessionEnd hook failed: ${detail}`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
322
432
|
export class ClaudeSessionService {
|
|
323
433
|
options;
|
|
324
434
|
schema;
|
|
@@ -340,9 +450,11 @@ export class ClaudeSessionService {
|
|
|
340
450
|
activeCostSessionId;
|
|
341
451
|
closeCostSavePromise;
|
|
342
452
|
sessionMemoryControllers = new Map();
|
|
453
|
+
hookLifecycle;
|
|
343
454
|
runtimeCwd;
|
|
344
455
|
constructor(options) {
|
|
345
456
|
this.options = options;
|
|
457
|
+
this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
|
|
346
458
|
this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
|
|
347
459
|
this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
|
|
348
460
|
this.scheduledPrompts =
|
|
@@ -481,6 +593,7 @@ export class ClaudeSessionService {
|
|
|
481
593
|
}
|
|
482
594
|
}
|
|
483
595
|
async close() {
|
|
596
|
+
await this.hookLifecycle.close();
|
|
484
597
|
this.scheduledPrompts?.close();
|
|
485
598
|
await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
|
|
486
599
|
await Promise.resolve();
|
|
@@ -494,6 +607,9 @@ export class ClaudeSessionService {
|
|
|
494
607
|
this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
|
|
495
608
|
await this.mcpClosePromise;
|
|
496
609
|
}
|
|
610
|
+
async transitionHookSession(sessionId, reason) {
|
|
611
|
+
await this.hookLifecycle.transition(sessionId, reason);
|
|
612
|
+
}
|
|
497
613
|
createHostedToolRegistry(sessionId) {
|
|
498
614
|
const baseTools = this.options.tools;
|
|
499
615
|
if (!baseTools)
|
|
@@ -755,7 +871,7 @@ export class ClaudeSessionService {
|
|
|
755
871
|
...(onDelta ? { onTextDelta: onDelta } : {}),
|
|
756
872
|
onMetrics: (recorded) => this.recordAuxiliaryMetrics(activeSessionId, recorded),
|
|
757
873
|
});
|
|
758
|
-
budget?.observeUsage(metrics.usage);
|
|
874
|
+
budget?.observeUsage(metrics.usage, messages);
|
|
759
875
|
if (metrics.toolCalls.length > 0) {
|
|
760
876
|
throw new Error('Side questions cannot call tools; press f to fork');
|
|
761
877
|
}
|
|
@@ -2243,17 +2359,11 @@ export class ClaudeSessionService {
|
|
|
2243
2359
|
}
|
|
2244
2360
|
},
|
|
2245
2361
|
};
|
|
2362
|
+
let turnCompleted = false;
|
|
2246
2363
|
try {
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
...hookSession,
|
|
2250
|
-
hook_event_name: 'SessionStart',
|
|
2251
|
-
source: requireExisting ? 'resume' : 'startup',
|
|
2252
|
-
}, requireExisting ? 'resume' : 'startup', signal);
|
|
2364
|
+
const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
|
|
2365
|
+
if (outcome) {
|
|
2253
2366
|
await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
|
|
2254
|
-
if (outcome.blockedReason) {
|
|
2255
|
-
throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
|
|
2256
|
-
}
|
|
2257
2367
|
}
|
|
2258
2368
|
const approveRecovery = this.options.approveRecovery;
|
|
2259
2369
|
const recoveryRequest = {
|
|
@@ -2620,15 +2730,9 @@ export class ClaudeSessionService {
|
|
|
2620
2730
|
// runtime-only context so the next request retains current
|
|
2621
2731
|
// instructions, plan state, session memory, and hook context.
|
|
2622
2732
|
if (this.options.hooks) {
|
|
2623
|
-
const outcome = await this.
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
source: 'compact',
|
|
2627
|
-
}, 'compact', signal);
|
|
2628
|
-
await recordHookOutcome(outcome);
|
|
2629
|
-
if (outcome.blockedReason) {
|
|
2630
|
-
throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
|
|
2631
|
-
}
|
|
2733
|
+
const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
|
|
2734
|
+
if (outcome)
|
|
2735
|
+
await recordHookOutcome(outcome);
|
|
2632
2736
|
}
|
|
2633
2737
|
await refreshRuntimeContext();
|
|
2634
2738
|
this.options.eventSink?.({
|
|
@@ -2947,7 +3051,7 @@ export class ClaudeSessionService {
|
|
|
2947
3051
|
: undefined) ??
|
|
2948
3052
|
Object.values(result.modelUsage ?? {})[0] ??
|
|
2949
3053
|
result.usage;
|
|
2950
|
-
budget?.observeUsage(observedUsage);
|
|
3054
|
+
budget?.observeUsage(observedUsage, runtimeRequest.messages, definitions);
|
|
2951
3055
|
if (structuredCapture && structuredCapture.calls !== 1) {
|
|
2952
3056
|
throw new Error(`StructuredOutput must be called exactly once (received ${structuredCapture.calls})`);
|
|
2953
3057
|
}
|
|
@@ -3044,19 +3148,23 @@ export class ClaudeSessionService {
|
|
|
3044
3148
|
}
|
|
3045
3149
|
if (sessionMemory && finalLeafUuid) {
|
|
3046
3150
|
const turnInputTokens = result.usage?.inputTokens ?? 0;
|
|
3151
|
+
const warn = (error) => this.options.eventSink?.({
|
|
3152
|
+
type: 'warning',
|
|
3153
|
+
message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3154
|
+
});
|
|
3047
3155
|
try {
|
|
3048
3156
|
await sessionMemory.observeDelta(turnInputTokens, currentTurnToolCalls, finalLeafUuid, projectClaudeModelMessages(snapshot.entries));
|
|
3049
|
-
|
|
3157
|
+
// Normal turns schedule extraction without awaiting it; failures
|
|
3158
|
+
// surface as a warning while the sidecar stays retryable.
|
|
3159
|
+
sessionMemory.waitForIdle().catch(warn);
|
|
3050
3160
|
}
|
|
3051
3161
|
catch (error) {
|
|
3052
|
-
// A failed
|
|
3162
|
+
// A failed observation must not fail the user turn; the sidecar
|
|
3053
3163
|
// retains a retryable error for the next observation.
|
|
3054
|
-
|
|
3055
|
-
type: 'warning',
|
|
3056
|
-
message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3057
|
-
});
|
|
3164
|
+
warn(error);
|
|
3058
3165
|
}
|
|
3059
3166
|
}
|
|
3167
|
+
turnCompleted = true;
|
|
3060
3168
|
return {
|
|
3061
3169
|
sessionId,
|
|
3062
3170
|
text: structuredCapture && structuredCapture.calls === 1
|
|
@@ -3074,35 +3182,8 @@ export class ClaudeSessionService {
|
|
|
3074
3182
|
};
|
|
3075
3183
|
}
|
|
3076
3184
|
finally {
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
...hookSession,
|
|
3080
|
-
hook_event_name: 'SessionEnd',
|
|
3081
|
-
reason: 'other',
|
|
3082
|
-
}, 'other');
|
|
3083
|
-
const failedExecutions = outcome?.executions.filter((execution) => execution.exitCode !== 0) ?? [];
|
|
3084
|
-
for (const execution of failedExecutions) {
|
|
3085
|
-
const detail = execution.stderr.trim() ||
|
|
3086
|
-
execution.stdout.trim() ||
|
|
3087
|
-
`exit code ${execution.exitCode}`;
|
|
3088
|
-
this.options.eventSink?.({
|
|
3089
|
-
type: 'warning',
|
|
3090
|
-
message: `SessionEnd hook failed: ${detail}`,
|
|
3091
|
-
});
|
|
3092
|
-
}
|
|
3093
|
-
if (outcome?.blockedReason &&
|
|
3094
|
-
outcome.executions.at(-1)?.exitCode === 0) {
|
|
3095
|
-
this.options.eventSink?.({
|
|
3096
|
-
type: 'warning',
|
|
3097
|
-
message: `SessionEnd hook failed: ${outcome.blockedReason}`,
|
|
3098
|
-
});
|
|
3099
|
-
}
|
|
3100
|
-
}
|
|
3101
|
-
catch (error) {
|
|
3102
|
-
this.options.eventSink?.({
|
|
3103
|
-
type: 'warning',
|
|
3104
|
-
message: `SessionEnd hook failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3105
|
-
});
|
|
3185
|
+
if (!turnCompleted) {
|
|
3186
|
+
await this.hookLifecycle.end(sessionId, 'other');
|
|
3106
3187
|
}
|
|
3107
3188
|
}
|
|
3108
3189
|
});
|
|
@@ -3774,6 +3855,10 @@ export class ClaudeSessionService {
|
|
|
3774
3855
|
return new ContextBudget({
|
|
3775
3856
|
contextWindowTokens,
|
|
3776
3857
|
windowSource: 'capability',
|
|
3858
|
+
onAccountingDiagnostic: (message) => this.options.eventSink?.({
|
|
3859
|
+
type: 'warning',
|
|
3860
|
+
message: `Context usage accounting: ${message}`,
|
|
3861
|
+
}),
|
|
3777
3862
|
...(this.options.contextReserveTokens === undefined
|
|
3778
3863
|
? {}
|
|
3779
3864
|
: { reserveTokens: this.options.contextReserveTokens }),
|
|
@@ -1461,6 +1461,10 @@ export class ClaudeSubagentExecutor {
|
|
|
1461
1461
|
const contextBudget = options.provider.capabilities.contextWindowTokens
|
|
1462
1462
|
? new ContextBudget({
|
|
1463
1463
|
contextWindowTokens: options.provider.capabilities.contextWindowTokens,
|
|
1464
|
+
onAccountingDiagnostic: (message) => this.options.eventSink?.({
|
|
1465
|
+
type: 'warning',
|
|
1466
|
+
message: `Context usage accounting: ${message}`,
|
|
1467
|
+
}),
|
|
1464
1468
|
...(this.options.contextReserveTokens === undefined
|
|
1465
1469
|
? {}
|
|
1466
1470
|
: { reserveTokens: this.options.contextReserveTokens }),
|
|
@@ -1469,6 +1473,7 @@ export class ClaudeSubagentExecutor {
|
|
|
1469
1473
|
const definitions = options.provider.capabilities.tools
|
|
1470
1474
|
? runtimeTools.definitions()
|
|
1471
1475
|
: [];
|
|
1476
|
+
let observedMessages;
|
|
1472
1477
|
const assembleMessages = async () => {
|
|
1473
1478
|
const assembledContext = await this.options.contextAssembler?.assemble({
|
|
1474
1479
|
cwd,
|
|
@@ -1479,6 +1484,7 @@ export class ClaudeSubagentExecutor {
|
|
|
1479
1484
|
...injectFirstUserMessageContext(projectClaudeModelMessages(snapshot.entries), assembledContext?.firstUserMessageContext),
|
|
1480
1485
|
...preloadedSkills,
|
|
1481
1486
|
];
|
|
1487
|
+
observedMessages = messages;
|
|
1482
1488
|
if (contextBudget) {
|
|
1483
1489
|
contextBudget.assertFits(contextBudget.evaluate(messages, definitions));
|
|
1484
1490
|
}
|
|
@@ -1547,6 +1553,7 @@ export class ClaudeSubagentExecutor {
|
|
|
1547
1553
|
: {}),
|
|
1548
1554
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1549
1555
|
});
|
|
1556
|
+
contextBudget?.observeUsage(result.usage, observedMessages ?? [], definitions);
|
|
1550
1557
|
this.options.eventSink?.({
|
|
1551
1558
|
type: 'task-progress',
|
|
1552
1559
|
taskId: options.agentId,
|
package/dist/cli/interactive.js
CHANGED
|
@@ -3435,9 +3435,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3435
3435
|
if (selectingSession) {
|
|
3436
3436
|
if (key.escape || value === '\u001B') {
|
|
3437
3437
|
if (pickerIncludesNewSessionRef.current && allowNewSession) {
|
|
3438
|
-
setSessionId(null);
|
|
3439
|
-
setPendingFork(false);
|
|
3440
3438
|
setSelectingSession(false);
|
|
3439
|
+
const previousSessionId = sessionIdRef.current;
|
|
3440
|
+
void (async () => {
|
|
3441
|
+
if (previousSessionId) {
|
|
3442
|
+
const commands = await service();
|
|
3443
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
3444
|
+
}
|
|
3445
|
+
openSession(null);
|
|
3446
|
+
setPendingFork(false);
|
|
3447
|
+
})().catch(warn);
|
|
3441
3448
|
}
|
|
3442
3449
|
else if (allowNewSession) {
|
|
3443
3450
|
setSelectingSession(false);
|
|
@@ -3462,10 +3469,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3462
3469
|
const selected = filterSessionChoices(currentPickerChoices, sessionSearchRef.current)[selectedIndexRef.current];
|
|
3463
3470
|
if (selected === undefined)
|
|
3464
3471
|
return;
|
|
3465
|
-
openSession(selected?.sessionId ?? null);
|
|
3466
|
-
if (!selected)
|
|
3467
|
-
setPendingFork(false);
|
|
3468
3472
|
setSelectingSession(false);
|
|
3473
|
+
const nextSessionId = selected?.sessionId ?? null;
|
|
3474
|
+
const previousSessionId = sessionIdRef.current;
|
|
3475
|
+
void (async () => {
|
|
3476
|
+
if (previousSessionId && previousSessionId !== nextSessionId) {
|
|
3477
|
+
const commands = await service();
|
|
3478
|
+
await commands.transitionHookSession?.(previousSessionId, nextSessionId === null ? 'clear' : 'resume');
|
|
3479
|
+
}
|
|
3480
|
+
openSession(nextSessionId);
|
|
3481
|
+
if (!selected)
|
|
3482
|
+
setPendingFork(false);
|
|
3483
|
+
})().catch(warn);
|
|
3469
3484
|
}
|
|
3470
3485
|
else if (key.backspace || key.delete) {
|
|
3471
3486
|
sessionSearchRef.current = sessionSearchRef.current.slice(0, -1);
|
|
@@ -5220,28 +5235,40 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
5220
5235
|
updateMenu({ kind: 'help', tabIndex: 0, selectedIndex: 0 });
|
|
5221
5236
|
}
|
|
5222
5237
|
else if (prompt === '/new') {
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5238
|
+
const previousSessionId = sessionIdRef.current;
|
|
5239
|
+
void (async () => {
|
|
5240
|
+
if (previousSessionId) {
|
|
5241
|
+
const commands = await service();
|
|
5242
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
5243
|
+
}
|
|
5244
|
+
statusLineSessionId.current = randomUUID();
|
|
5245
|
+
openSession(null);
|
|
5246
|
+
setPendingFork(false);
|
|
5247
|
+
append({ kind: 'notice', text: 'Started a new session.' });
|
|
5248
|
+
})().catch(warn);
|
|
5228
5249
|
}
|
|
5229
5250
|
else if (prompt === '/clear') {
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5251
|
+
const previousSessionId = sessionIdRef.current;
|
|
5252
|
+
void (async () => {
|
|
5253
|
+
if (previousSessionId) {
|
|
5254
|
+
const commands = await service();
|
|
5255
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
5256
|
+
}
|
|
5257
|
+
statusLineSessionId.current = randomUUID();
|
|
5258
|
+
openSession(null);
|
|
5259
|
+
setPendingFork(false);
|
|
5260
|
+
setHistory([]);
|
|
5261
|
+
setUsage(undefined);
|
|
5262
|
+
setCostUsd(undefined);
|
|
5263
|
+
streamingFrameRef.current?.resetText();
|
|
5264
|
+
streamingFrameRef.current?.resetThinking();
|
|
5265
|
+
streamingFrameRef.current?.flush();
|
|
5266
|
+
setThinkingExpanded(false);
|
|
5267
|
+
setStatus('ready');
|
|
5268
|
+
inputHistoryRef.current = [];
|
|
5269
|
+
inputHistoryIndexRef.current = null;
|
|
5270
|
+
inputHistoryDraftRef.current = '';
|
|
5271
|
+
})().catch(warn);
|
|
5245
5272
|
}
|
|
5246
5273
|
else if (prompt === '/model') {
|
|
5247
5274
|
updateMenu({ kind: 'model', selectedIndex: 0 });
|
package/dist/cli-runtime.js
CHANGED
|
@@ -1577,6 +1577,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
|
|
|
1577
1577
|
rename: (sessionId, name) => service.rename(sessionId, name),
|
|
1578
1578
|
sessionNameSuggestion: (sessionId, signal) => service.sessionNameSuggestion(sessionId, signal),
|
|
1579
1579
|
nextScheduledPrompt: (signal) => service.nextScheduledPrompt(signal),
|
|
1580
|
+
transitionHookSession: (sessionId, reason) => service.transitionHookSession(sessionId, reason),
|
|
1580
1581
|
close: async () => {
|
|
1581
1582
|
let failure;
|
|
1582
1583
|
try {
|
|
@@ -181,8 +181,10 @@ export function createClaudeNativeFork({ source, sourceSessionId, sessionId, res
|
|
|
181
181
|
(metadata.direction === 'from' ||
|
|
182
182
|
metadata.direction === 'up_to'));
|
|
183
183
|
});
|
|
184
|
+
const hasCompactHistory = source.some((entry) => entry.isCompactSummary === true ||
|
|
185
|
+
(entry.type === 'system' && entry.subtype === 'compact_boundary'));
|
|
184
186
|
const activeSource = resumeSessionAt === undefined
|
|
185
|
-
? hasSelectiveSummary
|
|
187
|
+
? hasCompactHistory || hasSelectiveSummary
|
|
186
188
|
? selectClaudeActiveTranscript(source)
|
|
187
189
|
: source
|
|
188
190
|
: selectClaudeTranscriptAtMessage(source, resumeSessionAt);
|
|
@@ -18,7 +18,11 @@ function latestLeafUuid(entries) {
|
|
|
18
18
|
entry.isSidechain !== true);
|
|
19
19
|
if (!hasNewDescendant)
|
|
20
20
|
return summary.uuid;
|
|
21
|
-
|
|
21
|
+
// After compaction the active leaf continues from the boundary's logical
|
|
22
|
+
// parent. Ignore unrelated physical entries that do not descend from the
|
|
23
|
+
// boundary and keep the compact summary as the leaf when none do.
|
|
24
|
+
const continuation = latestCompactBranchLeafUuid(entries, index + 1, boundary ? entryUuid(boundary) : null, summary.uuid);
|
|
25
|
+
return continuation === null ? summary.uuid : continuation;
|
|
22
26
|
}
|
|
23
27
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
24
28
|
const entry = entries[index];
|
|
@@ -33,6 +37,45 @@ function latestLeafUuid(entries) {
|
|
|
33
37
|
}
|
|
34
38
|
return null;
|
|
35
39
|
}
|
|
40
|
+
function latestCompactBranchLeafUuid(entries, fromIndex, boundaryUuid, summaryUuid) {
|
|
41
|
+
const byUuid = new Map();
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const uuid = entryUuid(entry);
|
|
44
|
+
if (uuid && entry.isSidechain !== true)
|
|
45
|
+
byUuid.set(uuid, entry);
|
|
46
|
+
}
|
|
47
|
+
for (let index = entries.length - 1; index >= fromIndex; index -= 1) {
|
|
48
|
+
const entry = entries[index];
|
|
49
|
+
if (!entry || entry.isSidechain === true)
|
|
50
|
+
continue;
|
|
51
|
+
const candidate = entry.type === 'last-prompt' && typeof entry.leafUuid === 'string'
|
|
52
|
+
? entry.leafUuid
|
|
53
|
+
: entryUuid(entry);
|
|
54
|
+
if (candidate === null)
|
|
55
|
+
continue;
|
|
56
|
+
let uuid = candidate;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
while (uuid !== null) {
|
|
59
|
+
if (uuid === summaryUuid || uuid === boundaryUuid)
|
|
60
|
+
return candidate;
|
|
61
|
+
if (seen.has(uuid))
|
|
62
|
+
break;
|
|
63
|
+
seen.add(uuid);
|
|
64
|
+
const node = byUuid.get(uuid);
|
|
65
|
+
if (!node)
|
|
66
|
+
break;
|
|
67
|
+
uuid =
|
|
68
|
+
node.type === 'system' &&
|
|
69
|
+
node.subtype === 'compact_boundary' &&
|
|
70
|
+
typeof node.logicalParentUuid === 'string'
|
|
71
|
+
? node.logicalParentUuid
|
|
72
|
+
: typeof node.parentUuid === 'string'
|
|
73
|
+
? node.parentUuid
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
36
79
|
function ancestryUuids(entries, leafUuid) {
|
|
37
80
|
const byUuid = new Map();
|
|
38
81
|
for (const entry of entries) {
|
|
@@ -72,6 +115,9 @@ function selectAncestry(entries, leafUuid) {
|
|
|
72
115
|
const uuid = entryUuid(entry);
|
|
73
116
|
if (uuid) {
|
|
74
117
|
return (active.has(uuid) ||
|
|
118
|
+
(entry.type === 'attachment' &&
|
|
119
|
+
typeof entry.parentUuid === 'string' &&
|
|
120
|
+
active.has(entry.parentUuid)) ||
|
|
75
121
|
(entry.type === 'user' &&
|
|
76
122
|
typeof entry.sourceToolAssistantUUID === 'string' &&
|
|
77
123
|
active.has(entry.sourceToolAssistantUUID)));
|
|
@@ -5,6 +5,9 @@ export interface ContextBudgetOptions {
|
|
|
5
5
|
/** Declares whether the configured window came from a provider capability so
|
|
6
6
|
* reports can distinguish provider-derived decisions from estimates. */
|
|
7
7
|
windowSource?: 'capability' | 'estimate';
|
|
8
|
+
/** Receives at most one bounded diagnostic when provider accounting is
|
|
9
|
+
* malformed and the deterministic estimate fallback is used. */
|
|
10
|
+
onAccountingDiagnostic?: (message: string) => void;
|
|
8
11
|
}
|
|
9
12
|
export interface ContextBudgetEvaluateOptions {
|
|
10
13
|
/** Most recent provider usage observation; a positive `contextWindow` is
|
|
@@ -18,6 +21,11 @@ export interface ContextBudgetEvaluateOptions {
|
|
|
18
21
|
export type ContextBudgetSource = 'provider' | 'capability' | 'estimate';
|
|
19
22
|
export interface ContextBudgetReport {
|
|
20
23
|
estimatedTokens: number;
|
|
24
|
+
/** Total context occupancy used for overflow accounting: the actual provider
|
|
25
|
+
* input/cache tokens at the observation watermark plus deterministic
|
|
26
|
+
* estimated tokens added after that watermark. Without a usable watermark
|
|
27
|
+
* this equals `estimatedTokens`. */
|
|
28
|
+
occupancyTokens: number;
|
|
21
29
|
contextWindowTokens: number;
|
|
22
30
|
reserveTokens: number;
|
|
23
31
|
availableTokens: number;
|
|
@@ -37,10 +45,29 @@ export declare class ContextBudget {
|
|
|
37
45
|
readonly reserveTokens: number;
|
|
38
46
|
readonly windowSource: 'capability' | 'estimate';
|
|
39
47
|
private observedUsage;
|
|
48
|
+
/** Actual provider input/cache tokens at the most recent usable observation;
|
|
49
|
+
* the watermark that anchors later occupancy. */
|
|
50
|
+
private watermarkActualInputTokens;
|
|
51
|
+
/** Deterministic estimate of the request snapshot captured at observation
|
|
52
|
+
* time; only growth beyond this baseline is added to the watermark. */
|
|
53
|
+
private watermarkBaselineEstimate;
|
|
54
|
+
private accountingDiagnosticEmitted;
|
|
55
|
+
private readonly onAccountingDiagnostic;
|
|
40
56
|
constructor(options: ContextBudgetOptions);
|
|
41
|
-
|
|
57
|
+
/** Record a completed provider request: `usage` carries the actual token
|
|
58
|
+
* counts and `messages`/`tools` are the exact snapshot used for that
|
|
59
|
+
* request. The snapshot's deterministic estimate becomes the watermark
|
|
60
|
+
* baseline so later evaluations add only post-watermark growth. Malformed
|
|
61
|
+
* usage is ignored (fail-open) and never throws; a valid `contextWindow`
|
|
62
|
+
* still updates the effective window through `observedUsage`. */
|
|
63
|
+
observeUsage(usage: ModelUsage, messages?: readonly ModelMessage[], tools?: readonly ModelToolDefinition[]): void;
|
|
42
64
|
effectiveContextWindow(usage?: ModelUsage): number;
|
|
43
65
|
evaluate(messages: readonly ModelMessage[], tools?: readonly ModelToolDefinition[], options?: ContextBudgetEvaluateOptions): ContextBudgetReport;
|
|
66
|
+
/** Occupancy anchored at the actual input/cache watermark, adding only the
|
|
67
|
+
* deterministic estimated growth past the observation baseline. Without a
|
|
68
|
+
* usable watermark this is the plain estimate fallback. */
|
|
69
|
+
private anchoredOccupancyTokens;
|
|
70
|
+
private emitAccountingDiagnostic;
|
|
44
71
|
assertFits(report: ContextBudgetReport): void;
|
|
45
72
|
/** Returns the positive provider-reported context window, if any. */
|
|
46
73
|
private providerContextWindow;
|
|
@@ -74,11 +74,38 @@ export function estimateModelRequestTokens(messages, tools = []) {
|
|
|
74
74
|
estimateTextTokens(JSON.stringify(tool.inputSchema)), 0);
|
|
75
75
|
return messageTokens + toolTokens;
|
|
76
76
|
}
|
|
77
|
+
/** Normalized provider input occupancy counting input and cache-read/creation
|
|
78
|
+
* fields without output tokens. Returns `undefined` for malformed, negative,
|
|
79
|
+
* or non-safe usage so accounting fails open. */
|
|
80
|
+
function normalizedInputAndCacheTokens(usage) {
|
|
81
|
+
if (!Number.isSafeInteger(usage.inputTokens) ||
|
|
82
|
+
usage.inputTokens < 0 ||
|
|
83
|
+
(usage.cacheReadInputTokens !== undefined &&
|
|
84
|
+
(!Number.isSafeInteger(usage.cacheReadInputTokens) ||
|
|
85
|
+
usage.cacheReadInputTokens < 0)) ||
|
|
86
|
+
(usage.cacheCreationInputTokens !== undefined &&
|
|
87
|
+
(!Number.isSafeInteger(usage.cacheCreationInputTokens) ||
|
|
88
|
+
usage.cacheCreationInputTokens < 0))) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const candidate = (usage.inputTokens ?? 0) +
|
|
92
|
+
(usage.cacheReadInputTokens ?? 0) +
|
|
93
|
+
(usage.cacheCreationInputTokens ?? 0);
|
|
94
|
+
return Number.isSafeInteger(candidate) ? candidate : undefined;
|
|
95
|
+
}
|
|
77
96
|
export class ContextBudget {
|
|
78
97
|
contextWindowTokens;
|
|
79
98
|
reserveTokens;
|
|
80
99
|
windowSource;
|
|
81
100
|
observedUsage;
|
|
101
|
+
/** Actual provider input/cache tokens at the most recent usable observation;
|
|
102
|
+
* the watermark that anchors later occupancy. */
|
|
103
|
+
watermarkActualInputTokens;
|
|
104
|
+
/** Deterministic estimate of the request snapshot captured at observation
|
|
105
|
+
* time; only growth beyond this baseline is added to the watermark. */
|
|
106
|
+
watermarkBaselineEstimate;
|
|
107
|
+
accountingDiagnosticEmitted = false;
|
|
108
|
+
onAccountingDiagnostic;
|
|
82
109
|
constructor(options) {
|
|
83
110
|
requirePositiveInteger(options.contextWindowTokens, 'Context window tokens');
|
|
84
111
|
const defaultReserve = Math.min(8192, Math.max(1, Math.floor(options.contextWindowTokens / 10)));
|
|
@@ -90,15 +117,32 @@ export class ContextBudget {
|
|
|
90
117
|
this.contextWindowTokens = options.contextWindowTokens;
|
|
91
118
|
this.reserveTokens = reserveTokens;
|
|
92
119
|
this.windowSource = options.windowSource ?? 'estimate';
|
|
120
|
+
this.onAccountingDiagnostic = options.onAccountingDiagnostic;
|
|
93
121
|
}
|
|
94
|
-
|
|
122
|
+
/** Record a completed provider request: `usage` carries the actual token
|
|
123
|
+
* counts and `messages`/`tools` are the exact snapshot used for that
|
|
124
|
+
* request. The snapshot's deterministic estimate becomes the watermark
|
|
125
|
+
* baseline so later evaluations add only post-watermark growth. Malformed
|
|
126
|
+
* usage is ignored (fail-open) and never throws; a valid `contextWindow`
|
|
127
|
+
* still updates the effective window through `observedUsage`. */
|
|
128
|
+
observeUsage(usage, messages = [], tools = []) {
|
|
95
129
|
this.observedUsage = usage;
|
|
130
|
+
const actualInputTokens = normalizedInputAndCacheTokens(usage);
|
|
131
|
+
if (actualInputTokens === undefined) {
|
|
132
|
+
this.emitAccountingDiagnostic();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (messages.length === 0 && tools.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
this.watermarkActualInputTokens = actualInputTokens;
|
|
138
|
+
this.watermarkBaselineEstimate = estimateModelRequestTokens(messages, tools);
|
|
96
139
|
}
|
|
97
140
|
effectiveContextWindow(usage) {
|
|
98
141
|
return this.providerContextWindow(usage) ?? this.contextWindowTokens;
|
|
99
142
|
}
|
|
100
143
|
evaluate(messages, tools = [], options = {}) {
|
|
101
144
|
const estimatedTokens = estimateModelRequestTokens(messages, tools);
|
|
145
|
+
const occupancyTokens = this.anchoredOccupancyTokens(estimatedTokens);
|
|
102
146
|
const providerWindow = this.providerContextWindow(options.lastUsage);
|
|
103
147
|
const contextWindowTokens = providerWindow ?? this.contextWindowTokens;
|
|
104
148
|
const outputTokens = options.outputTokens !== undefined &&
|
|
@@ -107,10 +151,11 @@ export class ContextBudget {
|
|
|
107
151
|
? options.outputTokens
|
|
108
152
|
: 0;
|
|
109
153
|
const availableTokens = Math.max(0, contextWindowTokens - this.reserveTokens);
|
|
110
|
-
const overflowTokens = Math.max(0,
|
|
154
|
+
const overflowTokens = Math.max(0, occupancyTokens + outputTokens - availableTokens);
|
|
111
155
|
const shouldCompact = options.promptTooLong === true || overflowTokens > 0;
|
|
112
156
|
return {
|
|
113
157
|
estimatedTokens,
|
|
158
|
+
occupancyTokens,
|
|
114
159
|
contextWindowTokens,
|
|
115
160
|
reserveTokens: this.reserveTokens,
|
|
116
161
|
availableTokens,
|
|
@@ -119,6 +164,29 @@ export class ContextBudget {
|
|
|
119
164
|
source: providerWindow === undefined ? this.windowSource : 'provider',
|
|
120
165
|
};
|
|
121
166
|
}
|
|
167
|
+
/** Occupancy anchored at the actual input/cache watermark, adding only the
|
|
168
|
+
* deterministic estimated growth past the observation baseline. Without a
|
|
169
|
+
* usable watermark this is the plain estimate fallback. */
|
|
170
|
+
anchoredOccupancyTokens(estimatedTokens) {
|
|
171
|
+
if (this.watermarkActualInputTokens === undefined ||
|
|
172
|
+
this.watermarkBaselineEstimate === undefined) {
|
|
173
|
+
return estimatedTokens;
|
|
174
|
+
}
|
|
175
|
+
const growthAfterWatermark = Math.max(0, estimatedTokens - this.watermarkBaselineEstimate);
|
|
176
|
+
return this.watermarkActualInputTokens + growthAfterWatermark;
|
|
177
|
+
}
|
|
178
|
+
emitAccountingDiagnostic() {
|
|
179
|
+
if (this.accountingDiagnosticEmitted)
|
|
180
|
+
return;
|
|
181
|
+
this.accountingDiagnosticEmitted = true;
|
|
182
|
+
try {
|
|
183
|
+
this.onAccountingDiagnostic?.('Provider input usage was malformed; using deterministic context estimates.');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Diagnostics are strictly best-effort. A broken sink must never turn
|
|
187
|
+
// fail-open accounting into a healthy-turn failure.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
122
190
|
assertFits(report) {
|
|
123
191
|
if (report.shouldCompact)
|
|
124
192
|
throw new ContextOverflowError(report);
|