Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/lk/agent_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func TestAgentProcessFailSignal(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node not on PATH")
}
// The first node.exe spawn on a Windows CI runner can take several seconds
// (Defender scans the binary on first exec), which blows the SDK version
// probe's 5s timeout inside startAgent and fails the test before the
// crash-signal behavior under test ever runs. Spawn a throwaway Node
// process first to absorb the cold start.
require.NoError(t, exec.Command("node", "-e",
`console.log('pre-warming node so the SDK version probe does not time out on a cold runner')`).Run())

// An agent whose job crashes logs a marker but keeps the process alive;
// Failed() must fire without waiting for exit.
Expand Down
87 changes: 67 additions & 20 deletions cmd/lk/agent_watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,53 @@ func watchExtensions(pt agentfs.ProjectType) map[string]bool {
return map[string]bool{".js": true, ".ts": true, ".mjs": true, ".mts": true}
}

// firstPythonCLIAddrVersion is the first Python livekit-agents release that
// accepts --cli-addr; releases up to and including 1.6.5 only accept the legacy
// --reload-addr.
const firstPythonCLIAddrVersion = "1.6.6"

// firstNodeCLIAddrVersion is the first @livekit/agents release that accepts
// --cli-addr; releases up to and including 1.5.0 reject unknown options, so
// older SDKs must not be passed any dev-channel flag.
const firstNodeCLIAddrVersion = "1.5.1"

// devChannelAddrFlag picks the CLI flag used to hand the agent the dev-channel
// address, or "" when the installed SDK predates any flag it would accept. The
// installed version is resolved via the project's interpreter so symlinked/
// workspace deps and loose constraints report what will actually run.
func devChannelAddrFlag(config AgentStartConfig) string {
return devChannelAddrFlagFor(config.ProjectType, agentfs.ResolveInstalledSDKVersion(config.Dir, config.Entrypoint, config.ProjectType))
}

// devChannelAddrFlagFor implements the flag choice. The flag was renamed
// --reload-addr -> --cli-addr (it now carries more than reloads), but an SDK
// must not be handed a flag it predates, so anything not positively new
// enough — including an undetermined version — falls back to what released
// SDKs accept: the legacy --reload-addr for Python, no flag at all for Node
// (whose CLI hard-fails on unknown options).
func devChannelAddrFlagFor(projectType agentfs.ProjectType, installedVersion string) string {
newEnough := func(minVersion string) bool {
if installedVersion == "" {
return false
}
ok, err := agentfs.IsVersionSatisfied(installedVersion, minVersion)
return err == nil && ok
}
switch {
case projectType.IsPython():
if newEnough(firstPythonCLIAddrVersion) {
return "--cli-addr"
}
return "--reload-addr"
case projectType.IsNode():
if newEnough(firstNodeCLIAddrVersion) {
return "--cli-addr"
}
return ""
}
return ""
}

