mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(memory): make background memory agent timeouts configurable (#6459)
* 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:
parent
74ebb10e8b
commit
87cad6f1ae
15 changed files with 282 additions and 44 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (2–5 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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (2–5 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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue