Skip to content

feat(cli): add lk agent session for headless text-mode agent runs#857

Open
toubatbrian wants to merge 5 commits into
mainfrom
feat/agent-session-daemon
Open

feat(cli): add lk agent session for headless text-mode agent runs#857
toubatbrian wants to merge 5 commits into
mainfrom
feat/agent-session-daemon

Conversation

@toubatbrian
Copy link
Copy Markdown
Contributor

@toubatbrian toubatbrian commented Jun 3, 2026

Summary

Adds lk agent session start|say|end — a headless, text-mode way to drive a LiveKit agent (Python or JS) straight from the terminal, with no audio/CGO dependency (it lives under the default tag-free build, not the console audio build).

It uses a three-process model that mirrors the existing lk agent console plumbing:

  1. Ephemeral CLI command (start/say/end) — short-lived, talks to the daemon and exits.
  2. Detached singleton daemon — the lk binary re-exec'd into a hidden daemon mode (gated by an env var, never exposed as a subcommand). It binds a fixed loopback TCP port to enforce a single active session, spawns the agent, and applies text mode.
  3. Agent subprocess — the user's agent, connected over the lk.agent.session protobuf protocol.

The CLI↔daemon control protocol reuses pkg/ipc length-prefixed framing on the same TCP port, disambiguated from agent connections by a 4-byte magic preamble. The headless renderer (session_render.go) prints user turns, agent replies, tool calls/outputs, and handoffs.

Command running / IO example

$ lk agent session start examples/voice_agents/basic_agent.py
Detected Python agent (basic_agent.py in .../examples/voice_agents)
Session started. Use `lk agent session say "..."` to talk, `lk agent session end` to stop.

$ lk agent session say "what's the weather in San Francisco?"

  ● You
    what's the weather in San Francisco?

  ● function_tool: lookup_weather
    ✓ sunny with a temperature of 70 degrees.

  ● Agent
    The weather in San Francisco is sunny with a temperature of 70 degrees. Want to know the forecast for any other city?

$ lk agent session say "thanks, that's all"

  ● You
    thanks, that's all

  ● function_tool: end_call
    ✓ say goodbye to the user

  ● Agent
    Goodbye! Have a great day!

$ lk agent session end
Session ended.

Notes

  • Singleton enforcement: a second start while a session is live is rejected (a session is already running on 127.0.0.1:<port>).
  • No CGO/audio: builds in the default tag-free binary; the audio pipeline stays behind the console tag. This drops the temporary //lint:file-ignore U1000 directives that were added while the shared spawn/detect helpers were unused.
  • TODO(node) / TODO(audio) placeholders mark the follow-up surfaces (JS agent detection, audio mode).

Test plan

  • go build ./... (default) and CGO_ENABLED=1 go build -tags console ./...
  • go vet -tags console ./cmd/lk/, gofmt clean
  • End-to-end start → say (tool call) → say (handoff/end_call) → end against basic_agent.py (see IO example above)
  • JS agent run (pending TODO(node))

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>
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>
theomonnom
theomonnom approved these changes Jun 3, 2026
Copy link
Copy Markdown
Member

@theomonnom theomonnom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh stamped the wrong PR

Comment thread cmd/lk/main.go Outdated
Comment on lines +35 to +41
// When re-exec'd as the detached session daemon, run that and never reach
// the CLI framework (the daemon is not an exposed subcommand).
if os.Getenv(envSessionDaemon) == "1" {
runSessionDaemon()
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we create a separate entrypoint instead?

Copy link
Copy Markdown
Contributor Author

@toubatbrian toubatbrian Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — moved it to a dedicated hidden subcommand entrypoint (lk agent session daemon) instead of the env gate in main().

Done in 5f0ca88 (entrypoint) + 0a32ba0 (guard).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I mean is can't this be it's own binary?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no then it's prob OK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a hard reason we need two binaries? One works fine for us today. Re-exec'ing os.Executable() guarantees the daemon is the exact same version as the CLI (no skew), it reuses the console/ipc/detection code directly, and it's a hidden impl detail — nobody installs or runs it on its own. A second binary would also double our release/build matrix. Happy to split it out if there's a concrete need though.

toubatbrian and others added 2 commits June 3, 2026 15:46
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>
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>
@toubatbrian toubatbrian requested a review from theomonnom June 3, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants