diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 8c15dbf5..bd86a668 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/agent_watcher.go b/cmd/lk/agent_watcher.go index cc58f2cd..c8f36032 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -15,8 +15,11 @@ package main import ( + "context" "fmt" + "io/fs" "os" + "os/exec" "path/filepath" "strings" "time" @@ -25,12 +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, + // 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. @@ -41,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 == "" { @@ -75,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) { @@ -101,22 +102,48 @@ type agentWatcher struct { session *devSession } -func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { +// 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) { + 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) } - - // Walk directory tree and add all non-skip directories - err = filepath.Walk(config.Dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() { + 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 skipDirs[name] || (strings.HasPrefix(name, ".") && name != ".") { + if path != dir && (skipDirs[name] || ignored[path] || strings.HasPrefix(name, ".")) { return filepath.SkipDir } return w.Add(path) @@ -125,7 +152,10 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { w.Close() return nil, fmt.Errorf("failed to setup file watcher: %w", err) } + return w, nil +} +func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { // 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 @@ -133,7 +163,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 @@ -146,7 +175,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 @@ -211,9 +239,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, @@ -231,9 +256,23 @@ 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 + } + // 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 + } + aw.watcher = watcher + var debounceTimer *time.Timer var debounceCh <-chan time.Time exitCh := aw.agent.exitCh diff --git a/cmd/lk/agent_watcher_test.go b/cmd/lk/agent_watcher_test.go new file mode 100644 index 00000000..e6715d52 --- /dev/null +++ b/cmd/lk/agent_watcher_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAgentFileWatcherSkipsBuildAndDependencyTrees(t *testing.T) { + // 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", + "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()) }) + // 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/banner.go b/cmd/lk/banner.go index 06efc4f8..a496c211 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 c88911cf..716e4818 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 a606db0c..9346f1f6 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 00000000..b292e69e --- /dev/null +++ b/cmd/lk/console_credentials_test.go @@ -0,0 +1,85 @@ +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"} + 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"} + case "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/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go index 6feaa982..ab7c8042 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,51 @@ 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})) -} +// A missing module gets an actionable error; an importable module from an +// arbitrary source directory launches without distribution metadata. +func TestStartPythonAgentImportsOnceAndReportsMissingDependencies(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Skip("uv not on PATH") + } + 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}, + } + 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") -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") + module := filepath.Join(source, "livekit", "agents") + require.NoError(t, os.MkdirAll(module, 0o755)) + 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', 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) + 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) + 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 b8d74ba8..66a7096b 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,12 @@ 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 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 := checkPythonSDKVersion(cfg); err != nil { - return nil, err } bin, args, err := buildAgentCommand(cfg) @@ -347,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) @@ -359,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) }) } @@ -381,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) } diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 7ccf6ce1..069a3a75 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")