feat(memory): make background memory agent timeouts configurable (#6459)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(memory): make background memory agent timeouts configurable

Adds a memory.agentTimeoutMinutes setting that overrides the hardcoded
max runtime of the four background memory agents (extraction, dream,
remember, skill review). Unset keeps each agent's built-in default
(2-5 minutes); 0 disables the time limit entirely.

Local LLM setups load large extraction prompts far slower than hosted
models, so the fixed 2-minute extractor budget times out before the
context even finishes loading — and each retry carries a longer
conversation, making the next timeout more likely.

Fixes #6308

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): address review — wire agentTimeoutMinutes to skill review, clamp negatives, add tests

The auto-skill scheduling path always passed an explicit timeoutMs, so
the new setting never reached the skill review agent; drop the redundant
pass-through so the planner's config fallback applies. Clamp negative
settings values at the Config constructor (schema validation only runs
on interactive edit paths). Add positive override tests for the dream,
remember, and skill review planners, and reduce the settings.md diff to
the single new table row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(memory): cover negative-clamp and remember default-timeout paths

Review follow-up: assert the Config constructor treats a negative
memory.agentTimeoutMinutes as unset, and that the remember planner keeps
its built-in 5-minute default when nothing is configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
Nothing Chan 2026-07-09 00:47:07 +08:00 committed by GitHub
parent 74ebb10e8b
commit 87cad6f1ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 282 additions and 44 deletions

View file

