diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index f7cd1d5c74..f1e762d464 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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 (2–5 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 `` or as `/` 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] > diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0b7833c55f..4cfc402b01 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 1d0c971984..2afb3959d9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index a428aff436..732e33b567 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4f860e0e59..33af6f236f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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(); 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(); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 4f8d90234a..a1cc5e860f 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -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') { diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 47c6ce48d3..525b81a653 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -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', diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index 8e67858070..47d67be185 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -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, diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 93ef2df685..ae58ca2e60 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -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', diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index c821b56a46..ee1e156bfb 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -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, diff --git a/packages/core/src/memory/remember.test.ts b/packages/core/src/memory/remember.test.ts index 180dff2942..b062a7ff8a 100644 --- a/packages/core/src/memory/remember.test.ts +++ b/packages/core/src/memory/remember.test.ts @@ -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< diff --git a/packages/core/src/memory/remember.ts b/packages/core/src/memory/remember.ts index 7108358ed7..d68e5d3321 100644 --- a/packages/core/src/memory/remember.ts +++ b/packages/core/src/memory/remember.ts @@ -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: [ diff --git a/packages/core/src/memory/skillReviewAgentPlanner.test.ts b/packages/core/src/memory/skillReviewAgentPlanner.test.ts index 5180dd7a47..ec60be6d37 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.test.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.test.ts @@ -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, + }), + ); + }); +}); diff --git a/packages/core/src/memory/skillReviewAgentPlanner.ts b/packages/core/src/memory/skillReviewAgentPlanner.ts index 8bb9e79454..fb963c85aa 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.ts @@ -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, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 26c7ce8124..bb623e907c 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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",