Skip to content

Commit a54b2bd

Browse files
committed
simulate: Node agent support, --view mode, and TUI/start UX fixes
Ports our simulate work onto the Node-support branch: - Node agent start: normalizeLogLevel, --log-format only for Python, main.ts/main.js entrypoints, and refuse entrypoint discovery when package.json defines a build task; drop the Python-only guard. - `lk agent simulate --view <runID>`: open a pre-existing simulation and print the reopen command on close. - TUI: ←/→ arrow navigation in/out of simulation detail, richer agent-exit errors (start command + recent output), and quit when the agent fails to register.
1 parent 77d2c3a commit a54b2bd

3 files changed

Lines changed: 127 additions & 54 deletions

File tree

cmd/lk/simulate.go

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"io"
2323
"math/rand"
2424
"os"
25+
"path/filepath"
2526
"strings"
2627
"time"
2728

@@ -88,6 +89,10 @@ var simulateCommand = &cli.Command{
8889
Aliases: []string{"y"},
8990
Usage: "Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)",
9091
},
92+
&cli.StringFlag{
93+
Name: "view",
94+
Usage: "Open a pre-existing simulation",
95+
},
9196
},
9297
}
9398

@@ -168,13 +173,15 @@ type simulateConfig struct {
168173
entrypoint string
169174
scenarioGroup *livekit.ScenarioGroup
170175
scenariosPath string // path to the --scenarios file (empty when generating from source)
176+
viewModeRunID string // non-empty when --view opens a pre-existing run
171177
}
172178

173179
type simulateMode int
174180

175181
const (
176182
modeScenarios simulateMode = iota
177183
modeGenerateFromSource
184+
modeView
178185
)
179186

