Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/npx-cli/commands/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const COLLECTED_FIELDS = [
'invalid_output_class xml / idle / prose / poisoned (never the output)',
'consecutive_invalid_outputs unusable outputs in a row before recovery',
'respawn_triggered whether the compression agent was restarted',
'abort_reason idle / shutdown / overflow / restart_guard / quota / poisoned / none',
'abort_reason idle / shutdown / overflow / context_bound / restart_guard / quota / poisoned / none',
'previous_shutdown crash / clean / unknown (detected at worker start)',
'previous_uptime_seconds / uptime_seconds',
' worker uptime in whole seconds (previous run / at stop)',
Expand Down
3 changes: 2 additions & 1 deletion src/services/telemetry/scrub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export const ALLOWED_PROPERTY_KEYS: Set<string> = new Set([
// session_compressed trust signals — booleans, counters, and our own
// closed enums (invalid_output_class: xml | idle | prose | poisoned, where
// 'xml' means XML-shaped output that still failed to parse; abort_reason:
// idle | shutdown | overflow | restart_guard | quota | poisoned | none).
// idle | shutdown | overflow | context_bound | restart_guard | quota |
// poisoned | none).
// Never model output, never raw abort strings.
'fabrication_detected',
'fabricated_count',
Expand Down
2 changes: 1 addition & 1 deletion src/services/worker-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface ActiveSession {
lastSummaryStored?: boolean;
pendingAgentId?: string | null;
pendingAgentType?: string | null;
abortReason?: 'idle' | 'shutdown' | 'overflow' | 'restart-guard' | 'quota' | string | null;
abortReason?: 'idle' | 'shutdown' | 'overflow' | 'context-bound' | 'restart-guard' | 'quota' | string | null;
respawnTimer?: ReturnType<typeof setTimeout>;
/** When the latest compression prompt was dispatched to the model — telemetry compression_ms. */
lastPromptSentAt?: number | null;
Expand Down
93 changes: 90 additions & 3 deletions src/services/worker/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,51 @@ export function classifyClaudeError(err: unknown): ClassifiedProviderError {
return new ClassifiedProviderError(message, { kind: 'transient', cause: err });
}

/**
* Default proactive observer context cap (#2956), used when
* CLAUDE_MEM_CLAUDE_MAX_TOKENS is unset or non-numeric.
*/
export const DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS = 150000;

/**
* Resolve the configured observer context cap, falling back to the default for
* unset / non-numeric / non-positive values. Exported so the proactive-bound
* tests exercise the real resolution rule rather than a copy.
*/
export function resolveObserverMaxTokens(
settings: { CLAUDE_MEM_CLAUDE_MAX_TOKENS?: string }
): number {
return parseInt(settings.CLAUDE_MEM_CLAUDE_MAX_TOKENS ?? '', 10) || DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS;
}

/**
* The full context the model read on a turn: fresh input + cache writes + cache
* reads. This is the value that grows across an observer session and eventually
* overflows the window. Exported so the tests assert the real accounting.
*/
export function computeFullContextTokens(
usage: {
input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
cache_read_input_tokens?: number | null;
} | undefined | null
): number {
if (!usage) return 0;
return (
(usage.input_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0)
);
}

/**
* The proactive-bound predicate. Exported and used by both the guard and its
* tests so a change here (e.g. > vs >=) cannot silently diverge from coverage.
*/
export function observerContextExceeded(currentContextTokens: number, maxObserverTokens: number): boolean {
return currentContextTokens > maxObserverTokens;
}

export class ClaudeProvider {
private dbManager: DatabaseManager;
private sessionManager: SessionManager;
Expand Down Expand Up @@ -202,6 +247,15 @@ export class ClaudeProvider {

const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
// #2956 — proactive cumulative context bound. Unlike the OpenRouter/Gemini
// providers (which resend a sliced history each stateless turn), the Agent
// SDK owns the conversation internally and we resume it via memorySessionId,
// so session.conversationHistory cannot be truncated to bound context. The
// only lever is to start a fresh SDK session once the SDK-reported context
// grows too large. We read the real per-turn context size from usage and, if
// it exceeds this cap, reset to a fresh start *before* hitting the hard
// "Prompt is too long" wall (which otherwise saves zero memory).
const maxObserverTokens = resolveObserverMaxTokens(settings);
await waitForSlot(maxConcurrent, session.abortController.signal);

const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth());
Expand Down Expand Up @@ -303,6 +357,10 @@ export class ClaudeProvider {
}

if (message.type === 'assistant') {
// #2956 — full read-context size reported by the SDK for this turn
// (fresh input + cache writes + cache reads). 0 when the turn carried
// no usage; the proactive bound below only fires on a real reading.
let currentContextTokens = 0;
const content = message.message.content;
const textContent = Array.isArray(content)
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
Expand Down Expand Up @@ -332,13 +390,17 @@ export class ClaudeProvider {

// Real per-response usage for telemetry (tokens_input includes the
// full context the model read: fresh + cache writes + cache reads).
const fullContextTokens = computeFullContextTokens(usage);
session.lastUsage = {
input: (usage.input_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0),
input: fullContextTokens,
output: usage.output_tokens || 0,
};

// #2956 — the full context the model just read. When this crosses the
// configured cap we reset to a fresh SDK session after the current
// turn's memory is saved (see the guard after processAgentResponse).
currentContextTokens = fullContextTokens;

logger.debug('SDK', 'Token usage captured', {
sessionId: session.sessionDbId,
inputTokens: usage.input_tokens,
Expand Down Expand Up @@ -388,6 +450,31 @@ export class ClaudeProvider {
cwdTracker.lastCwd,
modelId
);

// #2956 — proactive cumulative context bound. The current turn's
// memory has now been emitted, so resetting here loses nothing. If the
// SDK-reported context exceeded the cap, clear memorySessionId (forces
// a fresh, small SDK session on the next observation ingest) and abort
// this generator — mirroring the in-loop quota guard above. This stops
// the observer before it hits the hard "Prompt is too long" wall that
// would otherwise abort with zero memory saved.
if (observerContextExceeded(currentContextTokens, maxObserverTokens)) {
logger.warn('SDK', 'Observer context bound exceeded - resetting to a fresh SDK session', {
sessionId: session.sessionDbId,
contextTokens: currentContextTokens,
tokenLimit: maxObserverTokens
});
this.resetSessionForFreshStart(session);
// Internal abort reasons are hyphenated (cf. 'restart-guard');
// normalizeAbortReason maps this to the underscore telemetry enum.
session.abortReason = 'context-bound';
try {
session.abortController.abort();
} catch {
// best-effort
}
break;
}
}

if (message.type === 'result') {
Expand Down
3 changes: 2 additions & 1 deletion src/services/worker/http/routes/SessionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ const MAX_USER_PROMPT_BYTES = 256 * 1024;
*/
function normalizeAbortReason(
reason: string | null | undefined
): 'idle' | 'shutdown' | 'overflow' | 'restart_guard' | 'quota' | 'poisoned' | 'none' {
): 'idle' | 'shutdown' | 'overflow' | 'context_bound' | 'restart_guard' | 'quota' | 'poisoned' | 'none' {
switch ((reason ?? '').split(':')[0]) {
case 'idle': return 'idle';
case 'shutdown': return 'shutdown';
case 'overflow': return 'overflow';
case 'context-bound': return 'context_bound';
case 'restart-guard': return 'restart_guard';
case 'quota': return 'quota';
case 'poisoned': return 'poisoned';
Expand Down
8 changes: 8 additions & 0 deletions src/services/worker/http/routes/SettingsRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_WORKER_HOST',
'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
'CLAUDE_MEM_CLAUDE_MAX_TOKENS',
'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
Expand Down Expand Up @@ -202,6 +203,13 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}

if (settings.CLAUDE_MEM_CLAUDE_MAX_TOKENS) {
const tokens = parseInt(settings.CLAUDE_MEM_CLAUDE_MAX_TOKENS, 10);
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
return { valid: false, error: 'CLAUDE_MEM_CLAUDE_MAX_TOKENS must be between 1000 and 1000000' };
}
}

if (settings.CLAUDE_MEM_GEMINI_MODEL) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
Expand Down
4 changes: 3 additions & 1 deletion src/shared/SettingsDefaultsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export interface SettingsDefaults {
CLAUDE_MEM_API_TIMEOUT_MS: string;
CLAUDE_MEM_SKIP_TOOLS: string;
CLAUDE_MEM_PROVIDER: string;
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
CLAUDE_MEM_CLAUDE_MAX_TOKENS: string;
CLAUDE_MEM_GEMINI_API_KEY: string;
CLAUDE_MEM_GEMINI_MODEL: string;
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string;
Expand Down Expand Up @@ -94,6 +95,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
CLAUDE_MEM_CLAUDE_MAX_TOKENS: '150000', // #2956 — proactive SDK observer context cap (~150k); reset to a fresh SDK session before the model window overflows
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
Expand Down
124 changes: 124 additions & 0 deletions tests/claude-provider-context-bound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'bun:test';

import {
ClaudeProvider,
DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS,
resolveObserverMaxTokens,
computeFullContextTokens,
observerContextExceeded,
} from '../src/services/worker/ClaudeProvider.js';

/**
* #2956 — the Claude provider (Agent SDK) had no cumulative context bound, so a
* long observer session would grow until the SDK returned "Prompt is too long"
* and aborted with zero memory saved. The fix adds a proactive bound: after each
* turn, if the SDK-reported full context size exceeds CLAUDE_MEM_CLAUDE_MAX_TOKENS,
* the provider resets to a fresh SDK session (clearing memorySessionId so the next
* ingest starts small) and aborts the current generator.
*
* The SDK message loop is not unit-testable in isolation (it owns a live
* subprocess), so these tests pin the two pieces of the guard's logic that we
* own: (1) the cap-resolution + threshold predicate, and (2) the reset effect
* applied by resetSessionForFreshStart, which the guard reuses verbatim.
*/
// Compose the exact decision the guard makes, from the same exported units it
// uses: resolve the cap from settings, then compare. Testing through these
// means a change to the guard's resolution/comparison (e.g. > vs >=) is caught.
function guardWouldReset(rawSetting: string | undefined, contextTokens: number): boolean {
const cap = resolveObserverMaxTokens({ CLAUDE_MEM_CLAUDE_MAX_TOKENS: rawSetting });
return observerContextExceeded(contextTokens, cap);
}

describe('ClaudeProvider proactive context bound (#2956)', () => {
describe('cap resolution', () => {
it('uses the default cap when the setting is unset', () => {
expect(resolveObserverMaxTokens({})).toBe(DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS);
expect(DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS).toBe(150_000);
});

it('honors a configured numeric cap', () => {
expect(resolveObserverMaxTokens({ CLAUDE_MEM_CLAUDE_MAX_TOKENS: '50000' })).toBe(50_000);
});

it('falls back to the default for non-numeric or empty values', () => {
expect(resolveObserverMaxTokens({ CLAUDE_MEM_CLAUDE_MAX_TOKENS: '' })).toBe(DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS);
expect(resolveObserverMaxTokens({ CLAUDE_MEM_CLAUDE_MAX_TOKENS: 'nope' })).toBe(DEFAULT_CLAUDE_MAX_OBSERVER_TOKENS);
});
});

describe('threshold predicate', () => {
it('does NOT trigger when context is under the default cap', () => {
expect(guardWouldReset(undefined, 149_999)).toBe(false);
});

it('triggers when context exceeds the default cap', () => {
expect(guardWouldReset(undefined, 150_001)).toBe(true);
});

it('does NOT trigger exactly at the cap (strict greater-than)', () => {
expect(observerContextExceeded(150_000, 150_000)).toBe(false);
expect(guardWouldReset(undefined, 150_000)).toBe(false);
});

it('honors a configured cap', () => {
expect(guardWouldReset('50000', 60_000)).toBe(true);
expect(guardWouldReset('50000', 40_000)).toBe(false);
});

it('falls back to the default cap when the setting is non-numeric or empty', () => {
expect(guardWouldReset('', 160_000)).toBe(true);
expect(guardWouldReset('not-a-number', 160_000)).toBe(true);
expect(guardWouldReset('', 100_000)).toBe(false);
});
});

describe('full context size accounting', () => {
it('sums fresh input, cache writes, and cache reads', () => {
expect(
computeFullContextTokens({
input_tokens: 1_000,
cache_creation_input_tokens: 2_000,
cache_read_input_tokens: 147_500,
})
).toBe(150_500);
});

it('treats missing usage fields and absent usage as zero (no false trigger)', () => {
expect(computeFullContextTokens({})).toBe(0);
expect(computeFullContextTokens(undefined)).toBe(0);
expect(guardWouldReset(undefined, computeFullContextTokens({}))).toBe(false);
});
});

describe('resetSessionForFreshStart effect (reused by the guard)', () => {
function makeProvider() {
const updateCalls: Array<[number, string | null]> = [];
const dbManager = {
getSessionStore: () => ({
updateMemorySessionId: (id: number, value: string | null) => {
updateCalls.push([id, value]);
},
}),
};
const sessionManager = {};
const provider = new ClaudeProvider(dbManager as never, sessionManager as never);
return { provider, updateCalls };
}

it('clears memorySessionId and forces a fresh init so the next ingest starts small', () => {
const { provider, updateCalls } = makeProvider();
const session = {
sessionDbId: 42,
memorySessionId: 'live-session-id',
forceInit: false,
};

(provider as unknown as { resetSessionForFreshStart: (s: typeof session) => void })
.resetSessionForFreshStart(session);

expect(session.memorySessionId).toBeNull();
expect(session.forceInit).toBe(true);
expect(updateCalls).toEqual([[42, null]]);
});
});
});
11 changes: 11 additions & 0 deletions tests/shared/settings-defaults-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,17 @@ describe('SettingsDefaultsManager', () => {
expect(defaults.CLAUDE_MEM_DATA_DIR).toBeDefined();
expect(defaults.CLAUDE_MEM_LOG_LEVEL).toBeDefined();
});

// #2956 — the Claude provider needs its own cumulative context cap, on par
// with CLAUDE_MEM_GEMINI_MAX_TOKENS / CLAUDE_MEM_OPENROUTER_MAX_TOKENS.
it('should default CLAUDE_MEM_CLAUDE_MAX_TOKENS to a safe positive cap', () => {
const defaults = SettingsDefaultsManager.getAllDefaults();

expect(defaults.CLAUDE_MEM_CLAUDE_MAX_TOKENS).toBe('150000');
const parsed = parseInt(defaults.CLAUDE_MEM_CLAUDE_MAX_TOKENS, 10);
expect(Number.isNaN(parsed)).toBe(false);
expect(parsed).toBeGreaterThan(0);
});
});

describe('get', () => {
Expand Down