From b38f4bb8582f7f5882d83564538dca2995d8e20c Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 27 Jul 2026 19:59:27 -0400 Subject: [PATCH 1/3] fix(ai): re-read agent.maxSteps live so a raised limit takes effect mid-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step cap was captured once, as a plain number, when a goal started (maxSteps: cfg.get('agent.maxSteps')). Raising it from 25 to 1000 while autopilot was running had no effect — the loop kept comparing against the frozen 25 and paused at "step limit" anyway. Hand runAgent a LIVE getter instead, mirroring the neighbouring `autopilot` getter: it re-reads a fresh getConfiguration on every access, and agent.js's loop already gates on ctx.maxSteps each iteration, so a changed limit now takes effect on the very next step (both directions). The pause card also reports the actual limit in effect rather than the stale 25. Guarded by a source-invariant test (test/agentMaxSteps.test.js) in the same style as webviewCss/providerMode — a live runAgent needs a workspace + provider this pure-unit suite doesn't stand up — asserting both ends: the getter in extension.js (never a snapshot) and the live ctx.maxSteps read in agent.js (never hoisted/destructured). Full extension suite: 23 passing, 0 failing. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/extension.js | 7 +- .../levelcode-ai/test/agentMaxSteps.test.js | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 extensions/levelcode-ai/test/agentMaxSteps.test.js diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 834b106..2c00e98 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -1025,7 +1025,12 @@ async function agentFlow(text) { // else the provider's own label; keeps a 502 from being blamed on "OpenAI" apiKey: req.apiKey, model: req.model, - maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)), + // LIVE, not a snapshot (like `autopilot` below): a getter that re-reads via a FRESH + // getConfiguration on every access, so agent.js's step loop honours a changed + // levelcode.ai.agent.maxSteps WITHOUT restarting the goal. Raising 25 → 1000 mid-run extends + // the current autopilot run on the very next step. The captured `cfg` above is a snapshot from + // when the goal started, so reading it here would keep returning the old limit. + get maxSteps() { return Math.max(1, aiConfig().get('agent.maxSteps', 25)); }, maxTokens: Math.max(1024, cfg.get('agent.maxTokens', 8192)), // per-turn output cap; continued across turns if hit post, dbg, // LIVE, not a snapshot: agent.js reads ctx.autopilot at each run_command, so flipping the diff --git a/extensions/levelcode-ai/test/agentMaxSteps.test.js b/extensions/levelcode-ai/test/agentMaxSteps.test.js new file mode 100644 index 0000000..557c25a --- /dev/null +++ b/extensions/levelcode-ai/test/agentMaxSteps.test.js @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Guards that `levelcode.ai.agent.maxSteps` is read LIVE, not snapshotted — run: node test/agentMaxSteps.test.js + * + * The bug this locks down: the step cap was captured once, as a plain number, when a goal STARTED + * (`maxSteps: cfg.get('agent.maxSteps')`). Raising it from 25 to 1000 while the agent was running — + * exactly what you do when autopilot pauses at "step limit" and you want it to keep going — had no + * effect, because the loop kept comparing against the frozen 25. + * + * The fix mirrors the neighbouring `autopilot` getter: extension.js hands runAgent a live getter that + * re-reads a FRESH getConfiguration on each access, and agent.js's loop gates on `ctx.maxSteps` + * directly every iteration. Both halves matter, so both are asserted here (from source — a running + * runAgent needs a workspace + provider, which this pure-unit suite deliberately doesn't stand up). + * If either half regresses to a snapshot, a raised limit silently won't take effect mid-run again. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const pkg = require('../package.json'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + +/** The contributed setting node, tolerating configuration being a single object or an array. */ +function maxStepsSetting() { + const c = pkg.contributes.configuration; + const props = Array.isArray(c) ? Object.assign({}, ...c.map((x) => x.properties || {})) : (c && c.properties) || {}; + return props['levelcode.ai.agent.maxSteps']; +} + +test('the setting exists, defaults to 25, and has no upper bound (1000 is valid)', () => { + const s = maxStepsSetting(); + assert.ok(s, 'levelcode.ai.agent.maxSteps must be contributed'); + assert.equal(s.type, 'number'); + assert.equal(s.default, 25); + assert.equal(s.minimum, 1); + // No `maximum`: a user must be able to raise it well past the default (the 1000 in the bug report). + assert.ok(!('maximum' in s), 'maxSteps must not cap the user below large values like 1000'); +}); + +test('extension.js hands runAgent maxSteps as a LIVE getter over a fresh aiConfig()', () => { + const ext = read('extension.js'); + assert.match( + ext, + /get\s+maxSteps\s*\(\s*\)\s*\{[^}]*aiConfig\(\)\s*\.get\(\s*['"]agent\.maxSteps['"]/, + 'runAgent ctx must expose `get maxSteps()` re-reading a FRESH aiConfig() each access' + ); +}); + +test('extension.js does NOT re-snapshot the cap from the goal-start cfg (the original bug)', () => { + const ext = read('extension.js'); + // A `maxSteps:` property whose value pulls from the captured `cfg` freezes the limit for the whole + // run. (The start-of-run dbg log may still read cfg for telemetry; only the runAgent input matters.) + assert.ok( + !/\bmaxSteps:\s*Math\.max\([^)]*\bcfg\.get\(/.test(ext), + 'maxSteps passed to runAgent must not be a static snapshot of the start-of-goal cfg' + ); +}); + +test('agent.js gates the loop on ctx.maxSteps directly, and never hoists it into a local', () => { + const ag = read('agent.js'); + assert.match(ag, /while\s*\(\s*step\+\+\s*<\s*ctx\.maxSteps\s*\)/, 'the step loop must read ctx.maxSteps live'); + // A copy (`const max = ctx.maxSteps`) or a destructure (`const { maxSteps } = ctx`) taken before the + // loop would evaluate the getter exactly once — reintroducing the snapshot from the other side. + assert.ok(!/=\s*ctx\.maxSteps\b/.test(ag), 'ctx.maxSteps must not be assigned into a variable'); + assert.ok( + !/\b(?:const|let|var)\s*\{[^}]*\bmaxSteps\b[^}]*\}\s*=\s*ctx\b/.test(ag), + 'maxSteps must not be destructured off ctx' + ); +}); + +console.log(n + ' passing'); From bd9da627dfe0c7ba52cf2b9c8f95b8c338b05f6f Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 27 Jul 2026 20:03:31 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/levelcode-ai/test/agentMaxSteps.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/levelcode-ai/test/agentMaxSteps.test.js b/extensions/levelcode-ai/test/agentMaxSteps.test.js index 557c25a..69cd461 100644 --- a/extensions/levelcode-ai/test/agentMaxSteps.test.js +++ b/extensions/levelcode-ai/test/agentMaxSteps.test.js @@ -63,10 +63,10 @@ test('extension.js does NOT re-snapshot the cap from the goal-start cfg (the ori test('agent.js gates the loop on ctx.maxSteps directly, and never hoists it into a local', () => { const ag = read('agent.js'); - assert.match(ag, /while\s*\(\s*step\+\+\s*<\s*ctx\.maxSteps\s*\)/, 'the step loop must read ctx.maxSteps live'); + assert.match(ag, /while\s*\([^)]*\bctx\.maxSteps\b[^)]*\)/, 'the step loop must read ctx.maxSteps live'); // A copy (`const max = ctx.maxSteps`) or a destructure (`const { maxSteps } = ctx`) taken before the // loop would evaluate the getter exactly once — reintroducing the snapshot from the other side. - assert.ok(!/=\s*ctx\.maxSteps\b/.test(ag), 'ctx.maxSteps must not be assigned into a variable'); + assert.ok(!/\b(?:const|let|var)\s+\w+\s*=\s*ctx\.maxSteps\b/.test(ag), 'ctx.maxSteps must not be assigned into a variable'); assert.ok( !/\b(?:const|let|var)\s*\{[^}]*\bmaxSteps\b[^}]*\}\s*=\s*ctx\b/.test(ag), 'maxSteps must not be destructured off ctx' From c6f708c60deac767325d16b59ce72d35ac33605c Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 27 Jul 2026 20:03:38 -0400 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/levelcode-ai/test/agentMaxSteps.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/levelcode-ai/test/agentMaxSteps.test.js b/extensions/levelcode-ai/test/agentMaxSteps.test.js index 69cd461..380a062 100644 --- a/extensions/levelcode-ai/test/agentMaxSteps.test.js +++ b/extensions/levelcode-ai/test/agentMaxSteps.test.js @@ -46,7 +46,7 @@ test('extension.js hands runAgent maxSteps as a LIVE getter over a fresh aiConfi const ext = read('extension.js'); assert.match( ext, - /get\s+maxSteps\s*\(\s*\)\s*\{[^}]*aiConfig\(\)\s*\.get\(\s*['"]agent\.maxSteps['"]/, + /get\s+maxSteps\s*\(\s*\)\s*\{[\s\S]*?(?:\baiConfig\(\)\s*\.get|\b(?:const|let|var)\s+\w+\s*=\s*aiConfig\(\)[\s\S]*?\b\w+\s*\.get)\(\s*['"]agent\.maxSteps['"]/, 'runAgent ctx must expose `get maxSteps()` re-reading a FRESH aiConfig() each access' ); });