From d6dd5237fe3ee204239f229c884d6bce6444d0fc Mon Sep 17 00:00:00 2001 From: David Zhao Date: Fri, 10 Jul 2026 00:26:04 +0200 Subject: [PATCH 1/2] fix `lk agent dev` with Agents-JS JS agents will also support the OnServerStart protocol --- cmd/lk/agent_watcher.go | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 03094e80..4cd6b987 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -79,19 +79,27 @@ 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. The flag was + // renamed --reload-addr -> --cli-addr (it now carries more than reloads). Node + // uses the new name; Python keeps the legacy --reload-addr for backwards + // compatibility with already-released agents (newer Python accepts both), and + // will switch to --cli-addr once those are no longer supported. + addrFlag := "--reload-addr" + if config.ProjectType.IsNode() { + addrFlag = "--cli-addr" + } + config.CLIArgs = append(config.CLIArgs, addrFlag, rs.addr()) return &agentWatcher{ config: config, @@ -133,9 +141,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 } From fc282433928333ddc8aed1f4305ab56ca53a91b6 Mon Sep 17 00:00:00 2001 From: u9g Date: Fri, 10 Jul 2026 07:54:21 -0400 Subject: [PATCH 2/2] Gate the dev-channel flag on the installed agents SDK version (#901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove dead nil checks on devSrv, now created unconditionally Also normalizes an em dash in a nearby comment. * move interpreter-based SDK version resolution to pkg/agentfs Pure relocation, no behavior change: the Python/Node binary finders and installed-version probes (plus the embedded node_resolve_version.js) move from cmd/lk into pkg/agentfs as exported functions, with agentfs.ResolveInstalledSDKVersion as the shared entry point, so callers outside the startup checks can reuse the resolver. * pick the dev-channel flag from the installed SDK version An SDK must not be handed a flag it predates: Node's CLI hard-fails on unknown options, so every released @livekit/agents (<= 1.5.0) dies at startup when passed --cli-addr. Resolve the installed agents version via the project's own runtime and choose accordingly — --cli-addr when positively at/above the first supporting release (1.6.6 Python, 1.5.1 Node, placeholders until those releases exist), otherwise the legacy --reload-addr for Python and no flag at all for Node. Undetermined versions always take the safe branch. * remove agent_watcher_test.go * pre-warm node in TestAgentProcessFailSignal to avoid probe timeout On Windows CI runners the first node.exe spawn can take several seconds (Defender scans the binary on first exec), which blows the SDK version probe's 5s timeout inside startAgent — the test then fails with '@livekit/agents not found' at exactly 5.04s before the crash-signal behavior under test ever runs (same flake seen on main). Spawn a throwaway Node process first to absorb the cold start. --- cmd/lk/agent_run_test.go | 7 + cmd/lk/agent_watcher.go | 68 ++++++-- cmd/lk/python_sdk_version_test.go | 4 +- cmd/lk/simulate_subprocess.go | 118 ++------------ pkg/agentfs/installed_version.go | 150 ++++++++++++++++++ .../agentfs}/node_resolve_version.js | 0 6 files changed, 220 insertions(+), 127 deletions(-) create mode 100644 pkg/agentfs/installed_version.go rename {cmd/lk => pkg/agentfs}/node_resolve_version.js (100%) 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 4cd6b987..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 @@ -90,16 +137,10 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { return nil, err } rs.onServerInfo = config.OnServerInfo - // The agent connects back to this address over the dev channel. The flag was - // renamed --reload-addr -> --cli-addr (it now carries more than reloads). Node - // uses the new name; Python keeps the legacy --reload-addr for backwards - // compatibility with already-released agents (newer Python accepts both), and - // will switch to --cli-addr once those are no longer supported. - addrFlag := "--reload-addr" - if config.ProjectType.IsNode() { - addrFlag = "--cli-addr" + // The agent connects back to this address over the dev channel. + if addrFlag := devChannelAddrFlag(config); addrFlag != "" { + config.CLIArgs = append(config.CLIArgs, addrFlag, rs.addr()) } - config.CLIArgs = append(config.CLIArgs, addrFlag, rs.addr()) return &agentWatcher{ config: config, @@ -126,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 { @@ -192,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() }() @@ -260,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