Skip to content

Commit 684d392

Browse files
committed
fix(agent): multi-root workspace support — resolve paths across ALL folders
The agent's tool layer resolved every path against workspaceFolders[0] and its sandbox check rejected anything outside it, so folders added to the workspace (File > Add Folder) were invisible AND unreachable — while list_files (multi-root aware via findFiles/asRelativePath) kept showing folder-name-prefixed paths the other tools then failed to resolve. - resolveWorkspacePath(): shared resolver; sandbox = the UNION of workspace folders, read LIVE per call so a folder added mid-session works on the very next tool call. Accepts the asRelativePath folder-name prefix convention; probes other folders for reads; create-mode targets the named folder, else the first. - search: sweeps every folder, prefixing hits with the folder name so results feed straight back into read_file/edit_file. - run_command: optional "folder" input picks the working directory. - system prompt: names all workspace folders per run; errors hint the addressable folder names. - reviewSession applyEdit/applyDelete: use the SAME resolver so the agent and the Keep/Undo pipeline can never disagree about where a file lives. Tests: test/workspacePaths.test.js (14 cases incl. a literal repro of the mid-session Add Folder bug and folder-escape rejections).
1 parent fd81887 commit 684d392

3 files changed

Lines changed: 190 additions & 29 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const SYSTEM_BASE = [
2727
'- Use delete_file to remove an existing file (e.g. during a refactor). To RENAME/move a file, write_file the new path then delete_file the old one. Deletions are reviewable (Keep/Undo) and restorable from the per-turn checkpoint.',
2828
'- Your file edits are APPLIED IMMEDIATELY and the user reviews them afterward in the editor with Keep/Undo — do NOT wait for approval, and do NOT re-edit a file you just edited. Only run_command still needs approval; if the user skips a command, adapt or stop.',
2929
'- Commands that do NOT exit on their own (dev servers, file watchers, tail -f) MUST be run with run_command background:true — it returns immediately so you keep working instead of hanging. After starting one, call read_command_output with the returned id to watch for a readiness/port line (e.g. "listening on :3000") before you test against it. Use a normal foreground run_command for things that finish (builds, installs, tests, git, curl). This pairs with verification: bring the app up in the background, confirm it serves, fix, repeat.',
30-
'- Paths are relative to the workspace root.',
30+
'- Paths are relative to the workspace root. In a MULTI-ROOT workspace (several top-level folders), paths from list_files/search are prefixed with the folder name (e.g. "thin.ly/app/models/link.rb") — use them exactly as shown; an unprefixed path resolves against the first folder. To create a file in a specific folder, prefix its name. run_command accepts an optional "folder" to pick which folder it runs in.',
3131
'- For a multi-step goal, call update_plan FIRST with a short checklist (3-8 short items, all "pending"), then call it again to set an item "in_progress" when you start it and "done" when finished. Skip the plan for trivial single-step goals.',
3232
'- If the goal truly depends on a decision only the user can make (tech stack, scope, where to create files, must-have features), call ask_user ONCE with concise multiple-choice questions (a short header + 2-4 concrete options each) INSTEAD of writing the questions as prose. Then act on their answers and do not ask again. Do NOT ask about things you can reasonably decide yourself — prefer a sensible default and proceed.',
3333
'- You have SKILLS — short expert playbooks for common task types, listed under "Available skills" below with a name and a one-line description. When the user\'s goal clearly matches a skill\'s description (e.g. reviewing a diff, fixing one reported bug, writing tests), call use_skill with that exact name FIRST, before other tools, and follow the steps it returns. Pick at most one; if nothing clearly fits, just proceed normally. Do not mention skills to the user.',
@@ -37,13 +37,13 @@ const SYSTEM_BASE = [
3737

3838
const TOOLS = [
3939
{ name: 'list_files', description: 'List workspace files (optional glob like "**/*.js"). Excludes node_modules/.git/build dirs.', input_schema: { type: 'object', properties: { glob: { type: 'string' } } } },
40-
{ name: 'read_file', description: 'Read a workspace file (path relative to the workspace root).', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
40+
{ name: 'read_file', description: 'Read a workspace file (path relative to the workspace root; in a multi-root workspace use the folder-name prefix exactly as list_files shows it).', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
4141
{ name: 'search', description: 'Search file contents for a literal string. Returns file:line snippets.', input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
4242
{ name: 'update_plan', description: 'Declare or update your task checklist for a multi-step goal. Pass the FULL list each time, each item with a status. Call it once up front (all pending), then again to mark an item in_progress when you start it and done when finished. Skip for trivial single-step goals.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done'] } }, required: ['title', 'status'] } } }, required: ['todos'] } },
4343
{ name: 'edit_file', description: 'Make a targeted edit to an EXISTING file: replace an exact, unique snippet (old_str) with new_str. Applied immediately; the user reviews it with Keep/Undo. old_str must appear exactly once — include enough surrounding context to be unique.', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, required: ['path', 'old_str', 'new_str'] } },
4444
{ name: 'write_file', description: 'Create a new file (or fully overwrite a short one) with the COMPLETE content. For edits to existing files, prefer edit_file. Applied immediately; the user reviews it with Keep/Undo.', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
4545
{ name: 'delete_file', description: 'Delete an EXISTING workspace file (e.g. removing a file during a refactor). Applied immediately; the user reviews it with Keep/Undo, and the per-turn checkpoint can restore it. To RENAME or move a file: write_file the new path, then delete_file the old one.', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
46-
{ name: 'run_command', description: 'Run a shell command in the workspace root. Requires approval. Pass background:true for commands that do not exit on their own (servers, watchers) so the agent is not blocked — it returns immediately and you read progress later with read_command_output.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string' }, background: { type: 'boolean', description: 'true = start it and keep working without waiting (dev servers, watchers, tail -f). Returns immediately with an id; poll read_command_output for its output/status.' } }, required: ['command'] } },
46+
{ name: 'run_command', description: 'Run a shell command in the workspace root (or a named workspace folder via "folder" in multi-root workspaces). Requires approval. Pass background:true for commands that do not exit on their own (servers, watchers) so the agent is not blocked — it returns immediately and you read progress later with read_command_output.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string' }, folder: { type: 'string', description: 'multi-root workspaces only: the workspace folder NAME to run in; defaults to the first folder' }, background: { type: 'boolean', description: 'true = start it and keep working without waiting (dev servers, watchers, tail -f). Returns immediately with an id; poll read_command_output for its output/status.' } }, required: ['command'] } },
4747
{ name: 'read_command_output', description: 'Read recent output + status of a command started with run_command background:true. Returns a status header ([running on :3000] / [exited 0] / [stopped]) followed by the latest output lines. Poll this to wait for a server to become ready before testing against it.', input_schema: { type: 'object', properties: { id: { type: 'string', description: 'the id returned by a background run_command' }, lines: { type: 'number', description: 'max recent output lines to return (default 80, max 400)' } }, required: ['id'] } },
4848
{ name: 'ask_user', description: 'Ask the user one or more multiple-choice questions when the goal genuinely depends on a decision only they can make (tech stack, scope, where to put files, must-have features). The user picks by CLICKING — do NOT write questions as prose. Ask ONCE up front with all your questions, then proceed with the answers and never re-ask. Prefer sensible defaults over asking; only ask when a wrong guess would waste real work.', input_schema: { type: 'object', properties: { questions: { type: 'array', items: { type: 'object', properties: { header: { type: 'string', description: 'a 1-3 word tag for the question' }, question: { type: 'string' }, multiSelect: { type: 'boolean', description: 'true if several options can be picked at once' }, options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, description: { type: 'string' } }, required: ['label'] } } }, required: ['question', 'options'] } } }, required: ['questions'] } },
4949
{ name: 'use_skill', description: 'Load an expert playbook (SKILL.md) for a task type, chosen from the "Available skills" list in your system prompt. Returns the skill\'s step-by-step instructions as the tool result — then follow them. Read-only and instant (no approval). Call it once, early, when the goal matches a skill\'s description.', input_schema: { type: 'object', properties: { name: { type: 'string', description: 'the exact skill name from the Available skills list' } }, required: ['name'] } }
@@ -132,6 +132,11 @@ function makeDiff(oldStr, newStr) {
132132
}
133133

