From 02c4ea5cf4e3427ce48674a4589a4483c4123a33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 09:15:38 +0100 Subject: [PATCH] fix: make claude live output limits configurable --- CHANGELOG.md | 1 + docs/gateway/cli-backends.md | 6 ++ src/agents/cli-backends.test.ts | 42 ++++++++ src/agents/cli-backends.ts | 6 ++ src/agents/cli-runner.spawn.test.ts | 100 +++++++++++++++++++ src/agents/cli-runner/claude-live-session.ts | 60 +++++++++-- src/config/types.agent-defaults.ts | 7 ++ src/config/zod-schema.core.ts | 14 +++ 8 files changed, 229 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd0d0ee7d4..b41ed76da1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. - Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427. - Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah. +- CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840. - Providers/OpenAI: resolve `keychain::` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt. - Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai. - Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry. diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 8854ef62ab8..48eb1181a58 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -210,6 +210,12 @@ binary is not already on `PATH`. ids are verified against an existing readable project transcript before resume, so phantom bindings are cleared with `reason=transcript-missing` instead of silently starting a fresh Claude CLI session under `--resume`. +- Claude live sessions keep bounded JSONL output guards. Defaults allow up to + 8 MiB and 20,000 raw JSONL lines per turn. Tool-heavy Claude turns can raise + them per backend with + `agents.defaults.cliBackends.claude-cli.reliability.outputLimits.maxTurnRawChars` + and `maxTurnLines`; OpenClaw clamps those settings to 64 MiB and 100,000 + lines. - Stored CLI sessions are provider-owned continuity. The implicit daily session reset does not cut them; `/reset` and explicit `session.reset` policies still do. diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 0f4f74f1497..b488f3f6c66 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -430,6 +430,48 @@ describe("resolveCliBackendConfig reliability merge", () => { expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); }); + + it("deep-merges reliability output-limit overrides", () => { + runtimeBackendEntries.unshift( + createRuntimeBackendEntry({ + pluginId: "test", + id: "test-cli", + config: { + command: "test-cli", + reliability: { + outputLimits: { + maxTurnRawChars: 8192, + maxTurnLines: 20_000, + }, + }, + }, + }), + ); + const cfg = { + agents: { + defaults: { + cliBackends: { + "test-cli": { + command: "test-cli", + reliability: { + outputLimits: { + maxTurnRawChars: 16_384, + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("test-cli", cfg); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.reliability?.outputLimits).toEqual({ + maxTurnRawChars: 16_384, + maxTurnLines: 20_000, + }); + }); }); describe("resolveCliBackendLiveTest", () => { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index b3c9f003f12..b86d259e833 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -142,8 +142,10 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) } const baseFresh = base.reliability?.watchdog?.fresh ?? {}; const baseResume = base.reliability?.watchdog?.resume ?? {}; + const baseOutputLimits = base.reliability?.outputLimits ?? {}; const overrideFresh = override.reliability?.watchdog?.fresh ?? {}; const overrideResume = override.reliability?.watchdog?.resume ?? {}; + const overrideOutputLimits = override.reliability?.outputLimits ?? {}; return { ...base, ...override, @@ -157,6 +159,10 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) reliability: { ...base.reliability, ...override.reliability, + outputLimits: { + ...baseOutputLimits, + ...overrideOutputLimits, + }, watchdog: { ...base.reliability?.watchdog, ...override.reliability?.watchdog, diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index d8e080cafe1..b994402f5c0 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -957,6 +957,106 @@ describe("runCliAgent spawn path", () => { expect(result.text).toBe(largeText); }); + it("honors configured Claude live stream-json raw turn limits", async () => { + const largeText = "x".repeat(1500); + let stdoutListener: ((chunk: string) => void) | undefined; + const stdin = { + write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { + stdoutListener?.( + JSON.stringify({ + type: "result", + session_id: "live-session-tight-output-limit", + result: largeText, + }) + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + stdoutListener = input.onStdout; + return { + runId: "live-run-tight-output-limit", + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(), + }; + }); + + await expect( + executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-live-tight-output-limit", + backend: { + liveSession: "claude-stdio", + reliability: { + outputLimits: { + maxTurnRawChars: 1024, + }, + }, + }, + }), + ), + ).rejects.toMatchObject({ + name: "FailoverError", + message: "Claude CLI JSONL line exceeded output limit.", + }); + }); + + it("accepts operator-raised Claude live stream-json raw turn limits", async () => { + const largeText = "x".repeat(1500); + let stdoutListener: ((chunk: string) => void) | undefined; + const stdin = { + write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { + stdoutListener?.( + JSON.stringify({ + type: "result", + session_id: "live-session-raised-output-limit", + result: largeText, + }) + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + stdoutListener = input.onStdout; + return { + runId: "live-run-raised-output-limit", + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(), + }; + }); + + const result = await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-live-raised-output-limit", + backend: { + liveSession: "claude-stdio", + reliability: { + outputLimits: { + maxTurnRawChars: 4096, + }, + }, + }, + }), + ); + + expect(result.text).toHaveLength(largeText.length); + expect(result.text).toBe(largeText); + }); + it("reports Claude live session reply backends as streaming until the turn finishes", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let markWriteReady: (() => void) | undefined; diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index be250621b1b..3344724b0a1 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -19,6 +19,7 @@ type ProcessSupervisor = ReturnType< type ManagedRun = Awaited>; type ClaudeLiveTurn = { backend: CliBackendConfig; + outputLimits: ClaudeLiveOutputLimits; startedAtMs: number; rawLines: string[]; rawChars: number; @@ -49,13 +50,21 @@ type ClaudeLiveSession = { type ClaudeLiveRunResult = { output: CliOutput; }; +type ClaudeLiveOutputLimits = { + maxTurnRawChars: number; + maxPendingLineChars: number; + maxTurnLines: number; +}; const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; const CLAUDE_LIVE_MAX_SESSIONS = 16; const CLAUDE_LIVE_MAX_STDERR_CHARS = 64 * 1024; -const CLAUDE_LIVE_MAX_TURN_RAW_CHARS = 2 * 1024 * 1024; -const CLAUDE_LIVE_MAX_PENDING_LINE_CHARS = CLAUDE_LIVE_MAX_TURN_RAW_CHARS; -const CLAUDE_LIVE_MAX_TURN_LINES = 5_000; +const CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; +const CLAUDE_LIVE_MIN_TURN_RAW_CHARS = 1_024; +const CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_RAW_CHARS = 64 * 1024 * 1024; +const CLAUDE_LIVE_DEFAULT_MAX_TURN_LINES = 20_000; +const CLAUDE_LIVE_MIN_TURN_LINES = 100; +const CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_LINES = 100_000; const CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS = 5_000; const liveSessions = new Map(); const liveSessionCreates = new Map>(); @@ -439,11 +448,45 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function normalizePositiveInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { + if (!Number.isInteger(value)) { + return fallback; + } + return Math.min(Math.max(value, min), max); +} + +function resolveClaudeLiveOutputLimits(backend: CliBackendConfig): ClaudeLiveOutputLimits { + const configured = backend.reliability?.outputLimits; + const maxTurnRawChars = normalizePositiveInt( + configured?.maxTurnRawChars, + CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS, + CLAUDE_LIVE_MIN_TURN_RAW_CHARS, + CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_RAW_CHARS, + ); + return { + maxTurnRawChars, + maxPendingLineChars: maxTurnRawChars, + maxTurnLines: normalizePositiveInt( + configured?.maxTurnLines, + CLAUDE_LIVE_DEFAULT_MAX_TURN_LINES, + CLAUDE_LIVE_MIN_TURN_LINES, + CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_LINES, + ), + }; +} + function parseClaudeLiveJsonLine( session: ClaudeLiveSession, trimmed: string, ): Record | null { - if (trimmed.length > CLAUDE_LIVE_MAX_PENDING_LINE_CHARS) { + const maxPendingLineChars = + session.currentTurn?.outputLimits.maxPendingLineChars ?? CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS; + if (trimmed.length > maxPendingLineChars) { closeLiveSession( session, "abort", @@ -504,8 +547,8 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { } turn.rawChars += trimmed.length + 1; if ( - turn.rawChars > CLAUDE_LIVE_MAX_TURN_RAW_CHARS || - turn.rawLines.length >= CLAUDE_LIVE_MAX_TURN_LINES + turn.rawChars > turn.outputLimits.maxTurnRawChars || + turn.rawLines.length >= turn.outputLimits.maxTurnLines ) { closeLiveSession( session, @@ -541,7 +584,9 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { function handleClaudeStdout(session: ClaudeLiveSession, chunk: string) { resetNoOutputTimer(session); session.stdoutBuffer += chunk; - if (session.stdoutBuffer.length > CLAUDE_LIVE_MAX_PENDING_LINE_CHARS) { + const maxPendingLineChars = + session.currentTurn?.outputLimits.maxPendingLineChars ?? CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS; + if (session.stdoutBuffer.length > maxPendingLineChars) { closeLiveSession( session, "abort", @@ -719,6 +764,7 @@ function createTurn(params: { }): ClaudeLiveTurn { const turn: ClaudeLiveTurn = { backend: params.context.preparedBackend.backend, + outputLimits: resolveClaudeLiveOutputLimits(params.context.preparedBackend.backend), startedAtMs: Date.now(), rawLines: [], rawChars: 0, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 65a3c77ed18..08b847cc591 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -149,6 +149,13 @@ export type CliBackendConfig = { serialize?: boolean; /** Runtime reliability tuning for this backend's process lifecycle. */ reliability?: { + /** Live-session output caps for CLIs that stream JSONL through a long-lived process. */ + outputLimits?: { + /** Max raw JSONL characters retained for one live CLI turn. */ + maxTurnRawChars?: number; + /** Max raw JSONL lines retained for one live CLI turn. */ + maxTurnLines?: number; + }; /** No-output watchdog tuning (fresh vs resumed runs). */ watchdog?: { /** Fresh/new sessions (non-resume). */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4921c42b47a..137d7a4b2fd 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -571,6 +571,19 @@ const CliBackendWatchdogModeSchema = z .strict() .optional(); +const CliBackendOutputLimitsSchema = z + .object({ + maxTurnRawChars: z + .number() + .int() + .min(1024) + .max(64 * 1024 * 1024) + .optional(), + maxTurnLines: z.number().int().min(100).max(100_000).optional(), + }) + .strict() + .optional(); + export const CliBackendSchema = z .object({ command: z.string(), @@ -606,6 +619,7 @@ export const CliBackendSchema = z serialize: z.boolean().optional(), reliability: z .object({ + outputLimits: CliBackendOutputLimitsSchema, watchdog: z .object({ fresh: CliBackendWatchdogModeSchema,