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

@ -77,28 +77,28 @@ Settings are organized into categories. Most settings should be placed within th
#### general
| Setting | Type | Description | Default |
| ------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` |
| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` |
| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` |
| `general.gitCoAuthor.commit` | boolean | Add a Co-authored-by trailer to git commit messages AND attach a per-file AI-attribution git note (`refs/notes/ai-attribution`) for commits made through Qwen Code. Disabling skips both. | `true` |
| `general.gitCoAuthor.pr` | boolean | Append a Qwen Code attribution line to pull request descriptions when running `gh pr create`. | `true` |
| `general.defaultFileEncoding` | enum | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` |
| `general.voice.enabled` | boolean | Enable voice dictation in the prompt input. Also togglable with the `/voice` command. Requires a transcription model (`voiceModel`) to be configured. | `false` |
| `general.voice.mode` | enum | How push-to-talk behaves: `"hold"` to talk while the key is held, or `"tap"` to start and tap (or pause) to stop and submit. | `"hold"` |
| `general.voice.language` | string | Preferred spoken language for voice transcription (e.g. `"english"`, `"chinese"`). Leave empty to auto-detect. | `""` |
| Setting | Type | Description | Default |
| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` |
| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` |
| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` |
| `general.gitCoAuthor.commit` | boolean | Add a Co-authored-by trailer to git commit messages AND attach a per-file AI-attribution git note (`refs/notes/ai-attribution`) for commits made through Qwen Code. Disabling skips both. | `true` |
| `general.gitCoAuthor.pr` | boolean | Append a Qwen Code attribution line to pull request descriptions when running `gh pr create`. | `true` |
| `general.defaultFileEncoding` | enum | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` |
| `general.voice.enabled` | boolean | Enable voice dictation in the prompt input. Also togglable with the `/voice` command. Requires a transcription model (`voiceModel`) to be configured. | `false` |
| `general.voice.mode` | enum | How push-to-talk behaves: `"hold"` to talk while the key is held, or `"tap"` to start and tap (or pause) to stop and submit. | `"hold"` |
| `general.voice.language` | string | Preferred spoken language for voice transcription (e.g. `"english"`, `"chinese"`). Leave empty to auto-detect. | `""` |
| `general.voice.keytermsFile` | string | Path to a custom keyterms file (one term per line, `#` for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to `.qwen/voice-keyterms.txt` when present. Read only in trusted workspaces. Only applies to Qwen ASR models (`qwen3-asr-*`). | `""` |
| `general.voice.refineTranscript` | boolean | Clean up voice transcripts with the fast model before inserting them — removes filler words and fixes recognition errors while preserving meaning. Falls back to the raw transcript on failure, and is skipped when no fast model is configured. | `true` |
| `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` |
| `general.language` | enum | Language for the user interface. Use `"auto"` to detect from system settings, or a language code (e.g. `"zh-CN"`, `"fr"`). Custom codes can be added by placing JS locale files in `~/.qwen/locales/`. See [i18n](../features/language). Requires restart. | `"auto"` |
| `general.outputLanguage` | string | Language for model output. Use `"auto"` to detect from system settings, or set a specific language. Requires restart. | `"auto"` |
| `general.dynamicCommandTranslation` | boolean | Enable AI translation of dynamic slash-command descriptions. When disabled, dynamic commands keep their original descriptions and skip translation model calls. | `false` |
| `general.terminalBell` | boolean | Play a terminal bell sound when a response completes or needs approval. | `true` |
| `general.preventSystemSleep` | boolean | Prevent the system from sleeping while Qwen Code is streaming a model response or executing tools. Idle prompt time and permission prompts do not inhibit sleep. Read once at startup, so changes take effect after restart. | `true` |
| `general.chatRecording` | boolean | Save chat history to disk. Disabling this also prevents `--continue` and `--resume` from working. Requires restart. | `true` |
| `general.voice.refineTranscript` | boolean | Clean up voice transcripts with the fast model before inserting them — removes filler words and fixes recognition errors while preserving meaning. Falls back to the raw transcript on failure, and is skipped when no fast model is configured. | `true` |
| `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` |
| `general.language` | enum | Language for the user interface. Use `"auto"` to detect from system settings, or a language code (e.g. `"zh-CN"`, `"fr"`). Custom codes can be added by placing JS locale files in `~/.qwen/locales/`. See [i18n](../features/language). Requires restart. | `"auto"` |
| `general.outputLanguage` | string | Language for model output. Use `"auto"` to detect from system settings, or set a specific language. Requires restart. | `"auto"` |
| `general.dynamicCommandTranslation` | boolean | Enable AI translation of dynamic slash-command descriptions. When disabled, dynamic commands keep their original descriptions and skip translation model calls. | `false` |
| `general.terminalBell` | boolean | Play a terminal bell sound when a response completes or needs approval. | `true` |
| `general.preventSystemSleep` | boolean | Prevent the system from sleeping while Qwen Code is streaming a model response or executing tools. Idle prompt time and permission prompts do not inhibit sleep. Read once at startup, so changes take effect after restart. | `true` |
| `general.chatRecording` | boolean | Save chat history to disk. Disabling this also prevents `--continue` and `--resume` from working. Requires restart. | `true` |
#### output
@ -270,14 +270,14 @@ The `extra_body` field allows you to add custom parameters to the request body s
#### modelFallbacks
| Setting | Type | Description | Default |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Setting | Type | Description | Default |
| ---------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `modelFallbacks` | string | Ordered list of fallback model IDs (comma-separated, max 3) to try when the primary model hits capacity errors (429/503/529). Example: `"qwen-plus,qwen-turbo"`. Can also be set via the `--fallback-model` CLI flag. Requires restart. | `""` |
#### modelPricing
| Setting | Type | Description | Default |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Setting | Type | Description | Default |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `modelPricing` | object | Optional per-model pricing for cost estimation in `/stats model`. Example: `{ "qwen3-coder": { "inputPerMillionTokens": 0.30, "outputPerMillionTokens": 1.20 } }`. | `undefined` |
#### context
@ -325,7 +325,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
| `tools.computerUse.enabled` | boolean | Enable the built-in Computer Use tools (cua-driver native desktop automation). When `true` (default), the `computer_use__*` tools are registered as deferred built-ins; the first invocation downloads the pinned, signed cua-driver binary into `~/.qwen/computer-use/` and walks through macOS Accessibility / Screen Recording permissions. | `true` | Requires restart: Yes |
| `tools.computerUse.maxImageDimension` | number | Longest-edge pixel cap applied to cua-driver screenshots (via `set_config`'s `max_image_dimension`). `-1` (default) keeps cua-driver's built-in default (1568); `0` disables resizing (full resolution); a positive value caps the longest edge. Lower caps cut vision-token cost at the expense of fine detail. | `-1` | Requires restart: Yes. Env override: `QWEN_COMPUTER_USE_MAX_IMAGE_DIMENSION` (a non-negative integer; takes precedence over this setting) |
| `tools.computerUse.idleTimeoutMs` | number | Milliseconds to keep the cua-driver process alive after the last `computer_use__*` call. The default is `300000` (5 minutes). Set to `0` to keep it running until Qwen Code exits. | `300000` | Requires restart: Yes |
| `tools.computerUse.idleTimeoutMs` | number | Milliseconds to keep the cua-driver process alive after the last `computer_use__*` call. The default is `300000` (5 minutes). Set to `0` to keep it running until Qwen Code exits. | `300000` | Requires restart: Yes |
| `tools.toolSearch.enabled` | boolean | Load MCP tools on demand via ToolSearch to reduce prompt size. Disable this for models that rely on prefix-based KV caching (e.g. DeepSeek) to keep the prompt prefix stable and maximize cache hit rates. | `true` | Requires restart: Yes |
> [!note]
@ -342,6 +342,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `memory.autoSkillConfirm` | boolean | Ask for confirmation before auto-generated skills are added to the skill library. When off, auto-skills are saved immediately. | `true` |
| `memory.enableTeamMemory` | boolean | Enable a project memory tier shared with collaborators via the git-tracked `.qwen/team-memory/` directory. Writes to it are secret-scanned and reviewable in the git diff. | `false` |
| `memory.enableTeamMemorySync` | boolean | When team memory is enabled, automatically commit, fast-forward-pull, and push the `.qwen/team-memory/` directory at session start so collaborators stay in sync. Requires a configured git upstream. | `false` |
| `memory.agentTimeoutMinutes` | number | 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. | unset |
See [Memory](../features/memory) for details on how auto-memory works and how to use the `/memory`, `/remember`, and `/dream` commands.
@ -477,18 +478,18 @@ execute when typed.
Controls which [Skills](../features/skills) are exposed to the model.
| Setting | Type | Description | Default |
| ---------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Setting | Type | Description | Default |
| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `skills.disabled` | array of strings | Skill names to hide. Matched case-insensitively against the skill name. Hidden skills do not appear in `<available_skills>` or as `/<name>` slash commands. **Merged as a union** across user/project/system scopes, so a project cannot remove entries defined in user or system settings. | `undefined` |
#### mcp
| Setting | Type | Description | Default |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `mcp.serverCommand` | string | Command to start an MCP server. | `undefined` |
| `mcp.allowed` | array of strings | An allowlist of MCP servers to allow. Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Supports glob patterns (`*` matches any sequence, `?` matches a single character — e.g. `"*puppeteer*"`); entries without glob characters are matched exactly. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` |
| `mcp.excluded` | array of strings | A denylist of MCP servers to exclude. A server listed in both `mcp.excluded` and `mcp.allowed` is excluded. Supports glob patterns (`*`, `?`) the same way as `mcp.allowed`. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` |
| `mcp.toolIdleTimeoutMs` | number | Idle timeout in milliseconds for MCP tool calls. If the MCP server does not produce any response or progress update within this time, the call is aborted. Must be between `10000` and `3600000`. Can be overridden via the `QWEN_CODE_MCP_TOOL_IDLE_TIMEOUT_MS` environment variable. | `300000` |
| Setting | Type | Description | Default |
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `mcp.serverCommand` | string | Command to start an MCP server. | `undefined` |
| `mcp.allowed` | array of strings | An allowlist of MCP servers to allow. Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Supports glob patterns (`*` matches any sequence, `?` matches a single character — e.g. `"*puppeteer*"`); entries without glob characters are matched exactly. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` |
| `mcp.excluded` | array of strings | A denylist of MCP servers to exclude. A server listed in both `mcp.excluded` and `mcp.allowed` is excluded. Supports glob patterns (`*`, `?`) the same way as `mcp.allowed`. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` |
| `mcp.toolIdleTimeoutMs` | number | Idle timeout in milliseconds for MCP tool calls. If the MCP server does not produce any response or progress update within this time, the call is aborted. Must be between `10000` and `3600000`. Can be overridden via the `QWEN_CODE_MCP_TOOL_IDLE_TIMEOUT_MS` environment variable. | `300000` |
> [!note]
>

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