Skip to content

Commit 78ce378

Browse files
authored
Merge pull request #54 from levelcodeai/fix/agent-maxsteps-live
fix(ai): re-read agent.maxSteps live so a raised limit takes effect mid-run
2 parents a821e3c + c6f708c commit 78ce378

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

extensions/levelcode-ai/extension.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1025,7 +1025,12 @@ async function agentFlow(text) {
10251025
// else the provider's own label; keeps a 502 from being blamed on "OpenAI"
10261026
apiKey: req.apiKey,
10271027
model: req.model,
1028-
maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)),
1028+
// LIVE, not a snapshot (like `autopilot` below): a getter that re-reads via a FRESH
1029+
// getConfiguration on every access, so agent.js's step loop honours a changed
1030+
// levelcode.ai.agent.maxSteps WITHOUT restarting the goal. Raising 25 → 1000 mid-run extends
1031+
// the current autopilot run on the very next step. The captured `cfg` above is a snapshot from
1032+
// when the goal started, so reading it here would keep returning the old limit.
1033+
get maxSteps() { return Math.max(1, aiConfig().get('agent.maxSteps', 25)); },
10291034
maxTokens: Math.max(1024, cfg.get('agent.maxTokens', 8192)), // per-turn output cap; continued across turns if hit
10301035
post, dbg,
10311036
// LIVE, not a snapshot: agent.js reads ctx.autopilot at each run_command, so flipping the
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Guards that `levelcode.ai.agent.maxSteps` is read LIVE, not snapshotted — run: node test/agentMaxSteps.test.js
3+
*
4+
* The bug this locks down: the step cap was captured once, as a plain number, when a goal STARTED
5+
* (`maxSteps: cfg.get('agent.maxSteps')`). Raising it from 25 to 1000 while the agent was running —
6+
* exactly what you do when autopilot pauses at "step limit" and you want it to keep going — had no
7+
* effect, because the loop kept comparing against the frozen 25.
8+
*
9+
* The fix mirrors the neighbouring `autopilot` getter: extension.js hands runAgent a live getter that
10+
* re-reads a FRESH getConfiguration on each access, and agent.js's loop gates on `ctx.maxSteps`
11+
* directly every iteration. Both halves matter, so both are asserted here (from source — a running
12+
* runAgent needs a workspace + provider, which this pure-unit suite deliberately doesn't stand up).
13+
* If either half regresses to a snapshot, a raised limit silently won't take effect mid-run again.
14+
*--------------------------------------------------------------------------------------------*/
15+
// @ts-check
16+
'use strict';
17+
18+
const assert = require('assert');
19+
const fs = require('fs');
20+
const path = require('path');
21+
const pkg = require('../package.json');
22+
23+
let n = 0;
24+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
25+
26+
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
27+
28+
/** The contributed setting node, tolerating configuration being a single object or an array. */
29+
function maxStepsSetting() {
30+
const c = pkg.contributes.configuration;
31+
const props = Array.isArray(c) ? Object.assign({}, ...c.map((x) => x.properties || {})) : (c && c.properties) || {};
32+
return props['levelcode.ai.agent.maxSteps'];
33+
}
34+
35+
test('the setting exists, defaults to 25, and has no upper bound (1000 is valid)', () => {
36+
const s = maxStepsSetting();
37+
assert.ok(s, 'levelcode.ai.agent.maxSteps must be contributed');
38+
assert.equal(s.type, 'number');
39+
assert.equal(s.default, 25);
40+
assert.equal(s.minimum, 1);
41+
// No `maximum`: a user must be able to raise it well past the default (the 1000 in the bug report).
42+
assert.ok(!('maximum' in s), 'maxSteps must not cap the user below large values like 1000');
43+
});
44+
45+
test('extension.js hands runAgent maxSteps as a LIVE getter over a fresh aiConfig()', () => {
46+
const ext = read('extension.js');
47+
assert.match(
48+
ext,
49+
/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['"]/,
50+
'runAgent ctx must expose `get maxSteps()` re-reading a FRESH aiConfig() each access'
51+
);
52+
});
53+
54+
test('extension.js does NOT re-snapshot the cap from the goal-start cfg (the original bug)', () => {
55+
const ext = read('extension.js');
56+
// A `maxSteps:` property whose value pulls from the captured `cfg` freezes the limit for the whole
57+
// run. (The start-of-run dbg log may still read cfg for telemetry; only the runAgent input matters.)
58+
assert.ok(
59+
!/\bmaxSteps:\s*Math\.max\([^)]*\bcfg\.get\(/.test(ext),
60+
'maxSteps passed to runAgent must not be a static snapshot of the start-of-goal cfg'
61+
);
62+
});
63+
64+
test('agent.js gates the loop on ctx.maxSteps directly, and never hoists it into a local', () => {
65+
const ag = read('agent.js');
66+
assert.match(ag, /while\s*\([^)]*\bctx\.maxSteps\b[^)]*\)/, 'the step loop must read ctx.maxSteps live');
67+
// A copy (`const max = ctx.maxSteps`) or a destructure (`const { maxSteps } = ctx`) taken before the
68+
// loop would evaluate the getter exactly once — reintroducing the snapshot from the other side.
69+
assert.ok(!/\b(?:const|let|var)\s+\w+\s*=\s*ctx\.maxSteps\b/.test(ag), 'ctx.maxSteps must not be assigned into a variable');
70+
assert.ok(
71+
!/\b(?:const|let|var)\s*\{[^}]*\bmaxSteps\b[^}]*\}\s*=\s*ctx\b/.test(ag),
72+
'maxSteps must not be destructured off ctx'
73+
);
74+
});
75+
76+
console.log(n + ' passing');

0 commit comments

Comments
 (0)