Skip to content

feat(agent): a missing workspace gates the file tools, not the whole run - #78

Merged
ndemianc merged 3 commits into
developfrom
feat/agent-without-workspace
Aug 16, 2026
Merged

feat(agent): a missing workspace gates the file tools, not the whole run#78
ndemianc merged 3 commits into
developfrom
feat/agent-without-workspace

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

runAgent opened with a blanket refusal:

if (!root) { post({ type: 'agentError', message: 'Open a folder first — the agent works on your workspace.' }); return; }

Written for the file tools, but placed where it failed the entire run. So with no folder open you could not ask what an error meant, could not reach a single MCP server, and could not ask about the file open in the editor in front of you.

The reported case was exactly that — "Explain what the current file does", refused, for a request that never needed a workspace.

The root still gates the tools. It no longer gates the agent.

withheld rootless list_files, read_file, search, edit_file, write_file, delete_file, run_command, read_command_output
still available update_plan, ask_user, use_skill + every MCP tool

Withheld rather than offered-and-failing: a tool that's present but errors on every call is worse than one that's absent, because the model retries it.

The MCP half is most of the value here. None of those servers care whether the editor has a folder open — the GitHub server least of all — and until now a missing workspace disabled all of them.

Three things that would have made this worse than the refusal

PORTABLE_TOOLS is derived from NEEDS_ROOT, not a second hand-written list, so the two can't disagree about a tool.

baseTools switches too. Left on the full TOOLS it would bill the context-usage popover for schemas that were never sent.

The model is told why the tools are missing. Without that it sees a list with no read_file and improvises — answering about files it can't see, or apologising at length for a limit it can't name. The note also points at what still works, and at File > Open Folder when the request genuinely needs files.

MCP servers are still spawned with a cwd, so a null root now falls back to os.homedir() rather than being passed straight through.

Guards

Each bypass-verified by reverting the fix:

bypass caught
the blanket refusal restored — the reported bug
run_command un-gated
ask_user gated — rebuilding the refusal a tool at a time
NEEDS_ROOT naming a tool that no longer exists (rename drift)
the tool list no longer switching on the root
baseTools billing for tools that were never sent
MCP spawned with a null cwd
the note built but never concatenated into the prompt

That last one is the classic version of this bug — it looks right in review and does nothing.

What this does not do

It doesn't give the agent a way to read the open editor's file without a folder. read_file resolves against the workspace root, and a rootless "read whatever is in the active tab" tool is a separate design question. Today the answer comes from the conversation, attached context, and MCP. Worth a follow-up if the no-folder case turns out to be common.

5 tests in agentNoWorkspace, 33 suites green.

Follow-up to #76, which fixed the two instances in the code it introduced and left the
older ones alone to keep that review diff readable.

`executeCommand` returns a Thenable, so a bare call in a void context turns any rejection
into an unhandled promise rejection in the extension host — attributed to nothing, which
is the part that makes it useless.

SIX SITES, NOT SEVEN. The brief for this change listed the `levelcode.ai.focus`
registration as needing a `return`. It does not: `() => vscode.commands.executeCommand(…)`
is a concise arrow body, so it already returns the thenable and VS Code already reports its
failures. Left alone.

The other six are all BACKGROUND reveals — addSelection, addContext, resumeSession, the
editor panel's onDidDispose, the sessions view's newSession, and handleLaunch. In each the
accompanying work has already succeeded by the time the reveal runs, so failing that work
because the panel would not come forward would be worse than the panel not coming forward.
They route through one `focusChatView(why)` helper that logs and never rejects.

Logged, not swallowed: the complaint was "attributed to nothing", so `.catch(() => {})`
would answer the letter of it and none of the substance. `why` names the caller in the log.

ONE GUARD FOR THE WHOLE CLASS. Rather than six assertions naming six functions, the test
scans every occurrence of the call and accepts only returned / awaited / concise-arrow /
helper-wrapped / inline-handled forms. That covers the seventh site and the eighth nobody
has written yet — which matters, because this pattern reached six copies precisely because
nothing was watching for it.

Verified non-vacuous against develop: the same scan reports exactly the six bare sites
there (lines 423, 468, 1055, 2298, 2435, 2531) and zero here.

Bypasses, each reverted and confirmed to fail:
  - a single site returned to a bare call; the switch-case site returned to a bare call
  - the focus command wrapped in a block body so it stops returning (proves the guard
    protects the site that was already correct)
  - the helper swallowing silently; the log dropping its caller attribution