134134
// ---- workspace helpers -----------------------------------------------------
135+
// Multi-root aware: helpers read vscode.workspace.workspaceFolders LIVE on every call, so a
136+
// folder added to the workspace mid-session is usable by the very next tool call.
137+
function workspaceFolderList() {
138+
return (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath }));
139+
}
135140
function workspaceRoot() {
136141
const f = vscode.workspace.workspaceFolders;
137142
return f && f.length ? f[0].uri.fsPath : null;
@@ -141,6 +146,34 @@ function safeJoin(root, rel) {
141146
if (pth !== root && !pth.startsWith(root + path.sep)) { return null; }
142147
return pth;
143148
}
149+
/** Resolve a model-supplied workspace-relative path to an absolute path, multi-root aware.
150+
* Accepts VS Code's asRelativePath convention: in a multi-root workspace, paths are prefixed
151+
* with the folder NAME ("thin.ly/app/models/link.rb") — which is exactly what list_files
152+
* returns to the model. Containment is enforced per matched folder, so the sandbox is the
153+
* UNION of the workspace folders (never anything outside them).
154+
* mustExist=true (read/edit/delete): first existing candidate wins — folder-name prefix, then
155+
* the primary folder, then every other folder. Returns null if nowhere.
156+
* mustExist=false (create): the folder-name prefix targets that folder; else the primary. */
157+
function resolveWorkspacePath(rel, opts) {
158+
const mustExist = !!(opts && opts.mustExist);
159+
const folders = workspaceFolderList();
160+
if (!folders.length) { return null; }
161+
rel = String(rel || '');
162+
const seg = rel.split(/[\\/]/)[0];
163+
const named = folders.length > 1 ? folders.find((f) => f.name === seg) : null;
164+
const namedAbs = named ? safeJoin(named.root, rel.slice(seg.length).replace(/^[\\/]+/, '')) : null;
165+
const primaryAbs = safeJoin(folders[0].root, rel);
166+
if (!mustExist) { return namedAbs || primaryAbs; }
167+
const candidates = [namedAbs, primaryAbs];
168+
for (const f of folders.slice(1)) { candidates.push(safeJoin(f.root, rel)); }
169+
for (const c of candidates) { if (c && fs.existsSync(c)) { return c; } }
170+
return null;
171+
}
172+
/** "file not found" help for the model — names the folders it can address in a multi-root workspace. */
173+
function whereHint() {
174+
const folders = workspaceFolderList();
175+
return folders.length > 1 ? ' (workspace folders: ' + folders.map((f) => f.name).join(', ') + ' — prefix the folder name)' : '';
176+
}
144177

145178
/** EOL/BOM-tolerant edit. raw = file content (may have BOM + CRLF); old/new from the model (often LF).
146179
* Matches across line-ending differences and preserves the file's EOL; strips the BOM from the
@@ -239,16 +272,27 @@ async function runTool(tu, ctx) {
239272
}
240273
if (tu.name === 'read_file') {
241274
ctx.post({ type: 'agentTool', icon: 'file', text: 'read ' + input.path });
242-
const abs = safeJoin(root, input.path || '');
243-
if (!abs || !fs.existsSync(abs)) { return 'ERROR: file not found: ' + input.path; }
275+
const abs = resolveWorkspacePath(input.path || '', { mustExist: true });
276+
if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); }
244277
if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — not reading it as text.'; }
245278
let body = fs.readFileSync(abs, 'utf8').replace(/^/, ''); // drop BOM so old_str matches cleanly
246279
if (body.length > 100 * 1024) { body = body.slice(0, 100 * 1024) + '\n…(truncated)…'; }
247280
return body;
248281
}
249282
if (tu.name === 'search') {
250283
ctx.post({ type: 'agentTool', icon: 'search', text: 'search "' + input.query + '"' });
251-
return (await rgSearch(String(input.query || ''), root)) || '(no matches)';
284+
// Multi-root: search EVERY workspace folder, prefixing hits with the folder name so the
285+
// model can hand the paths straight back to read_file/edit_file.
286+
const folders = workspaceFolderList();
287+
let hits = '';
288+
for (const f of folders) {
289+
const r = await rgSearch(String(input.query || ''), f.root);
290+
if (!r) { continue; }
291+
hits += folders.length > 1
292+
? r.split('\n').map((ln) => (ln ? f.name + '/' + ln.replace(/^\.\//, '') : ln)).join('\n')
293+
: r;
294+
}
295+
return hits || '(no matches)';
252296
}
253297
if (tu.name === 'update_plan') {
254298
const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
@@ -257,9 +301,8 @@ async function runTool(tu, ctx) {
257301
return 'Plan updated (' + done + '/' + todos.length + ' done).';
258302
}
259303
if (tu.name === 'edit_file') {
260-
const abs = safeJoin(root, input.path || '');
261-
if (!abs) { return 'ERROR: path is outside the workspace'; }
262-
if (!fs.existsSync(abs)) { return 'ERROR: file not found: ' + input.path + ' (use write_file to create it)'; }
304+
const abs = resolveWorkspacePath(input.path || '', { mustExist: true });
305+
if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint() + ' (use write_file to create it)'; }
263306
if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — refusing to edit it as text.'; }
264307
const cur = fs.readFileSync(abs, 'utf8');
265308
const oldStr = String(input.old_str || '');
@@ -273,8 +316,8 @@ async function runTool(tu, ctx) {
273316
return 'Applied edit to ' + input.path + ' (the user is reviewing it with Keep/Undo; do not re-edit it).';
274317
}
275318
if (tu.name === 'write_file') {
276-
const abs = safeJoin(root, input.path || '');
277-
if (!abs) { return 'ERROR: path is outside the workspace'; }
319+
const abs = resolveWorkspacePath(input.path || '');
320+
if (!abs) { return 'ERROR: path is outside the workspace' + whereHint(); }
278321
const existed = fs.existsSync(abs);
279322
let newStr = String(input.content || '');
280323
if (existed) {
@@ -289,9 +332,8 @@ async function runTool(tu, ctx) {
289332
return 'Applied edit to ' + input.path + ' (pending the user\'s Keep/Undo review).';
290333
}
291334
if (tu.name === 'delete_file') {
292-
const abs = safeJoin(root, input.path || '');
293-
if (!abs) { return 'ERROR: path is outside the workspace'; }
294-
if (!fs.existsSync(abs)) { return 'ERROR: file not found: ' + input.path; }
335+
const abs = resolveWorkspacePath(input.path || '', { mustExist: true });
336+
if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); }
295337
try { if (fs.statSync(abs).isDirectory()) { return 'ERROR: ' + input.path + ' is a directory — delete_file removes a single file.'; } } catch { /* */ }
296338
const ok = ctx.applyDelete ? await ctx.applyDelete({ path: input.path }) : false;
297339
if (!ok) { return 'ERROR: could not delete ' + input.path + '.'; }
@@ -302,10 +344,17 @@ async function runTool(tu, ctx) {
302344
if (tu.name === 'run_command') {
303345
const cmd = String(input.command || '');
304346
const bg = input.background === true;
347+
// Multi-root: optional input.folder picks WHICH workspace folder the command runs in.
348+
let cwdRoot = root;
349+
if (input.folder) {
350+
const wf = workspaceFolderList().find((w) => w.name === String(input.folder));
351+
if (!wf) { return 'ERROR: no workspace folder named "' + input.folder + '"' + whereHint(); }
352+
cwdRoot = wf.root;
353+
}
305354
const approved = await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '' });
306355
if (!approved) { return 'User skipped this command. Do not retry it.'; }
307356
const runId = tu.id || ('run-' + Date.now());
308-
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(root) || 'workspace', background: bg, explanation: input.explanation || '' });
357+
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(cwdRoot) || 'workspace', background: bg, explanation: input.explanation || '' });
309358
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
310359
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
311360
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
@@ -330,10 +379,10 @@ async function runTool(tu, ctx) {
330379
// Fire-and-forget: keep streaming + tracking, but return NOW so the agent loop isn't blocked.
331380
// No timeout — a background server is meant to run long (Stop / New Chat reap it).
332381
ctx.post({ type: 'bgTask', id: runId, command: cmd, status: 'running' }); // add to the Background tasks tray
333-
(async () => { try { await runCommand(root, cmd, onChunk, onExit, onStart, 0); } catch (e) { entry.status = 'error'; entry.how = 'error'; ctx.post({ type: 'bgTask', id: runId, status: 'error', done: true }); dbg('bg.error', { id: runId, msg: String((e && e.message) || e) }); } })();
382+
(async () => { try { await runCommand(cwdRoot, cmd, onChunk, onExit, onStart, 0); } catch (e) { entry.status = 'error'; entry.how = 'error'; ctx.post({ type: 'bgTask', id: runId, status: 'error', done: true }); dbg('bg.error', { id: runId, msg: String((e && e.message) || e) }); } })();
334383
return 'Started in the background as id "' + runId + '" — it keeps running while you continue. Use read_command_output with this id to watch its output; wait for a readiness/port line before testing against it. Do not start it again.';
335384
}
336-
return await runCommand(root, cmd, onChunk, onExit, onStart, ctx.commandTimeout);
385+
return await runCommand(cwdRoot, cmd, onChunk, onExit, onStart, ctx.commandTimeout);
337386
}
338387
if (tu.name === 'read_command_output') {
339388
const runs = ctx.commandRuns;
@@ -405,7 +454,12 @@ async function runAgent(ctx) {
405454
if (!root) { ctx.post({ type: 'agentError', message: 'Open a folder first — the agent works on your workspace.' }); ctx.post({ type: 'agentDone', reason: 'error' }); return; }
406455
ctx.root = root;
407456
// M6.5 implicit skills: build the system prompt ONCE per run — append the tiny name+description menu.
408-
const system = ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE;
457+
// Multi-root: name every workspace folder so the model addresses them by prefix from turn one.
458+
const wsFolders = workspaceFolderList();
459+
const multiRootNote = wsFolders.length > 1
460+
? '\n\nWorkspace folders (multi-root — prefix paths with the folder name): ' + wsFolders.map((f) => f.name).join(', ') + '. The first folder ("' + wsFolders[0].name + '") is the default for unprefixed paths and run_command.'
461+
: '';
462+
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote;
409463
const systemTokensEst = Math.round(system.length / 4);
410464

411465
const dbg = ctx.dbg || (() => {});
@@ -647,4 +701,4 @@ async function runAgent(ctx) {
647701
}
648702
}
649703

650-
module.exports = { runAgent, makeDiff };
704+
module.exports = { runAgent, makeDiff, resolveWorkspacePath };

0 commit comments

Comments
 (0)