From 91289556d0cbd81de324f68d021a8e3b0b879c7d Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 9 Jun 2026 16:14:32 -0400 Subject: [PATCH 1/6] feat(agent): support Node.js agents in lk agent dev/start/console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the Node project detection and spawn helpers from the session daemon work (#861) to wire the remaining agent run commands for Node: - `lk agent dev`/`start`: spawn the agents-js `dev` subcommand (Python keeps `start --dev`), normalize --log-level casing per runtime (agents-js only accepts lowercase, Python expects uppercase), and make the reload-server handshake (--reload-addr) Python-only — Node reloads are a plain kill+respawn since agents-js has no job-restore protocol. - Entrypoint discovery: probe per-runtime candidate lists (agent.ts, agent.js, src/agent.{ts,js} for Node; agent.py, src/agent.py for Python) instead of a single root default, and make the not-found message runtime-aware. - Probe `node --version` before running a TypeScript entrypoint and fail with a clear message on Node < 22.6 (no --experimental-strip-types). - resolveCredentials: ignore the global --url flag's default value so the project config (--project) can supply the URL; previously the spawned agent was always pointed at http://localhost:7880 unless --url or LIVEKIT_URL was set explicitly. `lk agent console` needed no changes beyond #861 — the spawn path was already runtime-agnostic. Tested end to end against agents-js' console CLI branch (#1706): console text mode, audio mode (mic/speaker + playback-finished handshake), ctrl-T mode toggle, --record output, dev worker registration, file-watch reload, and clean shutdown. --- cmd/lk/agent_run.go | 31 ++++++--- cmd/lk/agent_run_test.go | 118 ++++++++++++++++++++++++++++++++++ cmd/lk/agent_utils.go | 10 +++ cmd/lk/agent_watcher.go | 60 ++++++++++------- cmd/lk/proc_unix.go | 2 +- cmd/lk/simulate_subprocess.go | 98 +++++++++++++++++++++++----- 6 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 cmd/lk/agent_run_test.go diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index b72574df..3388192e 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -26,6 +26,8 @@ import ( "syscall" "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" ) func init() { @@ -70,6 +72,11 @@ var devCommand = &cli.Command{ // resolveCredentials returns CLI args (--url, --api-key, --api-secret) for the agent subprocess. func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, error) { url := cmd.String("url") + if !cmd.IsSet("url") { + // Ignore the global flag's default (http://localhost:7880) so the + // project config can supply the URL. + url = "" + } apiKey := cmd.String("api-key") apiSecret := cmd.String("api-secret") @@ -106,10 +113,10 @@ func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, err return args, nil } -func buildCLIArgs(subcmd string, cmd *cli.Command, loadOpts ...loadOption) ([]string, error) { +func buildCLIArgs(projectType agentfs.ProjectType, subcmd string, cmd *cli.Command, loadOpts ...loadOption) ([]string, error) { args := []string{subcmd} if logLevel := cmd.String("log-level"); logLevel != "" { - args = append(args, "--log-level", logLevel) + args = append(args, "--log-level", normalizeLogLevel(projectType, logLevel)) } creds, err := resolveCredentials(cmd, loadOpts...) if err != nil { @@ -126,7 +133,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error { } fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) - cliArgs, err := buildCLIArgs("start", cmd, quietOutput) + cliArgs, err := buildCLIArgs(projectType, "start", cmd, quietOutput) if err != nil { return err } @@ -147,7 +154,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - // Forward every signal to the agent — Python decides + // Forward every signal to the agent — the agent decides: // first = graceful shutdown, second = force exit. go func() { for range sigCh { @@ -167,13 +174,21 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { return err } - cliArgs, err := buildCLIArgs("start", cmd, outputToStderr) + // Python has no dedicated dev subcommand: dev mode is `start --dev`. + // agents-js has a `dev` subcommand that already defaults to debug logs. + subcmd := "dev" + if projectType.IsPython() { + subcmd = "start" + } + cliArgs, err := buildCLIArgs(projectType, subcmd, cmd, outputToStderr) if err != nil { return err } - cliArgs = append(cliArgs, "--dev") - if cmd.String("log-level") == "" { - cliArgs = append(cliArgs, "--log-level", "DEBUG") + if projectType.IsPython() { + cliArgs = append(cliArgs, "--dev") + if cmd.String("log-level") == "" { + cliArgs = append(cliArgs, "--log-level", "DEBUG") + } } cfg := AgentStartConfig{ diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go new file mode 100644 index 00000000..dd1ae116 --- /dev/null +++ b/cmd/lk/agent_run_test.go @@ -0,0 +1,118 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" +) + +func TestNormalizeLogLevel(t *testing.T) { + assert.Equal(t, "debug", normalizeLogLevel(agentfs.ProjectTypeNode, "DEBUG")) + assert.Equal(t, "warn", normalizeLogLevel(agentfs.ProjectTypeNode, "warn")) + assert.Equal(t, "DEBUG", normalizeLogLevel(agentfs.ProjectTypePythonUV, "debug")) + assert.Equal(t, "INFO", normalizeLogLevel(agentfs.ProjectTypePythonPip, "INFO")) +} + +func TestDefaultEntrypoints(t *testing.T) { + assert.Equal(t, []string{"agent.ts", "agent.js"}, defaultEntrypoints(agentfs.ProjectTypeNode)) + assert.Equal(t, []string{"agent.py"}, defaultEntrypoints(agentfs.ProjectTypePythonUV)) + assert.Equal(t, []string{"src/agent.ts", "src/agent.js"}, fallbackEntrypoints(agentfs.ProjectTypeNode)) + assert.Equal(t, []string{"src/agent.py"}, fallbackEntrypoints(agentfs.ProjectTypePythonPip)) +} + +func TestFindEntrypointPrecedence(t *testing.T) { + touch := func(path string) { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, nil, 0o644)) + } + + // A root src/ layout must not shadow an agent next to the user's cwd. + root := t.TempDir() + touch(filepath.Join(root, "src", "agent.py")) + touch(filepath.Join(root, "examples", "foo", "agent.py")) + t.Chdir(filepath.Join(root, "examples", "foo")) + + entry, err := findEntrypoint(root, "", agentfs.ProjectTypePythonUV) + require.NoError(t, err) + assert.Equal(t, filepath.Join("examples", "foo", "agent.py"), entry) + + // With nothing cwd-relative, the root src/ fallback is found. + root2 := t.TempDir() + touch(filepath.Join(root2, "src", "agent.ts")) + t.Chdir(root2) + + entry, err = findEntrypoint(root2, "", agentfs.ProjectTypeNode) + require.NoError(t, err) + assert.Equal(t, "src/agent.ts", entry) + + // A bare root agent file still wins over everything. + touch(filepath.Join(root2, "agent.js")) + entry, err = findEntrypoint(root2, "", agentfs.ProjectTypeNode) + require.NoError(t, err) + assert.Equal(t, "agent.js", entry) +} + +func TestBuildAgentCommandNode(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not on PATH") + } + + // TypeScript entrypoints run via Node's type-stripping loader. + bin, args, err := buildAgentCommand(AgentStartConfig{ + Dir: t.TempDir(), + Entrypoint: "agent.ts", + ProjectType: agentfs.ProjectTypeNode, + CLIArgs: []string{"dev", "--url", "wss://example.com"}, + }) + require.NoError(t, err) + assert.Contains(t, bin, "node") + assert.Equal(t, []string{"--experimental-strip-types", "agent.ts", "dev", "--url", "wss://example.com"}, args) + + // Plain JS entrypoints don't need the flag. + _, args, err = buildAgentCommand(AgentStartConfig{ + Dir: t.TempDir(), + Entrypoint: "agent.js", + ProjectType: agentfs.ProjectTypeNode, + CLIArgs: []string{"console", "--connect-addr", "127.0.0.1:9999"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"agent.js", "console", "--connect-addr", "127.0.0.1:9999"}, args) +} + +func TestBuildAgentCommandPython(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + + // Pip project with no venv falls back to system python; argv ordering is + // ` `. + bin, args, err := buildAgentCommand(AgentStartConfig{ + Dir: t.TempDir(), + Entrypoint: "agent.py", + ProjectType: agentfs.ProjectTypePythonPip, + CLIArgs: []string{"start", "--log-level", "DEBUG", "--dev"}, + }) + require.NoError(t, err) + assert.NotEmpty(t, bin) + assert.Equal(t, []string{"agent.py", "start", "--log-level", "DEBUG", "--dev"}, args) +} diff --git a/cmd/lk/agent_utils.go b/cmd/lk/agent_utils.go index 4cb1296e..081cba32 100644 --- a/cmd/lk/agent_utils.go +++ b/cmd/lk/agent_utils.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/urfave/cli/v3" @@ -73,3 +74,12 @@ func buildConsoleArgs(addr string, record bool) []string { } return args } + +// normalizeLogLevel adapts the log level to the agent runtime's convention: +// agents-js accepts only lowercase levels, Python expects uppercase. +func normalizeLogLevel(projectType agentfs.ProjectType, level string) string { + if projectType.IsNode() { + return strings.ToLower(level) + } + return strings.ToUpper(level) +} diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index d81f9e5f..181ddeea 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -82,15 +82,19 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { return nil, fmt.Errorf("failed to setup file watcher: %w", err) } - rs, err := newReloadServer() - if err != nil { - w.Close() - return nil, err + // The reload protocol (capture running jobs from the old process, restore + // them in the new one) is Python-only; Node reloads are a plain kill+respawn. + var rs *reloadServer + if config.ProjectType.IsPython() { + rs, err = newReloadServer() + if err != nil { + w.Close() + return nil, err + } + // Append --reload-addr to CLI args so the Python process connects back + config.CLIArgs = append(config.CLIArgs, "--reload-addr", rs.addr()) } - // Append --dev and --reload-addr to CLI args so the Python process connects back - config.CLIArgs = append(config.CLIArgs, "--dev", "--reload-addr", rs.addr()) - return &agentWatcher{ config: config, exts: watchExtensions(config.ProjectType), @@ -109,15 +113,17 @@ func (aw *agentWatcher) start() error { aw.agent = agent // Accept connection from new Python process in background - go func() { - conn, err := aw.reloadSrv.listener.Accept() - if err != nil { - return - } - aw.conn = conn - // Serve the initial restore request (will be empty on first start) - go aw.reloadSrv.serveNewProcess(conn) - }() + if aw.reloadSrv != nil { + go func() { + conn, err := aw.reloadSrv.listener.Accept() + if err != nil { + return + } + aw.conn = conn + // Serve the initial restore request (will be empty on first start) + go aw.reloadSrv.serveNewProcess(conn) + }() + } return nil } @@ -145,14 +151,16 @@ func (aw *agentWatcher) restart() error { aw.agent = agent // 4. Accept new connection and serve restored jobs - go func() { - conn, err := aw.reloadSrv.listener.Accept() - if err != nil { - return - } - aw.conn = conn - go aw.reloadSrv.serveNewProcess(conn) - }() + if aw.reloadSrv != nil { + go func() { + conn, err := aw.reloadSrv.listener.Accept() + if err != nil { + return + } + aw.conn = conn + go aw.reloadSrv.serveNewProcess(conn) + }() + } return nil } @@ -178,7 +186,9 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { if aw.conn != nil { aw.conn.Close() } - aw.reloadSrv.close() + if aw.reloadSrv != nil { + aw.reloadSrv.close() + } aw.watcher.Close() }() diff --git a/cmd/lk/proc_unix.go b/cmd/lk/proc_unix.go index 9cff6a7b..e12c3bbb 100644 --- a/cmd/lk/proc_unix.go +++ b/cmd/lk/proc_unix.go @@ -32,7 +32,7 @@ func (ap *AgentProcess) sendKill() { } // sendShutdown sends SIGINT to the main process only (not the group), -// letting Python manage its own child cleanup. +// letting the agent manage its own child cleanup. func (ap *AgentProcess) sendShutdown() { if ap.cmd.Process != nil { ap.cmd.Process.Signal(syscall.SIGINT) diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index bf448f3d..571357e5 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -16,6 +16,7 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "io" @@ -23,6 +24,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -30,7 +32,7 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/agentfs" ) -// AgentProcess manages a Python agent subprocess. +// AgentProcess manages an agent subprocess. type AgentProcess struct { cmd *exec.Cmd readyCh chan struct{} @@ -97,6 +99,55 @@ func isTypeScriptEntry(entry string) bool { } } +var nodeVersionRe = regexp.MustCompile(`v(\d+)\.(\d+)`) + +// checkTypeStrippingSupport verifies the Node binary can run TypeScript +// directly (--experimental-strip-types requires Node >= 22.6). The probe +// runs in the project dir so version-manager shims resolve the same Node +// the spawn will use. Probing failures are ignored — the spawn itself will +// surface any real error. +func checkTypeStrippingSupport(dir, nodeBin string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, nodeBin, "--version") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return nil + } + version := strings.TrimSpace(string(out)) + m := nodeVersionRe.FindStringSubmatch(version) + if m == nil { + return nil + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + if major < 22 || (major == 22 && minor < 6) { + return fmt.Errorf("running a TypeScript entrypoint directly requires Node >= 22.6 (found %s); upgrade Node or point at built JS output", version) + } + return nil +} + +// defaultEntrypoints returns candidate entrypoint paths (relative to the +// project root or working directory) probed for a project type, in priority +// order. Forward slashes are valid on all platforms. +func defaultEntrypoints(projectType agentfs.ProjectType) []string { + if projectType.IsNode() { + return []string{"agent.ts", "agent.js"} + } + return []string{"agent.py"} +} + +// fallbackEntrypoints are probed at the project root only after cwd-relative +// candidates, so a root src/ layout doesn't shadow an agent next to the +// user's working directory. +func fallbackEntrypoints(projectType agentfs.ProjectType) []string { + if projectType.IsNode() { + return []string{"src/agent.ts", "src/agent.js"} + } + return []string{"src/agent.py"} +} + // findEntrypoint resolves the agent entrypoint file. func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (string, error) { if explicit != "" { @@ -109,34 +160,48 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri } return explicit, nil } - def := projectType.DefaultEntrypoint() - if def == "" { - def = "agent.py" - } + rootCandidates := defaultEntrypoints(projectType) + srcCandidates := fallbackEntrypoints(projectType) // Check project root first - checked := []string{filepath.Join(dir, def)} - if _, err := os.Stat(checked[0]); err == nil { - return def, nil + var checked []string + probe := func(rel string) bool { + abs := filepath.Join(dir, rel) + checked = append(checked, abs) + _, err := os.Stat(abs) + return err == nil + } + for _, def := range rootCandidates { + if probe(def) { + return def, nil + } } - // Fall back to cwd-relative path (e.g. running from examples/drive-thru/) + // Then cwd-relative paths (e.g. running from examples/drive-thru/) cwd, _ := os.Getwd() if rel, err := filepath.Rel(dir, cwd); err == nil && rel != "." { - candidate := filepath.Join(rel, def) - absCandidate := filepath.Join(dir, candidate) - checked = append(checked, absCandidate) - if _, err := os.Stat(absCandidate); err == nil { - return candidate, nil + for _, def := range append(append([]string{}, rootCandidates...), srcCandidates...) { + candidate := filepath.Join(rel, def) + if probe(candidate) { + return candidate, nil + } } } + // Finally the project root's src/ layout + for _, def := range srcCandidates { + if probe(def) { + return def, nil + } + } + + example := rootCandidates[0] msg := "no agent entrypoint found, checked:\n" for _, p := range checked { msg += fmt.Sprintf(" - %s\n", p) } msg += "\nMake sure you are running this command from a directory containing a LiveKit agent.\n" - msg += "Specify the entrypoint file as a positional argument, e.g.: lk agent simulate agent.py" + msg += fmt.Sprintf("Specify the entrypoint file as a positional argument, e.g.: lk agent dev %s", example) return "", fmt.Errorf("%s", msg) } @@ -163,6 +228,9 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { } args := make([]string, 0, len(cfg.CLIArgs)+2) if isTypeScriptEntry(cfg.Entrypoint) { + if err := checkTypeStrippingSupport(cfg.Dir, nodeBin); err != nil { + return "", nil, err + } args = append(args, "--experimental-strip-types") } args = append(args, cfg.Entrypoint) From 2430a241a34354bc33a40b4c307bbb91961f1eb3 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 9 Jun 2026 17:01:35 -0400 Subject: [PATCH 2/6] feat(agent): load discovered .env files into Node agents via --env-file Node agents spawned by lk agent dev/start/console inherited only the shell environment, so credentials in a project .env file were never seen (Python agents conventionally load_dotenv() themselves). Discover a known env file in the project dir and pass it to Node's built-in dotenv with --env-file. The flag requires Node >= 20.6, so the type-stripping version check is refactored into a shared nodeVersion probe and the flag is skipped on older Nodes. Shell-exported variables still take precedence over file values. --- cmd/lk/agent_run_test.go | 24 +++++++++++++++++ cmd/lk/simulate_subprocess.go | 51 ++++++++++++++++++++++------------- pkg/agentfs/secrets-file.go | 14 ++++++++++ 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index dd1ae116..ccf671db 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -97,6 +97,30 @@ func TestBuildAgentCommandNode(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, []string{"agent.js", "console", "--connect-addr", "127.0.0.1:9999"}, args) + + // A discovered env file in the project dir is loaded via --env-file. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("FOO=bar\n"), 0o644)) + _, args, err = buildAgentCommand(AgentStartConfig{ + Dir: dir, + Entrypoint: "agent.ts", + ProjectType: agentfs.ProjectTypeNode, + CLIArgs: []string{"dev"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"--experimental-strip-types", "--env-file=.env", "agent.ts", "dev"}, args) +} + +func TestFindEnvFile(t *testing.T) { + dir := t.TempDir() + assert.Equal(t, "", agentfs.FindEnvFile(dir)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.local"), []byte("A=1\n"), 0o644)) + assert.Equal(t, ".env.local", agentfs.FindEnvFile(dir)) + + // .env outranks .env.local in the known-file order. + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("A=1\n"), 0o644)) + assert.Equal(t, ".env", agentfs.FindEnvFile(dir)) } func TestBuildAgentCommandPython(t *testing.T) { diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 571357e5..d087f323 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -101,31 +101,37 @@ func isTypeScriptEntry(entry string) bool { var nodeVersionRe = regexp.MustCompile(`v(\d+)\.(\d+)`) -// checkTypeStrippingSupport verifies the Node binary can run TypeScript -// directly (--experimental-strip-types requires Node >= 22.6). The probe -// runs in the project dir so version-manager shims resolve the same Node -// the spawn will use. Probing failures are ignored — the spawn itself will -// surface any real error. -func checkTypeStrippingSupport(dir, nodeBin string) error { +// nodeVersion probes `node --version` and reports the major/minor version. +// The probe runs in the project dir so version-manager shims resolve the +// same Node the spawn will use. ok is false when the probe or parse fails; +// callers should treat that as inconclusive rather than an error — the +// spawn itself will surface any real problem. +func nodeVersion(dir, nodeBin string) (major, minor int, version string, ok bool) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, nodeBin, "--version") cmd.Dir = dir out, err := cmd.Output() if err != nil { - return nil + return 0, 0, "", false } - version := strings.TrimSpace(string(out)) + version = strings.TrimSpace(string(out)) m := nodeVersionRe.FindStringSubmatch(version) if m == nil { - return nil + return 0, 0, version, false } - major, _ := strconv.Atoi(m[1]) - minor, _ := strconv.Atoi(m[2]) - if major < 22 || (major == 22 && minor < 6) { - return fmt.Errorf("running a TypeScript entrypoint directly requires Node >= 22.6 (found %s); upgrade Node or point at built JS output", version) + major, _ = strconv.Atoi(m[1]) + minor, _ = strconv.Atoi(m[2]) + return major, minor, version, true +} + +// nodeVersionAtLeast reports whether the probed version is at least +// major.minor, treating an inconclusive probe optimistically. +func nodeVersionAtLeast(probedMajor, probedMinor int, ok bool, major, minor int) bool { + if !ok { + return true } - return nil + return probedMajor > major || (probedMajor == major && probedMinor >= minor) } // defaultEntrypoints returns candidate entrypoint paths (relative to the @@ -218,21 +224,28 @@ type AgentStartConfig struct { // buildAgentCommand resolves the interpreter and argv for an agent subprocess, // branching on project type. Python: ` ` (uv prefixes -// `run python`). Node: `node [--experimental-strip-types] `, -// where the type-stripping flag lets a `.ts` entrypoint run without a build. +// `run python`). Node: `node [--experimental-strip-types] [--env-file=...] +// `, where the type-stripping flag lets a `.ts` entrypoint run +// without a build, and --env-file loads a discovered env file from the project +// dir (Python agents conventionally load_dotenv() themselves; Node ones don't). func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { if cfg.ProjectType.IsNode() { nodeBin, err := findNodeBinary() if err != nil { return "", nil, err } - args := make([]string, 0, len(cfg.CLIArgs)+2) + major, minor, version, ok := nodeVersion(cfg.Dir, nodeBin) + args := make([]string, 0, len(cfg.CLIArgs)+3) if isTypeScriptEntry(cfg.Entrypoint) { - if err := checkTypeStrippingSupport(cfg.Dir, nodeBin); err != nil { - return "", nil, err + if !nodeVersionAtLeast(major, minor, ok, 22, 6) { + return "", nil, fmt.Errorf("running a TypeScript entrypoint directly requires Node >= 22.6 (found %s); upgrade Node or point at built JS output", version) } args = append(args, "--experimental-strip-types") } + // --env-file requires Node >= 20.6; skip the flag (old behavior) on older Nodes. + if envFile := agentfs.FindEnvFile(cfg.Dir); envFile != "" && nodeVersionAtLeast(major, minor, ok, 20, 6) { + args = append(args, "--env-file="+envFile) + } args = append(args, cfg.Entrypoint) args = append(args, cfg.CLIArgs...) return nodeBin, args, nil diff --git a/pkg/agentfs/secrets-file.go b/pkg/agentfs/secrets-file.go index bbb13e68..e1f7c2e3 100644 --- a/pkg/agentfs/secrets-file.go +++ b/pkg/agentfs/secrets-file.go @@ -17,6 +17,7 @@ package agentfs import ( "errors" "os" + "path/filepath" "github.com/charmbracelet/huh" "github.com/joho/godotenv" @@ -41,6 +42,19 @@ func ParseEnvFile(file string) (map[string]string, error) { return godotenv.Parse(f) } +// FindEnvFile returns the first known env file present in dir (in +// knownEnvFiles order), or "" if none exist. The returned path is relative +// to dir. Unlike DetectEnvFile it never prompts, making it safe for +// non-interactive agent launches. +func FindEnvFile(dir string) string { + for _, file := range knownEnvFiles { + if _, err := os.Stat(filepath.Join(dir, file)); err == nil { + return file + } + } + return "" +} + func DetectEnvFile(maybeFile string, skipPrompts bool) (string, map[string]string, error) { if maybeFile != "" { env, err := ParseEnvFile(maybeFile) From 7660aa2222251e5d5aaedfcac159fb4b7b4a08a3 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 9 Jun 2026 17:18:11 -0400 Subject: [PATCH 3/6] feat(console): fail fast when the agent job crashes before connecting A pre-connect job crash (e.g. missing API key in the entrypoint) leaves the worker process alive, so lk agent console sat on the spinner for the full 60s connect timeout before surfacing the traceback. Scan agent output for crash markers (Python's "job crashed" shutdown reason, agents-js's FATAL "console mode failed:") via a new FailSignals option on AgentStartConfig, and abort the connect wait immediately with the recent logs. The session daemon's wait gets the same treatment, reporting the log path through the ready pipe. Verified against agents @ a61578853: a missing .env now fails in ~4s with the full traceback instead of timing out at 60s. --- cmd/lk/agent_run_test.go | 28 ++++++++++++++++++++++++++++ cmd/lk/agent_utils.go | 10 ++++++++++ cmd/lk/console.go | 10 ++++++++++ cmd/lk/session_daemon.go | 5 +++++ cmd/lk/simulate_subprocess.go | 16 ++++++++++++++++ 5 files changed, 69 insertions(+) diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index ccf671db..ad6c78ff 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -19,6 +19,7 @@ import ( "os/exec" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -111,6 +112,33 @@ func TestBuildAgentCommandNode(t *testing.T) { assert.Equal(t, []string{"--experimental-strip-types", "--env-file=.env", "agent.ts", "dev"}, args) } +func TestAgentProcessFailSignal(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not on PATH") + } + + // An agent whose job crashes logs a marker but keeps the process alive; + // Failed() must fire without waiting for exit. + dir := t.TempDir() + script := `console.log('shutting down job task {"reason": "job crashed"}'); setTimeout(() => {}, 30000);` + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.js"), []byte(script), 0o644)) + + ap, err := startAgent(AgentStartConfig{ + Dir: dir, + Entrypoint: "agent.js", + ProjectType: agentfs.ProjectTypeNode, + FailSignals: consoleCrashSignals, + }) + require.NoError(t, err) + defer ap.Kill() + + select { + case <-ap.Failed(): + case <-time.After(10 * time.Second): + t.Fatal("Failed() did not fire on crash marker") + } +} + func TestFindEnvFile(t *testing.T) { dir := t.TempDir() assert.Equal(t, "", agentfs.FindEnvFile(dir)) diff --git a/cmd/lk/agent_utils.go b/cmd/lk/agent_utils.go index 081cba32..7b13f4be 100644 --- a/cmd/lk/agent_utils.go +++ b/cmd/lk/agent_utils.go @@ -65,6 +65,16 @@ func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error return projectDir, projectType, entrypoint, nil } +// consoleCrashSignals are output markers meaning the console job died even +// though the worker process may stay alive: the Python SDK keeps the worker +// running after the job task crashes (logging `"reason": "job crashed"`), and +// agents-js logs FATAL `console mode failed:` before exiting. Without these, +// a pre-connect crash leaves the user waiting out the full connect timeout. +var consoleCrashSignals = []string{ + `"job crashed"`, + "console mode failed:", +} + // buildConsoleArgs builds the agent subprocess argv for console mode, shared by // `lk agent console` and the `lk agent session` daemon. func buildConsoleArgs(addr string, record bool) []string { diff --git a/cmd/lk/console.go b/cmd/lk/console.go index d49868e6..9b7e7f04 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -144,6 +144,7 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { Entrypoint: entrypoint, ProjectType: projectType, CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")), + FailSignals: consoleCrashSignals, }) if err != nil { stopSpinner() @@ -183,6 +184,15 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("agent exited before connecting: %w", err) } return fmt.Errorf("agent exited before connecting") + case <-agentProc.Failed(): + stopSpinner() + // The crash marker arrives mid-traceback; give trailing output a moment. + time.Sleep(500 * time.Millisecond) + logs := agentProc.RecentLogs(40) + for _, l := range logs { + fmt.Fprintln(os.Stderr, l) + } + return fmt.Errorf("agent job crashed before connecting") case <-time.After(60 * time.Second): stopSpinner() logs := agentProc.RecentLogs(20) diff --git a/cmd/lk/session_daemon.go b/cmd/lk/session_daemon.go index f775cf15..e257a534 100644 --- a/cmd/lk/session_daemon.go +++ b/cmd/lk/session_daemon.go @@ -52,6 +52,7 @@ func runSessionDaemon() { Entrypoint: os.Getenv(envSessionEntry), ProjectType: agentfs.ProjectType(os.Getenv(envSessionPType)), CLIArgs: buildConsoleArgs(server.Addr().String(), false), + FailSignals: consoleCrashSignals, }) if err != nil { signalReady(ready, "error: failed to start agent: "+err.Error()) @@ -83,6 +84,10 @@ func runSessionDaemon() { signalReady(ready, msg) agentProc.Kill() os.Exit(1) + case <-agentProc.Failed(): + signalReady(ready, "error: agent job crashed before connecting; logs: "+agentProc.LogPath) + agentProc.Kill() + os.Exit(1) case <-time.After(60 * time.Second): signalReady(ready, "error: timed out waiting for agent to connect") agentProc.Kill() diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index d087f323..c83ade4c 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -36,6 +36,7 @@ import ( type AgentProcess struct { cmd *exec.Cmd readyCh chan struct{} + failCh chan struct{} // closed when output matches a FailSignal doneCh chan error exitCh chan struct{} // closed when process exits, safe to read multiple times shutdownCalled bool // true after Shutdown() sends SIGINT @@ -219,6 +220,7 @@ type AgentStartConfig struct { CLIArgs []string // e.g. ["start", "--url", "..."] or ["console", "--connect-addr", addr] Env []string // e.g. ["LIVEKIT_AGENT_NAME=x"] or nil ReadySignal string // substring to scan for in output (e.g. "registered worker"), empty to skip + FailSignals []string // output substrings meaning the agent has fatally failed even if the process is still alive ForwardOutput io.Writer // if set, forward each output line to this writer } @@ -292,6 +294,7 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { ap := &AgentProcess{ cmd: cmd, readyCh: make(chan struct{}), + failCh: make(chan struct{}), doneCh: make(chan error, 1), exitCh: make(chan struct{}), roomLogs: make(map[string][]string), @@ -308,6 +311,7 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { // Capture output from both stdout and stderr readyOnce := sync.Once{} + failOnce := sync.Once{} scanOutput := func(r io.Reader) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -320,6 +324,12 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { if cfg.ReadySignal != "" && strings.Contains(line, cfg.ReadySignal) { readyOnce.Do(func() { close(ap.readyCh) }) } + for _, sig := range cfg.FailSignals { + if strings.Contains(line, sig) { + failOnce.Do(func() { close(ap.failCh) }) + break + } + } } } @@ -373,6 +383,12 @@ func (ap *AgentProcess) Done() <-chan error { return ap.doneCh } +// Failed returns a channel that is closed when the agent's output matched one +// of the configured FailSignals — a fatal failure even if the process is alive. +func (ap *AgentProcess) Failed() <-chan struct{} { + return ap.failCh +} + // RecentLogs returns the last n log lines from the subprocess. If n <= 0, returns all lines. func (ap *AgentProcess) RecentLogs(n int) []string { ap.mu.Lock() From 3d21399003a3c57892d269a4348ebaf39e120e39 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 10 Jun 2026 06:28:43 -0400 Subject: [PATCH 4/6] Revert "feat(agent): load discovered .env files into Node agents via --env-file" This reverts commit 2430a241a34354bc33a40b4c307bbb91961f1eb3. Per PR review, agent code is expected to load its own env; auto-loading a discovered .env file second-guesses that. Arbitrary arg forwarding (e.g. a -- separator) is the preferred ease-of-use mechanism instead. --- cmd/lk/agent_run_test.go | 24 ----------------- cmd/lk/simulate_subprocess.go | 51 +++++++++++++---------------------- pkg/agentfs/secrets-file.go | 14 ---------- 3 files changed, 19 insertions(+), 70 deletions(-) diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index ad6c78ff..46355d26 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -98,18 +98,6 @@ func TestBuildAgentCommandNode(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, []string{"agent.js", "console", "--connect-addr", "127.0.0.1:9999"}, args) - - // A discovered env file in the project dir is loaded via --env-file. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("FOO=bar\n"), 0o644)) - _, args, err = buildAgentCommand(AgentStartConfig{ - Dir: dir, - Entrypoint: "agent.ts", - ProjectType: agentfs.ProjectTypeNode, - CLIArgs: []string{"dev"}, - }) - require.NoError(t, err) - assert.Equal(t, []string{"--experimental-strip-types", "--env-file=.env", "agent.ts", "dev"}, args) } func TestAgentProcessFailSignal(t *testing.T) { @@ -139,18 +127,6 @@ func TestAgentProcessFailSignal(t *testing.T) { } } -func TestFindEnvFile(t *testing.T) { - dir := t.TempDir() - assert.Equal(t, "", agentfs.FindEnvFile(dir)) - - require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.local"), []byte("A=1\n"), 0o644)) - assert.Equal(t, ".env.local", agentfs.FindEnvFile(dir)) - - // .env outranks .env.local in the known-file order. - require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("A=1\n"), 0o644)) - assert.Equal(t, ".env", agentfs.FindEnvFile(dir)) -} - func TestBuildAgentCommandPython(t *testing.T) { if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 not on PATH") diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index c83ade4c..2f06ff72 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -102,37 +102,31 @@ func isTypeScriptEntry(entry string) bool { var nodeVersionRe = regexp.MustCompile(`v(\d+)\.(\d+)`) -// nodeVersion probes `node --version` and reports the major/minor version. -// The probe runs in the project dir so version-manager shims resolve the -// same Node the spawn will use. ok is false when the probe or parse fails; -// callers should treat that as inconclusive rather than an error — the -// spawn itself will surface any real problem. -func nodeVersion(dir, nodeBin string) (major, minor int, version string, ok bool) { +// checkTypeStrippingSupport verifies the Node binary can run TypeScript +// directly (--experimental-strip-types requires Node >= 22.6). The probe +// runs in the project dir so version-manager shims resolve the same Node +// the spawn will use. Probing failures are ignored — the spawn itself will +// surface any real error. +func checkTypeStrippingSupport(dir, nodeBin string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, nodeBin, "--version") cmd.Dir = dir out, err := cmd.Output() if err != nil { - return 0, 0, "", false + return nil } - version = strings.TrimSpace(string(out)) + version := strings.TrimSpace(string(out)) m := nodeVersionRe.FindStringSubmatch(version) if m == nil { - return 0, 0, version, false + return nil } - major, _ = strconv.Atoi(m[1]) - minor, _ = strconv.Atoi(m[2]) - return major, minor, version, true -} - -// nodeVersionAtLeast reports whether the probed version is at least -// major.minor, treating an inconclusive probe optimistically. -func nodeVersionAtLeast(probedMajor, probedMinor int, ok bool, major, minor int) bool { - if !ok { - return true + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + if major < 22 || (major == 22 && minor < 6) { + return fmt.Errorf("running a TypeScript entrypoint directly requires Node >= 22.6 (found %s); upgrade Node or point at built JS output", version) } - return probedMajor > major || (probedMajor == major && probedMinor >= minor) + return nil } // defaultEntrypoints returns candidate entrypoint paths (relative to the @@ -226,28 +220,21 @@ type AgentStartConfig struct { // buildAgentCommand resolves the interpreter and argv for an agent subprocess, // branching on project type. Python: ` ` (uv prefixes -// `run python`). Node: `node [--experimental-strip-types] [--env-file=...] -// `, where the type-stripping flag lets a `.ts` entrypoint run -// without a build, and --env-file loads a discovered env file from the project -// dir (Python agents conventionally load_dotenv() themselves; Node ones don't). +// `run python`). Node: `node [--experimental-strip-types] `, +// where the type-stripping flag lets a `.ts` entrypoint run without a build. func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { if cfg.ProjectType.IsNode() { nodeBin, err := findNodeBinary() if err != nil { return "", nil, err } - major, minor, version, ok := nodeVersion(cfg.Dir, nodeBin) - args := make([]string, 0, len(cfg.CLIArgs)+3) + args := make([]string, 0, len(cfg.CLIArgs)+2) if isTypeScriptEntry(cfg.Entrypoint) { - if !nodeVersionAtLeast(major, minor, ok, 22, 6) { - return "", nil, fmt.Errorf("running a TypeScript entrypoint directly requires Node >= 22.6 (found %s); upgrade Node or point at built JS output", version) + if err := checkTypeStrippingSupport(cfg.Dir, nodeBin); err != nil { + return "", nil, err } args = append(args, "--experimental-strip-types") } - // --env-file requires Node >= 20.6; skip the flag (old behavior) on older Nodes. - if envFile := agentfs.FindEnvFile(cfg.Dir); envFile != "" && nodeVersionAtLeast(major, minor, ok, 20, 6) { - args = append(args, "--env-file="+envFile) - } args = append(args, cfg.Entrypoint) args = append(args, cfg.CLIArgs...) return nodeBin, args, nil diff --git a/pkg/agentfs/secrets-file.go b/pkg/agentfs/secrets-file.go index e1f7c2e3..bbb13e68 100644 --- a/pkg/agentfs/secrets-file.go +++ b/pkg/agentfs/secrets-file.go @@ -17,7 +17,6 @@ package agentfs import ( "errors" "os" - "path/filepath" "github.com/charmbracelet/huh" "github.com/joho/godotenv" @@ -42,19 +41,6 @@ func ParseEnvFile(file string) (map[string]string, error) { return godotenv.Parse(f) } -// FindEnvFile returns the first known env file present in dir (in -// knownEnvFiles order), or "" if none exist. The returned path is relative -// to dir. Unlike DetectEnvFile it never prompts, making it safe for -// non-interactive agent launches. -func FindEnvFile(dir string) string { - for _, file := range knownEnvFiles { - if _, err := os.Stat(filepath.Join(dir, file)); err == nil { - return file - } - } - return "" -} - func DetectEnvFile(maybeFile string, skipPrompts bool) (string, map[string]string, error) { if maybeFile != "" { env, err := ParseEnvFile(maybeFile) From 22f0e9745e218d3c20e97823bae591c2364ad940 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 10 Jun 2026 06:36:11 -0400 Subject: [PATCH 5/6] feat(agent): forward runtime args after -- to node/python Per PR review, replace the reverted .env auto-loading with explicit arg forwarding: everything after a -- separator is passed to the runtime interpreter ahead of the entrypoint in lk agent dev/start/console and the session daemon, so `lk agent console agent.ts -- --env-file=.env` runs `node --env-file=.env agent.ts console ...`. urfave/cli strips the -- and folds the trailing args into the positionals, so splitForwardedArgs recovers the split from os.Args; detectProject uses it so a forwarded flag with no entrypoint argument isn't mistaken for one. The detached session daemon receives the args JSON-encoded via LK_SESSION_FWD. --- cmd/lk/agent_run.go | 6 ++-- cmd/lk/agent_run_test.go | 47 +++++++++++++++++++++++++++-- cmd/lk/agent_utils.go | 33 ++++++++++++++++++++- cmd/lk/console.go | 3 +- cmd/lk/session.go | 30 ++++++++++++++++++- cmd/lk/session_daemon.go | 7 +++++ cmd/lk/session_test.go | 56 +++++++++++++++++++++++++++++++++++ cmd/lk/simulate_subprocess.go | 14 +++++---- 8 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 cmd/lk/session_test.go diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 3388192e..7502fed5 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -53,7 +53,7 @@ var agentRunFlags = []cli.Flag{ var startCommand = &cli.Command{ Name: "start", Usage: "Run an agent in production mode", - ArgsUsage: "[entrypoint]", + ArgsUsage: "[entrypoint] [-- node/python-args...]", Flags: agentRunFlags, Action: runAgentStart, } @@ -61,7 +61,7 @@ var startCommand = &cli.Command{ var devCommand = &cli.Command{ Name: "dev", Usage: "Run an agent in development mode with auto-reload", - ArgsUsage: "[entrypoint]", + ArgsUsage: "[entrypoint] [-- node/python-args...]", Flags: append(agentRunFlags, &cli.BoolFlag{ Name: "no-reload", Usage: "Disable auto-reload on file changes", @@ -142,6 +142,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error { Dir: projectDir, Entrypoint: entrypoint, ProjectType: projectType, + RuntimeArgs: forwardedArgs(cmd), CLIArgs: cliArgs, ForwardOutput: os.Stdout, }) @@ -195,6 +196,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { Dir: projectDir, Entrypoint: entrypoint, ProjectType: projectType, + RuntimeArgs: forwardedArgs(cmd), CLIArgs: cliArgs, ForwardOutput: os.Stdout, } diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index 46355d26..c46537e5 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -73,6 +73,37 @@ func TestFindEntrypointPrecedence(t *testing.T) { assert.Equal(t, "agent.js", entry) } +func TestSplitForwardedArgs(t *testing.T) { + // No separator: everything is an entrypoint arg. + entry, fwd := splitForwardedArgs( + []string{"lk", "agent", "dev", "agent.ts"}, + []string{"agent.ts"}) + assert.Equal(t, []string{"agent.ts"}, entry) + assert.Empty(t, fwd) + + // Entrypoint plus forwarded runtime args. + entry, fwd = splitForwardedArgs( + []string{"lk", "agent", "console", "agent.ts", "--", "--env-file=.env", "--inspect"}, + []string{"agent.ts", "--env-file=.env", "--inspect"}) + assert.Equal(t, []string{"agent.ts"}, entry) + assert.Equal(t, []string{"--env-file=.env", "--inspect"}, fwd) + + // Forwarded args only — nothing is mistaken for an entrypoint. + entry, fwd = splitForwardedArgs( + []string{"lk", "agent", "dev", "--", "--env-file=.env"}, + []string{"--env-file=.env"}) + assert.Empty(t, entry) + assert.Equal(t, []string{"--env-file=.env"}, fwd) + + // A "--" consumed as a flag's value is not a separator: the args after + // it were parsed as flags, so nothing is forwarded. + entry, fwd = splitForwardedArgs( + []string{"lk", "agent", "dev", "--log-level", "--", "--url", "x"}, + []string{}) + assert.Empty(t, entry) + assert.Empty(t, fwd) +} + func TestBuildAgentCommandNode(t *testing.T) { if _, err := exec.LookPath("node"); err != nil { t.Skip("node not on PATH") @@ -98,6 +129,17 @@ func TestBuildAgentCommandNode(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, []string{"agent.js", "console", "--connect-addr", "127.0.0.1:9999"}, args) + + // Forwarded runtime args land between node's own flags and the entrypoint. + _, args, err = buildAgentCommand(AgentStartConfig{ + Dir: t.TempDir(), + Entrypoint: "agent.ts", + ProjectType: agentfs.ProjectTypeNode, + RuntimeArgs: []string{"--env-file=.env"}, + CLIArgs: []string{"dev"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"--experimental-strip-types", "--env-file=.env", "agent.ts", "dev"}, args) } func TestAgentProcessFailSignal(t *testing.T) { @@ -133,14 +175,15 @@ func TestBuildAgentCommandPython(t *testing.T) { } // Pip project with no venv falls back to system python; argv ordering is - // ` `. + // ` `. bin, args, err := buildAgentCommand(AgentStartConfig{ Dir: t.TempDir(), Entrypoint: "agent.py", ProjectType: agentfs.ProjectTypePythonPip, + RuntimeArgs: []string{"-u"}, CLIArgs: []string{"start", "--log-level", "DEBUG", "--dev"}, }) require.NoError(t, err) assert.NotEmpty(t, bin) - assert.Equal(t, []string{"agent.py", "start", "--log-level", "DEBUG", "--dev"}, args) + assert.Equal(t, []string{"-u", "agent.py", "start", "--log-level", "DEBUG", "--dev"}, args) } diff --git a/cmd/lk/agent_utils.go b/cmd/lk/agent_utils.go index 7b13f4be..5893f4f7 100644 --- a/cmd/lk/agent_utils.go +++ b/cmd/lk/agent_utils.go @@ -25,8 +25,39 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/agentfs" ) +// splitForwardedArgs recovers the argument split around a "--" separator. +// urfave/cli strips the separator and appends everything after it to the +// parsed positional args, so the forwarded tail is recovered from the raw +// process argv and trimmed off the positionals. +func splitForwardedArgs(rawArgs, positional []string) (entryArgs, forwarded []string) { + for i, a := range rawArgs { + if a != "--" { + continue + } + forwarded = rawArgs[i+1:] + if len(forwarded) > len(positional) { + // The "--" was consumed as a flag's value, not a separator. + return positional, nil + } + return positional[:len(positional)-len(forwarded)], forwarded + } + return positional, nil +} + +// forwardedArgs returns the args the user passed after a "--" separator, +// forwarded to the runtime interpreter (node/python) ahead of the +// entrypoint, e.g. `lk agent console agent.ts -- --env-file=.env`. +func forwardedArgs(cmd *cli.Command) []string { + _, fwd := splitForwardedArgs(os.Args, cmd.Args().Slice()) + return fwd +} + func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { - explicit := cmd.Args().First() + entryArgs, _ := splitForwardedArgs(os.Args, cmd.Args().Slice()) + var explicit string + if len(entryArgs) > 0 { + explicit = entryArgs[0] + } detectFrom := "." if explicit != "" { diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 9b7e7f04..a2f11c77 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -43,7 +43,7 @@ func init() { var consoleCommand = &cli.Command{ Name: "console", Usage: "Voice chat with an agent via mic/speakers", - ArgsUsage: "[entrypoint]", + ArgsUsage: "[entrypoint] [-- node/python-args...]", Category: "Core", Flags: []cli.Flag{ &cli.IntFlag{ @@ -143,6 +143,7 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { Dir: projectDir, Entrypoint: entrypoint, ProjectType: projectType, + RuntimeArgs: forwardedArgs(cmd), CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")), FailSignals: consoleCrashSignals, }) diff --git a/cmd/lk/session.go b/cmd/lk/session.go index 0b964f1d..bf4d86cf 100644 --- a/cmd/lk/session.go +++ b/cmd/lk/session.go @@ -42,6 +42,7 @@ const ( envSessionDir = "LK_SESSION_DIR" // resolved project dir envSessionEntry = "LK_SESSION_ENTRY" // resolved entrypoint (project-relative) envSessionPType = "LK_SESSION_PTYPE" // agentfs.ProjectType string + envSessionFwd = "LK_SESSION_FWD" // JSON array of runtime (node/python) args forwarded after "--" envSessionReadyFD = "LK_SESSION_READY_FD" // sessionDaemonSubcommand is the hidden entrypoint `start` re-execs into. @@ -71,7 +72,7 @@ var agentSessionCommand = &cli.Command{ { Name: "start", Usage: "Start a detached agent session daemon", - ArgsUsage: "[entrypoint]", + ArgsUsage: "[entrypoint] [-- node/python-args...]", Flags: []cli.Flag{sessionPortFlag}, Action: runSessionStart, }, @@ -106,6 +107,30 @@ func sessionAddr(port int) string { return fmt.Sprintf("%s:%d", sessionHost, port) } +// sessionFwdEnv encodes the forwarded runtime args as the LK_SESSION_FWD env +// entry handed to the daemon, or "" when there is nothing to forward. JSON +// keeps args with spaces or quotes unambiguous in a single env var; +// sessionFwdArgs is the daemon-side inverse. +func sessionFwdEnv(fwd []string) string { + if len(fwd) == 0 { + return "" + } + encoded, _ := json.Marshal(fwd) // marshaling []string cannot fail + return envSessionFwd + "=" + string(encoded) +} + +// sessionFwdArgs decodes the LK_SESSION_FWD value set by `session start`. +func sessionFwdArgs(raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + var fwd []string + if err := json.Unmarshal([]byte(raw), &fwd); err != nil { + return nil, fmt.Errorf("invalid %s: %w", envSessionFwd, err) + } + return fwd, nil +} + func runSessionStart(ctx context.Context, cmd *cli.Command) error { projectDir, projectType, entrypoint, err := detectProject(cmd) if err != nil { @@ -142,6 +167,9 @@ func runSessionStart(ctx context.Context, cmd *cli.Command) error { envSessionPType+"="+string(projectType), envSessionReadyFD+"=3", // ExtraFiles[0] is fd 3 in the child ) + if env := sessionFwdEnv(forwardedArgs(cmd)); env != "" { + daemon.Env = append(daemon.Env, env) + } daemon.ExtraFiles = []*os.File{readyW} daemon.Stdout = logFile daemon.Stderr = logFile diff --git a/cmd/lk/session_daemon.go b/cmd/lk/session_daemon.go index e257a534..b2f89c77 100644 --- a/cmd/lk/session_daemon.go +++ b/cmd/lk/session_daemon.go @@ -47,10 +47,17 @@ func runSessionDaemon() { } defer server.Close() + runtimeArgs, err := sessionFwdArgs(os.Getenv(envSessionFwd)) + if err != nil { + signalReady(ready, "error: "+err.Error()) + os.Exit(1) + } + agentProc, err := startAgent(AgentStartConfig{ Dir: os.Getenv(envSessionDir), Entrypoint: os.Getenv(envSessionEntry), ProjectType: agentfs.ProjectType(os.Getenv(envSessionPType)), + RuntimeArgs: runtimeArgs, CLIArgs: buildConsoleArgs(server.Addr().String(), false), FailSignals: consoleCrashSignals, }) diff --git a/cmd/lk/session_test.go b/cmd/lk/session_test.go new file mode 100644 index 00000000..64763ea0 --- /dev/null +++ b/cmd/lk/session_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// `lk agent session start` runs the agent in a detached daemon process, so +// runtime args forwarded after "--" cross the process boundary through the +// LK_SESSION_FWD env var: sessionFwdEnv encodes them on the start side and +// sessionFwdArgs decodes them on the daemon side. +func TestSessionForwardedArgsEnvRoundTrip(t *testing.T) { + t.Run("forwarded args reach the daemon verbatim", func(t *testing.T) { + fwd := []string{"--env-file=.env", `--title=a "quoted" value`, "-X", "utf8=1"} + + entry := sessionFwdEnv(fwd) + name, value, ok := strings.Cut(entry, "=") + require.True(t, ok) + assert.Equal(t, envSessionFwd, name) + + decoded, err := sessionFwdArgs(value) + require.NoError(t, err) + assert.Equal(t, fwd, decoded) + }) + + t.Run("nothing forwarded sets no env var", func(t *testing.T) { + assert.Equal(t, "", sessionFwdEnv(nil)) + + // The daemon then sees an unset env var and runs without runtime args. + decoded, err := sessionFwdArgs("") + require.NoError(t, err) + assert.Nil(t, decoded) + }) + + t.Run("corrupted env value fails instead of dropping args", func(t *testing.T) { + _, err := sessionFwdArgs("not json") + assert.ErrorContains(t, err, envSessionFwd) + }) +} diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 2f06ff72..d1bf90c1 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -211,6 +211,7 @@ type AgentStartConfig struct { Dir string Entrypoint string ProjectType agentfs.ProjectType + RuntimeArgs []string // interpreter (node/python) args placed before the entrypoint, e.g. ["--env-file=.env"] CLIArgs []string // e.g. ["start", "--url", "..."] or ["console", "--connect-addr", addr] Env []string // e.g. ["LIVEKIT_AGENT_NAME=x"] or nil ReadySignal string // substring to scan for in output (e.g. "registered worker"), empty to skip @@ -219,22 +220,24 @@ type AgentStartConfig struct { } // buildAgentCommand resolves the interpreter and argv for an agent subprocess, -// branching on project type. Python: ` ` (uv prefixes -// `run python`). Node: `node [--experimental-strip-types] `, -// where the type-stripping flag lets a `.ts` entrypoint run without a build. +// branching on project type. Python: ` ` +// (uv prefixes `run python`). Node: `node [--experimental-strip-types] +// `, where the type-stripping flag lets a `.ts` +// entrypoint run without a build. func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { if cfg.ProjectType.IsNode() { nodeBin, err := findNodeBinary() if err != nil { return "", nil, err } - args := make([]string, 0, len(cfg.CLIArgs)+2) + args := make([]string, 0, len(cfg.RuntimeArgs)+len(cfg.CLIArgs)+2) if isTypeScriptEntry(cfg.Entrypoint) { if err := checkTypeStrippingSupport(cfg.Dir, nodeBin); err != nil { return "", nil, err } args = append(args, "--experimental-strip-types") } + args = append(args, cfg.RuntimeArgs...) args = append(args, cfg.Entrypoint) args = append(args, cfg.CLIArgs...) return nodeBin, args, nil @@ -244,8 +247,9 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { if err != nil { return "", nil, err } - args := make([]string, 0, len(prefixArgs)+len(cfg.CLIArgs)+1) + args := make([]string, 0, len(prefixArgs)+len(cfg.RuntimeArgs)+len(cfg.CLIArgs)+1) args = append(args, prefixArgs...) + args = append(args, cfg.RuntimeArgs...) args = append(args, cfg.Entrypoint) args = append(args, cfg.CLIArgs...) return pythonBin, args, nil From 5dbee0e5e0f2625d5a0be1e51dbc8126234d90be Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 11 Jun 2026 07:41:20 -0400 Subject: [PATCH 6/6] test(agent): trim agent run tests to the fail-signal case Remove the log-level, entrypoint-resolution, arg-splitting, and command- building unit tests along with the session forwarded-args round-trip test, keeping TestAgentProcessFailSignal. --- cmd/lk/agent_run_test.go | 135 --------------------------------------- cmd/lk/session_test.go | 56 ---------------- 2 files changed, 191 deletions(-) delete mode 100644 cmd/lk/session_test.go diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index c46537e5..a266f906 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -21,127 +21,11 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/livekit/livekit-cli/v2/pkg/agentfs" ) -func TestNormalizeLogLevel(t *testing.T) { - assert.Equal(t, "debug", normalizeLogLevel(agentfs.ProjectTypeNode, "DEBUG")) - assert.Equal(t, "warn", normalizeLogLevel(agentfs.ProjectTypeNode, "warn")) - assert.Equal(t, "DEBUG", normalizeLogLevel(agentfs.ProjectTypePythonUV, "debug")) - assert.Equal(t, "INFO", normalizeLogLevel(agentfs.ProjectTypePythonPip, "INFO")) -} - -func TestDefaultEntrypoints(t *testing.T) { - assert.Equal(t, []string{"agent.ts", "agent.js"}, defaultEntrypoints(agentfs.ProjectTypeNode)) - assert.Equal(t, []string{"agent.py"}, defaultEntrypoints(agentfs.ProjectTypePythonUV)) - assert.Equal(t, []string{"src/agent.ts", "src/agent.js"}, fallbackEntrypoints(agentfs.ProjectTypeNode)) - assert.Equal(t, []string{"src/agent.py"}, fallbackEntrypoints(agentfs.ProjectTypePythonPip)) -} - -func TestFindEntrypointPrecedence(t *testing.T) { - touch := func(path string) { - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, nil, 0o644)) - } - - // A root src/ layout must not shadow an agent next to the user's cwd. - root := t.TempDir() - touch(filepath.Join(root, "src", "agent.py")) - touch(filepath.Join(root, "examples", "foo", "agent.py")) - t.Chdir(filepath.Join(root, "examples", "foo")) - - entry, err := findEntrypoint(root, "", agentfs.ProjectTypePythonUV) - require.NoError(t, err) - assert.Equal(t, filepath.Join("examples", "foo", "agent.py"), entry) - - // With nothing cwd-relative, the root src/ fallback is found. - root2 := t.TempDir() - touch(filepath.Join(root2, "src", "agent.ts")) - t.Chdir(root2) - - entry, err = findEntrypoint(root2, "", agentfs.ProjectTypeNode) - require.NoError(t, err) - assert.Equal(t, "src/agent.ts", entry) - - // A bare root agent file still wins over everything. - touch(filepath.Join(root2, "agent.js")) - entry, err = findEntrypoint(root2, "", agentfs.ProjectTypeNode) - require.NoError(t, err) - assert.Equal(t, "agent.js", entry) -} - -func TestSplitForwardedArgs(t *testing.T) { - // No separator: everything is an entrypoint arg. - entry, fwd := splitForwardedArgs( - []string{"lk", "agent", "dev", "agent.ts"}, - []string{"agent.ts"}) - assert.Equal(t, []string{"agent.ts"}, entry) - assert.Empty(t, fwd) - - // Entrypoint plus forwarded runtime args. - entry, fwd = splitForwardedArgs( - []string{"lk", "agent", "console", "agent.ts", "--", "--env-file=.env", "--inspect"}, - []string{"agent.ts", "--env-file=.env", "--inspect"}) - assert.Equal(t, []string{"agent.ts"}, entry) - assert.Equal(t, []string{"--env-file=.env", "--inspect"}, fwd) - - // Forwarded args only — nothing is mistaken for an entrypoint. - entry, fwd = splitForwardedArgs( - []string{"lk", "agent", "dev", "--", "--env-file=.env"}, - []string{"--env-file=.env"}) - assert.Empty(t, entry) - assert.Equal(t, []string{"--env-file=.env"}, fwd) - - // A "--" consumed as a flag's value is not a separator: the args after - // it were parsed as flags, so nothing is forwarded. - entry, fwd = splitForwardedArgs( - []string{"lk", "agent", "dev", "--log-level", "--", "--url", "x"}, - []string{}) - assert.Empty(t, entry) - assert.Empty(t, fwd) -} - -func TestBuildAgentCommandNode(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not on PATH") - } - - // TypeScript entrypoints run via Node's type-stripping loader. - bin, args, err := buildAgentCommand(AgentStartConfig{ - Dir: t.TempDir(), - Entrypoint: "agent.ts", - ProjectType: agentfs.ProjectTypeNode, - CLIArgs: []string{"dev", "--url", "wss://example.com"}, - }) - require.NoError(t, err) - assert.Contains(t, bin, "node") - assert.Equal(t, []string{"--experimental-strip-types", "agent.ts", "dev", "--url", "wss://example.com"}, args) - - // Plain JS entrypoints don't need the flag. - _, args, err = buildAgentCommand(AgentStartConfig{ - Dir: t.TempDir(), - Entrypoint: "agent.js", - ProjectType: agentfs.ProjectTypeNode, - CLIArgs: []string{"console", "--connect-addr", "127.0.0.1:9999"}, - }) - require.NoError(t, err) - assert.Equal(t, []string{"agent.js", "console", "--connect-addr", "127.0.0.1:9999"}, args) - - // Forwarded runtime args land between node's own flags and the entrypoint. - _, args, err = buildAgentCommand(AgentStartConfig{ - Dir: t.TempDir(), - Entrypoint: "agent.ts", - ProjectType: agentfs.ProjectTypeNode, - RuntimeArgs: []string{"--env-file=.env"}, - CLIArgs: []string{"dev"}, - }) - require.NoError(t, err) - assert.Equal(t, []string{"--experimental-strip-types", "--env-file=.env", "agent.ts", "dev"}, args) -} - func TestAgentProcessFailSignal(t *testing.T) { if _, err := exec.LookPath("node"); err != nil { t.Skip("node not on PATH") @@ -168,22 +52,3 @@ func TestAgentProcessFailSignal(t *testing.T) { t.Fatal("Failed() did not fire on crash marker") } } - -func TestBuildAgentCommandPython(t *testing.T) { - if _, err := exec.LookPath("python3"); err != nil { - t.Skip("python3 not on PATH") - } - - // Pip project with no venv falls back to system python; argv ordering is - // ` `. - bin, args, err := buildAgentCommand(AgentStartConfig{ - Dir: t.TempDir(), - Entrypoint: "agent.py", - ProjectType: agentfs.ProjectTypePythonPip, - RuntimeArgs: []string{"-u"}, - CLIArgs: []string{"start", "--log-level", "DEBUG", "--dev"}, - }) - require.NoError(t, err) - assert.NotEmpty(t, bin) - assert.Equal(t, []string{"-u", "agent.py", "start", "--log-level", "DEBUG", "--dev"}, args) -} diff --git a/cmd/lk/session_test.go b/cmd/lk/session_test.go deleted file mode 100644 index 64763ea0..00000000 --- a/cmd/lk/session_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// `lk agent session start` runs the agent in a detached daemon process, so -// runtime args forwarded after "--" cross the process boundary through the -// LK_SESSION_FWD env var: sessionFwdEnv encodes them on the start side and -// sessionFwdArgs decodes them on the daemon side. -func TestSessionForwardedArgsEnvRoundTrip(t *testing.T) { - t.Run("forwarded args reach the daemon verbatim", func(t *testing.T) { - fwd := []string{"--env-file=.env", `--title=a "quoted" value`, "-X", "utf8=1"} - - entry := sessionFwdEnv(fwd) - name, value, ok := strings.Cut(entry, "=") - require.True(t, ok) - assert.Equal(t, envSessionFwd, name) - - decoded, err := sessionFwdArgs(value) - require.NoError(t, err) - assert.Equal(t, fwd, decoded) - }) - - t.Run("nothing forwarded sets no env var", func(t *testing.T) { - assert.Equal(t, "", sessionFwdEnv(nil)) - - // The daemon then sees an unset env var and runs without runtime args. - decoded, err := sessionFwdArgs("") - require.NoError(t, err) - assert.Nil(t, decoded) - }) - - t.Run("corrupted env value fails instead of dropping args", func(t *testing.T) { - _, err := sessionFwdArgs("not json") - assert.ErrorContains(t, err, envSessionFwd) - }) -}