fix: make claude live output limits configurable

This commit is contained in:
Peter Steinberger
2026-05-02 09:15:38 +01:00
parent b9c23547ee
commit 02c4ea5cf4
8 changed files with 229 additions and 7 deletions

View File

@@ -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:<service>:<account>` `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.

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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;

View File

@@ -19,6 +19,7 @@ type ProcessSupervisor = ReturnType<
type ManagedRun = Awaited<ReturnType<ProcessSupervisor["spawn"]>>;
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<string, ClaudeLiveSession>();
const liveSessionCreates = new Map<string, Promise<ClaudeLiveSession>>();
@@ -439,11 +448,45 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> | 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,

View File

@@ -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). */

View File

@@ -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,