From 61b038f0d59ed10687f096248bcec3ef59606e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 21:21:29 -0800 Subject: [PATCH 1/8] fix(agent): avoid Python package gates and Cargo build scans --- cmd/lk/agent_watcher.go | 17 ++++++-- cmd/lk/agent_watcher_test.go | 33 ++++++++++++++ cmd/lk/python_sdk_version_test.go | 71 +++++++++++++++++++++---------- cmd/lk/simulate_subprocess.go | 40 ++--------------- 4 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 cmd/lk/agent_watcher_test.go diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index cc58f2cdd..87fbd1268 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -16,6 +16,7 @@ package main import ( "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -31,6 +32,7 @@ var skipDirs = map[string]bool{ "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true, ".venv": true, "venv": true, "env": true, "node_modules": true, ".next": true, "dist": true, "build": true, + "target": true, // Cargo output can contain millions of files in mixed Python/Rust workspaces. } // watchExtensions returns file extensions to watch for a project type. @@ -101,14 +103,14 @@ type agentWatcher struct { session *devSession } -func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { +func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { w, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create file watcher: %w", err) } - // Walk directory tree and add all non-skip directories - err = filepath.Walk(config.Dir, func(path string, info os.FileInfo, err error) error { + // WalkDir avoids statting every file; prune build trees before reading their contents. + err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { if err != nil { return nil } @@ -126,6 +128,15 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { return nil, fmt.Errorf("failed to setup file watcher: %w", err) } + return w, nil +} + +func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { + w, err := newAgentFileWatcher(config.Dir) + if err != nil { + return nil, err + } + // 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 diff --git a/cmd/lk/agent_watcher_test.go b/cmd/lk/agent_watcher_test.go new file mode 100644 index 000000000..9ce9cd767 --- /dev/null +++ b/cmd/lk/agent_watcher_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { + root := t.TempDir() + // Even Python files under these trees must never be visited or watched. + ignored := []string{ + "rust/target/debug/deps", "target/release", ".venv/lib/python", + "node_modules/dependency", "build/generated", ".git/objects", + } + for _, dir := range append(ignored, "examples/frontdesk", "src/livekit") { + path := filepath.Join(root, filepath.FromSlash(dir)) + require.NoError(t, os.MkdirAll(path, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(path, "agent.py"), nil, 0o644)) + } + watcher, err := newAgentFileWatcher(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, watcher.Close()) }) + // The list checks the actual recursive registration, including that none of + // the ignored trees' ancestors were registered before pruning their leaves. + require.ElementsMatch(t, []string{ + root, filepath.Join(root, "rust"), filepath.Join(root, "examples"), + filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src"), + filepath.Join(root, "src", "livekit"), + }, watcher.WatchList()) +} diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index 6feaa982e..03afc1b7f 100644 --- a/cmd/lk/python_sdk_version_test.go +++ b/cmd/lk/python_sdk_version_test.go @@ -20,6 +20,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -77,28 +78,54 @@ func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) { require.False(t, notInstalled) } -func TestCheckPythonSDKVersion_TooOld(t *testing.T) { - dir := setupUvAgentProject(t, "1.0.0", "livekit-agents", true) - err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}) - require.Error(t, err) - require.Contains(t, err.Error(), "too old") -} - -func TestCheckPythonSDKVersion_InstalledBeatsLooseConstraint(t *testing.T) { - // The pyproject floor >=1.0 would fail static parsing, but the installed - // 1.6.7 is what gets used — proving the resolved version wins. - dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.0", true) - require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV})) -} - -func TestCheckPythonSDKVersion_UnsyncedUVProjectSuggestsSync(t *testing.T) { - // Models a fresh clone where dependencies were never installed. The CLI is - // a proxy for the local environment and never syncs it implicitly, so the - // agent would die at launch with ModuleNotFoundError; the pre-flight must - // fail fast and tell the user how to fix it instead. - dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.6.0", false) - err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}) - require.ErrorContains(t, err, "uv sync") +// Local startup depends on an importable module, not an installed distribution +// name or version. Exercise the production subprocess path with real Python. +func TestStartPythonAgentWithoutSDKPackageGate(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Skip("uv not on PATH") + } + for _, metadata := range []string{"none", "livekit-agents", "livekit-agents-next"} { + t.Run(metadata, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte( + "[project]\nname = \"test-agent\"\nversion = \"0.0.0\"\n"), 0o644)) + venv := exec.Command("uv", "venv", "--offline", ".venv") + venv.Dir = dir + out, err := venv.CombinedOutput() + require.NoError(t, err, "%s", out) + bin, prefix, err := agentfs.FindPythonBinary(dir, agentfs.ProjectTypePythonUV) + require.NoError(t, err) + // Install just a module and optional metadata, without a build backend, + // package index, or dependency sync. The canonical package is deliberately + // older than the retired preflight's floor. + install := exec.Command(bin, append(prefix, "-c", ` +import pathlib, sys, sysconfig +site = pathlib.Path(sysconfig.get_path("purelib")) +module = site / "livekit" / "agents" +module.mkdir(parents=True) +(module / "__main__.py").write_text("print('module launched', flush=True)\n") +if sys.argv[1] != "none": + info = site / (sys.argv[1].replace("-", "_") + "-1.0.0.dist-info") + info.mkdir() + (info / "METADATA").write_text("Metadata-Version: 2.1\nName: " + sys.argv[1] + "\nVersion: 1.0.0\n") +`, metadata)...) + install.Dir = dir + out, err = install.CombinedOutput() + require.NoError(t, err, "%s", out) + ap, err := startAgent(AgentStartConfig{ + Dir: dir, Entrypoint: "agent.py", ProjectType: agentfs.ProjectTypePythonUV, + CLIArgs: []string{"start", "--dev"}, ReadySignal: "module launched", + }) + require.NoError(t, err) + t.Cleanup(func() { ap.Kill() }) + select { + case <-ap.readyCh: + case <-time.After(10 * time.Second): + t.Fatal("Python module did not launch") + } + require.NoError(t, <-ap.doneCh) + }) + } } func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) { diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index b8d74ba8d..8775c151e 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -94,9 +94,7 @@ func checkTypeStrippingSupport(dir, nodeBin string) error { } // nodeAgentMinVersion is the minimum @livekit/agents (agents-js) release the -// CLI supports. Unlike the Python thin CLI (gated on thinCLIMinVersion), the -// Node entrypoint exposes the start/console/simulate subcommands directly, so -// the baseline differs. Local placeholder — the deploy path sources the +// CLI supports. Local placeholder — the deploy path sources the // equivalent floor from server client settings. const nodeAgentMinVersion = "1.0.0" @@ -120,30 +118,6 @@ func checkNodeSDKVersion(cfg AgentStartConfig) error { return nil } -// 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 := 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. - return fmt.Errorf("livekit-agents is not installed in the project environment; run `uv sync` and try again") - } - if version != "" { - // An unparseable version (e.g. a local "0.0.0.dev" tag) shouldn't block a run. - if ok, err := agentfs.IsVersionSatisfied(version, thinCLIMinVersion); err == nil && !ok { - return fmt.Errorf("livekit-agents version %s is too old, please upgrade to %s or newer", version, thinCLIMinVersion) - } - return nil - } - return agentfs.CheckSDKVersion(cfg.Dir, cfg.ProjectType, map[string]string{ - "python-min-sdk-version": thinCLIMinVersion, - "node-min-sdk-version": thinCLIMinVersion, - }) -} - // 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. @@ -240,10 +214,6 @@ type AgentStartConfig struct { OnServerInfo func(agentName, wsURL string) } -// thinCLIMinVersion is the first livekit-agents release that exposes the -// start/dev/console/simulate subcommands under `python -m livekit.agents`. -const thinCLIMinVersion = "1.6.0" - // buildAgentCommand resolves the interpreter and argv for an agent subprocess, // branching on project type. Python uses the thin CLI: // ` -m livekit.agents SUBCOMMAND ENTRYPOINT FLAGS` @@ -291,15 +261,13 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { // startAgent launches a Python or Node agent subprocess and monitors its output. func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { - // fail fast when the agent SDK is older than the baseline the CLI supports. - // Node resolves the installed package via the runtime so workspace/monorepo - // layouts work; Python parses project files against the thin-CLI baseline. + // Python runs the module in the project environment directly: distribution + // names and metadata need not match the importable livekit.agents module. + // Node still checks its SDK baseline before launching. if cfg.ProjectType.IsNode() { if err := checkNodeSDKVersion(cfg); err != nil { return nil, err } - } else if err := checkPythonSDKVersion(cfg); err != nil { - return nil, err } bin, args, err := buildAgentCommand(cfg) From 30ad361eb85dd38d615b7f97061460090715d477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 21:37:10 -0800 Subject: [PATCH 2/8] fix(agent): validate module imports and prune cache directories --- cmd/lk/agent_watcher.go | 43 ++++++++++++++--- cmd/lk/agent_watcher_test.go | 11 +++++ cmd/lk/python_sdk_version_test.go | 77 +++++++++++++------------------ cmd/lk/simulate_subprocess.go | 32 ++++++++++++- 4 files changed, 110 insertions(+), 53 deletions(-) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 87fbd1268..95d9ab22f 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -15,9 +15,11 @@ package main import ( + "context" "fmt" "io/fs" "os" + "os/exec" "path/filepath" "strings" "time" @@ -26,13 +28,18 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/agentfs" ) -// skipDirs are directories to never watch. +// skipDirs are generated output, installed dependencies, and caches. Hidden +// directories are also pruned by the walk, covering .git, .venv, .next, .cache, +// .tox, .nox, .mypy_cache, .pytest_cache, .ruff_cache, .turbo, and similar trees. var skipDirs = map[string]bool{ - ".git": true, ".hg": true, ".svn": true, - "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true, - ".venv": true, "venv": true, "env": true, - "node_modules": true, ".next": true, "dist": true, "build": true, - "target": true, // Cargo output can contain millions of files in mixed Python/Rust workspaces. + // Python environments and generated documentation. + "__pycache__": true, "__pypackages__": true, "site-packages": true, + "venv": true, "env": true, "htmlcov": true, "_build": true, + // JavaScript, Go, Swift, and other installed dependency trees. + "node_modules": true, "bower_components": true, "vendor": true, + "Pods": true, "Carthage": true, "elm-stuff": true, + // Build output and coverage reports, including Cargo's default target dir. + "target": true, "dist": true, "build": true, "out": true, "coverage": true, } // watchExtensions returns file extensions to watch for a project type. @@ -103,12 +110,34 @@ type agentWatcher struct { session *devSession } +// ignoredWatchDirs asks Git for whole ignored directories, without expanding +// their contents. This covers project-specific output such as a custom Cargo +// target directory. Watching still works outside Git repositories. +func ignoredWatchDirs(dir string) map[string]bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") + cmd.Dir = dir + output, err := cmd.Output() + ignored := make(map[string]bool) + if err != nil { + return ignored + } + for _, path := range strings.Split(string(output), "\x00") { + if strings.HasSuffix(path, "/") { + ignored[filepath.Join(dir, filepath.FromSlash(strings.TrimSuffix(path, "/")))] = true + } + } + return ignored +} + func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { w, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create file watcher: %w", err) } + ignored := ignoredWatchDirs(dir) // WalkDir avoids statting every file; prune build trees before reading their contents. err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { if err != nil { @@ -118,7 +147,7 @@ func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { return nil } name := info.Name() - if skipDirs[name] || (strings.HasPrefix(name, ".") && name != ".") { + if skipDirs[name] || ignored[path] || (strings.HasPrefix(name, ".") && name != ".") { return filepath.SkipDir } return w.Add(path) diff --git a/cmd/lk/agent_watcher_test.go b/cmd/lk/agent_watcher_test.go index 9ce9cd767..e29b1d542 100644 --- a/cmd/lk/agent_watcher_test.go +++ b/cmd/lk/agent_watcher_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "testing" @@ -14,12 +15,22 @@ func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { ignored := []string{ "rust/target/debug/deps", "target/release", ".venv/lib/python", "node_modules/dependency", "build/generated", ".git/objects", + ".cache/pip", "__pypackages__/3.12", "site-packages/livekit", + "vendor/dependency", "out/generated", "coverage/report", "_build/html", + "bower_components/dependency", "Pods/dependency", "Carthage/Build", "elm-stuff/packages", } for _, dir := range append(ignored, "examples/frontdesk", "src/livekit") { path := filepath.Join(root, filepath.FromSlash(dir)) require.NoError(t, os.MkdirAll(path, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(path, "agent.py"), nil, 0o644)) } + if _, err := exec.LookPath("git"); err == nil { + require.NoError(t, exec.Command("git", "init", "--quiet", root).Run()) + require.NoError(t, os.WriteFile(filepath.Join(root, ".gitignore"), []byte("cargo-output/\n"), 0o644)) + output := filepath.Join(root, "cargo-output", "debug") + require.NoError(t, os.MkdirAll(output, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(output, "generated.py"), nil, 0o644)) + } watcher, err := newAgentFileWatcher(root) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, watcher.Close()) }) diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index 03afc1b7f..e004772b7 100644 --- a/cmd/lk/python_sdk_version_test.go +++ b/cmd/lk/python_sdk_version_test.go @@ -78,54 +78,43 @@ func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) { require.False(t, notInstalled) } -// Local startup depends on an importable module, not an installed distribution -// name or version. Exercise the production subprocess path with real Python. -func TestStartPythonAgentWithoutSDKPackageGate(t *testing.T) { +// A missing module gets an actionable error; an importable module from an +// arbitrary source directory launches without distribution metadata. +func TestStartPythonAgentChecksImportInsteadOfDistribution(t *testing.T) { if _, err := exec.LookPath("uv"); err != nil { t.Skip("uv not on PATH") } - for _, metadata := range []string{"none", "livekit-agents", "livekit-agents-next"} { - t.Run(metadata, func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte( - "[project]\nname = \"test-agent\"\nversion = \"0.0.0\"\n"), 0o644)) - venv := exec.Command("uv", "venv", "--offline", ".venv") - venv.Dir = dir - out, err := venv.CombinedOutput() - require.NoError(t, err, "%s", out) - bin, prefix, err := agentfs.FindPythonBinary(dir, agentfs.ProjectTypePythonUV) - require.NoError(t, err) - // Install just a module and optional metadata, without a build backend, - // package index, or dependency sync. The canonical package is deliberately - // older than the retired preflight's floor. - install := exec.Command(bin, append(prefix, "-c", ` -import pathlib, sys, sysconfig -site = pathlib.Path(sysconfig.get_path("purelib")) -module = site / "livekit" / "agents" -module.mkdir(parents=True) -(module / "__main__.py").write_text("print('module launched', flush=True)\n") -if sys.argv[1] != "none": - info = site / (sys.argv[1].replace("-", "_") + "-1.0.0.dist-info") - info.mkdir() - (info / "METADATA").write_text("Metadata-Version: 2.1\nName: " + sys.argv[1] + "\nVersion: 1.0.0\n") -`, metadata)...) - install.Dir = dir - out, err = install.CombinedOutput() - require.NoError(t, err, "%s", out) - ap, err := startAgent(AgentStartConfig{ - Dir: dir, Entrypoint: "agent.py", ProjectType: agentfs.ProjectTypePythonUV, - CLIArgs: []string{"start", "--dev"}, ReadySignal: "module launched", - }) - require.NoError(t, err) - t.Cleanup(func() { ap.Kill() }) - select { - case <-ap.readyCh: - case <-time.After(10 * time.Second): - t.Fatal("Python module did not launch") - } - require.NoError(t, <-ap.doneCh) - }) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte( + "[project]\nname = \"test-agent\"\nversion = \"0.0.0\"\n"), 0o644)) + venv := exec.Command("uv", "venv", "--offline", ".venv") + venv.Dir = dir + out, err := venv.CombinedOutput() + require.NoError(t, err, "%s", out) + source := filepath.Join(dir, "custom-sdk-source") + cfg := AgentStartConfig{ + Dir: dir, Entrypoint: "agent.py", ProjectType: agentfs.ProjectTypePythonUV, + CLIArgs: []string{"start", "--dev"}, ReadySignal: "module launched", + Env: []string{"PYTHONPATH=" + source}, + } + _, err = startAgent(cfg) + require.ErrorContains(t, err, "cannot import livekit.agents") + require.ErrorContains(t, err, "sync or install the project's dependencies") + + module := filepath.Join(source, "livekit", "agents") + require.NoError(t, os.MkdirAll(module, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(module, "__init__.py"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(module, "__main__.py"), + []byte("print('module launched', flush=True)\n"), 0o644)) + ap, err := startAgent(cfg) + require.NoError(t, err) + t.Cleanup(func() { ap.Kill() }) + select { + case <-ap.readyCh: + case <-time.After(10 * time.Second): + t.Fatal("Python module did not launch") } + require.NoError(t, <-ap.doneCh) } func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) { diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 8775c151e..18aa365b2 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -259,15 +259,43 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { return pythonBin, args, nil } +// checkPythonAgentImport uses the same interpreter, runtime arguments, and +// environment as launch. Distribution names and metadata do not determine +// whether the livekit.agents module can actually run in this project. +func checkPythonAgentImport(cfg AgentStartConfig) error { + bin, prefix, err := agentfs.FindPythonBinary(cfg.Dir, cfg.ProjectType) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + args := append(prefix, cfg.RuntimeArgs...) + args = append(args, "-c", "import livekit.agents") + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = cfg.Dir + if len(cfg.Env) > 0 { + cmd.Env = append(os.Environ(), cfg.Env...) + } + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + return fmt.Errorf("checking the livekit.agents import timed out after 15 seconds; check the project's Python environment") + } + if err != nil { + return fmt.Errorf("cannot import livekit.agents in the project's Python environment; sync or install the project's dependencies and check its Python path:\n%s", strings.TrimSpace(string(output))) + } + return nil +} + // startAgent launches a Python or Node agent subprocess and monitors its output. func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { - // Python runs the module in the project environment directly: distribution - // names and metadata need not match the importable livekit.agents module. + // Check the module Python will import, rather than a distribution name. // Node still checks its SDK baseline before launching. if cfg.ProjectType.IsNode() { if err := checkNodeSDKVersion(cfg); err != nil { return nil, err } + } else if err := checkPythonAgentImport(cfg); err != nil { + return nil, err } bin, args, err := buildAgentCommand(cfg) From 5347aa2eb30c9754d8e4e14f25f158f2141ee535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 21:46:38 -0800 Subject: [PATCH 3/8] perf(agent): import Python once and overlap watcher setup --- cmd/lk/agent_watcher.go | 24 ++++++++-------- cmd/lk/python_sdk_version_test.go | 18 ++++++++---- cmd/lk/simulate_subprocess.go | 46 ++++++++++--------------------- 3 files changed, 40 insertions(+), 48 deletions(-) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 95d9ab22f..c0f60cf9d 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -161,11 +161,6 @@ func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { } func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { - w, err := newAgentFileWatcher(config.Dir) - if err != nil { - return nil, err - } - // 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 @@ -173,7 +168,6 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { // 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 @@ -186,7 +180,6 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { config: config, exts: watchExtensions(config.ProjectType), debounce: 500 * time.Millisecond, - watcher: w, restartCh: make(chan struct{}, 1), devSrv: rs, }, nil @@ -251,9 +244,6 @@ func (aw *agentWatcher) restart() error { // Run watches for file changes and restarts the agent. Blocks until done is closed. func (aw *agentWatcher) Run(done <-chan struct{}) error { - if err := aw.start(); err != nil { - return err - } defer func() { if aw.agent != nil { // If Shutdown() was already called by the signal forwarder, @@ -271,9 +261,21 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { aw.session.close() } aw.devSrv.close() - aw.watcher.Close() + if aw.watcher != nil { + aw.watcher.Close() + } }() + // Start Python before scanning the tree so watcher setup overlaps imports. + if err := aw.start(); err != nil { + return err + } + watcher, err := newAgentFileWatcher(aw.config.Dir) + if err != nil { + return err + } + aw.watcher = watcher + var debounceTimer *time.Timer var debounceCh <-chan time.Time exitCh := aw.agent.exitCh diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index e004772b7..8b43edb13 100644 --- a/cmd/lk/python_sdk_version_test.go +++ b/cmd/lk/python_sdk_version_test.go @@ -80,7 +80,7 @@ func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) { // A missing module gets an actionable error; an importable module from an // arbitrary source directory launches without distribution metadata. -func TestStartPythonAgentChecksImportInsteadOfDistribution(t *testing.T) { +func TestStartPythonAgentImportsOnceAndReportsMissingDependencies(t *testing.T) { if _, err := exec.LookPath("uv"); err != nil { t.Skip("uv not on PATH") } @@ -97,13 +97,18 @@ func TestStartPythonAgentChecksImportInsteadOfDistribution(t *testing.T) { CLIArgs: []string{"start", "--dev"}, ReadySignal: "module launched", Env: []string{"PYTHONPATH=" + source}, } - _, err = startAgent(cfg) - require.ErrorContains(t, err, "cannot import livekit.agents") - require.ErrorContains(t, err, "sync or install the project's dependencies") + missing, err := startAgent(cfg) + require.NoError(t, err) + t.Cleanup(func() { missing.Kill() }) + require.Error(t, <-missing.Done()) + require.Contains(t, strings.Join(missing.RecentLogs(0), "\n"), "sync or install the project's dependencies") module := filepath.Join(source, "livekit", "agents") require.NoError(t, os.MkdirAll(module, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(module, "__init__.py"), nil, 0o644)) + count := filepath.Join(dir, "imports.txt") + cfg.Env = append(cfg.Env, "IMPORT_COUNT="+count) + require.NoError(t, os.WriteFile(filepath.Join(module, "__init__.py"), + []byte("import os\nwith open(os.environ['IMPORT_COUNT'], 'a') as f: f.write('import\\n')\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(module, "__main__.py"), []byte("print('module launched', flush=True)\n"), 0o644)) ap, err := startAgent(cfg) @@ -115,6 +120,9 @@ func TestStartPythonAgentChecksImportInsteadOfDistribution(t *testing.T) { t.Fatal("Python module did not launch") } require.NoError(t, <-ap.doneCh) + imports, err := os.ReadFile(count) + require.NoError(t, err) + require.Equal(t, "import\n", string(imports)) } func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) { diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 18aa365b2..66a7096b8 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -259,43 +259,14 @@ func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { return pythonBin, args, nil } -// checkPythonAgentImport uses the same interpreter, runtime arguments, and -// environment as launch. Distribution names and metadata do not determine -// whether the livekit.agents module can actually run in this project. -func checkPythonAgentImport(cfg AgentStartConfig) error { - bin, prefix, err := agentfs.FindPythonBinary(cfg.Dir, cfg.ProjectType) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - args := append(prefix, cfg.RuntimeArgs...) - args = append(args, "-c", "import livekit.agents") - cmd := exec.CommandContext(ctx, bin, args...) - cmd.Dir = cfg.Dir - if len(cfg.Env) > 0 { - cmd.Env = append(os.Environ(), cfg.Env...) - } - output, err := cmd.CombinedOutput() - if ctx.Err() != nil { - return fmt.Errorf("checking the livekit.agents import timed out after 15 seconds; check the project's Python environment") - } - if err != nil { - return fmt.Errorf("cannot import livekit.agents in the project's Python environment; sync or install the project's dependencies and check its Python path:\n%s", strings.TrimSpace(string(output))) - } - return nil -} - // startAgent launches a Python or Node agent subprocess and monitors its output. func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { - // Check the module Python will import, rather than a distribution name. - // Node still checks its SDK baseline before launching. + // Python validates imports in the worker itself; do not import the SDK + // in a throwaway preflight process. Node still checks its SDK baseline. if cfg.ProjectType.IsNode() { if err := checkNodeSDKVersion(cfg); err != nil { return nil, err } - } else if err := checkPythonAgentImport(cfg); err != nil { - return nil, err } bin, args, err := buildAgentCommand(cfg) @@ -343,6 +314,7 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { // Capture output from both stdout and stderr readyOnce := sync.Once{} + importHintOnce := sync.Once{} failOnce := sync.Once{} scanOutput := func(r io.Reader) { scanner := bufio.NewScanner(r) @@ -355,6 +327,15 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { if cfg.ForwardOutput != nil { fmt.Fprintln(cfg.ForwardOutput, line) } + if cfg.ProjectType.IsPython() && (strings.Contains(line, "ModuleNotFoundError:") || strings.Contains(line, "ImportError:") || strings.Contains(line, ": No module named livekit.agents")) { + importHintOnce.Do(func() { + hint := "Python could not import the agent or one of its dependencies; sync or install the project's dependencies and check its Python path." + ap.appendLog(hint) + if cfg.ForwardOutput != nil { + fmt.Fprintln(cfg.ForwardOutput, hint) + } + }) + } if cfg.ReadySignal != "" && strings.Contains(line, cfg.ReadySignal) { readyOnce.Do(func() { close(ap.readyCh) }) } @@ -377,9 +358,10 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { go func() { defer scanWg.Done(); scanOutput(stdout) }() go func() { defer scanWg.Done(); scanOutput(stderr) }() go func() { - ap.doneCh <- cmd.Wait() + processErr := cmd.Wait() close(ap.exitCh) scanWg.Wait() + ap.doneCh <- processErr if ap.LogStream != nil { close(ap.LogStream) } From 982110e0d36454f8f2a45f73d67e859b5ac458cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 22:12:35 -0800 Subject: [PATCH 4/8] perf(agent): scope reload watches to agent source trees --- cmd/lk/agent_run.go | 5 ++++ cmd/lk/agent_watcher.go | 52 +++++++++++++++++++++++------------ cmd/lk/agent_watcher_test.go | 8 ++++++ cmd/lk/simulate_subprocess.go | 2 ++ 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 8c15dbf55..c739c95a0 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -120,6 +120,10 @@ var devCommand = &cli.Command{ Usage: "Run an agent locally with hot reload", ArgsUsage: "[entrypoint] [-- node/python-args...]", Flags: append(agentRunFlags, + &cli.StringSliceFlag{ + Name: "watch", + Usage: "Additional source directory to watch alongside the entrypoint directory (repeatable).", + }, &cli.BoolFlag{ Name: "no-reload", Usage: "Disable auto-reload on file changes.", @@ -376,6 +380,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { CLIArgs: cliArgs, Env: env, ForwardOutput: os.Stdout, + WatchDirs: cmd.StringSlice("watch"), } // When the agent reports its ServerInfo over the dev channel, print a console diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index c0f60cf9d..f1f7f6b6b 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -131,32 +131,41 @@ func ignoredWatchDirs(dir string) map[string]bool { return ignored } -func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { +func newAgentFileWatcher(dirs ...string) (*fsnotify.Watcher, error) { w, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create file watcher: %w", err) } - - ignored := ignoredWatchDirs(dir) - // WalkDir avoids statting every file; prune build trees before reading their contents. - err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { + seen := make(map[string]bool) + for _, dir := range dirs { + dir, err = filepath.Abs(dir) if err != nil { - return nil + w.Close() + return nil, fmt.Errorf("invalid watch directory: %w", err) } - if !info.IsDir() { - return nil + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + w.Close() + return nil, fmt.Errorf("watch path must be an existing directory: %s", dir) } - name := info.Name() - if skipDirs[name] || ignored[path] || (strings.HasPrefix(name, ".") && name != ".") { - return filepath.SkipDir + ignored := ignoredWatchDirs(dir) + // Prune artifacts before registration: kqueue also inspects each directory's files. + err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { + if err != nil || !info.IsDir() { + return nil + } + name := info.Name() + if seen[path] || skipDirs[name] || ignored[path] || (strings.HasPrefix(name, ".") && name != ".") { + return filepath.SkipDir + } + seen[path] = true + return w.Add(path) + }) + if err != nil { + w.Close() + return nil, fmt.Errorf("failed to setup file watcher: %w", err) } - return w.Add(path) - }) - if err != nil { - w.Close() - return nil, fmt.Errorf("failed to setup file watcher: %w", err) } - return w, nil } @@ -270,7 +279,14 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { if err := aw.start(); err != nil { return err } - watcher, err := newAgentFileWatcher(aw.config.Dir) + // The package-manager root may be an enormous monorepo. Watch the agent's + // own tree, with explicit additions for shared code or editable SDKs. + entrypoint := aw.config.Entrypoint + if !filepath.IsAbs(entrypoint) { + entrypoint = filepath.Join(aw.config.Dir, entrypoint) + } + roots := append([]string{filepath.Dir(entrypoint)}, aw.config.WatchDirs...) + watcher, err := newAgentFileWatcher(roots...) if err != nil { return err } diff --git a/cmd/lk/agent_watcher_test.go b/cmd/lk/agent_watcher_test.go index e29b1d542..b346cca46 100644 --- a/cmd/lk/agent_watcher_test.go +++ b/cmd/lk/agent_watcher_test.go @@ -41,4 +41,12 @@ func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src"), filepath.Join(root, "src", "livekit"), }, watcher.WatchList()) + // A nested agent plus explicitly shared sources must not register the + // unrelated workspace trees, even when they contain Python files. + narrow, err := newAgentFileWatcher(filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, narrow.Close()) }) + require.ElementsMatch(t, []string{ + filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src"), filepath.Join(root, "src", "livekit"), + }, narrow.WatchList()) } diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 66a7096b8..d92c682ca 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -198,6 +198,8 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri // AgentStartConfig configures how to launch an agent subprocess. type AgentStartConfig struct { + // Additional development source trees, relative to the caller's working directory. + WatchDirs []string Dir string Entrypoint string ProjectType agentfs.ProjectType From 06427ad7dc9c58b09d02e27576592d514964e3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 22:13:55 -0800 Subject: [PATCH 5/8] fix(agent): watch project sources with automatic exclusions --- cmd/lk/agent_run.go | 5 --- cmd/lk/agent_watcher.go | 59 ++++++++++++++--------------------- cmd/lk/agent_watcher_test.go | 11 ++----- cmd/lk/simulate_subprocess.go | 2 -- 4 files changed, 26 insertions(+), 51 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index c739c95a0..8c15dbf55 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -120,10 +120,6 @@ var devCommand = &cli.Command{ Usage: "Run an agent locally with hot reload", ArgsUsage: "[entrypoint] [-- node/python-args...]", Flags: append(agentRunFlags, - &cli.StringSliceFlag{ - Name: "watch", - Usage: "Additional source directory to watch alongside the entrypoint directory (repeatable).", - }, &cli.BoolFlag{ Name: "no-reload", Usage: "Disable auto-reload on file changes.", @@ -380,7 +376,6 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { CLIArgs: cliArgs, Env: env, ForwardOutput: os.Stdout, - WatchDirs: cmd.StringSlice("watch"), } // When the agent reports its ServerInfo over the dev channel, print a console diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index f1f7f6b6b..15ef887ad 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -131,40 +131,34 @@ func ignoredWatchDirs(dir string) map[string]bool { return ignored } -func newAgentFileWatcher(dirs ...string) (*fsnotify.Watcher, error) { +func newAgentFileWatcher(dir string) (*fsnotify.Watcher, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("invalid watch directory: %w", err) + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("watch path must be an existing directory: %s", dir) + } w, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create file watcher: %w", err) } - seen := make(map[string]bool) - for _, dir := range dirs { - dir, err = filepath.Abs(dir) - if err != nil { - w.Close() - return nil, fmt.Errorf("invalid watch directory: %w", err) - } - info, err := os.Stat(dir) + ignored := ignoredWatchDirs(dir) + // Prune artifacts before registration: kqueue also inspects each directory's files. + err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { if err != nil || !info.IsDir() { - w.Close() - return nil, fmt.Errorf("watch path must be an existing directory: %s", dir) + return nil } - ignored := ignoredWatchDirs(dir) - // Prune artifacts before registration: kqueue also inspects each directory's files. - err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { - if err != nil || !info.IsDir() { - return nil - } - name := info.Name() - if seen[path] || skipDirs[name] || ignored[path] || (strings.HasPrefix(name, ".") && name != ".") { - return filepath.SkipDir - } - seen[path] = true - return w.Add(path) - }) - if err != nil { - w.Close() - return nil, fmt.Errorf("failed to setup file watcher: %w", err) + name := info.Name() + if path != dir && (skipDirs[name] || ignored[path] || strings.HasPrefix(name, ".")) { + return filepath.SkipDir } + return w.Add(path) + }) + if err != nil { + w.Close() + return nil, fmt.Errorf("failed to setup file watcher: %w", err) } return w, nil } @@ -279,14 +273,9 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { if err := aw.start(); err != nil { return err } - // The package-manager root may be an enormous monorepo. Watch the agent's - // own tree, with explicit additions for shared code or editable SDKs. - entrypoint := aw.config.Entrypoint - if !filepath.IsAbs(entrypoint) { - entrypoint = filepath.Join(aw.config.Dir, entrypoint) - } - roots := append([]string{filepath.Dir(entrypoint)}, aw.config.WatchDirs...) - watcher, err := newAgentFileWatcher(roots...) + // Watch the user's project, including shared modules outside the entrypoint's + // directory. Dependency, cache and build trees are pruned during registration. + watcher, err := newAgentFileWatcher(aw.config.Dir) if err != nil { return err } diff --git a/cmd/lk/agent_watcher_test.go b/cmd/lk/agent_watcher_test.go index b346cca46..e6715d52d 100644 --- a/cmd/lk/agent_watcher_test.go +++ b/cmd/lk/agent_watcher_test.go @@ -10,7 +10,8 @@ import ( ) func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { - root := t.TempDir() + // A project can have any name; exclusions apply only beneath its root. + root := filepath.Join(t.TempDir(), "env") // Even Python files under these trees must never be visited or watched. ignored := []string{ "rust/target/debug/deps", "target/release", ".venv/lib/python", @@ -41,12 +42,4 @@ func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src"), filepath.Join(root, "src", "livekit"), }, watcher.WatchList()) - // A nested agent plus explicitly shared sources must not register the - // unrelated workspace trees, even when they contain Python files. - narrow, err := newAgentFileWatcher(filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src")) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, narrow.Close()) }) - require.ElementsMatch(t, []string{ - filepath.Join(root, "examples", "frontdesk"), filepath.Join(root, "src"), filepath.Join(root, "src", "livekit"), - }, narrow.WatchList()) } diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index d92c682ca..66a7096b8 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -198,8 +198,6 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri // AgentStartConfig configures how to launch an agent subprocess. type AgentStartConfig struct { - // Additional development source trees, relative to the caller's working directory. - WatchDirs []string Dir string Entrypoint string ProjectType agentfs.ProjectType From a7a036b263ece086f857bc955ee293785e14f0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 22:31:17 -0800 Subject: [PATCH 6/8] perf(agent): avoid a separate Python dev-channel version probe --- cmd/lk/agent_watcher.go | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 15ef887ad..c8f360329 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -50,30 +50,25 @@ 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. +// address, or "" when the Node SDK predates any flag it would accept. Python +// uses its backwards-compatible alias without a separate interpreter probe. func devChannelAddrFlag(config AgentStartConfig) string { + // Python keeps --reload-addr as an alias of --cli-addr. Use the shared flag + // directly instead of starting a second interpreter just to inspect metadata. + if config.ProjectType.IsPython() { + return "--reload-addr" + } 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). +// devChannelAddrFlagFor keeps Node SDKs that predate the dev channel from +// receiving an unknown flag. Python's shared alias needs no version probe. func devChannelAddrFlagFor(projectType agentfs.ProjectType, installedVersion string) string { newEnough := func(minVersion string) bool { if installedVersion == "" { @@ -84,9 +79,6 @@ func devChannelAddrFlagFor(projectType agentfs.ProjectType, installedVersion str } switch { case projectType.IsPython(): - if newEnough(firstPythonCLIAddrVersion) { - return "--cli-addr" - } return "--reload-addr" case projectType.IsNode(): if newEnough(firstNodeCLIAddrVersion) { From 89b987a3f3b6ce61e2235baf2931d85278840691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 1 Nov 2026 23:00:49 -0800 Subject: [PATCH 7/8] fix(agent): pass credentials to console, skip banner fetch, print console link when output settles --- cmd/lk/agent_run.go | 55 +++++++++++++++++-- cmd/lk/banner.go | 13 ++++- cmd/lk/banner_test.go | 39 ++++++++++++++ cmd/lk/console.go | 7 +++ cmd/lk/console_credentials_test.go | 84 ++++++++++++++++++++++++++++++ cmd/lk/utils.go | 13 ++++- 6 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 cmd/lk/console_credentials_test.go diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 8c15dbf55..bd86a668a 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -19,6 +19,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "net/url" "os" "os/signal" @@ -229,6 +230,19 @@ func resolveCredentials(ctx context.Context, cmd *cli.Command, dir string, loadO // Under --user-auth there is no stored API key to hand the agent; // it uses the one in its own env files (see agentEnvCredentials). if !explicit.complete() && userAuthEnabled(cmd) { + p := loadParams{} + for _, opt := range loadOpts { + opt(&p) + } + if p.allowMissingCredentials && !namedProject(cmd) { + creds, _, err := envFileCredentials(dir, agentEnvFiles...) + if err != nil { + return nil, err + } + if creds.apiKey == "" { + return explicit.env(), nil + } + } creds, err := agentEnvCredentials(ctx, cmd, dir) if err != nil { return nil, err @@ -368,6 +382,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { } } + agentOutput := &quietWriter{w: os.Stdout, last: time.Now()} cfg := AgentStartConfig{ Dir: projectDir, Entrypoint: entrypoint, @@ -375,7 +390,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { RuntimeArgs: forwardedArgs(cmd), CLIArgs: cliArgs, Env: env, - ForwardOutput: os.Stdout, + ForwardOutput: agentOutput, } // When the agent reports its ServerInfo over the dev channel, print a console @@ -385,9 +400,10 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { cfg.OnServerInfo = func(agentName, wsURL string) { if link := cloudConsoleURL(wsURL, agentName, deployment); link != "" { consoleLinkOnce.Do(func() { - // Delay briefly so the link prints after the agent's own startup - // logs rather than getting buried in them. - time.AfterFunc(time.Second, func() { + // Print once the agent's startup logs settle, so the link is not + // buried in them. + go func() { + agentOutput.waitQuiet(150*time.Millisecond, time.Second) // Accent-colored, and a clickable OSC 8 hyperlink on terminals // that support it (gated so the escape never leaks into pipes). label := util.Accented(link) @@ -396,7 +412,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { } out.Status("") out.Statusf("Agent console: %s", label) - }) + }() }) } } @@ -454,3 +470,32 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { signal.Stop(sigCh) return err } + +// quietWriter forwards agent output and remembers when it last wrote, so status +// lines can wait for the agent's logs to settle instead of a fixed delay. +type quietWriter struct { + w io.Writer + mu sync.Mutex + last time.Time +} + +func (q *quietWriter) Write(p []byte) (int, error) { + q.mu.Lock() + q.last = time.Now() + q.mu.Unlock() + return q.w.Write(p) +} + +// waitQuiet returns once nothing was written for quiet, or after max. +func (q *quietWriter) waitQuiet(quiet, max time.Duration) { + deadline := time.Now().Add(max) + for { + q.mu.Lock() + idle := time.Since(q.last) + q.mu.Unlock() + if idle >= quiet || !time.Now().Before(deadline) { + return + } + time.Sleep(min(quiet-idle, time.Until(deadline))) + } +} diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 06efc4f83..a496c211f 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -75,7 +75,7 @@ const bannerAudiencesSince = "2.19.2" // loadBanner returns banner.json from the cache when it is fresh, otherwise from // the network, falling back to a stale cache when the fetch fails. -func loadBanner(ctx context.Context) []byte { +func loadBanner(ctx context.Context, cmd *cli.Command) []byte { path, err := bannerCachePath() if err != nil { return nil @@ -84,6 +84,15 @@ func loadBanner(ctx context.Context) []byte { raw, _ := os.ReadFile(path) return raw } + // Local agents are frequently restarted. Never make their process startup + // wait for an optional promotional fetch, or display a stale cached offer. + leaf := leafCommand(cmd) + if lineage := leaf.Lineage(); len(lineage) > 1 && lineage[1].Name == "agent" { + switch leaf.Name { + case "dev", "start", "console", "debugger": + return nil + } + } raw := fetchBanner(ctx) if raw == nil { raw, _ = os.ReadFile(path) @@ -161,7 +170,7 @@ func showBanner(ctx context.Context, cmd *cli.Command) { if !out.Interactive() || SkipPrompts(cmd) { return } - printBanner(loadBanner(ctx), out.Err, userBannerAudiences()) + printBanner(loadBanner(ctx, cmd), out.Err, userBannerAudiences()) } // userBannerAudiences returns the banner audiences this user belongs to. diff --git a/cmd/lk/banner_test.go b/cmd/lk/banner_test.go index c88911cf5..716e48187 100644 --- a/cmd/lk/banner_test.go +++ b/cmd/lk/banner_test.go @@ -1,14 +1,53 @@ package main import ( + "context" "encoding/json" + "net/http" "os" + "path/filepath" "reflect" "testing" + "time" "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) +func TestLocalAgentStartupNeverFetchesAnUncachedOrExpiredBanner(t *testing.T) { + setHome(t, t.TempDir()) + previous := http.DefaultClient + http.DefaultClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("local agent startup tried to fetch the promotional banner") + return nil, nil + })} + t.Cleanup(func() { http.DefaultClient = previous }) + path, err := bannerCachePath() + require.NoError(t, err) + for _, command := range []string{"dev", "start", "console", "debugger"} { + for _, stale := range []bool{false, true} { + if stale { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(`[{"message":"expired offer"}]`), 0o600)) + old := time.Now().Add(-2 * bannerTTL) + require.NoError(t, os.Chtimes(path, old, old)) + } else { + _ = os.Remove(path) + } + app := &cli.Command{ + Name: "lk", + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + require.Nil(t, loadBanner(ctx, cmd)) + return ctx, nil + }, + Commands: []*cli.Command{{Name: "agent", Commands: []*cli.Command{{Name: command, Action: func(context.Context, *cli.Command) error { return nil }}}}}, + } + require.NoError(t, app.Run(context.Background(), []string{"lk", "agent", command})) + } + } +} + func TestBannerMessages(t *testing.T) { cases := []struct { name, raw, version string diff --git a/cmd/lk/console.go b/cmd/lk/console.go index a606db0c7..9346f1f64 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -161,6 +161,12 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { } out.Statusf("Detected %s agent (%s in %s)", projectType.Lang(), util.Accented(entrypoint), util.Accented(projectDir)) + // Console itself needs no project, but inference/storage in application + // imports need any configured credentials before Python or Node starts. + credsEnv, err := resolveCredentials(ctx, cmd, projectDir, optionalCredentials) + if err != nil { + return err + } // Show spinner while starting agent stopSpinner := startSpinner("Starting agent") @@ -170,6 +176,7 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { ProjectType: projectType, RuntimeArgs: forwardedArgs(cmd), CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")), + Env: credsEnv, FailSignals: consoleCrashSignals, }) if err != nil { diff --git a/cmd/lk/console_credentials_test.go b/cmd/lk/console_credentials_test.go new file mode 100644 index 000000000..46a9ded07 --- /dev/null +++ b/cmd/lk/console_credentials_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/livekit/livekit-cli/v2/pkg/config" +) + +func TestConsoleInjectsOptionalCredentialsBeforePythonImports(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("Python is not installed") + } + for _, scenario := range []string{"no project", "default project", "explicit credentials", "invalid named project"} { + t.Run(scenario, func(t *testing.T) { + dir := t.TempDir() + home := filepath.Join(dir, "home") + require.NoError(t, os.MkdirAll(filepath.Join(home, ".livekit"), 0o755)) + conf := config.CLIConfig{SkillsPrompted: true} + want := []string{"", "", ""} + if scenario == "default project" || scenario == "explicit credentials" { + conf.DefaultProject = "configured" + conf.Projects = []config.ProjectConfig{{Name: "configured", URL: "wss://configured.invalid", APIKey: "configured-key", APISecret: "configured-secret"}} + want = []string{"wss://configured.invalid", "configured-key", "configured-secret"} + } + raw, err := yaml.Marshal(conf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(home, ".livekit", "cli-config.yaml"), raw, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("livekit-agents\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.py"), nil, 0o644)) + module := filepath.Join(dir, "livekit", "agents") + require.NoError(t, os.MkdirAll(module, 0o755)) + // Observe credentials during the SDK import, before an app entrypoint + // or its CLI parser can do anything to populate them. + require.NoError(t, os.WriteFile(filepath.Join(module, "__init__.py"), []byte( + "import json, os\nfrom pathlib import Path\n"+ + "Path('import-env.json').write_text(json.dumps([os.environ.get(k, '') for k in ('LIVEKIT_URL', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET')]))\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(module, "__main__.py"), []byte("raise SystemExit(17)\n"), 0o644)) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + args := []string{"agent", "console", "--text", "agent.py"} + if scenario == "explicit credentials" { + args = append([]string{"--url", "wss://explicit.invalid", "--api-key", "explicit-key", "--api-secret", "explicit-secret"}, args...) + want = []string{"wss://explicit.invalid", "explicit-key", "explicit-secret"} + } else if scenario == "invalid named project" { + args = append([]string{"--project", "missing"}, args...) + } + cmd := exec.CommandContext(ctx, "lk", args...) + cmd.Dir = dir + for _, kv := range os.Environ() { + key := strings.SplitN(kv, "=", 2)[0] + if strings.HasPrefix(key, "LIVEKIT_") || key == "HOME" || key == "USERPROFILE" || key == "VIRTUAL_ENV" || key == "PYTHONPATH" { + continue + } + cmd.Env = append(cmd.Env, kv) + } + cmd.Env = append(cmd.Env, "HOME="+home, "USERPROFILE="+home, "PYTHONPATH="+dir, "LK_NO_SKILLS_PROMPT=1") + output, err := cmd.CombinedOutput() + require.Error(t, err, "stub must exit before console connects") + if scenario == "invalid named project" { + require.Contains(t, string(output), "project not found") + _, err := os.Stat(filepath.Join(dir, "import-env.json")) + require.True(t, os.IsNotExist(err)) + return + } + raw, err = os.ReadFile(filepath.Join(dir, "import-env.json")) + require.NoError(t, err, string(output)) + var got []string + require.NoError(t, json.Unmarshal(raw, &got)) + require.Equal(t, want, got) + require.NotContains(t, string(output), "configured-secret") + require.NotContains(t, string(output), "explicit-secret") + }) + } +} diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 7ccf6ce13..069a3a75c 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -320,13 +320,17 @@ func parseKeyValuePairs(c *cli.Command, flag string) (map[string]string, error) } type loadParams struct { - requireURL bool - confirmProject bool + requireURL bool + confirmProject bool + allowMissingCredentials bool } type loadOption func(*loadParams) var ( + optionalCredentials = func(p *loadParams) { + p.allowMissingCredentials = true + } ignoreURL = func(p *loadParams) { p.requireURL = false } @@ -479,6 +483,11 @@ func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) { if dp, err := config.LoadDefaultProject(); err == nil { return &resolvedProject{project: dp, source: sourceDefault}, nil } + // Local console sessions can use provider credentials of their own. Resolve + // configured LiveKit credentials when available, without requiring a project. + if p.allowMissingCredentials { + return &resolvedProject{project: &config.ProjectConfig{}, source: sourceInlineFlags}, nil + } if p.requireURL && pc.URL == "" { return nil, errors.New("url is required") From c1b5dc8c8d1250df2b2cef5329710f60e55aecec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Mon, 2 Nov 2026 19:49:35 -0800 Subject: [PATCH 8/8] test(agent): portable import counter on Windows, tagged switch for lint --- cmd/lk/console_credentials_test.go | 5 +++-- cmd/lk/python_sdk_version_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/lk/console_credentials_test.go b/cmd/lk/console_credentials_test.go index 46a9ded07..b292e69e9 100644 --- a/cmd/lk/console_credentials_test.go +++ b/cmd/lk/console_credentials_test.go @@ -48,10 +48,11 @@ func TestConsoleInjectsOptionalCredentialsBeforePythonImports(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() args := []string{"agent", "console", "--text", "agent.py"} - if scenario == "explicit credentials" { + switch scenario { + case "explicit credentials": args = append([]string{"--url", "wss://explicit.invalid", "--api-key", "explicit-key", "--api-secret", "explicit-secret"}, args...) want = []string{"wss://explicit.invalid", "explicit-key", "explicit-secret"} - } else if scenario == "invalid named project" { + case "invalid named project": args = append([]string{"--project", "missing"}, args...) } cmd := exec.CommandContext(ctx, "lk", args...) diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index 8b43edb13..ab7c80427 100644 --- a/cmd/lk/python_sdk_version_test.go +++ b/cmd/lk/python_sdk_version_test.go @@ -108,7 +108,7 @@ func TestStartPythonAgentImportsOnceAndReportsMissingDependencies(t *testing.T) count := filepath.Join(dir, "imports.txt") cfg.Env = append(cfg.Env, "IMPORT_COUNT="+count) require.NoError(t, os.WriteFile(filepath.Join(module, "__init__.py"), - []byte("import os\nwith open(os.environ['IMPORT_COUNT'], 'a') as f: f.write('import\\n')\n"), 0o644)) + []byte("import os\nwith open(os.environ['IMPORT_COUNT'], 'a', newline='') as f: f.write('import\\n')\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(module, "__main__.py"), []byte("print('module launched', flush=True)\n"), 0o644)) ap, err := startAgent(cfg)