Skip to content

Commit f1ec486

Browse files
committed
feat(agent): resolve installed livekit-agents version for Python run check
lk agent start/dev/console gated the Python SDK version by parsing project files, which can't know the resolved version from a loose constraint (and misread e.g. >=1.0 as 1.0). Resolve the installed version via the project interpreter (importlib.metadata) like the Node path already does, so the check reflects what will actually run regardless of installer. Falls back to static file parsing when dependencies aren't installed (e.g. before a sync). uv reads the existing env with --no-sync so the pre-flight check never syncs/downloads.
1 parent 6b42590 commit f1ec486

3 files changed

Lines changed: 280 additions & 4 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
name: Go Test (Windows)
2+
3+
# Runs the Go test suite on Windows. test.yaml covers Linux and macOS but
4+
# excludes Windows: pkg/apm/webrtc's C++ uses MSVC SEH (__try/__except) that the
5+
# runner's mingw GCC can't compile, and on native Windows the cgo link of ~560
6+
# webrtc/portaudio objects overflows the ~32KB command-line limit. So -- exactly
7+
# like .goreleaser.yaml and session-e2e.yaml -- the per-package test binaries are
8+
# cross-compiled on Linux with zig (clang) and then run natively on
9+
# windows-latest, where nothing is rebuilt.
10+
11+
on:
12+
push:
13+
branches: [main]
14+
pull_request:
15+
branches: [main]
16+
17+
concurrency:
18+
group: test-windows-${{ github.ref }}
19+
cancel-in-progress: true
20+
21+
jobs:
22+
# Cross-compile a test binary per package on Linux with zig, mirroring
23+
# .goreleaser.yaml's lk-windows-amd64 build. manifest.tsv maps each binary to
24+
# its package directory so the Windows job can run it from there (tests read
25+
# cwd-relative fixtures, e.g. cmd/lk/testdata and pkg/agentfs/examples).
26+
cross-build:
27+
runs-on: ubuntu-latest
28+
name: Cross-build Windows test binaries (zig)
29+
30+
permissions:
31+
contents: read
32+
33+
steps:
34+
- name: Checkout livekit-cli
35+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
36+
with:
37+
# pkg/portaudio/pa_src is a submodule with the vendored PortAudio C
38+
# source linked into cmd/lk via cgo.
39+
submodules: true
40+
41+
- name: Set up Zig
42+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
43+
with:
44+
version: 0.14.1
45+
46+
- name: Set up Go
47+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
48+
with:
49+
go-version-file: go.mod
50+
cache: true
51+
52+
# Generate the MinGW import libs lld needs for Go's /DEFAULTLIB references
53+
# (dbghelp, bcrypt, ...), exactly as goreleaser's before-hook does.
54+
- name: Generate MinGW import libs
55+
run: scripts/setup-cross.sh windows/amd64
56+
57+
- name: Cross-compile per-package test binaries
58+
env:
59+
GOOS: windows
60+
GOARCH: amd64
61+
CGO_ENABLED: "1"
62+
# zig cc/c++ as the cgo toolchain, matching .goreleaser.yaml's Windows
63+
# build. -fms-extensions (SEH) isn't on cgo's allowlist; -fno-sanitize
64+
# is a zig UBSan-under-lld workaround -- both copied from goreleaser.
65+
CC: zig cc -target x86_64-windows-gnu
66+
CXX: zig c++ -target x86_64-windows-gnu
67+
CGO_CXXFLAGS_ALLOW: -fms-extensions
68+
CGO_CXXFLAGS: -fno-sanitize=all
69+
run: |
70+
set -euo pipefail
71+
ldflags="-extldflags=-L$PWD/.cross/windows_amd64/mingw_lib"
72+
module=$(go list -m)
73+
mkdir -p dist
74+
: > dist/manifest.tsv
75+
for pkg in $(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...); do
76+
rel=${pkg#"$module"/}
77+
[ "$rel" = "$module" ] && rel="."
78+
safe=$(echo "$rel" | tr '/' '_')
79+
go test -c -ldflags="$ldflags" -o "dist/${safe}.test.exe" "$pkg"
80+
printf '%s\t%s\n' "${safe}.test.exe" "$rel" >> dist/manifest.tsv
81+
done
82+
echo "Built:"; cat dist/manifest.tsv
83+
84+
- name: Upload Windows test binaries
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: windows-test-binaries
88+
path: dist
89+
retention-days: 1
90+
if-no-files-found: error
91+
92+
# Run each prebuilt test binary natively on Windows, from its package
93+
# directory so cwd-relative fixtures resolve. No Go/zig/cgo here; node ships on
94+
# the runner and uv is installed for the Python agent SDK-version tests.
95+
test-windows:
96+
needs: cross-build
97+
runs-on: windows-latest
98+
name: Go test on windows-latest
99+
100+
permissions:
101+
contents: read
102+
103+
steps:
104+
- name: Checkout livekit-cli
105+
# Test fixtures (cmd/lk/testdata, pkg/agentfs/examples) are read at
106+
# runtime; submodules are C source already compiled into the binaries.
107+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
108+
109+
- name: Download Windows test binaries
110+
uses: actions/download-artifact@v4
111+
with:
112+
name: windows-test-binaries
113+
path: dist
114+
115+
- name: Set up Python
116+
uses: actions/setup-python@v5
117+
with:
118+
python-version: "3.12"
119+
120+
- name: Set up uv
121+
uses: astral-sh/setup-uv@v5
122+
with:
123+
enable-cache: true
124+
125+
- name: Run test binaries
126+
shell: bash
127+
run: |
128+
set -uo pipefail
129+
root=$(pwd)
130+
fail=0
131+
while IFS=$'\t' read -r exe rel; do
132+
echo "::group::$rel"
133+
( cd "$rel" && "$root/dist/$exe" -test.v -test.count=1 ) || fail=1
134+
echo "::endgroup::"
135+
done < "$root/dist/manifest.tsv"
136+
exit $fail

cmd/lk/python_sdk_version_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"os"
19+
"os/exec"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/stretchr/testify/require"
24+
25+
"github.com/livekit/livekit-cli/v2/pkg/agentfs"
26+
)
27+
28+
// setupUvAgentProject builds a real uv project whose only dependency is a local
29+
// stub package named "livekit-agents" pinned to stubVersion. This resolves a
30+
// known installed version through real uv — no network, no real SDK — and
31+
// works on every platform uv supports. depSpec is the dependency string written
32+
// to the project's pyproject.toml (e.g. "livekit-agents" or
33+
// "livekit-agents>=1.0"); when sync is true the stub is installed into the env.
34+
func setupUvAgentProject(t *testing.T, stubVersion, depSpec string, sync bool) string {
35+
t.Helper()
36+
if _, err := exec.LookPath("uv"); err != nil {
37+
t.Skip("uv not on PATH")
38+
}
39+
dir := t.TempDir()
40+
41+
stubMod := filepath.Join(dir, "stub", "src", "livekit_agents")
42+
require.NoError(t, os.MkdirAll(stubMod, 0o755))
43+
require.NoError(t, os.WriteFile(filepath.Join(stubMod, "__init__.py"), nil, 0o644))
44+
require.NoError(t, os.WriteFile(filepath.Join(dir, "stub", "pyproject.toml"), []byte(
45+
"[project]\n"+
46+
"name = \"livekit-agents\"\n"+
47+
"version = \""+stubVersion+"\"\n"+
48+
"requires-python = \">=3.8\"\n"+
49+
"[build-system]\n"+
50+
"requires = [\"uv_build>=0.5,<10\"]\n"+
51+
"build-backend = \"uv_build\"\n"), 0o644))
52+
53+
require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(
54+
"[project]\n"+
55+
"name = \"test-agent\"\n"+
56+
"version = \"0.0.0\"\n"+
57+
"requires-python = \">=3.8\"\n"+
58+
"dependencies = [\""+depSpec+"\"]\n"+
59+
"[tool.uv.sources]\n"+
60+
"livekit-agents = { path = \"stub\" }\n"), 0o644))
61+
62+
if sync {
63+
cmd := exec.Command("uv", "sync")
64+
cmd.Dir = dir
65+
if out, err := cmd.CombinedOutput(); err != nil {
66+
t.Fatalf("uv sync failed: %v\n%s", err, out)
67+
}
68+
}
69+
return dir
70+
}
71+
72+
func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) {
73+
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true)
74+
require.Equal(t, "1.6.7", resolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV))
75+
}
76+
77+
func TestCheckPythonSDKVersion_TooOld(t *testing.T) {
78+
dir := setupUvAgentProject(t, "1.0.0", "livekit-agents", true)
79+
err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV})
80+
require.Error(t, err)
81+
require.Contains(t, err.Error(), "too old")
82+
}
83+
84+
func TestCheckPythonSDKVersion_InstalledBeatsLooseConstraint(t *testing.T) {
85+
// The pyproject floor >=1.0 would fail static parsing, but the installed
86+
// 1.6.7 is what gets used — proving the resolved version wins.
87+
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.0", true)
88+
require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}))
89+
}
90+
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+
dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.6.0", false)
95+
require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}))
96+
}

