diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index caede654..b1c729d5 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -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. diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 03094e80..cc58f2cd 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -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 @@ -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, @@ -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 { @@ -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 } @@ -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() }() @@ -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 diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index 8c2f0904..6feaa982 100644 --- a/cmd/lk/python_sdk_version_test.go +++ b/cmd/lk/python_sdk_version_test.go @@ -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) } @@ -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 diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index f9845a0a..51700d3e 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -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 { @@ -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") } @@ -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. @@ -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 } @@ -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 } diff --git a/pkg/agentfs/installed_version.go b/pkg/agentfs/installed_version.go new file mode 100644 index 00000000..78c3b57e --- /dev/null +++ b/pkg/agentfs/installed_version.go @@ -0,0 +1,150 @@ +// 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 agentfs + +import ( + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// This file resolves the agents SDK version that is actually installed, by +// asking the project's own runtime rather than parsing manifests: symlinked +// and workspace deps, loose version constraints, and hoisting all report what +// will really run. Static project-file parsing (sdk_version_check.go) is the +// fallback for when no runtime environment exists. + +// FindPythonBinary locates a Python binary for the given project type. +func FindPythonBinary(dir string, projectType ProjectType) (string, []string, error) { + if projectType == 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 +} + +// 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)) +} + +// 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 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 +} + +// ResolveInstalledSDKVersion returns the installed agents SDK version for the +// project as its own runtime resolves it, or "" when it can't be determined. +// For Node the resolution starts 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; entrypoint is relative to dir. +func ResolveInstalledSDKVersion(dir, entrypoint string, projectType ProjectType) string { + if projectType.IsNode() { + nodeBin, err := FindNodeBinary() + if err != nil { + return "" + } + fromDir := filepath.Dir(filepath.Join(dir, entrypoint)) + return ResolveNodeAgentVersion(nodeBin, fromDir) + } + version, _ := ResolvePythonAgentVersion(dir, projectType) + return version +} diff --git a/cmd/lk/node_resolve_version.js b/pkg/agentfs/node_resolve_version.js similarity index 100% rename from cmd/lk/node_resolve_version.js rename to pkg/agentfs/node_resolve_version.js