One assertion deliberately relaxed after bypassing it: pinning `.then(undefined, …)` over
`.catch(…)` failed a refactor with no behavioural difference. It now accepts either and
still fails when the handler stops logging.

23 tests in chatSurface, 32 suites green.
runAgent opened with a blanket refusal — "Open a folder first — the agent works on your
workspace." — written for the file tools but placed where it failed the ENTIRE run.

So with no folder open you could not ask what an error meant, could not reach a single MCP
server (the GitHub server does not care whether you have a folder open), and could not ask
about the file open in the editor in front of you. The reported case was exactly that:
"Explain what the current file does", refused, for a request that never needed a workspace.

The root still gates the tools that resolve a path or a cwd against it. It no longer gates
the agent.

  withheld rootless : list_files, read_file, search, edit_file, write_file, delete_file,
                      run_command, read_command_output
  still available   : update_plan, ask_user, use_skill + every MCP tool

Withheld rather than offered-and-failing: a tool that is present but errors on every call
is worse than one that is absent, because the model retries it.

THREE THINGS THAT WOULD HAVE MADE THIS A WORSE EXPERIENCE THAN THE REFUSAL:

  - PORTABLE_TOOLS is DERIVED from NEEDS_ROOT, not a second hand-written list, so the two
    cannot disagree about a tool.
  - baseTools switches too. Left on the full TOOLS it would bill the context popover for
    schemas that were never sent.
  - The model is TOLD why the tools are missing. Without that it sees a list with no
    read_file and improvises — answering about files it cannot see, or apologising at
    length for a limit it cannot name. The note also points at what still works and, if the
    request genuinely needs files, at File > Open Folder.

MCP servers are still spawned with a cwd, so a null root now falls back to os.homedir()
rather than being passed through.

Guards, each bypass-verified by reverting the fix:
  - the blanket refusal restored (the reported bug)
  - run_command un-gated; ask_user gated (which would rebuild the refusal a tool at a time)
  - NEEDS_ROOT naming a tool that no longer exists, i.e. rename drift
  - the tool list no longer switching on the root; baseTools billing for unsent tools
  - MCP spawned with a null cwd
  - the note built but never concatenated into the prompt — the classic version of this bug,
    which looks right in review and does nothing

5 tests in agentNoWorkspace, 33 suites green.
Copilot AI lite review requested due to automatic review settings August 16, 2026 00:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts the LevelCode AI agent so it can run when no workspace folder is open by gating only the workspace-dependent built-in file/command tools, while keeping MCP tools (and conversational tools like ask_user) available.

Changes:

  • Remove the blanket “open a folder first” refusal from runAgent, and instead switch the built-in tool list to a portable subset when workspaceRoot() is null.
  • Ensure MCP servers still start in rootless mode by falling back to os.homedir() as the spawn cwd, and add a rootless system-prompt note explaining missing tools.
  • Add regression tests for rootless agent behavior, and introduce a shared focusChatView() helper to prevent unhandled promise rejections from chat reveal calls.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
extensions/levelcode-ai/agent.js Switch built-in tool exposure based on workspace root, keep MCP available, and add rootless prompt guidance + homedir cwd fallback.
extensions/levelcode-ai/extension.js Add focusChatView(why) helper and route background chat reveals through it to avoid unhandled rejections.
extensions/levelcode-ai/test/chatSurface.test.js Update/extend tests to enforce that chat-focus calls are not fire-and-forget and are centrally handled/logged.
extensions/levelcode-ai/test/agentNoWorkspace.test.js New unit suite verifying the agent runs rootless while properly gating only the root-dependent built-in tools.
Suppressed comments (1)

extensions/levelcode-ai/agent.js:758

  • toolsTokensEst falls back to TOOLS_TOKENS_EST even when builtins switches to PORTABLE_TOOLS (rootless + no MCP + no recall). That makes the contextUsage payload report the wrong tool-token cost in the no-workspace case. Use a builtins-specific cached estimate (e.g., PORTABLE_TOOLS_TOKENS_EST) for the fallback path.
	const builtins = root ? TOOLS : PORTABLE_TOOLS;
	let tools = mcp.tools.length ? builtins.concat(mcp.tools) : builtins;
	if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); }  // cross-session recall (host-gated by memory settings)
	const baseTools = ctx.recallSessions ? builtins.concat([RECALL_TOOL]) : builtins;   // built-ins + recall; MCP is the rest
	// Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +86 to 89
