Skip to content

bug(agents): subagent_run rejects every launch when optional root selectors are present but empty #1439

Description

@rjoleinik

Before submitting

  • I searched open and closed issues and did not find a report of this problem.
  • I reviewed this report and removed credentials, tokens, private paths, hostnames, and other sensitive data.

Problem

subagent_run declares workspace_root and repository_root as optional, but its guard rejects the call when both keys are present, before validating their values:

if (Object.hasOwn(params, "repository_root") && Object.hasOwn(params, "workspace_root")) throw new Error("repository_root and workspace_root are mutually exclusive.");

A caller or host that materializes optional string arguments as empty strings therefore sends both keys with "". The guard reads that as two destinations, throws, and the launch never happens — even though only one destination was actually named and the other value is blank.

In one full working session this blocked every delegation: more than ten subagent_run calls failed with the same error and no subagent was ever queued or spawned, so no delegated work was possible at all. The error message is also misleading, because the failing call did not name two roots.

Steps to reproduce

  1. From a session whose working directory is inside a Git worktree, call subagent_run with a valid agent and task.
  2. Pass workspace_root with a canonical worktree of the same clone, and pass repository_root as an empty string (the same happens when both are empty strings).
  3. Observe the error.
{
  "agent": "explore",
  "task": "Report the current directory",
  "workspace_root": "<canonical worktree of the same clone>",
  "repository_root": ""
}

Expected and actual behavior

Expected: a blank selector names no destination, so the call proceeds — and two real destinations are still rejected.

Actual: repository_root and workspace_root are mutually exclusive. is thrown before the string-type checks; nothing is queued, nothing is spawned, and no task id is returned.

Proposed fix

Treat a blank selector as naming no destination: normalize it to absent, and only reject two real roots. This cannot widen access — a blank value can never select a path — so mutual exclusion for real destinations, and the interactive consent path for a foreign repository, both stay intact.

--- a/extensions/gentle-agents.ts
+++ b/extensions/gentle-agents.ts
@@ -1238,7 +1238,11 @@
 		async (params, ctx, signal) => {
-			if (Object.hasOwn(params, "repository_root") && Object.hasOwn(params, "workspace_root")) throw new Error("repository_root and workspace_root are mutually exclusive.");
+			// An empty selector names no destination: only real roots are mutually
+			// exclusive, so a blank string must not masquerade as a second target.
+			const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
+			const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
+			if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
 			if ((Object.hasOwn(params, "repository_root") && typeof params.repository_root !== "string") || (Object.hasOwn(params, "workspace_root") && typeof params.workspace_root !== "string")) throw new Error("Root selectors must be strings.");
@@ -1249,7 +1253,9 @@
-			return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, typeof params.workspace_root === "string" ? params.workspace_root : undefined, signal, typeof params.repository_root === "string" ? params.repository_root : undefined), signal);
+			const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
+			const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
+			return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, workspaceRoot, signal, repositoryRoot), signal);

The patch also adds one regression assertion to the existing foreign clone tool requires consent before queueing and never enters parent Changes test: a call with blank selectors passes the exclusivity guard, reaches agent lookup, and spawns nothing and registers no worktree.

Verification I ran locally on a source checkout:

  • node --experimental-strip-types --test tests/gentle-agents.test.ts — the guard tests pass, including foreign clone tool requires consent before queueing and never enters parent Changes and foreign clone rejects aliases, absent UI, decline and changed session before any child starts.
  • The same file reports 8 pre-existing failures in that local checkout (owned child diff relay ..., a mutation dropped for ..., default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children). I reproduced all 8 in a pristine worktree at the same commit without the patch, so they are unrelated to this change and are not part of this report.

gentle-pi version

3.7.0 — source checkout at b50b417c, compared against upstream main at 42d9676 (the five commits between them do not touch extensions/gentle-agents.ts or tests/gentle-agents.test.ts, so the patch applies cleanly to main).

Pi version

0.87.1

Operating system

Windows

Relevant logs or error output

Error: repository_root and workspace_root are mutually exclusive.

The same error was observed with both selectors blank, which is the clearest form of the problem: the call named no destination and still could not launch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions