Skip to content

Commit 5818d1f

Browse files
committed
fix(agent): never sync the uv environment as a launch side effect
The CLI proxies the project's local environment; `uv run` implicitly re-locking and installing packages when launching an agent is a surprising side effect (and can change dependency versions mid-debug). Move --no-sync into findPythonBinary so both the version pre-flight and the launch run against the environment as it exists on disk. Since the launch no longer installs missing dependencies, the version probe now distinguishes "interpreter ran but livekit-agents isn't installed" and the pre-flight fails fast with a `uv sync` hint instead of letting the agent die with ModuleNotFoundError.
1 parent 1ecdb6e commit 5818d1f

2 files changed

Lines changed: 64 additions & 21 deletions

File tree

cmd/lk/python_sdk_version_test.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"os"
1919
"os/exec"
2020
"path/filepath"
21+
"strings"
2122
"testing"
2223

2324
"github.com/stretchr/testify/require"
@@ -71,7 +72,9 @@ func setupUvAgentProject(t *testing.T, stubVersion, depSpec string, sync bool) s
7172

7273
func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) {
7374
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true)
74-
require.Equal(t, "1.6.7", resolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV))
75+
version, notInstalled := resolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV)
76+
require.Equal(t, "1.6.7", version)
77+
require.False(t, notInstalled)
7578
}
7679

7780
func TestCheckPythonSDKVersion_TooOld(t *testing.T) {
@@ -88,9 +91,32 @@ func TestCheckPythonSDKVersion_InstalledBeatsLooseConstraint(t *testing.T) {
8891
require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}))
8992
}
9093

91-
func TestCheckPythonSDKVersion_FallsBackToFilesWhenNotInstalled(t *testing.T) {
92-
// Not synced: `uv run --no-sync` can't resolve an installed version, so the
93-
// check falls back to parsing the pyproject constraint (>=1.6.0 satisfies).
94+
func TestCheckPythonSDKVersion_UnsyncedUVProjectSuggestsSync(t *testing.T) {
95+
// Models a fresh clone where dependencies were never installed. The CLI is
96+
// a proxy for the local environment and never syncs it implicitly, so the
97+
// agent would die at launch with ModuleNotFoundError; the pre-flight must
98+
// fail fast and tell the user how to fix it instead.
9499
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.6.0", false)
95-
require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}))
100+
err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV})
101+
require.ErrorContains(t, err, "uv sync")
102+
}
103+
104+
func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) {
105+
// Models a user who synced their env, then edited a dependency's version
106+
// without re-syncing. Running through the CLI-resolved interpreter must
107+
// execute against the environment as it exists on disk — a plain `uv run`
108+
// would re-lock and install 9.9.9 as a side effect of launching.
109+
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true)
110+
stubPyproject := filepath.Join(dir, "stub", "pyproject.toml")
111+
orig, err := os.ReadFile(stubPyproject)
112+
require.NoError(t, err)
113+
require.NoError(t, os.WriteFile(stubPyproject, []byte(strings.Replace(string(orig), "1.6.7", "9.9.9", 1)), 0o644))
114+
115+
bin, prefixArgs, err := findPythonBinary(dir, agentfs.ProjectTypePythonUV)
116+
require.NoError(t, err)
117+
cmd := exec.Command(bin, append(prefixArgs, "-c", `import importlib.metadata as m; print(m.version("livekit-agents"))`)...)
118+
cmd.Dir = dir
119+
out, err := cmd.Output()
120+
require.NoError(t, err)
121+
require.Equal(t, "1.6.7", strings.TrimSpace(string(out)))
96122
}