const PORTABLE_TOOLS = TOOLS.filter((t) => !NEEDS_ROOT.has(t.name));

const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4);

Comment on lines +56 to +60
// Everything that resolves a path or a cwd. Miss one and it is offered rootless, then fails on the
// model's first call — which is worse than not offering it, because the model retries.
for (const name of ['list_files', 'read_file', 'search', 'edit_file', 'write_file', 'delete_file', 'run_command']) {
assert.ok(gated.includes(name), name + ' resolves a workspace path but is not in NEEDS_ROOT');
}
…lly sent

Both review points on #78 were right.

1. THE TOKEN ESTIMATE STILL COUNTED THE FULL TOOL LIST

`toolsTokensEst` falls back to a module constant on the plain path (no MCP, no recall) so
that path never re-stringifies. But that constant was built from TOOLS, and after this PR
there are TWO plain paths — a rootless run sends PORTABLE_TOOLS and was billed for TOOLS.

Measured: ~1000 tokens, about two thirds of the tool budget, reported against a window
that never spent it. That is worse than a missing feature; it is a meter reading high, and
the context popover exists precisely so that number can be trusted.

This is the same mistake as leaving baseTools on TOOLS, one line further down — which I
fixed, described in the PR body, and then missed here. PORTABLE_TOOLS_TOKENS_EST is a
second constant rather than a call-time derivation, because keeping the plain path free of
JSON.stringify is the whole reason the constant exists.

2. THE TEST DID NOT PIN read_command_output

It asserted seven of the eight gated tools. read_command_output was the omission, and it is
the plausible one to lose: it takes no path and reads as portable at a glance, so nothing
would have objected to un-gating it. Rootless it can only ever refer to a background
run_command that could not have started.

Guards, each bypass-verified by reverting the fix:
  - read_command_output un-gated (the second review point)
  - the estimate reverting to the full-TOOLS constant (the first)
  - the estimate no longer switching on the root; the rootless estimate deleted
  - PORTABLE_TOOLS no longer a filtered subset, which would make the estimate guard vacuous

6 tests in agentNoWorkspace, 33 suites green.
@ndemianc

Copy link
Copy Markdown
Contributor Author

Both right. Fixed in 3dde9b2.

1. The token estimate still counted the full tool list

Correct, and this is the same mistake as leaving baseTools on TOOLS — which I fixed one line below, described in the PR body, and then missed here.

toolsTokensEst falls back to a module constant on the plain path (no MCP, no recall) so that path never re-stringifies. But that constant was built from TOOLS, and after this PR there are two plain paths: a rootless run sends PORTABLE_TOOLS and was billed for TOOLS.

Measured:

est. tokens
full TOOLS ~1518
PORTABLE_TOOLS ~520
over-reported rootless ~1000 — about two thirds of the tool budget

That's worse than a missing feature; it's a meter reading high, and the context popover exists precisely so that number can be trusted.

PORTABLE_TOOLS_TOKENS_EST is a second constant rather than a call-time derivation, because keeping the plain path free of JSON.stringify is the entire reason the constant exists:

const builtinsTokensEst = root ? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;
const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;

2. The test did not pin read_command_output

Also right — it asserted seven of the eight gated tools. And it's the plausible one to lose: read_command_output takes no path and reads as portable at a glance, so nothing would have objected to un-gating it. Rootless it can only ever refer to a background run_command that could not have started.

Guards

Each bypass-verified by reverting the fix:

bypass caught
read_command_output un-gated — your second point
the estimate reverting to the full-TOOLS constant — your first
the estimate no longer switching on the root
the rootless estimate deleted
PORTABLE_TOOLS no longer a filtered subset

The last one matters: if PORTABLE_TOOLS stopped being a strict subset the two estimates would be identical and the new guard would pass vacuously, so the test asserts the subset relation and a non-empty NEEDS_ROOT rather than trusting it.

6 tests in agentNoWorkspace, 33 suites green.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@ndemianc
ndemianc merged commit 2f1ee0c into develop Aug 16, 2026
2 checks passed
@ndemianc
ndemianc deleted the feat/agent-without-workspace branch August 16, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants