Skip to content

Commit 6cece30

Browse files
authored
feat(agent): Node.js agent support for lk agent dev/start/console (#868)
* feat(agent): support Node.js agents in lk agent dev/start/console 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. * 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. * 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. * Revert "feat(agent): load discovered .env files into Node agents via --env-file" This reverts commit 2430a24. 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. * 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. * 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.
1 parent cc8379c commit 6cece30

9 files changed

Lines changed: 330 additions & 59 deletions

cmd/lk/agent_run.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import (
2626
"syscall"
2727

2828
"github.com/urfave/cli/v3"
29+
30+
"github.com/livekit/livekit-cli/v2/pkg/agentfs"
2931
)
3032

3133
func init() {
@@ -51,15 +53,15 @@ var agentRunFlags = []cli.Flag{
5153
var startCommand = &cli.Command{
5254
Name: "start",
5355
Usage: "Run an agent in production mode",
54-
ArgsUsage: "[entrypoint]",
56+
ArgsUsage: "[entrypoint] [-- node/python-args...]",
5557
Flags: agentRunFlags,
5658
Action: runAgentStart,
5759
}
5860

5961
var devCommand = &cli.Command{
6062
Name: "dev",
6163
Usage: "Run an agent in development mode with auto-reload",
62-
ArgsUsage: "[entrypoint]",
64+
ArgsUsage: "[entrypoint] [-- node/python-args...]",
6365
Flags: append(agentRunFlags, &cli.BoolFlag{
6466
Name: "no-reload",
6567
Usage: "Disable auto-reload on file changes",
@@ -70,6 +72,11 @@ var devCommand = &cli.Command{
7072
// resolveCredentials returns CLI args (--url, --api-key, --api-secret) for the agent subprocess.
7173
func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, error) {
7274
url := cmd.String("url")
75+
if !cmd.IsSet("url") {
76+
// Ignore the global flag's default (http://localhost:7880) so the
77+
// project config can supply the URL.
78+
url = ""
79+
}
7380
apiKey := cmd.String("api-key")
7481
apiSecret := cmd.String("api-secret")
7582

@@ -106,10 +113,10 @@ func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, err
106113
return args, nil
107114
}
108115

109-
func buildCLIArgs(subcmd string, cmd *cli.Command, loadOpts ...loadOption) ([]string, error) {
116+
func buildCLIArgs(projectType agentfs.ProjectType, subcmd string, cmd *cli.Command, loadOpts ...loadOption) ([]string, error) {
110117
args := []string{subcmd}
111118
if logLevel := cmd.String("log-level"); logLevel != "" {
112-
args = append(args, "--log-level", logLevel)
119+
args = append(args, "--log-level", normalizeLogLevel(projectType, logLevel))
113120
}
114121
creds, err := resolveCredentials(cmd, loadOpts...)
115122
if err != nil {
@@ -126,7 +133,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error {
126133
}
127134
fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir)
128135

129-
cliArgs, err := buildCLIArgs("start", cmd, quietOutput)
136+
cliArgs, err := buildCLIArgs(projectType, "start", cmd, quietOutput)
130137
if err != nil {
131138
return err
132139
}
@@ -135,6 +142,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error {
135142
Dir: projectDir,
136143
Entrypoint: entrypoint,
137144
ProjectType: projectType,
145+
RuntimeArgs: forwardedArgs(cmd),
138146
CLIArgs: cliArgs,
139147
ForwardOutput: os.Stdout,
140148
})
@@ -147,7 +155,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error {
147155
sigCh := make(chan os.Signal, 1)
148156
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
149157

150-
// Forward every signal to the agent — Python decides
158+
// Forward every signal to the agent — the agent decides:
151159
// first = graceful shutdown, second = force exit.
152160
go func() {
153161
for range sigCh {
@@ -167,19 +175,28 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error {
167175
return err
168176
}
169177

170-
cliArgs, err := buildCLIArgs("start", cmd, outputToStderr)
178+
// Python has no dedicated dev subcommand: dev mode is `start --dev`.
179+
// agents-js has a `dev` subcommand that already defaults to debug logs.
180+
subcmd := "dev"
181+
if projectType.IsPython() {
182+
subcmd = "start"
183+
}
184+
cliArgs, err := buildCLIArgs(projectType, subcmd, cmd, outputToStderr)
171185
if err != nil {
172186
return err
173187
}
174-
cliArgs = append(cliArgs, "--dev")
175-
if cmd.String("log-level") == "" {
176-
cliArgs = append(cliArgs, "--log-level", "DEBUG")
188+
if projectType.IsPython() {
189+
cliArgs = append(cliArgs, "--dev")
190+
if cmd.String("log-level") == "" {
191+
cliArgs = append(cliArgs, "--log-level", "DEBUG")
192+
}
177193
}
178194

179195
cfg := AgentStartConfig{
180196
Dir: projectDir,
181197
Entrypoint: entrypoint,
182198
ProjectType: projectType,
199+
RuntimeArgs: forwardedArgs(cmd),
183200
CLIArgs: cliArgs,
184201
ForwardOutput: os.Stdout,
185202
}

cmd/lk/agent_run_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"os"
19+
"os/exec"
20+
"path/filepath"
21+
"testing"
22+
"time"
23+
24+
"github.com/stretchr/testify/require"
25+
26+
"github.com/livekit/livekit-cli/v2/pkg/agentfs"
27+
)
28+
29+
func TestAgentProcessFailSignal(t *testing.T) {
30+
if _, err := exec.LookPath("node"); err != nil {
31+
t.Skip("node not on PATH")
32+
}
33+
34+
// An agent whose job crashes logs a marker but keeps the process alive;
35+
// Failed() must fire without waiting for exit.
36+
dir := t.TempDir()
37+
script := `console.log('shutting down job task {"reason": "job crashed"}'); setTimeout(() => {}, 30000);`
38+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.js"), []byte(script), 0o644))
39+
40+
ap, err := startAgent(AgentStartConfig{
41+
Dir: dir,
42+
Entrypoint: "agent.js",
43+
ProjectType: agentfs.ProjectTypeNode,
44+
FailSignals: consoleCrashSignals,
45+
})
46+
require.NoError(t, err)
47+
defer ap.Kill()
48+
49+
select {
50+
case <-ap.Failed():
51+
case <-time.After(10 * time.Second):
52+
t.Fatal("Failed() did not fire on crash marker")
53+
}
54+
}

cmd/lk/agent_utils.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,46 @@ import (
1818
"fmt"
1919
"os"
2020
"path/filepath"
21+
"strings"
2122

2223
"github.com/urfave/cli/v3"
2324

2425
"github.com/livekit/livekit-cli/v2/pkg/agentfs"
2526
)
2627

28+
// splitForwardedArgs recovers the argument split around a "--" separator.
29+
// urfave/cli strips the separator and appends everything after it to the
30+
// parsed positional args, so the forwarded tail is recovered from the raw
31+
// process argv and trimmed off the positionals.
32+
func splitForwardedArgs(rawArgs, positional []string) (entryArgs, forwarded []string) {
33+
for i, a := range rawArgs {
34+
if a != "--" {
35+
continue
36+
}
37+
forwarded = rawArgs[i+1:]
38+
if len(forwarded) > len(positional) {
39+
// The "--" was consumed as a flag's value, not a separator.
40+
return positional, nil
41+
}
42+
return positional[:len(positional)-len(forwarded)], forwarded
43+
}
44+
return positional, nil
45+
}
46+
47+
// forwardedArgs returns the args the user passed after a "--" separator,
48+
// forwarded to the runtime interpreter (node/python) ahead of the
49+
// entrypoint, e.g. `lk agent console agent.ts -- --env-file=.env`.
50+
func forwardedArgs(cmd *cli.Command) []string {
51+
_, fwd := splitForwardedArgs(os.Args, cmd.Args().Slice())
52+
return fwd
53+
}
54+
2755
func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) {
28-
explicit := cmd.Args().First()
56+
entryArgs, _ := splitForwardedArgs(os.Args, cmd.Args().Slice())
57+
var explicit string
58+
if len(entryArgs) > 0 {
59+
explicit = entryArgs[0]
60+
}
2961

3062
detectFrom := "."
3163
if explicit != "" {
@@ -64,6 +96,16 @@ func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error
6496
return projectDir, projectType, entrypoint, nil
6597
}
6698

99+
// consoleCrashSignals are output markers meaning the console job died even
100+
// though the worker process may stay alive: the Python SDK keeps the worker
101+
// running after the job task crashes (logging `"reason": "job crashed"`), and
102+
// agents-js logs FATAL `console mode failed:` before exiting. Without these,
103+
// a pre-connect crash leaves the user waiting out the full connect timeout.
104+
var consoleCrashSignals = []string{
105+
`"job crashed"`,
106+
"console mode failed:",
107+
}
108+
67109
// buildConsoleArgs builds the agent subprocess argv for console mode, shared by
68110
// `lk agent console` and the `lk agent session` daemon.
69111
func buildConsoleArgs(addr string, record bool) []string {
@@ -73,3 +115,12 @@ func buildConsoleArgs(addr string, record bool) []string {
73115
}
74116
return args
75117
}
118+
119+
// normalizeLogLevel adapts the log level to the agent runtime's convention:
120+
// agents-js accepts only lowercase levels, Python expects uppercase.
121+
func normalizeLogLevel(projectType agentfs.ProjectType, level string) string {
122+
if projectType.IsNode() {
123+
return strings.ToLower(level)
124+
}
125+
return strings.ToUpper(level)
126+
}

cmd/lk/agent_watcher.go

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,19 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) {
8282
return nil, fmt.Errorf("failed to setup file watcher: %w", err)
8383
}
8484

85-
rs, err := newReloadServer()
86-
if err != nil {
87-
w.Close()
88-
return nil, err
85+
// The reload protocol (capture running jobs from the old process, restore
86+
// them in the new one) is Python-only; Node reloads are a plain kill+respawn.
87+
var rs *reloadServer
88+
if config.ProjectType.IsPython() {
89+
rs, err = newReloadServer()
90+
if err != nil {
91+
w.Close()
92+
return nil, err
93+
}
94+
// Append --reload-addr to CLI args so the Python process connects back
95+
config.CLIArgs = append(config.CLIArgs, "--reload-addr", rs.addr())
8996
}
9097

91-
// Append --dev and --reload-addr to CLI args so the Python process connects back
92-
config.CLIArgs = append(config.CLIArgs, "--dev", "--reload-addr", rs.addr())
93-
9498
return &agentWatcher{
9599
config: config,
96100
exts: watchExtensions(config.ProjectType),
@@ -109,15 +113,17 @@ func (aw *agentWatcher) start() error {
109113
aw.agent = agent
110114

111115
// Accept connection from new Python process in background
112-
go func() {
113-
conn, err := aw.reloadSrv.listener.Accept()
114-
if err != nil {
115-
return
116-
}
117-
aw.conn = conn
118-
// Serve the initial restore request (will be empty on first start)
119-
go aw.reloadSrv.serveNewProcess(conn)
120-
}()
116+
if aw.reloadSrv != nil {
117+
go func() {
118+
conn, err := aw.reloadSrv.listener.Accept()
119+
if err != nil {
120+
return
121+
}
122+
aw.conn = conn
123+
// Serve the initial restore request (will be empty on first start)
124+
go aw.reloadSrv.serveNewProcess(conn)
125+
}()
126+
}
121127

122128
return nil
123129
}
@@ -145,14 +151,16 @@ func (aw *agentWatcher) restart() error {
145151
aw.agent = agent
146152

147153
// 4. Accept new connection and serve restored jobs
148-
go func() {
149-
conn, err := aw.reloadSrv.listener.Accept()
150-
if err != nil {
151-
return
152-
}
153-
aw.conn = conn
154-
go aw.reloadSrv.serveNewProcess(conn)
155-
}()
154+
if aw.reloadSrv != nil {
155+
go func() {
156+
conn, err := aw.reloadSrv.listener.Accept()
157+
if err != nil {
158+
return
159+
}
160+
aw.conn = conn
161+
go aw.reloadSrv.serveNewProcess(conn)
162+
}()
163+
}
156164

157165
return nil
158166
}
@@ -178,7 +186,9 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error {
178186
if aw.conn != nil {
179187
aw.conn.Close()
180188
}
181-
aw.reloadSrv.close()
189+
if aw.reloadSrv != nil {
190+
aw.reloadSrv.close()
191+
}
182192
aw.watcher.Close()
183193
}()
184194

cmd/lk/console.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func init() {
4343
var consoleCommand = &cli.Command{
4444
Name: "console",
4545
Usage: "Voice chat with an agent via mic/speakers",
46-
ArgsUsage: "[entrypoint]",
46+
ArgsUsage: "[entrypoint] [-- node/python-args...]",
4747
Category: "Core",
4848
Flags: []cli.Flag{
4949
&cli.IntFlag{
@@ -143,7 +143,9 @@ func runConsole(ctx context.Context, cmd *cli.Command) error {
143143
Dir: projectDir,
144144
Entrypoint: entrypoint,
145145
ProjectType: projectType,
146+
RuntimeArgs: forwardedArgs(cmd),
146147
CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")),
148+
FailSignals: consoleCrashSignals,
147149
})
148150
if err != nil {
149151
stopSpinner()
@@ -183,6 +185,15 @@ func runConsole(ctx context.Context, cmd *cli.Command) error {
183185
return fmt.Errorf("agent exited before connecting: %w", err)
184186
}
185187
return fmt.Errorf("agent exited before connecting")
188+
case <-agentProc.Failed():
189+
stopSpinner()
190+
// The crash marker arrives mid-traceback; give trailing output a moment.
191+
time.Sleep(500 * time.Millisecond)
192+
logs := agentProc.RecentLogs(40)
193+
for _, l := range logs {
194+
fmt.Fprintln(os.Stderr, l)
195+
}
196+
return fmt.Errorf("agent job crashed before connecting")
186197
case <-time.After(60 * time.Second):
187198
stopSpinner()
188199
logs := agentProc.RecentLogs(20)

cmd/lk/proc_unix.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func (ap *AgentProcess) sendKill() {
3232
}
3333

3434
// sendShutdown sends SIGINT to the main process only (not the group),
35-
// letting Python manage its own child cleanup.
35+
// letting the agent manage its own child cleanup.
3636
func (ap *AgentProcess) sendShutdown() {
3737
if ap.cmd.Process != nil {
3838
ap.cmd.Process.Signal(syscall.SIGINT)

0 commit comments

Comments
 (0)