@ -2193,6 +2193,7 @@ export async function loadCliConfig(
bareMode || safeMode
? false
: (settings.memory?.autoSkillConfirm ?? true),
memoryAgentTimeoutMinutes: settings.memory?.agentTimeoutMinutes,
fastModel: settings.fastModel || undefined,
visionModel: settings.visionModel || undefined,
modelFallbacks: resolveModelFallbacks(

View file

@ -1815,6 +1815,17 @@ const SETTINGS_SCHEMA = {
'Ask for confirmation before auto-generated skills are added to the skill library. When off, auto-skills are saved immediately.',
showInDialog: false,
},
agentTimeoutMinutes: {
type: 'number',
label: 'Memory Agent Timeout (minutes)',
category: 'Memory',
requiresRestart: true,
default: undefined as number | undefined,
minimum: 0,
description:
"Max runtime in minutes for background memory agents (extraction, dream, remember, skill review). Unset uses each agent's built-in default (25 minutes); 0 disables the time limit. Useful for slow local models that need longer than the defaults.",
showInDialog: false,
},
enableTeamMemory: {
type: 'boolean',
label: 'Enable Team Memory',

View file

@ -525,6 +525,38 @@ describe('Server Config (config.ts)', () => {
});
});
describe('getMemoryAgentTimeoutMinutes', () => {
it('returns undefined when unset', () => {
expect(
new Config(baseParams).getMemoryAgentTimeoutMinutes(),
).toBeUndefined();
});
it('passes through non-negative values, including 0 (no time limit)', () => {
expect(
new Config({
...baseParams,
memoryAgentTimeoutMinutes: 30,
}).getMemoryAgentTimeoutMinutes(),
).toBe(30);
expect(
new Config({
...baseParams,
memoryAgentTimeoutMinutes: 0,
}).getMemoryAgentTimeoutMinutes(),
).toBe(0);
});
it('treats negative values as unset (schema validation is bypassed on load)', () => {
expect(
new Config({
...baseParams,
memoryAgentTimeoutMinutes: -5,
}).getMemoryAgentTimeoutMinutes(),
).toBeUndefined();
});
});
describe('getMaxSubagentDepth', () => {
it('defaults to 5 when unset', () => {
expect(new Config(baseParams).getMaxSubagentDepth()).toBe(5);

View file

@ -1131,6 +1131,11 @@ export interface ConfigParameters {
enableAutoSkill?: boolean;
/** Require user confirmation before persisting an auto-activated skill. Defaults to true. */
autoSkillConfirm?: boolean;
/**
* Max runtime in minutes for background memory agents (extraction, dream,
* remember, skill review). Unset per-agent defaults; 0 no time limit.
*/
memoryAgentTimeoutMinutes?: number;
/**
* Lightweight model for background tasks (memory extraction, dream, /btw side questions).
* When set and valid for the current auth type, forked agents use this model instead of
@ -1712,6 +1717,7 @@ export class Config {
private readonly teamMemoryShareabilityChecked = new Set<string>();
private enableAutoSkill: boolean;
private readonly autoSkillConfirm: boolean;
private readonly memoryAgentTimeoutMinutes: number | undefined;
private fastModel?: string;
private visionModel?: string;
private readonly modelFallbacks: string[];
@ -2033,6 +2039,14 @@ export class Config {
this.enableTeamMemorySync = params.enableTeamMemorySync ?? false;
this.enableAutoSkill = params.enableAutoSkill ?? true;
this.autoSkillConfirm = params.autoSkillConfirm ?? true;
// Clamp: schema validation only runs on interactive edit paths, so a
// negative value in settings.json would otherwise reach the agent runtime
// and make every memory agent time out immediately.
this.memoryAgentTimeoutMinutes =
params.memoryAgentTimeoutMinutes !== undefined &&
params.memoryAgentTimeoutMinutes >= 0
? params.memoryAgentTimeoutMinutes
: undefined;
this.fastModel = params.fastModel || undefined;
this.visionModel = params.visionModel || undefined;
this.modelFallbacks = normalizeModelFallbacks(params.modelFallbacks);
@ -5469,6 +5483,15 @@ export class Config {
return this.autoSkillConfirm && !this.getBareMode();
}
/**
* Max runtime in minutes for background memory agents (extraction, dream,
* remember, skill review). Resolves the `memory.agentTimeoutMinutes`
* setting. Unset each agent's built-in default; 0 no time limit.
*/
getMemoryAgentTimeoutMinutes(): number | undefined {
return this.memoryAgentTimeoutMinutes;
}
getPreventSystemSleepEnabled(): boolean {
return this.preventSystemSleep && !this.isSafeMode();
}

View file

@ -67,10 +67,7 @@ import { CommitAttributionService } from '../services/commitAttribution.js';
// Tools
import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js';
import { AUTO_SKILL_THRESHOLD } from '../memory/manager.js';
import {
DEFAULT_AUTO_SKILL_MAX_TURNS,
DEFAULT_AUTO_SKILL_TIMEOUT_MS,
} from '../memory/skillReviewAgentPlanner.js';
import { DEFAULT_AUTO_SKILL_MAX_TURNS } from '../memory/skillReviewAgentPlanner.js';
import { isProjectSkillPath } from '../skills/skill-paths.js';
import { ToolNames } from '../tools/tool-names.js';
@ -1593,7 +1590,6 @@ export class GeminiClient {
enabled: autoSkillEnabled,
threshold: AUTO_SKILL_THRESHOLD,
maxTurns: DEFAULT_AUTO_SKILL_MAX_TURNS,
timeoutMs: DEFAULT_AUTO_SKILL_TIMEOUT_MS,
confirmBeforePersist: this.config.getAutoSkillConfirmEnabled(),
});
if (skillReviewResult.status === 'scheduled') {

View file

@ -50,6 +50,7 @@ describe('dreamAgentPlanner', () => {
getSessionId: vi.fn().mockReturnValue('session-1'),
getModel: vi.fn().mockReturnValue('qwen-test'),
getApprovalMode: vi.fn(),
getMemoryAgentTimeoutMinutes: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
vi.mocked(runForkedAgent).mockReset();
});
@ -141,6 +142,20 @@ describe('dreamAgentPlanner', () => {
);
});
it('threads the configured memory agent timeout into the forked agent', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
filesTouched: [],
} satisfies ForkedAgentResult);
vi.mocked(config.getMemoryAgentTimeoutMinutes).mockReturnValueOnce(30);
await planManagedAutoMemoryDreamByAgent(config, projectRoot);
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 30 }),
);
});
it('can read transcripts while keeping writes project-memory-only', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',

View file

@ -108,7 +108,7 @@ export async function planManagedAutoMemoryDreamByAgent(
taskPrompt: buildConsolidationTaskPrompt(memoryRoot, transcriptDir),
systemPrompt: DREAM_AGENT_SYSTEM_PROMPT,
maxTurns: MAX_TURNS,
maxTimeMinutes: MAX_TIME_MINUTES,
maxTimeMinutes: config.getMemoryAgentTimeoutMinutes() ?? MAX_TIME_MINUTES,
tools: [
ToolNames.READ_FILE,
ToolNames.GREP,

View file

@ -44,6 +44,7 @@ describe('runAutoMemoryExtractionByAgent', () => {
getSessionId: vi.fn().mockReturnValue('session-1'),
getModel: vi.fn().mockReturnValue('qwen3-coder-plus'),
getApprovalMode: vi.fn(),
getMemoryAgentTimeoutMinutes: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
beforeEach(() => {
@ -105,6 +106,38 @@ describe('runAutoMemoryExtractionByAgent', () => {
);
});
it('threads the configured memory agent timeout into the forked agent', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
});
vi.mocked(mockConfig.getMemoryAgentTimeoutMinutes).mockReturnValueOnce(30);
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 30 }),
);
});
it('passes 0 through to disable the time limit', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
});
vi.mocked(mockConfig.getMemoryAgentTimeoutMinutes).mockReturnValueOnce(0);
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 0 }),
);
});
it('returns empty touchedTopics when agent touches no files', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',

View file

@ -271,7 +271,7 @@ export async function runAutoMemoryExtractionByAgent(
),
systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT,
maxTurns: 5,
maxTimeMinutes: 2,
maxTimeMinutes: config.getMemoryAgentTimeoutMinutes() ?? 2,
tools: [
ToolNames.READ_FILE,
ToolNames.GREP,

View file

@ -46,6 +46,7 @@ function createConfig(
isManagedMemoryAvailable: vi.fn().mockReturnValue(managed),
getProjectRoot: vi.fn().mockReturnValue(projectRoot),
getUserMemory: vi.fn().mockReturnValue('QWEN/AGENTS guidance'),
getMemoryAgentTimeoutMinutes: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
}
@ -178,6 +179,48 @@ describe('remember memory helper', () => {
expect(rebuildManagedAutoMemoryIndex).toHaveBeenCalledWith(projectRoot);
});
it('threads the configured memory agent timeout into the forked agent', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
} satisfies ForkedAgentResult);
const config = createConfig(projectRoot);
vi.mocked(config.getMemoryAgentTimeoutMinutes).mockReturnValue(30);
await runManagedRememberByAgent({
config,
projectRoot,
content: 'Remember this.',
contextMode: 'workspace',
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 30 }),
);
});
it('keeps the built-in 5-minute default when no timeout is configured', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
} satisfies ForkedAgentResult);
await runManagedRememberByAgent({
config: createConfig(projectRoot),
projectRoot,
content: 'Remember this.',
contextMode: 'workspace',
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 5 }),
);
});
it('lets managed-memory writes bypass base ask rules', async () => {
const touched = path.join(getUserAutoMemoryRoot(), 'user.md');
const basePm: Pick<

View file

@ -185,7 +185,7 @@ export async function runManagedRememberByAgent(params: {
}),
systemPrompt: buildRememberSystemPrompt(memoryPrompt),
maxTurns: 6,
maxTimeMinutes: 5,
maxTimeMinutes: params.config.getMemoryAgentTimeoutMinutes() ?? 5,
extraHistory: params.contextMode === 'clean' ? [] : undefined,
preserveEmptyExtraHistory: params.contextMode === 'clean',
tools: [

View file

@ -16,16 +16,23 @@
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '../config/config.js';
import {
AUTO_SKILL_DIR_PREFIX,
buildTaskPrompt,
createSkillScopedAgentConfig,
DEFAULT_AUTO_SKILL_TIMEOUT_MS,
listExistingSkillDirNames,
runSkillReviewByAgent,
SKILL_REVIEW_SYSTEM_PROMPT,
} from './skillReviewAgentPlanner.js';
import { ToolNames } from '../tools/tool-names.js';
import { runForkedAgent } from '../utils/forkedAgent.js';
vi.mock('../utils/forkedAgent.js', () => ({
runForkedAgent: vi.fn(),
}));
function makeMinimalConfig(projectRoot: string): Config {
return {
@ -378,3 +385,71 @@ describe('SKILL_REVIEW_SYSTEM_PROMPT', () => {
expect(SKILL_REVIEW_SYSTEM_PROMPT).toMatch(/MUST use/i);
});
});
describe('runSkillReviewByAgent timeout wiring', () => {
let tempDir: string;
let projectRoot: string;
function makeConfig(timeoutMinutes: number | undefined): Config {
return {
getProjectRoot: () => projectRoot,
getPermissionManager: () => undefined,
getMemoryAgentTimeoutMinutes: vi.fn().mockReturnValue(timeoutMinutes),
} as unknown as Config;
}
beforeEach(async () => {
vi.mocked(runForkedAgent).mockReset();
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
});
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-timeout-'));
projectRoot = path.join(tempDir, 'project');
await fs.mkdir(projectRoot, { recursive: true });
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('uses the configured memory agent timeout when no timeoutMs param is passed', async () => {
await runSkillReviewByAgent({
config: makeConfig(30),
projectRoot,
history: [],
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 30 }),
);
});
it('lets an explicit timeoutMs param override the configured value', async () => {
await runSkillReviewByAgent({
config: makeConfig(30),
projectRoot,
history: [],
timeoutMs: 60_000,
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ maxTimeMinutes: 1 }),
);
});
it('falls back to the built-in default when neither is set', async () => {
await runSkillReviewByAgent({
config: makeConfig(undefined),
projectRoot,
history: [],
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({
maxTimeMinutes: DEFAULT_AUTO_SKILL_TIMEOUT_MS / 60_000,
}),
);
});
});

View file

@ -373,7 +373,10 @@ export async function runSkillReviewByAgent(params: {
systemPrompt: SKILL_REVIEW_SYSTEM_PROMPT,
maxTurns: params.maxTurns ?? DEFAULT_AUTO_SKILL_MAX_TURNS,
maxTimeMinutes:
(params.timeoutMs ?? DEFAULT_AUTO_SKILL_TIMEOUT_MS) / 60_000,
params.timeoutMs !== undefined
? params.timeoutMs / 60_000
: (params.config.getMemoryAgentTimeoutMinutes() ??
DEFAULT_AUTO_SKILL_TIMEOUT_MS / 60_000),
tools: [
ToolNames.READ_FILE,
ToolNames.LS,

View file

@ -823,6 +823,11 @@
"type": "boolean",
"default": true
},
"agentTimeoutMinutes": {
"description": "Max runtime in minutes for background memory agents (extraction, dream, remember, skill review). Unset uses each agent's built-in default (25 minutes); 0 disables the time limit. Useful for slow local models that need longer than the defaults.",
"type": "number",
"minimum": 0
},
"enableTeamMemory": {
"description": "Enable a project memory tier shared with collaborators via the git-tracked `.qwen/team-memory/` directory. Off by default; writes to it are secret-scanned and reviewable in the git diff.",
"type": "boolean",