fix(subagents): run bundled JS launcher via runtime on Windows (#975)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user