From b5d60fae209d7b371feba1deda7bb665affcfdb4 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Mon, 12 Oct 2026 16:04:47 +0100 Subject: [PATCH 1/7] agent simulate: read simulator flow steps from scenarios.yaml Read the top-level `steps` map and the per-scenario `start` and `max_duration` keys, and send them in the ScenarioGroup. Each step is decoded with protojson, so field names, enum names, durations, and unknown-key errors match the server's parser. Check only clear user errors locally: a scenario must set exactly one of `start` and `instructions`, and `start` must name a step or a built-in. The server validates the rest at CreateSimulationRun. Bump github.com/livekit/protocol to b440b893 for the new fields. Addresses AGT-3725 --- cmd/lk/simulate.go | 89 ++++++++++- cmd/lk/simulate_scenario_ids_test.go | 222 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 312 insertions(+), 5 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 97b59ca89..e90bce242 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -43,7 +43,9 @@ import ( "github.com/livekit/protocol/utils" lksdk "github.com/livekit/server-sdk-go/v2" "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" ) func init() { @@ -352,14 +354,32 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { if f.ID == "" { f.ID = utils.NewGuid(utils.ScenarioGroupPrefix) } + for name, step := range group.GetSteps() { + v, err := protoToYAMLValue(step) + if err != nil { + return nil, fmt.Errorf("failed to encode step %q: %w", name, err) + } + if f.Steps == nil { + f.Steps = map[string]any{} + } + f.Steps[name] = v + } for _, s := range group.GetScenarios() { ys := yamlScenario{ ID: s.GetId(), Label: s.GetLabel(), Instructions: s.GetInstructions(), + Start: s.GetStart(), AgentExpectations: s.GetAgentExpectations(), Tags: s.GetTags(), } + if s.GetMaxDuration() != nil { + v, err := protoToYAMLValue(s.GetMaxDuration()) + if err != nil { + return nil, fmt.Errorf("failed to encode max_duration for scenario %q: %w", s.GetLabel(), err) + } + ys.MaxDuration, _ = v.(string) + } if ys.ID == "" { ys.ID = utils.NewGuid(utils.ScenarioPrefix) } @@ -376,17 +396,21 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { } // scenariosFile mirrors a scenarios.yaml; `userdata` is a nested mapping here -// and JSON-encoded into the proto's string field. +// and JSON-encoded into the proto's string field. Each `steps` entry is a +// SimulatorStep in protojson form. type scenariosFile struct { ID string `yaml:"id"` Name string `yaml:"name"` + Steps map[string]any `yaml:"steps,omitempty"` Scenarios []yamlScenario `yaml:"scenarios"` } type yamlScenario struct { ID string `yaml:"id"` Label string `yaml:"label"` - Instructions string `yaml:"instructions"` + Instructions string `yaml:"instructions,omitempty"` + Start string `yaml:"start,omitempty"` + MaxDuration string `yaml:"max_duration,omitempty"` AgentExpectations string `yaml:"agent_expectations"` Tags map[string]string `yaml:"tags"` Userdata map[string]any `yaml:"userdata"` @@ -454,6 +478,21 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("scenarios file has no id") } group := &livekit.ScenarioGroup{Id: f.ID, Name: f.Name} + names := make([]string, 0, len(f.Steps)) + for name := range f.Steps { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + step := &livekit.SimulatorStep{} + if err := yamlValueToProto(f.Steps[name], step); err != nil { + return nil, fmt.Errorf("step %q: %w", name, err) + } + if group.Steps == nil { + group.Steps = map[string]*livekit.SimulatorStep{} + } + group.Steps[name] = step + } seen := map[string]bool{} for _, s := range f.Scenarios { if s.ID == "" { @@ -463,6 +502,19 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("duplicate scenario id %q", s.ID) } seen[s.ID] = true + if (s.Start == "") == (s.Instructions == "") { + return nil, fmt.Errorf("scenario %q: set exactly one of `start` and `instructions`", s.Label) + } + if s.Start != "" && group.Steps[s.Start] == nil && !builtinSimulatorSteps[s.Start] { + return nil, fmt.Errorf("scenario %q: start %q is not a step", s.Label, s.Start) + } + var maxDuration *durationpb.Duration + if s.MaxDuration != "" { + maxDuration = &durationpb.Duration{} + if err := yamlValueToProto(s.MaxDuration, maxDuration); err != nil { + return nil, fmt.Errorf("scenario %q: max_duration: %w", s.Label, err) + } + } var userdata string if len(s.Userdata) > 0 { b, err := json.Marshal(s.Userdata) @@ -475,6 +527,8 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { Id: s.ID, Label: s.Label, Instructions: s.Instructions, + Start: s.Start, + MaxDuration: maxDuration, AgentExpectations: s.AgentExpectations, Tags: s.Tags, Userdata: userdata, @@ -483,6 +537,37 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return group, nil } +// builtinSimulatorSteps are the steps the server provides; a scenario may start at one. +var builtinSimulatorSteps = map[string]bool{ + "livekit/hangup": true, + "livekit/voicemail": true, + "livekit/unavailable": true, +} + +// yamlValueToProto decodes a YAML value through its JSON form with protojson, +// so field names, enum names, durations, and unknown-key errors match the +// server's parser. +func yamlValueToProto(v any, m proto.Message) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + return protojson.Unmarshal(b, m) +} + +// protoToYAMLValue is the inverse of yamlValueToProto. +func protoToYAMLValue(m proto.Message) (any, error) { + b, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(m) + if err != nil { + return nil, err + } + var v any + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return v, nil +} + func generateAgentName() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index 73f8b1142..fa0323f9f 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -20,9 +20,12 @@ import ( "regexp" "strings" "testing" + "time" "github.com/livekit/protocol/livekit" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "gopkg.in/yaml.v3" ) @@ -197,3 +200,222 @@ func TestScenarioGroupToYAMLFillsIDs(t *testing.T) { require.True(t, strings.HasPrefix(loaded.GetScenarios()[0].GetId(), "SCN_")) require.Equal(t, "fixed", loaded.GetScenarios()[1].GetId()) } + +func TestLoadScenarioGroupSimulatorFlow(t *testing.T) { + const steps = ` +steps: + menu: + say: Press 1 for sales. + speaker: MACHINE + routes: + - match: "1" + to: sales + timeout: + after: 8s + to: livekit/hangup + sales: + instructions: You are a sales rep. + voicemail: + beep: false +` + cases := []struct { + name string + yaml string + want *livekit.ScenarioGroup + wantErr []string + }{ + { + name: "steps and start", + yaml: ` +id: g1 +name: n` + steps + ` +scenarios: + - id: s1 + label: call + start: menu + max_duration: 60s +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Steps: map[string]*livekit.SimulatorStep{ + "menu": { + Say: "Press 1 for sales.", + Speaker: livekit.SimulatorStep_MACHINE, + Routes: []*livekit.SimulatorStep_Route{{Match: "1", To: "sales"}}, + Timeout: &livekit.SimulatorStep_Timeout{After: durationpb.New(8 * time.Second), To: "livekit/hangup"}, + }, + "sales": { + Instructions: "You are a sales rep.", + Voicemail: &livekit.SimulatorStep_Voicemail{Beep: proto.Bool(false)}, + }, + }, + Scenarios: []*livekit.Scenario{{ + Id: "s1", + Label: "call", + Start: "menu", + MaxDuration: durationpb.New(60 * time.Second), + }}, + }, + }, + { + name: "built-in start", + yaml: ` +id: g1 +name: n +scenarios: + - id: s1 + label: call + start: livekit/unavailable +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Scenarios: []*livekit.Scenario{{Id: "s1", Label: "call", Start: "livekit/unavailable"}}, + }, + }, + { + name: "instructions only", + yaml: ` +id: g1 +name: n +scenarios: + - id: s1 + label: order + instructions: order a burger + agent_expectations: confirms the order +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Scenarios: []*livekit.Scenario{{ + Id: "s1", + Label: "order", + Instructions: "order a burger", + AgentExpectations: "confirms the order", + }}, + }, + }, + { + name: "unknown step key", + yaml: ` +id: g1 +name: n +steps: + menu: + sya: typo +scenarios: + - id: s1 + label: call + start: menu +`, + wantErr: []string{`step "menu"`, "sya"}, + }, + { + name: "start and instructions", + yaml: ` +id: g1 +name: n` + steps + ` +scenarios: + - id: s1 + label: call + start: menu + instructions: hi +`, + wantErr: []string{`scenario "call"`, "exactly one of `start` and `instructions`"}, + }, + { + name: "unknown start", + yaml: ` +id: g1 +name: n` + steps + ` +scenarios: + - id: s1 + label: call + start: nowhere +`, + wantErr: []string{`scenario "call"`, `start "nowhere"`}, + }, + { + name: "max_duration without unit", + yaml: ` +id: g1 +name: n` + steps + ` +scenarios: + - id: s1 + label: call + start: menu + max_duration: 60 +`, + wantErr: []string{`scenario "call"`, "max_duration"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + group, err := loadScenarioGroup(writeScenariosFile(t, c.yaml)) + if c.wantErr != nil { + for _, e := range c.wantErr { + require.ErrorContains(t, err, e) + } + return + } + require.NoError(t, err) + require.True(t, proto.Equal(c.want, group), "got %v", group) + }) + } +} + +func TestScenarioGroupToYAMLRoundTripsFlow(t *testing.T) { + path := writeScenariosFile(t, ` +id: g1 +name: n +steps: + menu: + say: Press 1. + speaker: MACHINE + max_visits: + count: 2 + to: livekit/hangup + voicemail: + beep: false + silence_timeout: 1.5s +scenarios: + - id: s1 + label: call + start: menu + max_duration: 60s +`) + group, err := loadScenarioGroup(path) + require.NoError(t, err) + out, err := scenarioGroupToYAML(group) + require.NoError(t, err) + again, err := loadScenarioGroup(writeScenariosFile(t, string(out))) + require.NoError(t, err) + require.True(t, proto.Equal(group, again), "got %v\nyaml:\n%s", again, out) +} + +func TestInsertScenarioIDsKeepsSteps(t *testing.T) { + in := `name: n +steps: + menu: + say: hi # greet +scenarios: + - label: call + start: menu +` + out, added, err := insertScenarioIDs([]byte(in)) + require.NoError(t, err) + require.Equal(t, 2, added) + ids := regexp.MustCompile(`(SCNG?)_\w+`) + want := `id: SCNG_x +name: n +steps: + menu: + say: hi # greet +scenarios: + - id: SCN_x + label: call + start: menu +` + require.Equal(t, want, ids.ReplaceAllString(string(out), "${1}_x")) +} diff --git a/go.mod b/go.mod index 71cfb20e4..35d334b8d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780 + github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 255293c62..39493bc7a 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780 h1:7T5JGMTDLcbI/emnTcrspygdWlVepIdHYA54um4v6Qo= -github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0 h1:9vgVVkqvoMqY9gDTeY7sjc43OsgCaQXFi/sKrawi5nU= +github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From 8fd64f1b6204734c5ffcf2db6c7fd88805290565 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Tue, 13 Oct 2026 15:03:54 +0100 Subject: [PATCH 2/7] agent simulate: rename simulator flow steps to nodes Follows the protocol rename: scenarios.yaml takes a top-level `nodes` map, and errors say node. A node can no longer be a voicemail box; scenarios reach one through the built-in livekit/voicemail. --- cmd/lk/simulate.go | 42 ++++++++++++------------- cmd/lk/simulate_scenario_ids_test.go | 47 ++++++++++++++-------------- go.mod | 2 +- go.sum | 2 ++ 4 files changed, 47 insertions(+), 46 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index e90bce242..2376a3ad0 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -354,15 +354,15 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { if f.ID == "" { f.ID = utils.NewGuid(utils.ScenarioGroupPrefix) } - for name, step := range group.GetSteps() { - v, err := protoToYAMLValue(step) + for name, node := range group.GetNodes() { + v, err := protoToYAMLValue(node) if err != nil { - return nil, fmt.Errorf("failed to encode step %q: %w", name, err) + return nil, fmt.Errorf("failed to encode node %q: %w", name, err) } - if f.Steps == nil { - f.Steps = map[string]any{} + if f.Nodes == nil { + f.Nodes = map[string]any{} } - f.Steps[name] = v + f.Nodes[name] = v } for _, s := range group.GetScenarios() { ys := yamlScenario{ @@ -396,12 +396,12 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { } // scenariosFile mirrors a scenarios.yaml; `userdata` is a nested mapping here -// and JSON-encoded into the proto's string field. Each `steps` entry is a -// SimulatorStep in protojson form. +// and JSON-encoded into the proto's string field. Each `nodes` entry is a +// SimulatorNode in protojson form. type scenariosFile struct { ID string `yaml:"id"` Name string `yaml:"name"` - Steps map[string]any `yaml:"steps,omitempty"` + Nodes map[string]any `yaml:"nodes,omitempty"` Scenarios []yamlScenario `yaml:"scenarios"` } @@ -478,20 +478,20 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("scenarios file has no id") } group := &livekit.ScenarioGroup{Id: f.ID, Name: f.Name} - names := make([]string, 0, len(f.Steps)) - for name := range f.Steps { + names := make([]string, 0, len(f.Nodes)) + for name := range f.Nodes { names = append(names, name) } sort.Strings(names) for _, name := range names { - step := &livekit.SimulatorStep{} - if err := yamlValueToProto(f.Steps[name], step); err != nil { - return nil, fmt.Errorf("step %q: %w", name, err) + node := &livekit.SimulatorNode{} + if err := yamlValueToProto(f.Nodes[name], node); err != nil { + return nil, fmt.Errorf("node %q: %w", name, err) } - if group.Steps == nil { - group.Steps = map[string]*livekit.SimulatorStep{} + if group.Nodes == nil { + group.Nodes = map[string]*livekit.SimulatorNode{} } - group.Steps[name] = step + group.Nodes[name] = node } seen := map[string]bool{} for _, s := range f.Scenarios { @@ -505,8 +505,8 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { if (s.Start == "") == (s.Instructions == "") { return nil, fmt.Errorf("scenario %q: set exactly one of `start` and `instructions`", s.Label) } - if s.Start != "" && group.Steps[s.Start] == nil && !builtinSimulatorSteps[s.Start] { - return nil, fmt.Errorf("scenario %q: start %q is not a step", s.Label, s.Start) + if s.Start != "" && group.Nodes[s.Start] == nil && !builtinSimulatorNodes[s.Start] { + return nil, fmt.Errorf("scenario %q: start %q is not a node", s.Label, s.Start) } var maxDuration *durationpb.Duration if s.MaxDuration != "" { @@ -537,8 +537,8 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return group, nil } -// builtinSimulatorSteps are the steps the server provides; a scenario may start at one. -var builtinSimulatorSteps = map[string]bool{ +// builtinSimulatorNodes are the nodes the server provides; a scenario may start at one. +var builtinSimulatorNodes = map[string]bool{ "livekit/hangup": true, "livekit/voicemail": true, "livekit/unavailable": true, diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index fa0323f9f..307146e2a 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -202,8 +202,8 @@ func TestScenarioGroupToYAMLFillsIDs(t *testing.T) { } func TestLoadScenarioGroupSimulatorFlow(t *testing.T) { - const steps = ` -steps: + const nodes = ` +nodes: menu: say: Press 1 for sales. speaker: MACHINE @@ -215,8 +215,7 @@ steps: to: livekit/hangup sales: instructions: You are a sales rep. - voicemail: - beep: false + expectation: Asks about the order. ` cases := []struct { name string @@ -225,10 +224,10 @@ steps: wantErr []string }{ { - name: "steps and start", + name: "nodes and start", yaml: ` id: g1 -name: n` + steps + ` +name: n` + nodes + ` scenarios: - id: s1 label: call @@ -238,16 +237,16 @@ scenarios: want: &livekit.ScenarioGroup{ Id: "g1", Name: "n", - Steps: map[string]*livekit.SimulatorStep{ + Nodes: map[string]*livekit.SimulatorNode{ "menu": { Say: "Press 1 for sales.", - Speaker: livekit.SimulatorStep_MACHINE, - Routes: []*livekit.SimulatorStep_Route{{Match: "1", To: "sales"}}, - Timeout: &livekit.SimulatorStep_Timeout{After: durationpb.New(8 * time.Second), To: "livekit/hangup"}, + Speaker: livekit.SimulatorNode_MACHINE, + Routes: []*livekit.SimulatorNode_Route{{Match: "1", To: "sales"}}, + Timeout: &livekit.SimulatorNode_Timeout{After: durationpb.New(8 * time.Second), To: "livekit/hangup"}, }, "sales": { Instructions: "You are a sales rep.", - Voicemail: &livekit.SimulatorStep_Voicemail{Beep: proto.Bool(false)}, + Expectation: "Asks about the order.", }, }, Scenarios: []*livekit.Scenario{{ @@ -297,11 +296,11 @@ scenarios: }, }, { - name: "unknown step key", + name: "unknown node key", yaml: ` id: g1 name: n -steps: +nodes: menu: sya: typo scenarios: @@ -309,13 +308,13 @@ scenarios: label: call start: menu `, - wantErr: []string{`step "menu"`, "sya"}, + wantErr: []string{`node "menu"`, "sya"}, }, { name: "start and instructions", yaml: ` id: g1 -name: n` + steps + ` +name: n` + nodes + ` scenarios: - id: s1 label: call @@ -328,7 +327,7 @@ scenarios: name: "unknown start", yaml: ` id: g1 -name: n` + steps + ` +name: n` + nodes + ` scenarios: - id: s1 label: call @@ -340,7 +339,7 @@ scenarios: name: "max_duration without unit", yaml: ` id: g1 -name: n` + steps + ` +name: n` + nodes + ` scenarios: - id: s1 label: call @@ -369,16 +368,16 @@ func TestScenarioGroupToYAMLRoundTripsFlow(t *testing.T) { path := writeScenariosFile(t, ` id: g1 name: n -steps: +nodes: menu: say: Press 1. speaker: MACHINE max_visits: count: 2 to: livekit/hangup - voicemail: - beep: false - silence_timeout: 1.5s + timeout: + after: 1.5s + to: livekit/voicemail scenarios: - id: s1 label: call @@ -394,9 +393,9 @@ scenarios: require.True(t, proto.Equal(group, again), "got %v\nyaml:\n%s", again, out) } -func TestInsertScenarioIDsKeepsSteps(t *testing.T) { +func TestInsertScenarioIDsKeepsNodes(t *testing.T) { in := `name: n -steps: +nodes: menu: say: hi # greet scenarios: @@ -409,7 +408,7 @@ scenarios: ids := regexp.MustCompile(`(SCNG?)_\w+`) want := `id: SCNG_x name: n -steps: +nodes: menu: say: hi # greet scenarios: diff --git a/go.mod b/go.mod index 35d334b8d..d8b253200 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0 + github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 39493bc7a..0c1d3c150 100644 --- a/go.sum +++ b/go.sum @@ -366,6 +366,8 @@ github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:Bcli github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0 h1:9vgVVkqvoMqY9gDTeY7sjc43OsgCaQXFi/sKrawi5nU= github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872 h1:h27C5QKbZko55gFrvj4zFFYOlXHH8V0dSzif9PqJ+qM= +github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From 51104265870405f30a2b73ad987711624d983ddc Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Tue, 20 Oct 2026 15:10:41 +0100 Subject: [PATCH 3/7] agent simulate: drop max_visits from simulator flow nodes Menus now repeat at most three times inside the simulator, so the schema no longer has max_visits. --- cmd/lk/simulate_scenario_ids_test.go | 6 +++--- go.mod | 2 +- go.sum | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index 307146e2a..8704609b3 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -372,9 +372,9 @@ nodes: menu: say: Press 1. speaker: MACHINE - max_visits: - count: 2 - to: livekit/hangup + routes: + - match: "1" + to: livekit/hangup timeout: after: 1.5s to: livekit/voicemail diff --git a/go.mod b/go.mod index d8b253200..841709bf0 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872 + github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 0c1d3c150..01a1a0adc 100644 --- a/go.sum +++ b/go.sum @@ -364,10 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0 h1:9vgVVkqvoMqY9gDTeY7sjc43OsgCaQXFi/sKrawi5nU= -github.com/livekit/protocol v1.52.2-0.20261012145918-b440b89363b0/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= -github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872 h1:h27C5QKbZko55gFrvj4zFFYOlXHH8V0dSzif9PqJ+qM= -github.com/livekit/protocol v1.52.2-0.20261013140121-6cffd8387872/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51 h1:fuI+SDujw0elgfFn3sdyt9qIDpU61sKnXSb3MLtQJic= +github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From ac77dcab6b1f58adace148b18b03e0e5daf2373f Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Wed, 21 Oct 2026 11:58:18 +0100 Subject: [PATCH 4/7] agent simulate: drop max_duration from scenarios.yaml --- cmd/lk/simulate.go | 17 ----------------- cmd/lk/simulate_scenario_ids_test.go | 22 +++------------------- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 6 insertions(+), 39 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 2376a3ad0..4090eb5dc 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -45,7 +45,6 @@ import ( "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/durationpb" ) func init() { @@ -373,13 +372,6 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { AgentExpectations: s.GetAgentExpectations(), Tags: s.GetTags(), } - if s.GetMaxDuration() != nil { - v, err := protoToYAMLValue(s.GetMaxDuration()) - if err != nil { - return nil, fmt.Errorf("failed to encode max_duration for scenario %q: %w", s.GetLabel(), err) - } - ys.MaxDuration, _ = v.(string) - } if ys.ID == "" { ys.ID = utils.NewGuid(utils.ScenarioPrefix) } @@ -410,7 +402,6 @@ type yamlScenario struct { Label string `yaml:"label"` Instructions string `yaml:"instructions,omitempty"` Start string `yaml:"start,omitempty"` - MaxDuration string `yaml:"max_duration,omitempty"` AgentExpectations string `yaml:"agent_expectations"` Tags map[string]string `yaml:"tags"` Userdata map[string]any `yaml:"userdata"` @@ -508,13 +499,6 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { if s.Start != "" && group.Nodes[s.Start] == nil && !builtinSimulatorNodes[s.Start] { return nil, fmt.Errorf("scenario %q: start %q is not a node", s.Label, s.Start) } - var maxDuration *durationpb.Duration - if s.MaxDuration != "" { - maxDuration = &durationpb.Duration{} - if err := yamlValueToProto(s.MaxDuration, maxDuration); err != nil { - return nil, fmt.Errorf("scenario %q: max_duration: %w", s.Label, err) - } - } var userdata string if len(s.Userdata) > 0 { b, err := json.Marshal(s.Userdata) @@ -528,7 +512,6 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { Label: s.Label, Instructions: s.Instructions, Start: s.Start, - MaxDuration: maxDuration, AgentExpectations: s.AgentExpectations, Tags: s.Tags, Userdata: userdata, diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index 8704609b3..d50b99831 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -232,7 +232,6 @@ scenarios: - id: s1 label: call start: menu - max_duration: 60s `, want: &livekit.ScenarioGroup{ Id: "g1", @@ -250,10 +249,9 @@ scenarios: }, }, Scenarios: []*livekit.Scenario{{ - Id: "s1", - Label: "call", - Start: "menu", - MaxDuration: durationpb.New(60 * time.Second), + Id: "s1", + Label: "call", + Start: "menu", }}, }, }, @@ -335,19 +333,6 @@ scenarios: `, wantErr: []string{`scenario "call"`, `start "nowhere"`}, }, - { - name: "max_duration without unit", - yaml: ` -id: g1 -name: n` + nodes + ` -scenarios: - - id: s1 - label: call - start: menu - max_duration: 60 -`, - wantErr: []string{`scenario "call"`, "max_duration"}, - }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -382,7 +367,6 @@ scenarios: - id: s1 label: call start: menu - max_duration: 60s `) group, err := loadScenarioGroup(path) require.NoError(t, err) diff --git a/go.mod b/go.mod index 841709bf0..0da469e9a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51 + github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 01a1a0adc..88816cd9c 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51 h1:fuI+SDujw0elgfFn3sdyt9qIDpU61sKnXSb3MLtQJic= -github.com/livekit/protocol v1.52.2-0.20261020140226-e747784b3b51/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668 h1:mHob82bvXkNlaEFaOMQehcM0aDJ6gvZ4JYpfCkkQJko= +github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From 4bdff164449ee7edf33343dcec7d6792a69ce78d Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Wed, 21 Oct 2026 12:17:57 +0100 Subject: [PATCH 5/7] agent simulate: pin protocol to the flow schema head --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0da469e9a..95ebf0394 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668 + github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 88816cd9c..e6eabefcd 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668 h1:mHob82bvXkNlaEFaOMQehcM0aDJ6gvZ4JYpfCkkQJko= -github.com/livekit/protocol v1.53.1-0.20261021105631-cc82b7991668/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= +github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7 h1:Ysee11wxSCqtFF9GJbl6hFlW88hBLZuvKierCjtdWzI= +github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From 62f05ec27b6fed5ce3c4f5e29d7a46c096246749 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Wed, 21 Oct 2026 12:30:26 +0100 Subject: [PATCH 6/7] agent simulate: drop node expectation --- cmd/lk/simulate_scenario_ids_test.go | 2 -- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index d50b99831..dac4a8014 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -215,7 +215,6 @@ nodes: to: livekit/hangup sales: instructions: You are a sales rep. - expectation: Asks about the order. ` cases := []struct { name string @@ -245,7 +244,6 @@ scenarios: }, "sales": { Instructions: "You are a sales rep.", - Expectation: "Asks about the order.", }, }, Scenarios: []*livekit.Scenario{{ diff --git a/go.mod b/go.mod index 95ebf0394..7279a333a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7 + github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index e6eabefcd..1a5e9b334 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7 h1:Ysee11wxSCqtFF9GJbl6hFlW88hBLZuvKierCjtdWzI= -github.com/livekit/protocol v1.53.1-0.20261021111358-8f0255c23cd7/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= +github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658 h1:eGv4bMT1XXAF67kUJoqq3743358SbqENzvD3XxIhqtA= +github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA= From 80f194a168bdae2a00cf5b39af1dd10a7d5c2b65 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Wed, 21 Oct 2026 15:47:42 +0100 Subject: [PATCH 7/7] agent simulate: a scenario without start or instructions loads, as on main --- cmd/lk/simulate.go | 4 ++-- cmd/lk/simulate_scenario_ids_test.go | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 4090eb5dc..4f131e70b 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -493,8 +493,8 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("duplicate scenario id %q", s.ID) } seen[s.ID] = true - if (s.Start == "") == (s.Instructions == "") { - return nil, fmt.Errorf("scenario %q: set exactly one of `start` and `instructions`", s.Label) + if s.Start != "" && s.Instructions != "" { + return nil, fmt.Errorf("scenario %q: set `start` or `instructions`, not both", s.Label) } if s.Start != "" && group.Nodes[s.Start] == nil && !builtinSimulatorNodes[s.Start] { return nil, fmt.Errorf("scenario %q: start %q is not a node", s.Label, s.Start) diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index dac4a8014..0d161d18e 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -317,7 +317,7 @@ scenarios: start: menu instructions: hi `, - wantErr: []string{`scenario "call"`, "exactly one of `start` and `instructions`"}, + wantErr: []string{`scenario "call"`, "`start` or `instructions`, not both"}, }, { name: "unknown start", @@ -400,3 +400,15 @@ scenarios: ` require.Equal(t, want, ids.ReplaceAllString(string(out), "${1}_x")) } + +func TestScenarioWithoutStartOrInstructionsLoads(t *testing.T) { + group, err := loadScenarioGroup(writeScenariosFile(t, ` +id: g1 +name: n +scenarios: + - id: s1 + label: call +`)) + require.NoError(t, err) + require.Len(t, group.GetScenarios(), 1) +}