From 4809307bb03412282108c061ef5918f4cd6c3703 Mon Sep 17 00:00:00 2001 From: Arseny Boykov Date: Mon, 29 Jun 2026 22:11:19 +0400 Subject: [PATCH 1/4] simulate: allow pointing simulation to already running agent --- cmd/lk/simulate.go | 68 ++++++++++++++++++++++++++++------------- cmd/lk/simulate_ci.go | 59 +++++++++++++++++++----------------- cmd/lk/simulate_tui.go | 69 +++++++++++++++++++++++------------------- 3 files changed, 116 insertions(+), 80 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 4abeb2049..e6305f73b 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -93,6 +93,10 @@ var simulateCommand = &cli.Command{ Name: "view", Usage: "Open a pre-existing simulation", }, + &cli.StringFlag{ + Name: "agent-name", + Usage: "Run against an already-running agent registered under this `NAME` instead of spawning one locally. Requires --scenarios.", + }, }, } @@ -174,6 +178,7 @@ type simulateConfig struct { scenarioGroup *livekit.ScenarioGroup scenariosPath string // path to the --scenarios file (empty when generating from source) viewModeRunID string // non-empty when --view opens a pre-existing run + liveAgent bool // --agent-name: run against an already-running agent, don't spawn one } type simulateMode int @@ -251,34 +256,54 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { numSimulations := int32(cmd.Int("num-simulations")) concurrency := int32(cmd.Int("concurrency")) runID := cmd.String("view") - agentName := generateAgentName() + liveAgentName := cmd.String("agent-name") - projectDir, projectType, err := agentfs.DetectProjectRoot(".") - if err != nil { - return err - } - - entrypointArg := cmd.Args().First() + // never auto-discovered: an explicit --scenarios file is the source of + // truth, otherwise scenarios are generated from the agent's source + scenariosPath := cmd.String("scenarios") - // check if a script called "build" exists in the package.json, if so, refuse to discover the - // entrypoint: build tasks usually mean the entrypoint path is nontrivial (e.g. dist/main.js) - if projectType.IsNode() && entrypointArg == "" { - buildTaskDoesExist, err := buildTaskExists(projectDir) + var ( + agentName string + projectDir string + projectType agentfs.ProjectType + entrypoint string + liveAgent bool + err error + ) + + if liveAgentName != "" { + // Run against an already-running agent (registered under liveAgentName): + // nothing is spawned, so there's no source to generate scenarios from. + if scenariosPath == "" { + return fmt.Errorf("--agent-name requires --scenarios (no source to generate scenarios from when running against a live agent)") + } + liveAgent = true + agentName = liveAgentName + } else { + agentName = generateAgentName() + projectDir, projectType, err = agentfs.DetectProjectRoot(".") if err != nil { return err - } else if buildTaskDoesExist { - return fmt.Errorf("you currently have a build task in your package.json, but no entrypoint was explicitly given; so you must add an entrypoint to the simulate cli") } - } - entrypoint, err := findEntrypoint(projectDir, entrypointArg, projectType) - if err != nil { - return err - } + entrypointArg := cmd.Args().First() - // never auto-discovered: an explicit --scenarios file is the source of - // truth, otherwise scenarios are generated from the agent's source - scenariosPath := cmd.String("scenarios") + // check if a script called "build" exists in the package.json, if so, refuse to discover the + // entrypoint: build tasks usually mean the entrypoint path is nontrivial (e.g. dist/main.js) + if projectType.IsNode() && entrypointArg == "" { + buildTaskDoesExist, err := buildTaskExists(projectDir) + if err != nil { + return err + } else if buildTaskDoesExist { + return fmt.Errorf("you currently have a build task in your package.json, but no entrypoint was explicitly given; so you must add an entrypoint to the simulate cli") + } + } + + entrypoint, err = findEntrypoint(projectDir, entrypointArg, projectType) + if err != nil { + return err + } + } var scenarioGroup *livekit.ScenarioGroup if scenariosPath != "" { @@ -326,6 +351,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { scenarioGroup: scenarioGroup, scenariosPath: scenariosPath, viewModeRunID: runID, + liveAgent: liveAgent, } if !isInteractive() { diff --git a/cmd/lk/simulate_ci.go b/cmd/lk/simulate_ci.go index d40182fe7..55ee32e23 100644 --- a/cmd/lk/simulate_ci.go +++ b/cmd/lk/simulate_ci.go @@ -72,37 +72,39 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error { report := newSimLog(out.ResultWriter(), out.StatusWriter()) report.BeginSetup() - report.StartingAgent() - start := time.Now() - logFwd := &toggleWriter{w: out.StatusWriter()} - logFwd.enabled.Store(true) var err error - agent, err = startSimulationAgent(config, logFwd) - if err != nil { - report.AgentStartFailed(err) - report.EndSetup() - return fmt.Errorf("failed to start agent: %w", err) - } + if !config.liveAgent { + report.StartingAgent() + start := time.Now() + logFwd := &toggleWriter{w: out.StatusWriter()} + logFwd.enabled.Store(true) + agent, err = startSimulationAgent(config, logFwd) + if err != nil { + report.AgentStartFailed(err) + report.EndSetup() + return fmt.Errorf("failed to start agent: %w", err) + } - report.WaitingForRegister() - timeout := time.NewTimer(agentRegisterTimeout) - defer timeout.Stop() - select { - case <-agent.Ready(): - logFwd.enabled.Store(false) - report.AgentRegistered(time.Since(start)) - case <-agent.Done(): - report.EndSetup() - return fmt.Errorf("the agent exited before registering.\n\n%s", agentExitDetail(agent)) - case <-timeout.C: - report.EndSetup() - return fmt.Errorf("timed out after %s waiting for the agent to register.\n\n%s", agentRegisterTimeout, agentExitDetail(agent)) - case <-ctx.Done(): - report.EndSetup() - return ctx.Err() + report.WaitingForRegister() + timeout := time.NewTimer(agentRegisterTimeout) + defer timeout.Stop() + select { + case <-agent.Ready(): + logFwd.enabled.Store(false) + report.AgentRegistered(time.Since(start)) + case <-agent.Done(): + report.EndSetup() + return fmt.Errorf("the agent exited before registering.\n\n%s", agentExitDetail(agent)) + case <-timeout.C: + report.EndSetup() + return fmt.Errorf("timed out after %s waiting for the agent to register.\n\n%s", agentRegisterTimeout, agentExitDetail(agent)) + case <-ctx.Done(): + report.EndSetup() + return ctx.Err() + } } - start = time.Now() + start := time.Now() var presigned *livekit.PresignedPostRequest runID, presigned, err = createSimulationRun(ctx, config) if err != nil { @@ -144,7 +146,8 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error { } out.Warnf("Warning: poll failed: %v", err) } else { - // the worker is failing systemically: stop early and surface its log + // the worker is failing systemically (or, in live-agent mode, the + // agent never joined): stop early and surface its log if !brokenAgent && agentBroken(run, agent) { brokenAgent = true report.BrokenAgent() diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 69ac71648..041662db5 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -35,31 +35,32 @@ import ( ) func runSimulateTUI(config *simulateConfig) error { - launcher := launchSimulationAgent(config) - m := newSimulateModel(config, launcher) + m := newSimulateModel(config) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) _, runErr := p.Run() - // A second ctrl+c during cleanup would kill the CLI and leak the worker - // (own process group, port stays bound); escalate to SIGKILL instead. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt) - defer signal.Stop(sigCh) - go func() { - <-sigCh - launcher.ForceStop() - os.Exit(130) - }() - - if agentProc := launcher.Stop(); agentProc != nil { - if m.brokenAgent { - writeBrokenAgentNote(out.WarnWriter(), agentProc) - fmt.Fprintln(out.WarnWriter()) - } - if agentProc.LogPath != "" { - out.Statusf("Agent logs: %s", agentProc.LogPath) + if m.launcher != nil { + // A second ctrl+c during cleanup would kill the CLI and leak the worker + // (own process group, port stays bound); escalate to SIGKILL instead. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + go func() { + <-sigCh + m.launcher.ForceStop() + os.Exit(130) + }() + + if agentProc := m.launcher.Stop(); agentProc != nil { + if m.brokenAgent { + writeBrokenAgentNote(out.WarnWriter(), agentProc) + fmt.Fprintln(out.WarnWriter()) + } + if agentProc.LogPath != "" { + out.Statusf("Agent logs: %s", agentProc.LogPath) + } + m.agent = agentProc } - m.agent = agentProc } // generated scenarios are saved to a temp file so they're never lost @@ -297,14 +298,13 @@ func (m *simulateModel) showToast(text string, ok bool) tea.Cmd { return tea.Tick(4*time.Second, func(time.Time) tea.Msg { return toastExpireMsg{id: id} }) } -func newSimulateModel(config *simulateConfig, launcher *agentLauncher) *simulateModel { +func newSimulateModel(config *simulateConfig) *simulateModel { ti := textinput.New() ti.Placeholder = "scenarios.yaml" ti.CharLimit = 128 ti.Prompt = "" return &simulateModel{ config: config, - launcher: launcher, reporter: newRunReporter(), numSimulations: config.numSimulations, width: 80, @@ -374,25 +374,32 @@ func (m *simulateModel) runSetup() tea.Cmd { m.steps = append(m.steps, step{label: label, status: "done"}) } m.currentStep = len(m.steps) - m.steps = append(m.steps, - step{label: "Starting agent", status: "running"}, - step{label: "Creating simulation", status: "pending"}, - ) - if c.mode == modeGenerateFromSource { - m.steps = append(m.steps, step{label: "Uploading source", status: "pending"}) - } m.reporter.BeginSetup() if c.mode == modeScenarios && c.scenarioGroup != nil { m.reporter.ScenariosLoaded(c.scenarioGroup, c.scenariosPath) } - m.reporter.StartingAgent() ctx, cancel := context.WithCancel(c.ctx) m.setupCtx = ctx m.setupCancel = cancel m.stepStart = time.Now() + if c.liveAgent { + m.steps = append(m.steps, step{label: "Creating simulation", status: "running"}) + return m.createSimulationCmd() + } + + m.steps = append(m.steps, + step{label: "Starting agent", status: "running"}, + step{label: "Creating simulation", status: "pending"}, + ) + if c.mode == modeGenerateFromSource { + m.steps = append(m.steps, step{label: "Uploading source", status: "pending"}) + } + m.reporter.StartingAgent() + + m.launcher = launchSimulationAgent(c) return m.startAgentCmd() } From ee19568630ffd35519e9ae3d07253387dadb2683 Mon Sep 17 00:00:00 2001 From: Arseny Boykov Date: Mon, 29 Jun 2026 23:47:55 +0400 Subject: [PATCH 2/4] simulate: allow using empty agent name --- cmd/lk/simulate.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index e6305f73b..12e9c34f6 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -95,7 +95,7 @@ var simulateCommand = &cli.Command{ }, &cli.StringFlag{ Name: "agent-name", - Usage: "Run against an already-running agent registered under this `NAME` instead of spawning one locally. Requires --scenarios.", + Usage: "Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or \"\" to target the project's default agent (the one that auto-joins every room). Requires --scenarios.", }, }, } @@ -271,8 +271,10 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { err error ) - if liveAgentName != "" { - // Run against an already-running agent (registered under liveAgentName): + // --agent-name (even empty) means: run against an already-running agent, + // don't spawn one. An empty name targets the project's default agent — the + // one that auto-joins every room (registered with no agent_name). + if cmd.IsSet("agent-name") { // nothing is spawned, so there's no source to generate scenarios from. if scenariosPath == "" { return fmt.Errorf("--agent-name requires --scenarios (no source to generate scenarios from when running against a live agent)") From 79070601bcee21fa46bae469f585118fa45604e1 Mon Sep 17 00:00:00 2001 From: Arseny Boykov Date: Tue, 30 Jun 2026 01:18:36 +0400 Subject: [PATCH 3/4] simulate: --agent-name fish autocomplete --- autocomplete/fish_autocomplete | 1 + 1 file changed, 1 insertion(+) diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 26cc99c2e..1161da58e 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -208,6 +208,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 simulate' -f -l audio -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline. By default, simulations run in text-only mode.' 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 view -r -d 'Open a pre-existing simulation' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d 'Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or "" to target the project\'s default agent (the one that auto-joins every room). Requires --scenarios.' 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 daemon simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' From 3ce7d2aedb2dd43315dbe380e7845b9d4cf9e44c Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:25:11 +0400 Subject: [PATCH 4/4] Update comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Théo Monnom --- cmd/lk/simulate.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 12e9c34f6..e1527a773 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -272,8 +272,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { ) // --agent-name (even empty) means: run against an already-running agent, - // don't spawn one. An empty name targets the project's default agent — the - // one that auto-joins every room (registered with no agent_name). + // don't spawn one. https://docs.livekit.io/agents/server/agent-dispatch/#automatic if cmd.IsSet("agent-name") { // nothing is spawned, so there's no source to generate scenarios from. if scenariosPath == "" {