180187
func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) {
@@ -217,22 +224,54 @@ func generateAgentName() string {
217224
return "simulation-" + string(b)
218225
}
219226

227+
type PackageJSON struct {
228+
Scripts map[string]string `json:"scripts"`
229+
}
230+
231+
// buildTaskExists reports whether package.json defines a "build" script. Such a
232+
// task usually means the entrypoint path is nontrivial (todo: check dist/main.js).
233+
func buildTaskExists(projectDir string) (bool, error) {
234+
data, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
235+
if err != nil {
236+
return false, err
237+
}
238+
239+
var pkg PackageJSON
240+
if err := json.Unmarshal(data, &pkg); err != nil {
241+
return false, err
242+
}
243+
244+
_, ok := pkg.Scripts["build"]
245+
return ok, nil
246+
}
247+
220248
func runSimulate(ctx context.Context, cmd *cli.Command) error {
221249
pc := simulateProjectConfig
222250

223251
numSimulations := int32(cmd.Int("num-simulations"))
224252
concurrency := int32(cmd.Int("concurrency"))
253+
runID := cmd.String("view")
225254
agentName := generateAgentName()
226255

227256
projectDir, projectType, err := agentfs.DetectProjectRoot(".")
228257
if err != nil {
229258
return err
230259
}
231-
if !projectType.IsPython() {
232-
return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType)
260+
261+
entrypointArg := cmd.Args().First()
262+
263+
// check if a script called "build" exists in the package.json, if so, refuse to discover the
264+
// entrypoint: build tasks usually mean the entrypoint path is nontrivial (e.g. dist/main.js)
265+
if projectType.IsNode() && entrypointArg == "" {
266+
buildTaskDoesExist, err := buildTaskExists(projectDir)
267+
if err != nil {
268+
return err
269+
} else if buildTaskDoesExist {
270+
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")
271+
}
233272
}
234273

235-
entrypoint, err := findEntrypoint(projectDir, cmd.Args().First(), projectType)
274+
entrypoint, err := findEntrypoint(projectDir, entrypointArg, projectType)
236275
if err != nil {
237276
return err
238277
}
@@ -250,9 +289,12 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
250289
}
251290

252291
var mode simulateMode
253-
if scenarioGroup != nil && len(scenarioGroup.Scenarios) > 0 {
292+
switch {
293+
case runID != "":
294+
mode = modeView
295+
case scenarioGroup != nil && len(scenarioGroup.Scenarios) > 0:
254296
mode = modeScenarios
255-
} else {
297+
default:
256298
mode = modeGenerateFromSource
257299
}
258300

@@ -283,6 +325,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error {
283325
entrypoint: entrypoint,
284326
scenarioGroup: scenarioGroup,
285327
scenariosPath: scenariosPath,
328+
viewModeRunID: runID,
286329
}
287330

288331
if !isInteractive() {
@@ -380,20 +423,26 @@ func (l *agentLauncher) ForceStop() {
380423
}
381424

382425
func startSimulationAgent(c *simulateConfig, forwardOutput io.Writer) (*AgentProcess, error) {
426+
args := []string{
427+
"start",
428+
"--url", c.pc.URL,
429+
"--api-key", c.pc.APIKey,
430+
"--api-secret", c.pc.APISecret,
431+
"--log-level", normalizeLogLevel(c.projectType, "DEBUG"),
432+
// disable the worker load limit so the run can saturate the agent
433+
"--simulation",
434+
}
435+
436+
// --log-format is a Python-only flag; the Node CLI doesn't accept it.
437+
if c.projectType.IsPython() {
438+
args = append(args, "--log-format", "colored")
439+
}
440+
383441
return startAgent(AgentStartConfig{
384442
Dir: c.projectDir,
385443
Entrypoint: c.entrypoint,
386444
ProjectType: c.projectType,
387-
CLIArgs: []string{
388-
"start",
389-
"--url", c.pc.URL,
390-
"--api-key", c.pc.APIKey,
391-
"--api-secret", c.pc.APISecret,
392-
"--log-level", "DEBUG",
393-
"--log-format", "colored",
394-
// disable the worker load limit so the run can saturate the agent
395-
"--simulation",
396-
},
445+
CLIArgs: args,
397446
Env: []string{
398447
// register under the dispatch name regardless of any agent_name
399448
// hardcoded in the user's code

cmd/lk/simulate_subprocess.go

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func checkTypeStrippingSupport(dir, nodeBin string) error {
134134
// order. Forward slashes are valid on all platforms.
135135
func defaultEntrypoints(projectType agentfs.ProjectType) []string {
136136
if projectType.IsNode() {
137-
return []string{"agent.ts", "agent.js"}
137+
return []string{"main.ts", "src/main.js"}
138138
}
139139
return []string{"agent.py"}
140140
}
@@ -144,7 +144,7 @@ func defaultEntrypoints(projectType agentfs.ProjectType) []string {
144144
// user's working directory.
145145
func fallbackEntrypoints(projectType agentfs.ProjectType) []string {
146146
if projectType.IsNode() {
147-
return []string{"src/agent.ts", "src/agent.js"}
147+
return []string{"src/main.ts", "src/main.js"}
148148
}
149149
return []string{"src/agent.py"}
150150
}
@@ -223,36 +223,6 @@ type AgentStartConfig struct {
223223
// start/dev/console/simulate subcommands under `python -m livekit.agents`.
224224
const thinCLIMinVersion = "1.6.0"
225225

226-
// agentExitDetail surfaces the agent's own output and the log path when the
227-
// worker exits early or never registers.
228-
func agentExitDetail(ap *AgentProcess) string {
229-
var b strings.Builder
230-
if tail := lastNonEmptyLines(ap.RecentLogs(0), 12); len(tail) > 0 {
231-
for i, l := range tail {
232-
tail[i] = ansiEscapeRe.ReplaceAllString(l, "")
233-
}
234-
b.WriteString("Agent output:\n " + strings.Join(tail, "\n "))
235-
}
236-
if ap.LogPath != "" {
237-
if b.Len() > 0 {
238-
b.WriteString("\n\n")
239-
}
240-
b.WriteString("Full log: " + ap.LogPath)
241-
}
242-
return b.String()
243-
}
244-
245-
// lastNonEmptyLines returns up to n trailing non-blank lines, in order.
246-
func lastNonEmptyLines(lines []string, n int) []string {
247-
var out []string
248-
for i := len(lines) - 1; i >= 0 && len(out) < n; i-- {
249-
if strings.TrimSpace(lines[i]) != "" {
250-
out = append([]string{lines[i]}, out...)
251-
}
252-
}
253-
return out
254-
}
255-
256226
// buildAgentCommand resolves the interpreter and argv for an agent subprocess,
257227
// branching on project type. Python uses the thin CLI:
258228
// `<python> <runtime-args> -m livekit.agents SUBCOMMAND ENTRYPOINT FLAGS`
@@ -503,6 +473,41 @@ func (ap *AgentProcess) RecentRoomLogsByPrefix(n int, roomName string) []string
503473

504474
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
505475

476+
// agentExitDetail surfaces the agent's own output and the log path when the
477+
// worker exits early or never registers.
478+
func agentExitDetail(ap *AgentProcess) string {
479+
logs := ap.RecentLogs(0)
480+
481+
var b strings.Builder
482+
483+
if len(logs) == 0 {
484+
b.WriteString("Agent exited with no output.")
485+
} else if tail := lastNonEmptyLines(logs, 12); len(tail) > 0 {
486+
for i, l := range tail {
487+
tail[i] = ansiEscapeRe.ReplaceAllString(l, "")
488+
}
489+
b.WriteString("Agent output:\n " + strings.Join(tail, "\n "))
490+
}
491+
492+
if ap.LogPath != "" {
493+
if b.Len() > 0 {
494+
b.WriteString("\n\n")
495+
}
496+
b.WriteString("Full log: " + ap.LogPath)
497+
}
498+
return b.String()
499+
}
500+
501+
func lastNonEmptyLines(lines []string, n int) []string {
502+
var out []string
503+
for i := len(lines) - 1; i >= 0 && len(out) < n; i-- {
504+
if strings.TrimSpace(lines[i]) != "" {
505+
out = append([]string{lines[i]}, out...)
506+
}
507+
}
508+
return out
509+
}
510+
506511
func extractLogRoom(line string) string {
507512
idx := strings.LastIndex(line, "{")
508513
if idx < 0 {

cmd/lk/simulate_tui.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,12 @@ func runSimulateTUI(config *simulateConfig) error {
7878
out.Statusf("Dashboard: %s", url)
7979
}
8080

81-
if m.runID != "" && !m.runFinished {
81+
if m.config.mode == modeView {
82+
fmt.Fprintf(os.Stderr, "To re-open this simulation, run: lk agent simulate --view %s\n", m.config.viewModeRunID)
83+
} else if m.runID != "" && !m.runFinished {
8284
cancelSimulationRun(config.client, m.runID)
85+
} else if m.runID != "" {
86+
fmt.Fprintf(os.Stderr, "To re-open this simulation, run: lk agent simulate --view %s\n", m.runID)
8387
}
8488

8589
if runErr != nil {
@@ -344,6 +348,18 @@ func (m *simulateModel) Init() tea.Cmd {
344348
func (m *simulateModel) runSetup() tea.Cmd {
345349
c := m.config
346350

351+
if c.mode == modeView {
352+
ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout)
353+
defer cancel()
354+
run, err := getSimulationRun(ctx, m.config.client, m.config.viewModeRunID)
355+
if err != nil {
356+
m.err = err
357+
}
358+
m.run = run
359+
m.setupDone = true
360+
return nil
361+
}
362+
347363
m.steps = nil
348364
if c.mode == modeScenarios && c.scenarioGroup != nil {
349365
// loaded before the TUI started, so it renders as already done
@@ -423,7 +439,7 @@ func (m *simulateModel) waitAgentReadyCmd() tea.Cmd {
423439
case <-m.agent.Ready():
424440
return agentReadyMsg{elapsed: time.Since(stepStart)}
425441
case <-m.agent.Done():
426-
return agentReadyMsg{err: fmt.Errorf("the agent exited before registering.\n\n%s", agentExitDetail(m.agent))}
442+
return agentReadyMsg{err: fmt.Errorf("the agent exited before registering.\n\nCommand used to start agent: %s\n\n%s", m.agent.cmd.String(), agentExitDetail(m.agent))}
427443
case <-timeout.C:
428444
m.agent.Kill()
429445
return agentReadyMsg{err: fmt.Errorf("timed out after %s waiting for the agent to register.\n\n%s", agentRegisterTimeout, agentExitDetail(m.agent))}
@@ -511,7 +527,10 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
511527
case agentReadyMsg:
512528
if msg.err != nil {
513529
m.failSetupStep(msg.err)
514-
return m, nil
530+
if m.setupCancel != nil {
531+
m.setupCancel()
532+
}
533+
return m, tea.Quit
515534
}
516535
m.reporter.AgentRegistered(msg.elapsed)
517536
m.advanceSetupStep(msg.elapsed)
@@ -783,15 +802,15 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
783802
m.scrollActive(-pageScroll, true)
784803
case "pgdown":
785804
m.scrollActive(pageScroll, true)
786-
case "enter":
805+
case "enter", "right":
787806
if m.detailJobID == "" {
788807
jobs := m.filteredJobs()
789808
if m.cursor >= 0 && m.cursor < len(jobs) {
790809
m.detailJobID = jobs[m.cursor].job.Id
791810
m.detailScrollOff = 0
792811
}
793812
}
794-
case "esc", "backspace":
813+
case "esc", "left", "backspace":
795814
if m.detailJobID != "" {
796815
m.detailJobID = ""
797816
m.detailScrollOff = 0
@@ -1746,7 +1765,7 @@ func (m *simulateModel) renderHint() string {
17461765
var parts []string
17471766
switch {
17481767
case m.detailJobID != "":
1749-
parts = append(parts, "↑↓ scroll · c copy scenario · ESC back")
1768+
parts = append(parts, "↑↓ scroll · c copy scenario · ESC/← back")
17501769
if m.hasLogs() {
17511770
if m.showLogs {
17521771
parts = append(parts, "Ctrl+L hide logs")
@@ -1758,7 +1777,7 @@ func (m *simulateModel) renderHint() string {
17581777
parts = append(parts, "↑↓ scroll · d collapse description")
17591778
default:
17601779
// the collapsed description block already carries "(press d to expand)"
1761-
nav := "↑↓ navigate · ENTER detail"
1780+
nav := "↑↓ navigate · ENTER/→ detail"
17621781
if m.canExportScenarios() {
17631782
nav += " · s save scenarios"
17641783
}

0 commit comments

Comments
 (0)