From 702e888300636c0bc8a3396bbd16011ba09487d7 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 16 Feb 2026 01:11:41 -0800 Subject: [PATCH] fix(subagents): run bundled JS launcher via runtime on Windows (#975) Co-authored-by: Letta --- src/agent/subagents/manager.ts | 83 +++++++++--- .../agent/subagent-model-resolution.test.ts | 119 +++++++++++++++++- src/tests/tools/shell-env.test.ts | 16 +++ src/tools/impl/shellEnv.ts | 20 ++- 4 files changed, 217 insertions(+), 21 deletions(-) diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index e6f4db9..b6c3844 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -22,6 +22,7 @@ import { cliPermissions } from "../../permissions/cli"; import { permissionMode } from "../../permissions/mode"; import { sessionPermissions } from "../../permissions/session"; import { settingsManager } from "../../settings-manager"; +import { resolveLettaInvocation } from "../../tools/impl/shellEnv"; import { getErrorMessage } from "../../utils/error"; import { getAvailableModelHandles } from "../available-models"; @@ -418,6 +419,66 @@ function parseResultFromStdout( } } +interface ResolveSubagentLauncherOptions { + env?: NodeJS.ProcessEnv; + argv?: string[]; + execPath?: string; + platform?: NodeJS.Platform; +} + +interface SubagentLauncher { + command: string; + args: string[]; +} + +export function resolveSubagentLauncher( + cliArgs: string[], + options: ResolveSubagentLauncherOptions = {}, +): SubagentLauncher { + const env = options.env ?? process.env; + const argv = options.argv ?? process.argv; + const execPath = options.execPath ?? process.execPath; + const platform = options.platform ?? process.platform; + + const invocation = resolveLettaInvocation(env, argv, execPath); + if (invocation) { + return { + command: invocation.command, + args: [...invocation.args, ...cliArgs], + }; + } + + const currentScript = argv[1] || ""; + + // Preserve historical subagent behavior: any .ts entrypoint uses runtime binary. + if (currentScript.endsWith(".ts")) { + return { + command: execPath, + args: [currentScript, ...cliArgs], + }; + } + + // Windows cannot reliably spawn bundled .js directly (EFTYPE/EINVAL). + if (currentScript.endsWith(".js") && platform === "win32") { + return { + command: execPath, + args: [currentScript, ...cliArgs], + }; + } + + if (currentScript.endsWith(".js")) { + return { + command: currentScript, + args: cliArgs, + }; + } + + return { + command: "letta", + args: cliArgs, + }; +} + // ============================================================================ // Core Functions // ============================================================================ @@ -557,7 +618,7 @@ async function executeSubagent( } try { - let cliArgs = buildSubagentArgs( + const cliArgs = buildSubagentArgs( type, config, model, @@ -567,23 +628,7 @@ async function executeSubagent( maxTurns, ); - // Spawn Letta Code in headless mode. - // Use the same binary as the current process, with fallbacks: - // 1. LETTA_CODE_BIN env var (explicit override) - // 2. Current process argv[1] if it's a .js file (built letta.js) - // 3. Dev mode: use process.execPath (bun) with the .ts script as first arg - // 4. "letta" (global install) - const currentScript = process.argv[1] || ""; - let lettaCmd = - process.env.LETTA_CODE_BIN || - (currentScript.endsWith(".js") ? currentScript : null) || - "letta"; - // In dev mode (running .ts file via bun), use the runtime binary directly - // and prepend the script path to the CLI args - if (currentScript.endsWith(".ts") && !process.env.LETTA_CODE_BIN) { - lettaCmd = process.execPath; // e.g., /path/to/bun - cliArgs = [currentScript, ...cliArgs]; - } + const launcher = resolveSubagentLauncher(cliArgs); // Pass parent agent ID so subagents can access parent's context (e.g., search history) let parentAgentId: string | undefined; try { @@ -600,7 +645,7 @@ async function executeSubagent( const inheritedBaseUrl = process.env.LETTA_BASE_URL || settings.env?.LETTA_BASE_URL; - const proc = spawn(lettaCmd, cliArgs, { + const proc = spawn(launcher.command, launcher.args, { cwd: process.cwd(), env: { ...process.env, diff --git a/src/tests/agent/subagent-model-resolution.test.ts b/src/tests/agent/subagent-model-resolution.test.ts index 463a020..46ac369 100644 --- a/src/tests/agent/subagent-model-resolution.test.ts +++ b/src/tests/agent/subagent-model-resolution.test.ts @@ -1,5 +1,122 @@ import { describe, expect, test } from "bun:test"; -import { resolveSubagentModel } from "../../agent/subagents/manager"; +import { + resolveSubagentLauncher, + resolveSubagentModel, +} from "../../agent/subagents/manager"; + +describe("resolveSubagentLauncher", () => { + test("explicit launcher takes precedence over .ts script autodetection", () => { + const launcher = resolveSubagentLauncher(["-p", "hi"], { + env: { + LETTA_CODE_BIN: "custom-bun", + LETTA_CODE_BIN_ARGS_JSON: JSON.stringify(["run", "src/index.ts"]), + } as NodeJS.ProcessEnv, + argv: ["bun", "/tmp/dev-entry.ts"], + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + }); + + expect(launcher).toEqual({ + command: "custom-bun", + args: ["run", "src/index.ts", "-p", "hi"], + }); + }); + + test("explicit launcher takes precedence over .js script autodetection", () => { + const launcher = resolveSubagentLauncher(["-p", "hi"], { + env: { + LETTA_CODE_BIN: "custom-node", + } as NodeJS.ProcessEnv, + argv: ["node", "/tmp/letta.js"], + execPath: "/usr/local/bin/node", + platform: "win32", + }); + + expect(launcher).toEqual({ + command: "custom-node", + args: ["-p", "hi"], + }); + }); + + test("preserves existing .ts dev behavior for any ts entrypoint", () => { + const launcher = resolveSubagentLauncher( + ["--output-format", "stream-json"], + { + env: {} as NodeJS.ProcessEnv, + argv: ["bun", "/tmp/custom-runner.ts"], + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + }, + ); + + expect(launcher).toEqual({ + command: "/opt/homebrew/bin/bun", + args: ["/tmp/custom-runner.ts", "--output-format", "stream-json"], + }); + }); + + test("uses node runtime for bundled js on win32", () => { + const launcher = resolveSubagentLauncher(["-p", "prompt"], { + env: {} as NodeJS.ProcessEnv, + argv: ["node", "C:\\Program Files\\Letta\\letta.js"], + execPath: "C:\\Program Files\\nodejs\\node.exe", + platform: "win32", + }); + + expect(launcher).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: ["C:\\Program Files\\Letta\\letta.js", "-p", "prompt"], + }); + }); + + test("keeps direct js spawn behavior on non-win32", () => { + const launcher = resolveSubagentLauncher(["-p", "prompt"], { + env: {} as NodeJS.ProcessEnv, + argv: ["node", "/usr/local/lib/letta.js"], + execPath: "/usr/local/bin/node", + platform: "linux", + }); + + expect(launcher).toEqual({ + command: "/usr/local/lib/letta.js", + args: ["-p", "prompt"], + }); + }); + + test("falls back to global letta when no launcher hints available", () => { + const launcher = resolveSubagentLauncher(["-p", "prompt"], { + env: {} as NodeJS.ProcessEnv, + argv: ["node", ""], + execPath: "/usr/local/bin/node", + platform: "linux", + }); + + expect(launcher).toEqual({ + command: "letta", + args: ["-p", "prompt"], + }); + }); + + test("keeps explicit launcher with spaces as a single command token", () => { + const launcher = resolveSubagentLauncher( + ["--output-format", "stream-json"], + { + env: { + LETTA_CODE_BIN: + '"C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd"', + } as NodeJS.ProcessEnv, + argv: ["node", "C:\\Program Files\\Letta\\letta.js"], + execPath: "C:\\Program Files\\nodejs\\node.exe", + platform: "win32", + }, + ); + + expect(launcher).toEqual({ + command: "C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd", + args: ["--output-format", "stream-json"], + }); + }); +}); describe("resolveSubagentModel", () => { test("prefers BYOK-swapped handle when available", async () => { diff --git a/src/tests/tools/shell-env.test.ts b/src/tests/tools/shell-env.test.ts index b086cb6..70407d5 100644 --- a/src/tests/tools/shell-env.test.ts +++ b/src/tests/tools/shell-env.test.ts @@ -24,6 +24,22 @@ describe("shellEnv letta shim", () => { }); }); + test("resolveLettaInvocation strips accidental wrapping quotes in LETTA_CODE_BIN", () => { + const invocation = resolveLettaInvocation( + { + LETTA_CODE_BIN: + '"C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd"', + }, + ["node", "/irrelevant/script.js"], + "/opt/homebrew/bin/bun", + ); + + expect(invocation).toEqual({ + command: "C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd", + args: [], + }); + }); + test("resolveLettaInvocation infers dev entrypoint launcher", () => { const invocation = resolveLettaInvocation( {}, diff --git a/src/tools/impl/shellEnv.ts b/src/tools/impl/shellEnv.ts index a7d4503..0a06d16 100644 --- a/src/tools/impl/shellEnv.ts +++ b/src/tools/impl/shellEnv.ts @@ -54,6 +54,24 @@ interface LettaInvocation { const LETTA_BIN_ARGS_ENV = "LETTA_CODE_BIN_ARGS_JSON"; +function normalizeInvocationCommand(raw: string | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + const wrappedInDoubleQuotes = + trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"'); + const wrappedInSingleQuotes = + trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'"); + + const normalized = + wrappedInDoubleQuotes || wrappedInSingleQuotes + ? trimmed.slice(1, -1).trim() + : trimmed; + + return normalized || null; +} + function parseInvocationArgs(raw: string | undefined): string[] { if (!raw) return []; try { @@ -80,7 +98,7 @@ export function resolveLettaInvocation( argv: string[] = process.argv, execPath: string = process.execPath, ): LettaInvocation | null { - const explicitBin = env.LETTA_CODE_BIN?.trim(); + const explicitBin = normalizeInvocationCommand(env.LETTA_CODE_BIN); if (explicitBin) { return { command: explicitBin,