Skip to content

Commit fa83ac4

Browse files
committed
agent: a run with skills off sends the host's prompt, not the desktop's
runAgent built the system prompt as (skills ? buildSystem(menu) : SYSTEM_BASE). buildSystem already answers a run with no menu with the HOST's base prompt, but the fallback bypassed it: in the browser build, with levelcode.ai.skills.enabled off, the model was told about run_command, background:true and 'EVERY run_command' while run_command itself was withheld. No effect on the desktop (SYSTEM_BASE_HOST is SYSTEM_BASE there).
1 parent c914e89 commit fa83ac4

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,10 @@ async function runAgent(ctx) {
894894
// told about a tool it does not have. The block goes straight after the base prompt — it is the
895895
// same text for every run, so it belongs with the part of the prompt that never changes.
896896
const rich = richClient(ctx);
897-
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + (rich ? '\n\n' + diagramTool.PROMPT : '')
897+
// buildSystem already answers a run with no skills menu with the HOST's base prompt. Falling back to SYSTEM_BASE
898+
// here sent the desktop prompt — run_command, background:true, "EVERY run_command" — to a tab, whenever
899+
// levelcode.ai.skills.enabled was off, while run_command itself was withheld.
900+
const system = buildSystem(ctx.skills ? ctx.skills.menu() : null) + (rich ? '\n\n' + diagramTool.PROMPT : '')
898901
+ multiRootNote + noWorkspaceNote + autopilotNote + rules.text
899902
+ (ctx.projectMemory ? '\n\n' + ctx.projectMemory : '');
900903
const systemTokensEst = Math.round(system.length / 4);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* The prompt a run sends is the HOST's prompt, with or without a skills menu — run: node test/agentPromptHost.test.js
3+
*
4+
* runAgent built its system prompt as (ctx.skills ? buildSystem(menu) : SYSTEM_BASE). buildSystem answers a run
5+
* with no menu with the host's base prompt, but the fallback bypassed it and sent SYSTEM_BASE: in the browser
6+
* build, with levelcode.ai.skills.enabled off, the model was told about run_command, background:true and
7+
* "EVERY run_command" while run_command itself was withheld.
8+
*
9+
* Real runAgent with the browser flag, `vscode` and the provider replaced (the harness of agentRunCommand.test.js).
10+
*--------------------------------------------------------------------------------------------*/
11+
// @ts-check
12+
'use strict';
13+
14+
const assert = require('assert');
15+
const fs = require('fs');
16+
const os = require('os');
17+
const path = require('path');
18+
const Module = require('module');
19+
20+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-prompt-'));
21+
const vscodeMock = {
22+
workspace: { workspaceFolders: [{ name: 'app', uri: { fsPath: root, path: root, toString: () => 'file://' + root } }], findFiles: async () => [], asRelativePath: (u) => String(u.fsPath || u) },
23+
env: { appRoot: '' },
24+
Uri: { file: (p) => ({ fsPath: p, path: p }) },
25+
extensions: { getExtension: () => undefined },
26+
};
27+
const origLoad = Module._load;
28+
// @ts-ignore — test-only loader shim
29+
Module._load = function (request, parent, isMain) { return request === 'vscode' ? vscodeMock : origLoad.call(this, request, parent, isMain); };
30+
const AI = path.join(__dirname, '..');
31+
const providers = require('../providers/index');
32+
const turns = [];
33+
providers.streamAgentTurn = async (o) => { turns.push({ system: o.system, tools: o.tools }); return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'Done: x' }] }; };
34+
35+
function load(browser) {
36+
for (const k of Object.keys(require.cache)) { if (k.startsWith(AI + path.sep) && !k.includes(path.sep + 'test' + path.sep) && !k.includes(path.sep + 'providers' + path.sep)) { delete require.cache[k]; } }
37+
if (browser) { globalThis.__LEVELCODE_BROWSER_HOST__ = true; } else { delete globalThis.__LEVELCODE_BROWSER_HOST__; }
38+
return require('../agent');
39+
}
40+
const ctxFor = (over) => Object.assign({ messages: [{ role: 'user', content: 'go' }], maxSteps: 3, post: () => {}, dbg: () => {}, approve: async () => true, commandRuns: new Map(), commandStops: new Map(), signal: new AbortController().signal }, over);
41+
const MENU = [{ name: 'code-review', description: 'review a diff' }];
42+
43+
let n = 0;
44+
async function test(name, fn) { turns.length = 0; await fn(); n++; console.log(' ok - ' + name); }
45+
46+
(async () => {
47+
try {
48+
await test('browser, skills off: no run_command promises (this was the leak)', async () => {
49+
const agent = load(true);
50+
await agent.runAgent(ctxFor({ skills: null }));
51+
const { system, tools } = turns[0];
52+
assert.ok(!tools.some((t) => t.name === 'run_command'), 'run_command is withheld');
53+
// What the desktop prompt promises: a background flag, a rule about EVERY run_command, approval for commands.
54+
assert.ok(!/background:true|EVERY run_command|Only run_command still needs approval|run_command accepts an optional/.test(system), 'the desktop prompt leaked');
55+
assert.match(system, /There is no terminal/);
56+
});
57+
await test('browser, skills on: the same host prompt plus the menu', async () => {
58+
const agent = load(true);
59+
await agent.runAgent(ctxFor({ skills: { menu: () => MENU, load: async () => '' } }));
60+
const { system } = turns[0];
61+
assert.match(system, /There is no terminal/);
62+
assert.match(system, /Available skills \(call use_skill with the name\):\n- code-review: review a diff/);
63+
});
64+
await test('desktop, skills off: the desktop prompt, byte for byte what it was', async () => {
65+
const agent = load(false);
66+
await agent.runAgent(ctxFor({ skills: null }));
67+
assert.match(turns[0].system, /Commands that do NOT exit on their own \(dev servers, file watchers, tail -f\) MUST be run with run_command background:true/);
68+
assert.ok(!/There is no terminal/.test(turns[0].system));
69+
});
70+
console.log(`\n${n} tests passed`);
71+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
72+
})().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)