diff --git a/.github/workflows/session-e2e.yaml b/.github/workflows/session-e2e.yaml new file mode 100644 index 00000000..6edc947e --- /dev/null +++ b/.github/workflows/session-e2e.yaml @@ -0,0 +1,207 @@ +name: Session E2E + +# Drives the real `lk agent session` start/say/end lifecycle against the minimal +# one-file echo agent in cmd/lk/testdata/echo-agent, on Linux, macOS, and +# Windows. This exercises the detached daemon, the readiness handshake, the +# console IPC transport, and the model round-trip end to end -- runtime behavior +# that `go test` alone never covers. +# +# Runs on manual dispatch and on pushes to any repo branch (forks can't trigger +# `push`, so secrets are only exposed to trusted collaborators). It needs live +# LiveKit credentials -- set these repo secrets first: LIVEKIT_API_KEY, +# LIVEKIT_API_SECRET, LIVEKIT_URL. The echo agent drives its LLM through LiveKit +# Inference, so no other provider keys are needed. +# +# The echo agent depends on plain PyPI livekit-agents (synced by `uv sync` from +# its pyproject.toml). Note: on current releases/main, `cli.run_app()` routes +# `console` through the legacy click CLI, which has no --connect-addr (that +# lives behind `python -m livekit.agents`). The fixture's __main__ dispatches +# console mode to the TCP console directly to bridge the daemon's +# `python console --connect-addr` launch. +# +# Windows is split into two jobs: pkg/apm/webrtc's C++ uses MSVC SEH +# (__try/__except) that the runner's mingw GCC can't compile, so we build with +# zig (clang) exactly as .goreleaser.yaml does. zig-as-CC must run on Linux: on +# native Windows the cgo link of ~560 webrtc/portaudio objects overflows the +# ~32KB command-line limit. So `cross-build-windows` cross-compiles lk.exe AND +# the e2e test binary on Linux and uploads them; `e2e-windows` downloads them +# and runs the test natively (LK_SESSION_E2E_BIN points the test at the +# prebuilt lk, so nothing is rebuilt on the Windows runner). +# +# Node is intentionally not in the matrix yet: this branch's session daemon only +# supports Python agents (`detectProject` rejects non-Python), and Node console +# support depends on the brian/agent-session-node-support CLI line (#868/#878) +# plus agents-js #1804. Add a node arm once those land. + +on: + workflow_dispatch: + push: + branches: ['**'] + +concurrency: + group: session-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Linux and macOS build + run natively in one job -- their linkers have no + # command-line-length limit, so `go test` building lk in-process is fine. + e2e: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + runs-on: ${{ matrix.os }} + name: Agent Session with Python Agent on ${{ matrix.os }} + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # pkg/portaudio/pa_src is a submodule with the vendored PortAudio C + # source; needed now that console (cgo) builds unconditionally. + submodules: true + + - name: Install ALSA headers (Linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync echo agent deps + working-directory: cmd/lk/testdata/echo-agent + run: uv sync + + - name: Run session e2e + env: + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + run: go test ./cmd/lk -run TestSessionE2E -count=1 -v -timeout 600s + + # Cross-compile the Windows artifacts on Linux with zig, mirroring + # .goreleaser.yaml's lk-windows-amd64 build. Produces lk.exe (the binary under + # test) and the compiled e2e test binary, both shipped to e2e-windows. + cross-build-windows: + runs-on: ubuntu-latest + name: Cross-build Windows artifacts (zig) + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + submodules: true + + - name: Set up Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: 0.14.1 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + # Generate the MinGW import libs lld needs for Go's /DEFAULTLIB references + # (dbghelp, bcrypt, ...), exactly as goreleaser's before-hook does. + - name: Generate MinGW import libs + run: scripts/setup-cross.sh windows/amd64 + + - name: Cross-compile lk.exe and the e2e test binary + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: "1" + # zig cc/c++ as the cgo toolchain, matching .goreleaser.yaml's Windows + # build. -fms-extensions (SEH) isn't on cgo's allowlist; -fno-sanitize + # is a zig UBSan-under-lld workaround -- both copied from goreleaser. + CC: zig cc -target x86_64-windows-gnu + CXX: zig c++ -target x86_64-windows-gnu + CGO_CXXFLAGS_ALLOW: -fms-extensions + CGO_CXXFLAGS: -fno-sanitize=all + run: | + ldflags="-extldflags=-L$PWD/.cross/windows_amd64/mingw_lib" + mkdir -p dist + go build -ldflags="$ldflags" -o dist/lk.exe ./cmd/lk + go test -c -ldflags="$ldflags" -o dist/session-e2e.test.exe ./cmd/lk + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-e2e + path: dist/*.exe + retention-days: 1 + if-no-files-found: error + + # Run the prebuilt Windows artifacts natively. No Go/zig/cgo here: the test + # binary just drives lk.exe (via LK_SESSION_E2E_BIN) against the echo agent. + e2e-windows: + needs: cross-build-windows + runs-on: windows-latest + name: Agent Session with Python Agent on windows-latest + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + # No submodules: the prebuilt binaries already contain the cgo code; + # only the echo-agent fixture (plain repo files) is needed at runtime. + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-e2e + path: dist + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync echo agent deps + working-directory: cmd/lk/testdata/echo-agent + run: uv sync + + - name: Run session e2e + # Run from cmd/lk so the test's default testdata/echo-agent/agent.py + # path resolves; point it at the cross-built lk.exe so nothing rebuilds. + shell: bash + working-directory: cmd/lk + env: + # Relative to cmd/lk; Go's filepath.Abs resolves it. Forward slashes + # keep bash happy (GITHUB_WORKSPACE would carry Windows backslashes). + LK_SESSION_E2E_BIN: ../../dist/lk.exe + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + run: | + ../../dist/session-e2e.test.exe \ + -test.run TestSessionE2E -test.count=1 -test.v -test.timeout 600s diff --git a/.gitignore b/.gitignore index 23f3a401..13af0447 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,13 @@ dist/ .DS_Store /lk + +# local secrets copied for e2e testing +.env + +# python venvs created for e2e agent fixtures +.venv/ +cmd/lk/testdata/**/.venv + +# uv lockfiles for test agent fixtures (resolved fresh in CI) +cmd/lk/testdata/**/uv.lock diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 13ccd2e8..123a12ef 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -53,7 +53,7 @@ complete -x -c lk -n '__fish_seen_subcommand_from app; and __fish_seen_subcomman complete -x -c lk -n '__fish_seen_subcommand_from app; and not __fish_seen_subcommand_from create list-templates install run env help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_lk_no_subcommand' -a 'agent' -d 'Manage LiveKit Cloud Agents' complete -c lk -n '__fish_seen_subcommand_from agent a' -f -l help -s h -d 'show help' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'init' -d 'Initialize a new LiveKit Cloud agent project' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'init' -d 'Initialize a new LiveKit Cloud agent project' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init' -f -l region -r -d 'Region to deploy the agent to. If unset, will deploy to the nearest region.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init' -f -l install -d 'Run installation after creating the application' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init' -f -l help -s h -d 'show help' @@ -61,7 +61,7 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init' -f -l template -r -d '`TEMPLATE` to instantiate, see https://github.com/livekit-examples' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init' -f -l template-url -r -d '`URL` to instantiate, must contain a taskfile.yaml' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from init; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'create' -d 'Create a new LiveKit Cloud Agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'create' -d 'Create a new LiveKit Cloud Agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l secrets -r -d 'KEY=VALUE comma separated secrets. These will be injected as environment variables into the agent. These take precedence over secrets-file.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -l secrets-file -r -d '`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l secret-mount -r -d 'Local path to a secret file to be mounted on agent environment' @@ -71,15 +71,15 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l image-tar -r -d 'Pre-built image from an OCI tar file (e.g. ./image.tar). No Docker daemon required.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'dockerfile' -d 'Generate Dockerfile and .dockerignore for your project' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'dockerfile' -d 'Generate Dockerfile and .dockerignore for your project' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dockerfile' -f -l overwrite -d 'Overwrite existing Dockerfile and/or .dockerignore if they exist' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dockerfile' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dockerfile; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'config' -d 'Creates a livekit.toml in the working directory for an existing agent.' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'config' -d 'Creates a livekit.toml in the working directory for an existing agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from config' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from config' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from config; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'deploy' -d 'Deploy a new version of the agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'deploy' -d 'Deploy a new version of the agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l secrets -r -d 'KEY=VALUE comma separated secrets. These will be injected as environment variables into the agent. These take precedence over secrets-file.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -l secrets-file -r -d '`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l secret-mount -r -d 'Local path to a secret file to be mounted on agent environment' @@ -89,48 +89,48 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l image-tar -r -d 'Pre-built image from an OCI tar file (e.g. ./image.tar). No Docker daemon required.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'status' -d 'Get the status of an agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'status' -d 'Get the status of an agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'update' -d 'Update an agent metadata and secrets. This will restart the agent.' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'update' -d 'Update an agent metadata and secrets. This will restart the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update' -f -l secrets -r -d 'KEY=VALUE comma separated secrets. These will be injected as environment variables into the agent. These take precedence over secrets-file.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update' -l secrets-file -r -d '`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update' -f -l secret-mount -r -d 'Local path to a secret file to be mounted on agent environment' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update' -f -l ignore-empty-secrets -d 'If set, will skip environment variables with empty values from secrets files instead of failing' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'restart' -d 'Restart an agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'restart' -d 'Restart an agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from restart' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from restart' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from restart; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'rollback' -d 'Rollback an agent to a previous version' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'rollback' -d 'Rollback an agent to a previous version' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from rollback' -f -l version -r -d 'Version to rollback to, defaults to most recent previous to current.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from rollback' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from rollback' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from rollback; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'logs' -d 'Tail logs from agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'logs' -d 'Tail logs from agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from logs tail' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from logs tail' -f -l log-type -r -d 'Type of logs to retrieve. Valid values are \'deploy\' and \'build\'' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from logs tail' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from logs tail; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'delete' -d 'Delete an agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'delete' -d 'Delete an agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from delete destroy' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from delete destroy' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from delete destroy; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'versions' -d 'List versions of an agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'versions' -d 'List versions of an agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'list' -d 'List all LiveKit Cloud Agents' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'list' -d 'List all LiveKit Cloud Agents' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list' -f -l id -r -d '`IDs` of agent(s)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'secrets' -d 'List secrets for an agent' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'secrets' -d 'List secrets for an agent' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'update-secrets' -d 'Update secrets for an agent, will cause a re-start of the agent.' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'update-secrets' -d 'Update secrets for an agent, will cause a re-start of the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets' -f -l secrets -r -d 'KEY=VALUE comma separated secrets. These will be injected as environment variables into the agent. These take precedence over secrets-file.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets' -l secrets-file -r -d '`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets' -f -l secret-mount -r -d 'Local path to a secret file to be mounted on agent environment' @@ -139,7 +139,7 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets' -f -l overwrite -d 'If set, will overwrite existing secrets' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from update-secrets; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'private-link' -d 'Manage private links for agents' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'private-link' -d 'Manage private links for agents' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link; and not __fish_seen_subcommand_from create list delete health-status help h' -a 'create' -d 'Create a private link' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link; and __fish_seen_subcommand_from create' -f -l name -r -d 'Private link name' @@ -165,16 +165,16 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link; and __fish_seen_subcommand_from health-status' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link; and __fish_seen_subcommand_from health-status; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from private-link; and not __fish_seen_subcommand_from create list delete health-status help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'start' -d 'Run an agent in production mode' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'start' -d 'Run an agent in production mode' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from start' -f -l log-level -r -d 'Log level (TRACE, DEBUG, INFO, WARN, ERROR)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from start' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from start; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'dev' -d 'Run an agent in development mode with auto-reload' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'dev' -d 'Run an agent in development mode with auto-reload' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dev' -f -l log-level -r -d 'Log level (TRACE, DEBUG, INFO, WARN, ERROR)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dev' -f -l no-reload -d 'Disable auto-reload on file changes' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dev' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from dev; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'console' -d 'Voice chat with an agent via mic/speakers' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'console' -d 'Voice chat with an agent via mic/speakers' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console' -f -l port -s p -r -d 'TCP port for agent communication' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console' -f -l input-device -r -d 'Input device index or name substring' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console' -f -l output-device -r -d 'Output device index or name substring' @@ -184,14 +184,31 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console' -f -l record -d 'Record audio and session report to console-recordings/' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from console; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'simulate' -d 'Run agent simulations against LiveKit Cloud' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'session' -d 'Drive a single local agent session in text mode (start/say/end)' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from start say end daemon help h' -a 'start' -d 'Start a detached agent session daemon' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from start' -f -l port -r -d 'Fixed loopback port shared by the agent and control connections' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from start' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from start; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from start say end daemon help h' -a 'say' -d 'Send a text turn to the running session and print the reply' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from say' -f -l port -r -d 'Fixed loopback port shared by the agent and control connections' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from say' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from say; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from start say end daemon help h' -a 'end' -d 'Stop the running session and its agent' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from end' -f -l port -r -d 'Fixed loopback port shared by the agent and control connections' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from end' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from end; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from daemon' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from start say end daemon help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'simulate' -d 'Run agent simulations against LiveKit Cloud' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l num-simulations -s n -r -d 'Number of scenarios to generate' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l concurrency -r -d 'Max simulations running in parallel (default: server-side limit)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l scenarios -r -d 'Path to a scenarios `FILE` (yaml). If omitted, scenarios are generated from the agent\'s source' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l yes -s y -d 'Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console session simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_lk_no_subcommand' -a 'cloud' -d 'Interact with LiveKit Cloud services' complete -c lk -n '__fish_seen_subcommand_from cloud' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from cloud; and not __fish_seen_subcommand_from auth help h' -a 'auth' -d 'Authenticate LiveKit Cloud account to link your projects' diff --git a/cmd/lk/agent_utils.go b/cmd/lk/agent_utils.go index 3ea0522b..d6bca7db 100644 --- a/cmd/lk/agent_utils.go +++ b/cmd/lk/agent_utils.go @@ -14,8 +14,6 @@ package main -//lint:file-ignore U1000 consumed by console-tagged commands (hidden from the default tag-free lint build) and the lk session daemon (follow-up PR); remove once the daemon merges - import ( "fmt" "os" @@ -70,7 +68,7 @@ func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error } // buildConsoleArgs builds the agent subprocess argv for console mode, shared by -// `lk agent console` and the `lk session` daemon. +// `lk agent console` and the `lk agent session` daemon. func buildConsoleArgs(addr string, record bool) []string { args := []string{"console", "--connect-addr", addr} if record { diff --git a/cmd/lk/proc_unix.go b/cmd/lk/proc_unix.go index fb379634..9cff6a7b 100644 --- a/cmd/lk/proc_unix.go +++ b/cmd/lk/proc_unix.go @@ -2,8 +2,6 @@ package main -//lint:file-ignore U1000 consumed by console-tagged commands (hidden from the default tag-free lint build) and the lk session daemon (follow-up PR); remove once the daemon merges - import ( "os/exec" "syscall" diff --git a/cmd/lk/session.go b/cmd/lk/session.go new file mode 100644 index 00000000..4ecb43ee --- /dev/null +++ b/cmd/lk/session.go @@ -0,0 +1,315 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/urfave/cli/v3" +) + +// Single-session model: the fixed loopback port is the singleton registry. +// The daemon binds it; whoever wins the bind() is "the session". start, say, +// and end all rendezvous on this one port. No session id, manifest, or dir. +const ( + sessionMagic = "LKCP" // 4-byte preamble that marks a control connection + sessionHost = "127.0.0.1" + defaultSessionPort = 8775 + + envSessionPort = "LK_SESSION_PORT" // fixed port + envSessionDir = "LK_SESSION_DIR" // resolved project dir + envSessionEntry = "LK_SESSION_ENTRY" // resolved entrypoint (project-relative) + envSessionPType = "LK_SESSION_PTYPE" // agentfs.ProjectType string + envSessionReadyFile = "LK_SESSION_READY_FILE" // path the daemon writes its status to + + // sessionDaemonSubcommand is the hidden entrypoint `start` re-execs into. + sessionDaemonSubcommand = "daemon" +) + +var sessionPortFlag = &cli.IntFlag{ + Name: "port", + Sources: cli.EnvVars(envSessionPort), + Value: defaultSessionPort, + Usage: "Fixed loopback port shared by the agent and control connections", +} + +func init() { + // Register under the `agent` group as `lk agent session`, mirroring how + // `lk agent console` attaches itself. Unlike console, this command is not + // gated behind the `console` build tag: it is CGO-free and ships in the + // default binary. + AgentCommands[0].Commands = append(AgentCommands[0].Commands, agentSessionCommand) +} + +var agentSessionCommand = &cli.Command{ + Name: "session", + Usage: "Drive a single local agent session in text mode (start/say/end)", + Category: "Core", + Commands: []*cli.Command{ + { + Name: "start", + Usage: "Start a detached agent session daemon", + ArgsUsage: "[entrypoint]", + Flags: []cli.Flag{sessionPortFlag}, + Action: runSessionStart, + }, + { + Name: "say", + Usage: "Send a text turn to the running session and print the reply", + ArgsUsage: "", + Flags: []cli.Flag{sessionPortFlag}, + Action: runSessionSay, + }, + { + Name: "end", + Usage: "Stop the running session and its agent", + Flags: []cli.Flag{sessionPortFlag}, + Action: runSessionEnd, + }, + { + Name: sessionDaemonSubcommand, + Hidden: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + if os.Getenv(envSessionReadyFile) == "" { + return fmt.Errorf("`session daemon` is an internal entrypoint; run `lk agent session start ` instead") + } + runSessionDaemon() + return nil + }, + }, + }, +} + +func sessionAddr(port int) string { + return fmt.Sprintf("%s:%d", sessionHost, port) +} + +func runSessionStart(ctx context.Context, cmd *cli.Command) error { + projectDir, projectType, entrypoint, err := detectProject(cmd) + if err != nil { + return err + } + port := int(cmd.Int("port")) + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not resolve own binary: %w", err) + } + + // Readiness file the daemon writes once it is up (or failed) before we + // return, so we don't race a TCP probe against the agent's own connect. + readyFile, err := os.CreateTemp("", "lk-session-ready-*.txt") + if err != nil { + return err + } + readyPath := readyFile.Name() + readyFile.Close() + defer os.Remove(readyPath) + + // The daemon is detached, so its own stdout/stderr (panics etc.) go to a + // temp log rather than the user's terminal. + logFile, err := os.CreateTemp("", "lk-session-daemon-*.log") + if err != nil { + return err + } + + daemon := exec.Command(exe, "agent", "session", sessionDaemonSubcommand) + daemon.Env = append(os.Environ(), + envSessionPort+"="+strconv.Itoa(port), + envSessionDir+"="+projectDir, + envSessionEntry+"="+entrypoint, + envSessionPType+"="+string(projectType), + envSessionReadyFile+"="+readyPath, + ) + daemon.Stdout = logFile + daemon.Stderr = logFile + setDetachedProcAttr(daemon) + + if err := daemon.Start(); err != nil { + logFile.Close() + return fmt.Errorf("failed to start session daemon: %w", err) + } + logFile.Close() + + status := awaitDaemonReady(daemon, readyPath) + switch { + case status == "ready": + fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) + fmt.Printf("Session started. Use `lk agent session say \"...\"` to talk, `lk agent session end` to stop.\n") + return nil + case strings.HasPrefix(status, "error:"): + return fmt.Errorf("%s", strings.TrimSpace(strings.TrimPrefix(status, "error:"))) + default: + return fmt.Errorf("session daemon exited before becoming ready (see %s)", logFile.Name()) + } +} + +// awaitDaemonReady waits for the detached daemon to report via the readiness +// file, returning its status line ("ready" or "error: ...") or "" if the +// daemon exits or times out without reporting. +func awaitDaemonReady(daemon *exec.Cmd, readyPath string) string { + exited := make(chan struct{}) + go func() { _ = daemon.Wait(); close(exited) }() + + // Slightly longer than the daemon's own 60s agent-connect timeout so its + // "error: timed out ..." status reaches us before we give up. + timeout := time.After(65 * time.Second) + for { + if status, ok := readReadyStatus(readyPath); ok { + return status + } + select { + case <-exited: + if status, ok := readReadyStatus(readyPath); ok { + return status + } + return "" + case <-timeout: + return "" + case <-time.After(50 * time.Millisecond): + } + } +} + +// readReadyStatus returns the daemon's status line once the readiness file has +// content (written atomically via rename), or ok=false while it is still empty. +func readReadyStatus(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "", false + } + return strings.TrimSpace(string(data)), true +} + +func runSessionSay(ctx context.Context, cmd *cli.Command) error { + text := strings.TrimSpace(strings.Join(cmd.Args().Slice(), " ")) + if text == "" { + return fmt.Errorf("usage: lk agent session say ") + } + conn, err := dialControl(int(cmd.Int("port"))) + if err != nil { + return err + } + defer conn.Close() + + if err := writeControlFrame(conn, controlRequest{Cmd: "say", Text: text}); err != nil { + return err + } + return streamControlReplies(conn) +} + +func runSessionEnd(ctx context.Context, cmd *cli.Command) error { + conn, err := dialControl(int(cmd.Int("port"))) + if err != nil { + return err + } + defer conn.Close() + + if err := writeControlFrame(conn, controlRequest{Cmd: "end"}); err != nil { + return err + } + if err := streamControlReplies(conn); err != nil { + return err + } + fmt.Println("Session ended.") + return nil +} + +// dialControl connects to the session daemon and sends the control preamble. +func dialControl(port int) (net.Conn, error) { + conn, err := net.Dial("tcp", sessionAddr(port)) + if err != nil { + return nil, fmt.Errorf("no session running on %s (run `lk agent session start` first)", sessionAddr(port)) + } + if _, err := conn.Write([]byte(sessionMagic)); err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + +func streamControlReplies(conn net.Conn) error { + for { + var reply controlReply + if err := readControlFrame(conn, &reply); err != nil { + if err == io.EOF { + return nil + } + return err + } + if reply.Line != "" { + fmt.Println(reply.Line) + } + if reply.Done { + if reply.Error != "" { + return fmt.Errorf("%s", reply.Error) + } + return nil + } + } +} + +// Control protocol: a 4-byte big-endian length prefix + a JSON payload, mirroring +// pkg/ipc's framing but with JSON instead of protobuf (no new protobufs needed). +type controlRequest struct { + Cmd string `json:"cmd"` + Text string `json:"text,omitempty"` +} + +type controlReply struct { + Line string `json:"line,omitempty"` + Done bool `json:"done,omitempty"` + Error string `json:"error,omitempty"` +} + +func writeControlFrame(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(data))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err = w.Write(data) + return err +} + +func readControlFrame(r io.Reader, v any) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return err + } + length := binary.BigEndian.Uint32(hdr[:]) + if length > 1<<20 { + return fmt.Errorf("control frame too large: %d bytes", length) + } + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/cmd/lk/session_daemon.go b/cmd/lk/session_daemon.go new file mode 100644 index 00000000..046f2759 --- /dev/null +++ b/cmd/lk/session_daemon.go @@ -0,0 +1,374 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "strconv" + "sync" + "time" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" + "github.com/livekit/livekit-cli/v2/pkg/console" + "github.com/livekit/livekit-cli/v2/pkg/ipc" + + agent "github.com/livekit/protocol/livekit/agent" +) + +// runSessionDaemon is the entry point for the hidden `lk agent session daemon` +// subcommand that `lk agent session start` re-execs. It runs the detached +// daemon to completion (until the agent exits or `end` is received). +func runSessionDaemon() { + ready := readyWriter() + port, _ := strconv.Atoi(os.Getenv(envSessionPort)) + + // The fixed port is the singleton: if the bind fails, a session already + // owns it, which is how `lk agent session start` learns to reject. + server, err := console.NewTCPServer(sessionAddr(port)) + if err != nil { + signalReady(ready, "error: a session is already running on "+sessionAddr(port)) + os.Exit(1) + } + defer server.Close() + + // TODO(node): detect a node/JS agent project and build the equivalent + // `node console --connect-addr ` argv. + agentProc, err := startAgent(AgentStartConfig{ + Dir: os.Getenv(envSessionDir), + Entrypoint: os.Getenv(envSessionEntry), + ProjectType: agentfs.ProjectType(os.Getenv(envSessionPType)), + CLIArgs: buildConsoleArgs(server.Addr().String(), false), + }) + if err != nil { + signalReady(ready, "error: failed to start agent: "+err.Error()) + os.Exit(1) + } + + d := &sessionDaemon{ + server: server, + agentProc: agentProc, + events: make(chan *agent.AgentSessionEvent, 64), + responses: make(chan *agent.SessionResponse, 8), + queue: make(chan *sessionCommand, 16), + agentReady: make(chan struct{}), + agentDone: make(chan struct{}), + shutdown: make(chan struct{}), + } + + go d.acceptLoop() + + select { + case <-d.agentReady: + d.setTextMode() + signalReady(ready, "ready") + case waitErr := <-agentProc.Done(): + msg := "error: agent exited before connecting" + if waitErr != nil { + msg += ": " + waitErr.Error() + } + signalReady(ready, msg) + agentProc.Kill() + os.Exit(1) + case <-time.After(60 * time.Second): + signalReady(ready, "error: timed out waiting for agent to connect") + agentProc.Kill() + os.Exit(1) + } + + go d.worker() + + select { + case <-d.agentDone: + case <-d.shutdown: + } + agentProc.Kill() +} + +// readyWriter returns the path of the readiness file `lk agent session start` +// polls to learn the daemon became ready (or failed). Empty if not launched +// via start. +func readyWriter() string { + return os.Getenv(envSessionReadyFile) +} + +// signalReady atomically writes the daemon's status to the readiness file the +// parent `start` is polling. The write-then-rename keeps the parent from +// reading a partial line. +func signalReady(path, msg string) { + if path == "" { + return + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(msg+"\n"), 0o600); err != nil { + return + } + _ = os.Rename(tmp, path) +} + +type sessionDaemon struct { + server *console.TCPServer + agentProc *AgentProcess + + agentMu sync.Mutex + agentConn net.Conn + agentRead io.Reader + writeMu sync.Mutex // serializes writes to the agent connection + + events chan *agent.AgentSessionEvent + responses chan *agent.SessionResponse + queue chan *sessionCommand + + agentReady chan struct{} + agentDone chan struct{} + doneOnce sync.Once + shutdown chan struct{} + shutOnce sync.Once + + reqCounter int +} + +type sessionCommand struct { + kind string + text string + out net.Conn + done chan struct{} +} + +func (d *sessionDaemon) acceptLoop() { + for { + conn, err := d.server.AcceptConn() + if err != nil { + return // listener closed + } + go d.handleConn(conn) + } +} + +func (d *sessionDaemon) handleConn(conn net.Conn) { + isControl, reader, err := classifyConn(conn) + if err != nil { + conn.Close() + return + } + if isControl { + d.handleControlConn(conn) + return + } + + // First non-control connection is the agent. + d.agentMu.Lock() + if d.agentConn != nil { + d.agentMu.Unlock() + conn.Close() + return + } + d.agentConn = conn + d.agentRead = reader + d.agentMu.Unlock() + + close(d.agentReady) + go d.agentMessageLoop() +} + +// classifyConn routes a connection by its 4-byte preamble. Control clients send +// the magic; the unmodified agent never does. "LKCP" decodes to a ~1.28 GB +// length prefix, which exceeds pkg/ipc's 1 MB cap, so a real agent frame can +// never begin with these bytes. +func classifyConn(conn net.Conn) (bool, io.Reader, error) { + var hdr [4]byte + if _, err := io.ReadFull(conn, hdr[:]); err != nil { + return false, nil, err + } + if string(hdr[:]) == sessionMagic { + return true, conn, nil + } + // Push the peeked bytes back so proto framing sees a complete frame. + return false, io.MultiReader(bytes.NewReader(hdr[:]), conn), nil +} + +func (d *sessionDaemon) agentMessageLoop() { + for { + msg := &agent.AgentSessionMessage{} + if err := ipc.ReadProto(d.agentRead, msg); err != nil { + d.doneOnce.Do(func() { close(d.agentDone) }) + return + } + switch m := msg.Message.(type) { + case *agent.AgentSessionMessage_Event: + select { + case d.events <- m.Event: + default: + } + case *agent.AgentSessionMessage_Response: + if m.Response != nil { + select { + case d.responses <- m.Response: + default: + } + } + case *agent.AgentSessionMessage_AudioOutput, *agent.AgentSessionMessage_AudioPlaybackClear: + // No audio sink in text mode: drop. + case *agent.AgentSessionMessage_AudioPlaybackFlush: + // Nothing to drain, so ack immediately or the agent's turn (and the + // RunInputResponse we await) never completes. + _ = d.writeAgent(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_AudioPlaybackFinished{ + AudioPlaybackFinished: &agent.AgentSessionMessage_ConsoleIO_AudioPlaybackFinished{}, + }, + }) + } + } +} + +func (d *sessionDaemon) writeAgent(msg *agent.AgentSessionMessage) error { + d.agentMu.Lock() + conn := d.agentConn + d.agentMu.Unlock() + if conn == nil { + return fmt.Errorf("agent not connected") + } + d.writeMu.Lock() + defer d.writeMu.Unlock() + return ipc.WriteProto(conn, msg) +} + +// setTextMode disables the agent's audio I/O so it runs as a pure text turn +// handler, matching what `lk agent console` does when switching to text mode. +func (d *sessionDaemon) setTextMode() { + off := false + _ = d.writeAgent(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_Request{ + Request: &agent.SessionRequest{ + RequestId: "session-io", + Request: &agent.SessionRequest_UpdateIo{ + UpdateIo: &agent.SessionRequest_UpdateIO{ + Input: &agent.SessionRequest_UpdateIO_Input{AudioEnabled: &off}, + Output: &agent.SessionRequest_UpdateIO_Output{AudioEnabled: &off, TranscriptionEnabled: &off}, + }, + }, + }, + }, + }) +} + +func (d *sessionDaemon) handleControlConn(conn net.Conn) { + var req controlRequest + if err := readControlFrame(conn, &req); err != nil { + conn.Close() + return + } + cmd := &sessionCommand{kind: req.Cmd, text: req.Text, out: conn, done: make(chan struct{})} + select { + case d.queue <- cmd: + case <-d.shutdown: + conn.Close() + return + } + <-cmd.done + conn.Close() +} + +func (d *sessionDaemon) worker() { + for { + select { + case cmd := <-d.queue: + d.runCommand(cmd) + case <-d.shutdown: + return + } + } +} + +func (d *sessionDaemon) runCommand(cmd *sessionCommand) { + defer close(cmd.done) + switch cmd.kind { + case "say": + d.runSay(cmd) + case "end": + _ = writeControlFrame(cmd.out, controlReply{Done: true}) + d.shutOnce.Do(func() { close(d.shutdown) }) + default: + _ = writeControlFrame(cmd.out, controlReply{Done: true, Error: "unknown command: " + cmd.kind}) + } +} + +func (d *sessionDaemon) runSay(cmd *sessionCommand) { + d.reqCounter++ + reqID := "session-" + strconv.Itoa(d.reqCounter) + + d.drainEvents() // discard anything emitted before this turn (e.g. greeting) + _ = writeControlFrame(cmd.out, controlReply{Line: renderUserMessage(cmd.text)}) + + if err := d.writeAgent(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_Request{ + Request: &agent.SessionRequest{ + RequestId: reqID, + Request: &agent.SessionRequest_RunInput_{ + RunInput: &agent.SessionRequest_RunInput{Text: cmd.text}, + }, + }, + }, + }); err != nil { + _ = writeControlFrame(cmd.out, controlReply{Done: true, Error: err.Error()}) + return + } + + for { + select { + case ev := <-d.events: + if line := renderEvent(ev); line != "" { + if err := writeControlFrame(cmd.out, controlReply{Line: line}); err != nil { + return + } + } + case resp := <-d.responses: + if resp.GetRequestId() == reqID { + d.flushEvents(cmd.out) + _ = writeControlFrame(cmd.out, controlReply{Done: true}) + return + } + case <-d.agentDone: + _ = writeControlFrame(cmd.out, controlReply{Done: true, Error: "agent exited"}) + return + } + } +} + +func (d *sessionDaemon) drainEvents() { + for { + select { + case <-d.events: + default: + return + } + } +} + +func (d *sessionDaemon) flushEvents(out net.Conn) { + for { + select { + case ev := <-d.events: + if line := renderEvent(ev); line != "" { + _ = writeControlFrame(out, controlReply{Line: line}) + } + default: + return + } + } +} diff --git a/cmd/lk/session_e2e_test.go b/cmd/lk/session_e2e_test.go new file mode 100644 index 00000000..e60b8247 --- /dev/null +++ b/cmd/lk/session_e2e_test.go @@ -0,0 +1,167 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "context" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const sessionE2ETimeout = 5 * time.Second + +// TestSessionE2E drives the real `lk agent session` lifecycle end to end: +// build the binary, `start` the detached daemon, `say` to make the model echo +// a token (asserting the CLI→daemon→agent→LLM round-trip), `end`, confirm a +// second `say` cannot still reach the agent, then confirm the daemon exited +// (nothing answers on the port). +// +// Opt-in: needs a prepared agent venv + live creds, so it skips unless +// LIVEKIT_API_KEY is set. Defaults to testdata/echo-agent; override with LK_SESSION_E2E_AGENT. +func TestSessionE2E(t *testing.T) { + if os.Getenv("LIVEKIT_API_KEY") == "" { + t.Skip("set LIVEKIT_API_KEY (and prepare the agent venv) to run the session e2e test") + } + entrypoint := os.Getenv("LK_SESSION_E2E_AGENT") + if entrypoint == "" { + entrypoint = filepath.Join("testdata", "echo-agent", "agent.py") + } + entrypoint, err := filepath.Abs(entrypoint) + require.NoError(t, err) + require.FileExists(t, entrypoint, "agent entrypoint not found (set LK_SESSION_E2E_AGENT to override)") + + // Dedicated port so the test can't collide with a real session on 8775. + port := "18775" + if p := os.Getenv("LK_SESSION_E2E_PORT"); p != "" { + port = p + } + + bin := buildLK(t) + + type runResult struct { + stdout string + stderr string + exitCode int + } + + runCapture := func(timeout time.Duration, args ...string) (runResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = os.Environ() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + require.NotNil(t, cmd.ProcessState, "command did not start: %v", err) + + return runResult{ + stdout: stdout.String(), + stderr: stderr.String(), + exitCode: cmd.ProcessState.ExitCode(), + }, err + } + + run := func(timeout time.Duration, args ...string) (string, error) { + res, err := runCapture(timeout, args...) + return res.stdout + res.stderr, err + } + + portIsFree := func() bool { + conn, derr := net.DialTimeout("tcp", "127.0.0.1:"+port, 200*time.Millisecond) + if derr != nil { + return true // refused -> daemon exited + } + conn.Close() + return false + } + + // Best-effort teardown so a mid-run failure doesn't leave the daemon alive. + t.Cleanup(func() { + _, _ = run(sessionE2ETimeout, "agent", "session", "end", "--port", port) + }) + + // start: launches the detached daemon and returns once the agent is ready. + startOut, err := run(15*time.Second, "agent", "session", "start", "--port", port, entrypoint) + require.NoError(t, err, "session start failed:\n%s", startOut) + require.Contains(t, startOut, "Session started.", "start did not report readiness:\n%s", startOut) + + // say: the token appears once in the echoed prompt and again in the reply, so + // >=2 occurrences proves the agent answered, not just the local echo. + token := "PINEAPPLE7351" + sayOut, err := run(sessionE2ETimeout, "agent", "session", "say", "--port", port, + "Repeat this token back to me exactly and nothing else: "+token) + require.NoError(t, err, "session say failed:\n%s", sayOut) + require.GreaterOrEqualf(t, strings.Count(sayOut, token), 2, + "agent did not echo the token back; say output:\n%s", sayOut) + + endOut, err := run(sessionE2ETimeout, "agent", "session", "end", "--port", port) + require.NoError(t, err, "session end failed:\n%s", endOut) + require.Contains(t, endOut, "Session ended.", "end did not confirm shutdown:\n%s", endOut) + + require.Eventually(t, portIsFree, sessionE2ETimeout, 200*time.Millisecond, + "session daemon still listening on port %s after end", port) + + // After a successful match and shutdown, another say must not reach a live + // agent or reproduce the token. + afterEndSay, err := runCapture(sessionE2ETimeout, "agent", "session", "say", "--port", port, + "Repeat this token back to me exactly and nothing else: "+token) + afterEndSayOut := afterEndSay.stdout + afterEndSay.stderr + require.Error(t, err, "session say unexpectedly succeeded after end:\n%s", afterEndSayOut) + require.Equal(t, 1, afterEndSay.exitCode, + "session say after end exited with wrong code; stdout:\n%s\nstderr:\n%s", + afterEndSay.stdout, afterEndSay.stderr) + require.Truef(t, strings.HasPrefix(afterEndSayOut, "no session running"), + "session say after end output did not start with no session running; stdout:\n%s\nstderr:\n%s", + afterEndSay.stdout, afterEndSay.stderr) + require.NotContains(t, afterEndSayOut, token, + "session say after end unexpectedly contained the matched token; stdout:\n%s\nstderr:\n%s", + afterEndSay.stdout, afterEndSay.stderr) + + require.True(t, portIsFree(), "session daemon started listening again on port %s after failed say", port) +} + +// buildLK returns the path to the lk binary under test. If LK_SESSION_E2E_BIN +// points at a prebuilt binary it's used as-is (the Windows CI arm cross-builds +// lk on Linux and ships it here, so the heavy cgo build never runs on the +// Windows runner); otherwise lk is compiled into a temp dir. +func buildLK(t *testing.T) string { + t.Helper() + if prebuilt := os.Getenv("LK_SESSION_E2E_BIN"); prebuilt != "" { + abs, err := filepath.Abs(prebuilt) + require.NoError(t, err) + require.FileExists(t, abs, "LK_SESSION_E2E_BIN does not point at a binary") + return abs + } + bin := filepath.Join(t.TempDir(), "lk") + if runtime.GOOS == "windows" { + bin += ".exe" + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + build := exec.CommandContext(ctx, "go", "build", "-o", bin, ".") + out, err := build.CombinedOutput() + require.NoErrorf(t, err, "failed to build lk binary:\n%s", out) + return bin +} diff --git a/cmd/lk/session_render.go b/cmd/lk/session_render.go new file mode 100644 index 00000000..b4a084b8 --- /dev/null +++ b/cmd/lk/session_render.go @@ -0,0 +1,181 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + agent "github.com/livekit/protocol/livekit/agent" +) + +// Styles for the headless session output. Named distinctly from the console +// TUI styles so both can coexist in the console-tagged build. +var ( + sessionCyan = lipgloss.Color("#1fd5f9") + sessionGreen = lipgloss.Color("#6BCB77") + sessionPurple = lipgloss.Color("#8f83ff") + sessionRed = lipgloss.Color("#FF6B6B") + sessionUserStyle = lipgloss.NewStyle().Foreground(sessionCyan).Bold(true) + sessionAgentStyle = lipgloss.NewStyle().Foreground(sessionGreen).Bold(true) + sessionDimStyle = lipgloss.NewStyle().Faint(true) + sessionRedStyle = lipgloss.NewStyle().Foreground(sessionRed) +) + +// renderUserMessage formats the text the user said, echoed back by `say`. +func renderUserMessage(text string) string { + var b strings.Builder + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Foreground(sessionCyan).Render("● ")) + b.WriteString(sessionUserStyle.Render("You")) + for _, line := range strings.Split(text, "\n") { + b.WriteString("\n ") + b.WriteString(line) + } + return b.String() +} + +// renderEvent turns an AgentSessionEvent into a printable line, or "" if the +// event carries nothing worth showing in text mode. +func renderEvent(ev *agent.AgentSessionEvent) string { + if ev == nil { + return "" + } + switch e := ev.Event.(type) { + case *agent.AgentSessionEvent_ConversationItemAdded_: + if item := e.ConversationItemAdded.Item; item != nil { + return renderChatItem(item) + } + case *agent.AgentSessionEvent_FunctionToolsExecuted_: + return renderFunctionTools(e.FunctionToolsExecuted) + case *agent.AgentSessionEvent_Error_: + return " " + sessionRedStyle.Render("✗ "+e.Error.Message) + } + return "" +} + +func renderChatItem(item *agent.ChatContext_ChatItem) string { + switch i := item.Item.(type) { + case *agent.ChatContext_ChatItem_Message: + msg := i.Message + if msg.Role == agent.ChatRole_USER { + return "" // the user message is echoed separately by `say` + } + var parts []string + for _, c := range msg.Content { + if t := c.GetText(); t != "" { + parts = append(parts, t) + } + } + text := strings.Join(parts, "") + if text == "" { + return "" + } + var b strings.Builder + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Foreground(sessionGreen).Render("● ")) + b.WriteString(sessionAgentStyle.Render("Agent")) + for _, line := range strings.Split(text, "\n") { + b.WriteString("\n ") + b.WriteString(line) + } + return b.String() + + case *agent.ChatContext_ChatItem_FunctionCall: + return "\n ● function_tool: " + i.FunctionCall.Name + + case *agent.ChatContext_ChatItem_FunctionCallOutput: + fco := i.FunctionCallOutput + summary := sessionSummarizeOutput(fco.Output) + if fco.IsError { + return " " + sessionRedStyle.Render("✗ "+summary) + } + if summary == "" { + return "" + } + return " " + sessionDimStyle.Render("✓ "+summary) + + case *agent.ChatContext_ChatItem_AgentHandoff: + h := i.AgentHandoff + old := "" + if h.OldAgentId != nil && *h.OldAgentId != "" { + old = sessionDimStyle.Render(*h.OldAgentId) + " → " + } + return " " + lipgloss.NewStyle().Foreground(sessionPurple).Render("● ") + + sessionDimStyle.Render("handoff: ") + old + h.NewAgentId + + case *agent.ChatContext_ChatItem_AgentConfigUpdate: + u := i.AgentConfigUpdate + var parts []string + if u.Instructions != nil { + parts = append(parts, "instructions updated") + } + if len(u.ToolsAdded) > 0 { + parts = append(parts, "tools added: "+strings.Join(u.ToolsAdded, ", ")) + } + if len(u.ToolsRemoved) > 0 { + parts = append(parts, "tools removed: "+strings.Join(u.ToolsRemoved, ", ")) + } + if len(parts) == 0 { + return "" + } + return " " + lipgloss.NewStyle().Foreground(sessionPurple).Render("● ") + + sessionDimStyle.Render("config: "+strings.Join(parts, "; ")) + } + return "" +} + +func renderFunctionTools(ft *agent.AgentSessionEvent_FunctionToolsExecuted) string { + if ft == nil { + return "" + } + outputs := make(map[string]*agent.FunctionCallOutput, len(ft.FunctionCallOutputs)) + for _, fco := range ft.FunctionCallOutputs { + outputs[fco.CallId] = fco + } + var b strings.Builder + for i, fc := range ft.FunctionCalls { + if i > 0 { + b.WriteString("\n") + } + b.WriteString("\n ● function_tool: ") + b.WriteString(fc.Name) + if fco, ok := outputs[fc.CallId]; ok { + summary := sessionSummarizeOutput(fco.Output) + if fco.IsError { + b.WriteString("\n ") + b.WriteString(sessionRedStyle.Render("✗ " + summary)) + } else if summary != "" { + b.WriteString("\n ") + b.WriteString(sessionDimStyle.Render("✓ " + summary)) + } + } + } + return b.String() +} + +// sessionSummarizeOutput collapses a tool output to a single, length-capped line. +func sessionSummarizeOutput(out string) string { + out = strings.TrimSpace(out) + if idx := strings.IndexByte(out, '\n'); idx >= 0 { + out = out[:idx] + } + const max = 120 + if len(out) > max { + out = out[:max] + "…" + } + return out +} diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index dae6183c..322d68c7 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -14,8 +14,6 @@ package main -//lint:file-ignore U1000 consumed by console-tagged commands (hidden from the default tag-free lint build) and the lk session daemon (follow-up PR); remove once the daemon merges - import ( "bufio" "encoding/json" diff --git a/cmd/lk/testdata/echo-agent/agent.py b/cmd/lk/testdata/echo-agent/agent.py new file mode 100644 index 00000000..ed9ea971 --- /dev/null +++ b/cmd/lk/testdata/echo-agent/agent.py @@ -0,0 +1,48 @@ +"""Minimal one-file echo agent for the `lk agent session` e2e test. + +Driven in text mode, so an LLM is the only component needed. Echoes the user's +text verbatim, which the test asserts on. +""" + +from dotenv import load_dotenv +from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference + +load_dotenv() + +server = AgentServer() + + +@server.rtc_session() +async def entrypoint(ctx: JobContext): + session = AgentSession(llm=inference.LLM(model="openai/gpt-4o-mini")) + await session.start( + agent=Agent( + instructions=( + "You are an echo bot. Reply with exactly the text the user " + "sends, verbatim, and nothing else." + ), + ), + room=ctx.room, + ) + # No TTS, so disable audio output or the turn crashes in tts_node. + session.output.set_audio_enabled(False) + await ctx.connect() + + +if __name__ == "__main__": + import sys + + argv = sys.argv[1:] + if argv and argv[0] == "console": + # The daemon launches `python agent.py console --connect-addr `, but + # cli.run_app() sends `console` to the legacy click CLI (no --connect-addr), + # so dispatch to the TCP console directly. + from livekit.agents.cli.cli import _run_tcp_console + + _run_tcp_console( + server=server, + connect_addr=argv[argv.index("--connect-addr") + 1], + record="--record" in argv, + ) + else: + cli.run_app(server) diff --git a/cmd/lk/testdata/echo-agent/pyproject.toml b/cmd/lk/testdata/echo-agent/pyproject.toml new file mode 100644 index 00000000..a0f07ffa --- /dev/null +++ b/cmd/lk/testdata/echo-agent/pyproject.toml @@ -0,0 +1,10 @@ +# uv project marker: [tool.uv] lets the daemon's `uv run python` auto-sync +# these deps -- no separate install step. +[project] +name = "lk-session-e2e-echo-agent" +version = "0" +requires-python = ">=3.12" +dependencies = ["livekit-agents", "python-dotenv"] + +[tool.uv] +package = false