fix(subagents): run bundled JS launcher via runtime on Windows (#975)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-16 01:11:41 -08:00
committed by GitHub
parent 1b8dab2040
commit 702e888300
4 changed files with 217 additions and 21 deletions

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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(
{},

View File

@@ -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,