// agentWatcher watches for file changes and restarts an agent subprocess.
type agentWatcher struct {
config AgentStartConfig
Expand Down Expand Up @@ -79,19 +126,21 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) {
return nil, fmt.Errorf("failed to setup file watcher: %w", 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 *devServer
if config.ProjectType.IsPython() {
rs, err = newDevServer()
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())
// The dev server backs two things over one channel: the ServerInfo the agent
// reports on connect (e.g. for the Cloud console link) and the reload protocol
// (capture running jobs from the old process, restore them in the new one). It
// is created for every agent type so ServerInfo works; the job capture/restore
// is Python-only and gated in restart() (Node reloads are a plain kill+respawn).
rs, err := newDevServer()
if err != nil {
w.Close()
return nil, err
}
rs.onServerInfo = config.OnServerInfo
// The agent connects back to this address over the dev channel.
if addrFlag := devChannelAddrFlag(config); addrFlag != "" {
config.CLIArgs = append(config.CLIArgs, addrFlag, rs.addr())
}

return &agentWatcher{
config: config,
Expand All @@ -118,9 +167,6 @@ func (aw *agentWatcher) start() error {
// acceptSession waits (in the background) for the next process to connect back on
// the reload channel and hands the connection to a devSession read loop.
func (aw *agentWatcher) acceptSession() {
if aw.devSrv == nil {
return
}
go func() {
conn, err := aw.devSrv.listener.Accept()
if err != nil {
Expand All @@ -133,9 +179,12 @@ func (aw *agentWatcher) acceptSession() {
}

func (aw *agentWatcher) restart() error {
// 1. Capture active jobs from the current process (best-effort)
// 1. Capture active jobs from the current process (best-effort). Job
// capture/restore is Python-only; Node reloads are a plain kill+respawn.
if aw.session != nil {
aw.devSrv.captureJobs(aw.session)
if aw.config.ProjectType.IsPython() {
aw.devSrv.captureJobs(aw.session)
}
aw.session.close()
aw.session = nil
}
Expand Down Expand Up @@ -181,9 +230,7 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error {
if aw.session != nil {
aw.session.close()
}
if aw.devSrv != nil {
aw.devSrv.close()
}
aw.devSrv.close()
aw.watcher.Close()
}()

Expand Down Expand Up @@ -249,7 +296,7 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error {
case <-exitCh:
// Nil the channel so this case won't fire again (nil channels block forever)
exitCh = nil
// Drain any pending debounce don't restart immediately
// Drain any pending debounce - don't restart immediately
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
Expand Down
4 changes: 2 additions & 2 deletions cmd/lk/python_sdk_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func setupUvAgentProject(t *testing.T, stubVersion, depSpec string, sync bool) s

func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) {
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true)
version, notInstalled := resolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV)
version, notInstalled := agentfs.ResolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV)
require.Equal(t, "1.6.7", version)
require.False(t, notInstalled)
}
Expand Down Expand Up @@ -112,7 +112,7 @@ func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) {
require.NoError(t, err)
require.NoError(t, os.WriteFile(stubPyproject, []byte(strings.Replace(string(orig), "1.6.7", "9.9.9", 1)), 0o644))

bin, prefixArgs, err := findPythonBinary(dir, agentfs.ProjectTypePythonUV)
bin, prefixArgs, err := agentfs.FindPythonBinary(dir, agentfs.ProjectTypePythonUV)
require.NoError(t, err)
cmd := exec.Command(bin, append(prefixArgs, "-c", `import importlib.metadata as m; print(m.version("livekit-agents"))`)...)
cmd.Dir = dir
Expand Down
118 changes: 9 additions & 109 deletions cmd/lk/simulate_subprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,45 +53,6 @@ type AgentProcess struct {
LogPath string
}

// findPythonBinary locates a Python binary for the given project type.
func findPythonBinary(dir string, projectType agentfs.ProjectType) (string, []string, error) {
if projectType == agentfs.ProjectTypePythonUV {
uvPath, err := exec.LookPath("uv")
if err == nil {
// --no-sync: the CLI proxies the environment as it exists on disk
// and must never install or upgrade packages as a side effect.
return uvPath, []string{"run", "--no-sync", "python"}, nil
}
}

// Check common venv locations
for _, venvDir := range []string{".venv", "venv"} {
candidate := filepath.Join(dir, venvDir, "bin", "python")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil, nil
}
}

// Fall back to system python
pythonPath, err := exec.LookPath("python3")
if err != nil {
pythonPath, err = exec.LookPath("python")
if err != nil {
return "", nil, fmt.Errorf("could not find Python binary; ensure a virtual environment exists or Python is on PATH")
}
}
return pythonPath, nil, nil
}

// findNodeBinary locates the Node binary used to run a JS/TS agent.
func findNodeBinary() (string, error) {
nodePath, err := exec.LookPath("node")
if err != nil {
return "", fmt.Errorf("could not find Node binary; ensure node is on PATH")
}
return nodePath, nil
}

// isTypeScriptEntry reports whether the entrypoint is TypeScript source that
// needs Node's type-stripping loader to run directly (no build step).
func isTypeScriptEntry(entry string) bool {
Expand Down Expand Up @@ -139,39 +100,16 @@ func checkTypeStrippingSupport(dir, nodeBin string) error {
// equivalent floor from server client settings.
const nodeAgentMinVersion = "1.0.0"

// nodeResolveVersionScript asks Node to report the installed @livekit/agents
// version using its own module resolution paths (so pnpm/workspace symlinks
// and hoisting resolve exactly as they will at runtime). See the source file
// for details.
//
//go:embed node_resolve_version.js
var nodeResolveVersionScript string

// resolveNodeAgentVersion returns the installed @livekit/agents version as Node
// resolves it from fromDir, or "" if it can't be determined.
func resolveNodeAgentVersion(nodeBin, fromDir string) string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, nodeBin, "-e", nodeResolveVersionScript)
cmd.Dir = fromDir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

// checkNodeSDKVersion gates a Node agent on nodeAgentMinVersion, resolving the
// installed @livekit/agents from the entrypoint's directory so monorepo and
// workspace layouts (where the dep is a workspace:* symlink, not a versioned
// entry in the root package.json) report the version that will actually run.
// installed @livekit/agents via the project's own runtime (see
// agentfs.ResolveInstalledSDKVersion).
func checkNodeSDKVersion(cfg AgentStartConfig) error {
nodeBin, err := findNodeBinary()
if err != nil {
// Surface a missing Node binary as its own error rather than folding it
// into "package not found".
if _, err := agentfs.FindNodeBinary(); err != nil {
return err
}
fromDir := filepath.Dir(filepath.Join(cfg.Dir, cfg.Entrypoint))
version := resolveNodeAgentVersion(nodeBin, fromDir)
version := agentfs.ResolveInstalledSDKVersion(cfg.Dir, cfg.Entrypoint, cfg.ProjectType)
if version == "" {
return fmt.Errorf("@livekit/agents not found; install dependencies and make sure this is a LiveKit agent project")
}
Expand All @@ -182,50 +120,12 @@ func checkNodeSDKVersion(cfg AgentStartConfig) error {
return nil
}

// pythonResolveVersionScript prints the installed livekit-agents version, or a
// sentinel when the interpreter runs fine but the package isn't installed —
// distinguishing "dependencies not synced" from probe failures (no
// interpreter, timeout), which exit non-zero.
const pythonResolveVersionScript = `import importlib.metadata as m
try:
print(m.version("livekit-agents"))
except m.PackageNotFoundError:
print("` + pythonAgentNotInstalled + `")`

const pythonAgentNotInstalled = "__NOT_INSTALLED__"

// resolvePythonAgentVersion returns the installed livekit-agents version read
// via the project's interpreter, so any installer (uv, pip, poetry) reports the
// version that will actually run. notInstalled reports that the interpreter ran
// but the package is missing from its environment; version is "" when it can't
// be determined at all (no interpreter, probe failure, etc.).
func resolvePythonAgentVersion(dir string, projectType agentfs.ProjectType) (version string, notInstalled bool) {
pythonBin, prefixArgs, err := findPythonBinary(dir, projectType)
if err != nil {
return "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args := append(append([]string{}, prefixArgs...), "-c", pythonResolveVersionScript)
cmd := exec.CommandContext(ctx, pythonBin, args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", false
}
v := strings.TrimSpace(string(out))
if v == pythonAgentNotInstalled {
return "", true
}
return v, false
}

// checkPythonSDKVersion gates a Python agent on thinCLIMinVersion. It prefers
// the installed version (resolved via the interpreter, accurate regardless of
// the package manager and not fooled by a loose version constraint); when
// dependencies aren't installed it falls back to static project-file parsing.
func checkPythonSDKVersion(cfg AgentStartConfig) error {
version, notInstalled := resolvePythonAgentVersion(cfg.Dir, cfg.ProjectType)
version, notInstalled := agentfs.ResolvePythonAgentVersion(cfg.Dir, cfg.ProjectType)
if notInstalled && cfg.ProjectType == agentfs.ProjectTypePythonUV {
// The launch runs `uv run --no-sync`, so a missing package won't be
// installed on the way up — fail fast with the fix instead.
Expand Down Expand Up @@ -351,7 +251,7 @@ const thinCLIMinVersion = "1.6.0"
// 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()
nodeBin, err := agentfs.FindNodeBinary()
if err != nil {
return "", nil, err
}
Expand All @@ -368,7 +268,7 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) {
return nodeBin, args, nil
}

pythonBin, prefixArgs, err := findPythonBinary(cfg.Dir, cfg.ProjectType)
pythonBin, prefixArgs, err := agentfs.FindPythonBinary(cfg.Dir, cfg.ProjectType)
if err != nil {
return "", nil, err
}
Expand Down
Loading
Loading