cmd/lk/simulate_subprocess.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,53 @@ func checkNodeSDKVersion(cfg AgentStartConfig) error {
180180
return nil
181181
}
182182

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"))`
186+
187+
// resolvePythonAgentVersion returns the installed livekit-agents version read
188+
// 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 {
193+
pythonBin, prefixArgs, err := findPythonBinary(dir, projectType)
194+
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:]...)
199+
}
200+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
201+
defer cancel()
202+
args := append(append([]string{}, prefixArgs...), "-c", pythonResolveVersionScript)
203+
cmd := exec.CommandContext(ctx, pythonBin, args...)
204+
cmd.Dir = dir
205+
out, err := cmd.Output()
206+
if err != nil {
207+
return ""
208+
}
209+
return strings.TrimSpace(string(out))
210+
}
211+
212+
// checkPythonSDKVersion gates a Python agent on thinCLIMinVersion. It prefers
213+
// the installed version (resolved via the interpreter, accurate regardless of
214+
// the package manager and not fooled by a loose version constraint); when
215+
// dependencies aren't installed it falls back to static project-file parsing.
216+
func checkPythonSDKVersion(cfg AgentStartConfig) error {
217+
if version := resolvePythonAgentVersion(cfg.Dir, cfg.ProjectType); version != "" {
218+
// An unparseable version (e.g. a local "0.0.0.dev" tag) shouldn't block a run.
219+
if ok, err := agentfs.IsVersionSatisfied(version, thinCLIMinVersion); err == nil && !ok {
220+
return fmt.Errorf("livekit-agents version %s is too old, please upgrade to %s or newer", version, thinCLIMinVersion)
221+
}
222+
return nil
223+
}
224+
return agentfs.CheckSDKVersion(cfg.Dir, cfg.ProjectType, map[string]string{
225+
"python-min-sdk-version": thinCLIMinVersion,
226+
"node-min-sdk-version": thinCLIMinVersion,
227+
})
228+
}
229+
183230
// defaultEntrypoints returns candidate entrypoint paths (relative to the
184231
// project root or working directory) probed for a project type, in priority
185232
// order. Forward slashes are valid on all platforms.
@@ -328,10 +375,7 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) {
328375
if err := checkNodeSDKVersion(cfg); err != nil {
329376
return nil, err
330377
}
331-
} else if err := agentfs.CheckSDKVersion(cfg.Dir, cfg.ProjectType, map[string]string{
332-
"python-min-sdk-version": thinCLIMinVersion,
333-
"node-min-sdk-version": thinCLIMinVersion,
334-
}); err != nil {
378+
} else if err := checkPythonSDKVersion(cfg); err != nil {
335379
return nil, err
336380
}
337381

0 commit comments

Comments
 (0)