Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions autocomplete/fish_autocomplete
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
69 changes: 48 additions & 21 deletions cmd/lk/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.",
},
},
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,34 +256,55 @@ 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
)

// --agent-name (even empty) means: run against an already-running agent,
// 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 == "" {
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 != "" {
Expand Down Expand Up @@ -326,6 +352,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
scenarioGroup: scenarioGroup,
scenariosPath: scenariosPath,
viewModeRunID: runID,
liveAgent: liveAgent,
}

if !isInteractive() {
Expand Down
59 changes: 31 additions & 28 deletions cmd/lk/simulate_ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
69 changes: 38 additions & 31 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}

Expand Down
Loading