Skip to content

Commit e88443e

Browse files
toubatbriancursoragentu9gclaude
authored
feat(cli): add lk agent session for headless text-mode agent runs (#857)
* feat(cli): add `lk agent session` for headless text-mode agent runs Introduces a three-process model (ephemeral CLI command, detached singleton daemon, agent subprocess) that drives a Python/JS agent over TCP using the lk.agent.session protobuf protocol, with no audio/CGO dependency: - `lk agent session start <file>`: re-execs the lk binary as a detached daemon bound to a fixed loopback port (singleton), which spawns the agent and applies text mode; rejects start if a session already runs. - `lk agent session say "..."`: streams a user turn and renders the agent reply, tool calls/outputs, and handoffs to the terminal. - `lk agent session end`: tears down the daemon and agent. The CLI<->daemon control protocol reuses pkg/ipc length-prefixed framing over the same TCP port, disambiguated from agent connections by a magic preamble. The headless renderer covers all ChatItem variants plus the FunctionToolsExecuted event. Drops the now-unnecessary U1000 file-ignore directives added while the helpers were unused. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): skip empty function-tool output line in session renderer Tools that return no string (e.g. handoff tools returning an Agent) produced a bare "✓ " line. Suppress the output line when the summarized output is empty for successful calls; error outputs still render. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(cli): dispatch session daemon via hidden subcommand entrypoint Replace the env-gated branch at the top of main() with a dedicated, hidden `lk agent session daemon` subcommand (mirroring the existing hidden `generate-fish-completion` command). `start` now re-execs the binary into that subcommand instead of setting LK_SESSION_DAEMON=1, so the daemon has its own entrypoint dispatched by the CLI framework rather than special-casing main(). Re-exec of the same binary is retained (a separate binary can't be located reliably after `go install`); runtime params still flow through the LK_SESSION_* env vars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reject direct invocation of hidden session daemon entrypoint A registered subcommand is always invokable (Hidden only drops it from help), so a stray `lk agent session daemon` previously spawned a half-configured daemon (random port, empty project dir) that exited silently. Guard the entrypoint on the inherited readiness pipe that `start` always provides: without it, return a clear error directing the user to `lk agent session start`. Co-authored-by: Cursor <cursoragent@cursor.com> * Update session.go * test(cli): e2e test for the agent session lifecycle (#882) * test(cli): add e2e test for the agent session lifecycle Adds an opt-in end-to-end test that drives the real `lk agent session` start/say/end flow against a minimal one-file echo agent (testdata/echo-agent), asserting the model echoes a token back and that the detached daemon exits afterward. The fixture is a uv project so the daemon's `uv run python` auto-syncs deps; its __main__ dispatches console mode to the TCP console directly since cli.run_app() doesn't expose --connect-addr on released agents. Includes a GitHub Actions workflow that runs the test on Linux and Windows, triggered by workflow_dispatch and pushes to any repo branch. Gated behind LIVEKIT_API_KEY so it skips without credentials. * fix(cli): use a readiness file instead of an inherited pipe fd The session daemon spawn passed the readiness pipe to the detached child via cmd.ExtraFiles (fd 3), but os/exec's ExtraFiles is unsupported on Windows, so daemon.Start() failed with "fork/exec ...: not supported by windows" and the session never started there. Replace the inherited fd with a temp readiness file: the daemon writes its status atomically (write + rename) and `start` polls it until it sees a status, the daemon process exits, or a timeout slightly past the daemon's own agent-connect deadline. Works identically on Linux and Windows. * chore: regenerate fish_autocomplete for agent session command * ci(session-e2e): build the Windows arm via Linux cross-compile Round out the Session E2E workflow: add the macOS arm, wire up the portaudio submodule + ALSA headers, and build Windows with zig to match .goreleaser.yaml's toolchain. Native Windows can't link the webrtc/portaudio cgo objects (the ~560-object link overflows the command-line limit), so cross-compile lk.exe and the e2e test binary on Linux and run them natively on Windows. buildLK honors LK_SESSION_E2E_BIN so the Windows runner drives the prebuilt binary instead of rebuilding. * Tighten agent session e2e shutdown assertions (#883) * Tighten agent session e2e shutdown assertions * test(session-e2e): give start a 15s timeout The detached daemon boot + agent venv + LLM connect takes longer than the shared 5s sessionE2ETimeout, which killed start on the ubuntu and windows CI runners (signal: killed / exit status 1). Bump start to 15s. * feat(cli): rename "lk agent session end" to "stop" Rename the `end` subcommand of `lk agent session` to `stop` per review feedback. Renames the CLI subcommand, the runSessionEnd handler to runSessionStop, the control-protocol verb ("end" -> "stop") on both the client and daemon sides, usage strings, comments, the e2e test, and the generated fish completion. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): rename "lk agent session" command to "daemon" Address review feedback (theomonnom): the user-facing top-level command is now `lk agent daemon {start,say,stop}`. The hidden internal re-exec entrypoint that `start` launches is renamed `daemon` -> `run` to avoid the awkward `daemon daemon` path, so `start` now re-execs `lk agent daemon run`. Updates all user-facing strings, the e2e test invocations, the session-e2e workflow header, and regenerates fish completions. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): rename hidden daemon entrypoint to "serve" Co-authored-by: Cursor <cursoragent@cursor.com> * test(session-e2e): simplify echo-agent entrypoint to cli.run_app The daemon launches the agent via the thin CLI (python -m livekit.agents console <entrypoint> --connect-addr), which discovers the server and dispatches to the TCP console itself. The agent file's __main__ console-dispatch hack was compensating for the old python agent.py launch path and is now dead code. Collapse it to the thin cli.run_app(server) form, matching agent-starter-python. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: u9g <jason.lernerman@livekit.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c6fea02 commit e88443e

12 files changed

Lines changed: 1336 additions & 28 deletions

File tree

.github/workflows/session-e2e.yaml

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
name: Session E2E
2+
3+
# Drives the real `lk agent daemon` start/say/stop lifecycle against the minimal
4+
# one-file echo agent in cmd/lk/testdata/echo-agent, on Linux, macOS, and
5+
# Windows. This exercises the detached daemon, the readiness handshake, the
6+
# console IPC transport, and the model round-trip end to end -- runtime behavior
7+
# that `go test` alone never covers.
8+
#
9+
# Runs on manual dispatch and on pushes to any repo branch (forks can't trigger
10+
# `push`, so secrets are only exposed to trusted collaborators). It needs live
11+
# LiveKit credentials -- set these repo secrets first: LIVEKIT_API_KEY,
12+
# LIVEKIT_API_SECRET, LIVEKIT_URL. The echo agent drives its LLM through LiveKit
13+
# Inference, so no other provider keys are needed.
14+
#
15+
# The echo agent depends on plain PyPI livekit-agents (synced by `uv sync` from
16+
# its pyproject.toml). Note: on current releases/main, `cli.run_app()` routes
17+
# `console` through the legacy click CLI, which has no --connect-addr (that
18+
# lives behind `python -m livekit.agents`). The fixture's __main__ dispatches
19+
# console mode to the TCP console directly to bridge the daemon's
20+
# `python <entry> console --connect-addr` launch.
21+
#
22+
# Windows is split into two jobs: pkg/apm/webrtc's C++ uses MSVC SEH
23+
# (__try/__except) that the runner's mingw GCC can't compile, so we build with
24+
# zig (clang) exactly as .goreleaser.yaml does. zig-as-CC must run on Linux: on
25+
# native Windows the cgo link of ~560 webrtc/portaudio objects overflows the
26+
# ~32KB command-line limit. So `cross-build-windows` cross-compiles lk.exe AND
27+
# the e2e test binary on Linux and uploads them; `e2e-windows` downloads them
28+
# and runs the test natively (LK_SESSION_E2E_BIN points the test at the
29+
# prebuilt lk, so nothing is rebuilt on the Windows runner).
30+
#
31+
# Node is intentionally not in the matrix yet: this branch's session daemon only
32+
# supports Python agents (`detectProject` rejects non-Python), and Node console
33+
# support depends on the brian/agent-session-node-support CLI line (#868/#878)
34+
# plus agents-js #1804. Add a node arm once those land.
35+
36+
on:
37+
workflow_dispatch:
38+
push:
39+
branches: ['**']
40+
41+
concurrency:
42+
group: session-e2e-${{ github.ref }}
43+
cancel-in-progress: true
44+
45+
jobs:
46+
# Linux and macOS build + run natively in one job -- their linkers have no
47+
# command-line-length limit, so `go test` building lk in-process is fine.
48+
e2e:
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
os: [ubuntu-latest, macos-latest]
53+
54+
runs-on: ${{ matrix.os }}
55+
name: Agent Session with Python Agent on ${{ matrix.os }}
56+
57+
permissions:
58+
contents: read
59+
60+
steps:
61+
- name: Checkout livekit-cli
62+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
63+
with:
64+
# pkg/portaudio/pa_src is a submodule with the vendored PortAudio C
65+
# source; needed now that console (cgo) builds unconditionally.
66+
submodules: true
67+
68+
- name: Install ALSA headers (Linux)
69+
if: matrix.os == 'ubuntu-latest'
70+
run: sudo apt-get update && sudo apt-get install -y libasound2-dev
71+
72+
- name: Set up Go
73+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
74+
with:
75+
go-version-file: go.mod
76+
cache: true
77+
78+
- name: Set up Python
79+
uses: actions/setup-python@v5
80+
with:
81+
python-version: "3.12"
82+
83+
- name: Set up uv
84+
uses: astral-sh/setup-uv@v5
85+
with:
86+
enable-cache: true
87+
88+
- name: Sync echo agent deps
89+
working-directory: cmd/lk/testdata/echo-agent
90+
run: uv sync
91+
92+
- name: Run session e2e
93+
env:
94+
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
95+
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
96+
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
97+
run: go test ./cmd/lk -run TestSessionE2E -count=1 -v -timeout 600s
98+
99+
# Cross-compile the Windows artifacts on Linux with zig, mirroring
100+
# .goreleaser.yaml's lk-windows-amd64 build. Produces lk.exe (the binary under
101+
# test) and the compiled e2e test binary, both shipped to e2e-windows.
102+
cross-build-windows:
103+
runs-on: ubuntu-latest
104+
name: Cross-build Windows artifacts (zig)
105+
106+
permissions:
107+
contents: read
108+
109+
steps:
110+
- name: Checkout livekit-cli
111+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
112+
with:
113+
submodules: true
114+
115+
- name: Set up Zig
116+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
117+
with:
118+
version: 0.14.1
119+
120+
- name: Set up Go
121+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
122+
with:
123+
go-version-file: go.mod
124+
cache: true
125+
126+
# Generate the MinGW import libs lld needs for Go's /DEFAULTLIB references
127+
# (dbghelp, bcrypt, ...), exactly as goreleaser's before-hook does.
128+
- name: Generate MinGW import libs
129+
run: scripts/setup-cross.sh windows/amd64
130+
131+
- name: Cross-compile lk.exe and the e2e test binary
132+
env:
133+
GOOS: windows
134+
GOARCH: amd64
135+
CGO_ENABLED: "1"
136+
# zig cc/c++ as the cgo toolchain, matching .goreleaser.yaml's Windows
137+
# build. -fms-extensions (SEH) isn't on cgo's allowlist; -fno-sanitize
138+
# is a zig UBSan-under-lld workaround -- both copied from goreleaser.
139+
CC: zig cc -target x86_64-windows-gnu
140+
CXX: zig c++ -target x86_64-windows-gnu
141+
CGO_CXXFLAGS_ALLOW: -fms-extensions
142+
CGO_CXXFLAGS: -fno-sanitize=all
143+
run: |
144+
ldflags="-extldflags=-L$PWD/.cross/windows_amd64/mingw_lib"
145+
mkdir -p dist
146+
go build -ldflags="$ldflags" -o dist/lk.exe ./cmd/lk
147+
go test -c -ldflags="$ldflags" -o dist/session-e2e.test.exe ./cmd/lk
148+
149+
- name: Upload Windows artifacts
150+
uses: actions/upload-artifact@v4
151+
with:
152+
name: windows-e2e
153+
path: dist/*.exe
154+
retention-days: 1
155+
if-no-files-found: error
156+
157+
# Run the prebuilt Windows artifacts natively. No Go/zig/cgo here: the test
158+
# binary just drives lk.exe (via LK_SESSION_E2E_BIN) against the echo agent.
159+
e2e-windows:
160+
needs: cross-build-windows
161+
runs-on: windows-latest
162+
name: Agent Session with Python Agent on windows-latest
163+
164+
permissions:
165+
contents: read
166+
167+
steps:
168+
- name: Checkout livekit-cli
169+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
170+
# No submodules: the prebuilt binaries already contain the cgo code;
171+
# only the echo-agent fixture (plain repo files) is needed at runtime.
172+
173+
- name: Download Windows artifacts
174+
uses: actions/download-artifact@v4
175+
with:
176+
name: windows-e2e
177+
path: dist
178+
179+
- name: Set up Python
180+
uses: actions/setup-python@v5
181+
with:
182+
python-version: "3.12"
183+
184+
- name: Set up uv
185+
uses: astral-sh/setup-uv@v5
186+
with:
187+
enable-cache: true
188+
189+
- name: Sync echo agent deps
190+
working-directory: cmd/lk/testdata/echo-agent
191+
run: uv sync
192+
193+
- name: Run session e2e
194+
# Run from cmd/lk so the test's default testdata/echo-agent/agent.py
195+
# path resolves; point it at the cross-built lk.exe so nothing rebuilds.
196+
shell: bash
197+
working-directory: cmd/lk
198+
env:
199+
# Relative to cmd/lk; Go's filepath.Abs resolves it. Forward slashes
200+
# keep bash happy (GITHUB_WORKSPACE would carry Windows backslashes).
201+
LK_SESSION_E2E_BIN: ../../dist/lk.exe
202+
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
203+
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
204+
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
205+
run: |
206+
../../dist/session-e2e.test.exe \
207+
-test.run TestSessionE2E -test.count=1 -test.v -test.timeout 600s

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@ dist/
2323

2424
.DS_Store
2525
/lk
26+
27+
# local secrets copied for e2e testing
28+
.env
29+
30+
# python venvs created for e2e agent fixtures
31+
.venv/
32+
cmd/lk/testdata/**/.venv
33+
34+
# uv lockfiles for test agent fixtures (resolved fresh in CI)
35+
cmd/lk/testdata/**/uv.lock

0 commit comments

Comments
 (0)