cmd/lk/simulate_subprocess.go

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ func findPythonBinary(dir string, projectType agentfs.ProjectType) (string, []st
5858
if projectType == agentfs.ProjectTypePythonUV {
5959
uvPath, err := exec.LookPath("uv")
6060
if err == nil {
61-
return uvPath, []string{"run", "python"}, nil
61+
// --no-sync: the CLI proxies the environment as it exists on disk
62+
// and must never install or upgrade packages as a side effect.
63+
return uvPath, []string{"run", "--no-sync", "python"}, nil
6264
}
6365
}
6466

@@ -180,22 +182,27 @@ func checkNodeSDKVersion(cfg AgentStartConfig) error {
180182
return nil
181183
}
182184

183-
// pythonResolveVersionScript prints the installed livekit-agents version, or
184-
// exits non-zero if it isn't importable.
185-
const pythonResolveVersionScript = `import importlib.metadata as m; print(m.version("livekit-agents"))`
185+
// pythonResolveVersionScript prints the installed livekit-agents version, or a
186+
// sentinel when the interpreter runs fine but the package isn't installed —
187+
// distinguishing "dependencies not synced" from probe failures (no
188+
// interpreter, timeout), which exit non-zero.
189+
const pythonResolveVersionScript = `import importlib.metadata as m
190+
try:
191+
print(m.version("livekit-agents"))
192+
except m.PackageNotFoundError:
193+
print("` + pythonAgentNotInstalled + `")`
194+
195+
const pythonAgentNotInstalled = "__NOT_INSTALLED__"
186196

187197
// resolvePythonAgentVersion returns the installed livekit-agents version read
188198
// via the project's interpreter, so any installer (uv, pip, poetry) reports the
189-
// version that will actually run. Returns "" when it can't be determined (no
190-
// interpreter, dependencies not installed, etc.). For uv it reads the existing
191-
// environment without syncing, so a pre-flight check never mutates or downloads.
192-
func resolvePythonAgentVersion(dir string, projectType agentfs.ProjectType) string {
199+
// version that will actually run. notInstalled reports that the interpreter ran
200+
// but the package is missing from its environment; version is "" when it can't
201+
// be determined at all (no interpreter, probe failure, etc.).
202+
func resolvePythonAgentVersion(dir string, projectType agentfs.ProjectType) (version string, notInstalled bool) {
193203
pythonBin, prefixArgs, err := findPythonBinary(dir, projectType)
194204
if err != nil {
195-
return ""
196-
}
197-
if projectType == agentfs.ProjectTypePythonUV && len(prefixArgs) > 0 && prefixArgs[0] == "run" {
198-
prefixArgs = append([]string{"run", "--no-sync"}, prefixArgs[1:]...)
205+
return "", false
199206
}
200207
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
201208
defer cancel()
@@ -204,17 +211,27 @@ func resolvePythonAgentVersion(dir string, projectType agentfs.ProjectType) stri
204211
cmd.Dir = dir
205212
out, err := cmd.Output()
206213
if err != nil {
207-
return ""
214+
return "", false
208215
}
209-
return strings.TrimSpace(string(out))
216+
v := strings.TrimSpace(string(out))
217+
if v == pythonAgentNotInstalled {
218+
return "", true
219+
}
220+
return v, false
210221
}
211222

212223
// checkPythonSDKVersion gates a Python agent on thinCLIMinVersion. It prefers
213224
// the installed version (resolved via the interpreter, accurate regardless of
214225
// the package manager and not fooled by a loose version constraint); when
215226
// dependencies aren't installed it falls back to static project-file parsing.
216227
func checkPythonSDKVersion(cfg AgentStartConfig) error {
217-
if version := resolvePythonAgentVersion(cfg.Dir, cfg.ProjectType); version != "" {
228+
version, notInstalled := resolvePythonAgentVersion(cfg.Dir, cfg.ProjectType)
229+
if notInstalled && cfg.ProjectType == agentfs.ProjectTypePythonUV {
230+
// The launch runs `uv run --no-sync`, so a missing package won't be
231+
// installed on the way up — fail fast with the fix instead.
232+
return fmt.Errorf("livekit-agents is not installed in the project environment; run `uv sync` and try again")
233+
}
234+
if version != "" {
218235
// An unparseable version (e.g. a local "0.0.0.dev" tag) shouldn't block a run.
219236
if ok, err := agentfs.IsVersionSatisfied(version, thinCLIMinVersion); err == nil && !ok {
220237
return fmt.Errorf("livekit-agents version %s is too old, please upgrade to %s or newer", version, thinCLIMinVersion)
@@ -324,7 +341,7 @@ const thinCLIMinVersion = "1.6.0"
324341
// buildAgentCommand resolves the interpreter and argv for an agent subprocess,
325342
// branching on project type. Python uses the thin CLI:
326343
// `<python> <runtime-args> -m livekit.agents SUBCOMMAND ENTRYPOINT FLAGS`
327-
// (uv prefixes `run python`). Node runs the entrypoint directly:
344+
// (uv prefixes `run --no-sync python`). Node runs the entrypoint directly:
328345
// `node [--experimental-strip-types] <runtime-args> ENTRYPOINT SUBCOMMAND FLAGS`,
329346
// where the type-stripping flag lets a `.ts` entrypoint run without a build.
330347
func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) {

0 commit comments

Comments
 (0)