diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index d7df2680..8eb50d74 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -227,6 +227,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 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 export -r -d 'Print the run with run `ID` and its exact per-job chat contexts as JSON. Nothing is run or polled: the run must already be finished' 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 deployment -s d -r -d 'deployment of the agent to dispatch to (leave empty for production)' 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 audio' -a 'audio' -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from audio' -f -l background-noise -d 'Mix ambient noise into the simulated user\'s audio' diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 315345b8..e2480ce5 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -105,6 +105,11 @@ var simulateCommand = &cli.Command{ 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.", }, + &cli.StringFlag{ + Name: "deployment", + Usage: "deployment of the agent to dispatch to (leave empty for production)", + Aliases: []string{"d"}, + }, }, } @@ -114,7 +119,7 @@ var simulateCommand = &cli.Command{ var simulateAudioCommand = &cli.Command{ Name: "audio", Usage: "Simulate speech-to-speech interactions using the agent's full audio pipeline", - Description: "Options on lk agent simulate apply here too, e.g. --scenarios and --agent-name.", + Description: "Options on lk agent simulate apply here too, e.g. --scenarios, --agent-name, and --deployment.", ArgsUsage: "[entrypoint]", HideHelpCommand: true, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -222,8 +227,9 @@ type simulateConfig struct { lowQualityMicrophone bool packetLoss bool - // TODO (steveyoon): add agent deployment support - // agentDeployment string + // Cloud AgentDispatch.deployment. Empty = production. Only meaningful + // with --agent-name; a locally spawned worker is not a Cloud deployment. + agentDeployment string } type simulateMode int @@ -339,6 +345,11 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S // --agent-name (even empty) means: run against an already-running agent, // don't spawn one. https://docs.livekit.io/agents/server/agent-dispatch/#automatic + agentDeployment := cmd.String("deployment") + if cmd.IsSet("deployment") && agentDeployment != "" && !cmd.IsSet("agent-name") { + return fmt.Errorf("--deployment requires --agent-name (a locally spawned worker is not a Cloud deployment)") + } + if cmd.IsSet("agent-name") { // nothing is spawned, so there's no source to generate scenarios from. if scenariosPath == "" { @@ -402,22 +413,23 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S simClient := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) simCfg := &simulateConfig{ - ctx: ctx, - client: simClient, - pc: pc, - numSimulations: numSimulations, - concurrency: concurrency, - mode: mode, - simulationMode: simulationMode, - agentName: agentName, - projectDir: projectDir, - projectType: projectType, - entrypoint: entrypoint, - scenarioGroup: scenarioGroup, - scenariosPath: scenariosPath, - viewModeRunID: runID, - liveAgent: liveAgent, - warnings: simulateConfigWarnings(mode, numSimulations), + ctx: ctx, + client: simClient, + pc: pc, + numSimulations: numSimulations, + concurrency: concurrency, + mode: mode, + simulationMode: simulationMode, + agentName: agentName, + projectDir: projectDir, + projectType: projectType, + entrypoint: entrypoint, + scenarioGroup: scenarioGroup, + scenariosPath: scenariosPath, + viewModeRunID: runID, + liveAgent: liveAgent, + agentDeployment: agentDeployment, + warnings: simulateConfigWarnings(mode, numSimulations), } if simulationMode == livekit.SimulationMode_SIMULATION_MODE_AUDIO { @@ -570,6 +582,7 @@ func createSimulationRun(ctx context.Context, c *simulateConfig) (string, *livek LowQualityMicrophone: c.lowQualityMicrophone, PacketLoss: c.packetLoss, } + setSimulationCreateDeployment(req, c.agentDeployment) if c.concurrency > 0 { req.Concurrency = &c.concurrency } diff --git a/cmd/lk/simulate_deployment.go b/cmd/lk/simulate_deployment.go new file mode 100644 index 00000000..5b9dfae4 --- /dev/null +++ b/cmd/lk/simulate_deployment.go @@ -0,0 +1,44 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/livekit/protocol/livekit" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// simulationCreateDeploymentField is SimulationRun.Create.Request.deployment. +// Empty/unset = production, matching CreateAgentDispatchRequest.deployment. +const simulationCreateDeploymentField protowire.Number = 14 + +// setSimulationCreateDeployment writes deployment onto the create request. +// Prefer the generated field when this CLI's protocol module has it; otherwise +// emit protobuf field 14 as unknown bytes so Cloud can pin AgentDispatch before +// a protocol module bump lands here. +func setSimulationCreateDeployment(req *livekit.SimulationRun_Create_Request, deployment string) { + if req == nil || deployment == "" { + return + } + msg := req.ProtoReflect() + if fd := msg.Descriptor().Fields().ByName("deployment"); fd != nil { + msg.Set(fd, protoreflect.ValueOfString(deployment)) + return + } + var b []byte + b = protowire.AppendTag(b, simulationCreateDeploymentField, protowire.BytesType) + b = protowire.AppendString(b, deployment) + msg.SetUnknown(append(msg.GetUnknown(), b...)) +} diff --git a/cmd/lk/simulate_deployment_test.go b/cmd/lk/simulate_deployment_test.go new file mode 100644 index 00000000..27e0feed --- /dev/null +++ b/cmd/lk/simulate_deployment_test.go @@ -0,0 +1,59 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +func TestSetSimulationCreateDeploymentEmitsField14(t *testing.T) { + req := &livekit.SimulationRun_Create_Request{AgentName: "myudda-agent"} + setSimulationCreateDeployment(req, "staging") + + raw, err := proto.Marshal(req) + require.NoError(t, err) + + var got string + rest := raw + for len(rest) > 0 { + num, typ, n := protowire.ConsumeTag(rest) + require.Greater(t, n, 0) + rest = rest[n:] + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(rest) + require.Greater(t, n, 0) + rest = rest[n:] + if num == simulationCreateDeploymentField { + got = string(val) + } + continue + } + skip := protowire.ConsumeFieldValue(num, typ, rest) + require.Greater(t, skip, 0) + rest = rest[skip:] + } + require.Equal(t, "staging", got) +} + +func TestSetSimulationCreateDeploymentEmptyIsOmitted(t *testing.T) { + req := &livekit.SimulationRun_Create_Request{AgentName: "myudda-agent"} + setSimulationCreateDeployment(req, "") + require.Empty(t, req.ProtoReflect().GetUnknown()) +}