diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index e84a4f60c99..d90990e8dae 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -13,6 +13,14 @@ Use `--no-inspector` to run only the local agent process: azd ai agent run --no-inspector ``` +The Agent Inspector UI binds port `8087` by default. Use `--inspector-port` to +move it, which is what you need when running two agents side by side or when a +stale process still holds the default port: + +```bash +azd ai agent run --port 9091 --inspector-port 9002 +``` + ## Migrating Legacy Agent Configuration New Foundry agent projects keep the agent definition directly on the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 81ca1988fa4..bfce324c30a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -24,6 +24,7 @@ import ( "time" "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" @@ -36,15 +37,27 @@ import ( const ( agentInspectorExtensionID = "azure.ai.inspector" agentInspectorReadyPollPeriod = 250 * time.Millisecond + // defaultInspectorUIPort mirrors the default UI port of the + // azure.ai.inspector extension. It is referenced only in help text: when + // --inspector-port is unset we do not forward the flag, so the inspector + // extension remains the single source of truth for the actual default. + defaultInspectorUIPort = 8087 ) type runFlags struct { - port int - name string - startCommand string - noInspector bool - noClient bool - channel string + port int + // inspectorPort is the port the Agent Inspector UI listens on. When + // inspectorPortSet is false the flag was not supplied and + // --inspector-port is not forwarded to the inspector. + inspectorPort int + // inspectorPortSet records whether --inspector-port was explicitly + // supplied, so an explicit (and invalid) 0 is not mistaken for unset. + inspectorPortSet bool + name string + startCommand string + noInspector bool + noClient bool + channel string } type environmentEntry struct { @@ -83,6 +96,9 @@ Playground for activity agents. Use --no-client to skip this.`, # Start on a custom port azd ai agent run --port 9090 + # Start a second agent with its own Agent Inspector UI port + azd ai agent run --port 9091 --inspector-port 9002 + # Start without opening a local client azd ai agent run --no-client @@ -93,12 +109,15 @@ Playground for activity agents. Use --no-client to skip this.`, if len(args) > 0 { flags.name = args[0] } + flags.inspectorPortSet = cmd.Flags().Changed("inspector-port") ctx := azdext.WithAccessToken(cmd.Context()) return runRun(ctx, flags, extCtx.NoPrompt) }, } cmd.Flags().IntVarP(&flags.port, "port", "p", DefaultPort, "Port to listen on") + cmd.Flags().IntVar(&flags.inspectorPort, "inspector-port", 0, + fmt.Sprintf("Port the Agent Inspector UI listens on (default: %d)", defaultInspectorUIPort)) cmd.Flags().StringVarP(&flags.startCommand, "start-command", "c", "", "Explicit startup command (overrides azure.yaml and auto-detection)") cmd.Flags().BoolVar(&flags.noInspector, "no-inspector", false, "Do not open the local client (Agent Inspector or Playground)") @@ -115,6 +134,10 @@ Playground for activity agents. Use --no-client to skip this.`, } func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { + if err := validateInspectorPort(flags.inspectorPort, flags.inspectorPortSet); err != nil { + return err + } + azdClient, err := azdext.NewAzdClient() if err != nil { return fmt.Errorf("failed to create azd client: %w", err) @@ -309,6 +332,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { ctx, azdClient.Workflow(), flags.port, + flags.inspectorPort, suppressClient, inspectorInstalled, inspectorInstallErr, @@ -366,6 +390,7 @@ func handleInspectorAutoLaunch( ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int, + inspectorPort int, noInspector bool, inspectorInstalled bool, inspectorInstallErr error, @@ -386,6 +411,7 @@ func handleInspectorAutoLaunch( ctx, workflow, agentPort, + inspectorPort, agentInspectorReadyPollPeriod, stderr, ) @@ -395,6 +421,7 @@ func startInspectorAfterAgentReadyWithOptions( ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int, + inspectorPort int, pollPeriod time.Duration, stderr io.Writer, ) { @@ -411,7 +438,7 @@ func startInspectorAfterAgentReadyWithOptions( return } - if err := launchInspector(ctx, workflow, agentPort); err != nil && !isContextCancellation(err) { + if err := launchInspector(ctx, workflow, agentPort, inspectorPort); err != nil && !isContextCancellation(err) { fmt.Fprintln(stderr, inspectorLaunchWarning(err)) } }() @@ -441,21 +468,33 @@ func waitForLocalPort(ctx context.Context, port int, pollPeriod time.Duration) e } } -func launchInspector(ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int) error { +func launchInspector( + ctx context.Context, + workflow azdext.WorkflowServiceClient, + agentPort int, + inspectorPort int, +) error { + args := []string{ + "ai", + "inspector", + "launch", + "--port", + strconv.Itoa(agentPort), + } + // Only forward --inspector-port when the user asked for a specific UI port, + // so the inspector extension keeps applying its own default otherwise. + if inspectorPort > 0 { + args = append(args, "--inspector-port", strconv.Itoa(inspectorPort)) + } + args = append(args, "--silent") + _, err := workflow.Run(ctx, &azdext.RunWorkflowRequest{ Workflow: &azdext.Workflow{ Name: "launch-agent-inspector", Steps: []*azdext.WorkflowStep{ { Command: &azdext.WorkflowCommand{ - Args: []string{ - "ai", - "inspector", - "launch", - "--port", - strconv.Itoa(agentPort), - "--silent", - }, + Args: args, }, }, }, @@ -464,6 +503,23 @@ func launchInspector(ctx context.Context, workflow azdext.WorkflowServiceClient, return err } +// validateInspectorPort rejects out-of-range --inspector-port values. When the +// flag was not supplied (set is false) the inspector extension applies its own +// default UI port. An explicitly supplied zero is out of range and rejected. +// Validating here keeps an invalid value from being silently dropped or failing +// later inside the inspector with a less obvious message. +func validateInspectorPort(inspectorPort int, set bool) error { + if !set || (inspectorPort >= 1 && inspectorPort <= 65535) { + return nil + } + + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--inspector-port must be between 1 and 65535, got %d", inspectorPort), + "pass a free TCP port, for example --inspector-port 9002", + ) +} + func isInspectorExtensionInstalled(ctx context.Context, azdClient *azdext.AzdClient) (bool, error) { configHelper, err := azdext.NewConfigHelper(azdClient) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index 883e3502a47..1d606caa386 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -16,6 +16,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" "sync" "testing" @@ -240,19 +241,108 @@ func TestWaitForLocalPort(t *testing.T) { func TestLaunchInspectorUsesWorkflowCommand(t *testing.T) { t.Parallel() - workflow := &recordingWorkflowClient{} - if err := launchInspector(t.Context(), workflow, 9090); err != nil { - t.Fatalf("launchInspector returned error: %v", err) + tests := []struct { + name string + agentPort int + inspectorPort int + want []string + }{ + { + name: "inspector port unset is not forwarded", + agentPort: 9090, + want: []string{"ai", "inspector", "launch", "--port", "9090", "--silent"}, + }, + { + name: "inspector port is forwarded when set", + agentPort: 9091, + inspectorPort: 9002, + want: []string{ + "ai", "inspector", "launch", + "--port", "9091", + "--inspector-port", "9002", + "--silent", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + workflow := &recordingWorkflowClient{} + if err := launchInspector(t.Context(), workflow, tt.agentPort, tt.inspectorPort); err != nil { + t.Fatalf("launchInspector returned error: %v", err) + } + + if workflow.request == nil || workflow.request.Workflow == nil || + len(workflow.request.Workflow.Steps) != 1 { + t.Fatalf("unexpected workflow request: %#v", workflow.request) + } + + got := workflow.request.Workflow.Steps[0].Command.Args + if !slices.Equal(got, tt.want) { + t.Fatalf("workflow args = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRunCommandInspectorPortFlag(t *testing.T) { + t.Parallel() + + cmd := newRunCommand(nil) + + flag := cmd.Flags().Lookup("inspector-port") + if flag == nil { + t.Fatal("run command should expose --inspector-port") + } + // Zero means unset so the inspector extension keeps applying its own + // default UI port; the effective default is documented in the usage text. + if flag.DefValue != "0" { + t.Fatalf("--inspector-port default = %q, want %q", flag.DefValue, "0") } + if !strings.Contains(flag.Usage, strconv.Itoa(defaultInspectorUIPort)) { + t.Fatalf("--inspector-port usage should document the %d default, got %q", + defaultInspectorUIPort, flag.Usage) + } +} - if workflow.request == nil || workflow.request.Workflow == nil || len(workflow.request.Workflow.Steps) != 1 { - t.Fatalf("unexpected workflow request: %#v", workflow.request) +func TestValidateInspectorPort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + set bool + wantErr bool + }{ + {name: "unset is allowed", port: 0}, + {name: "explicit zero is rejected", port: 0, set: true, wantErr: true}, + {name: "lower bound", port: 1, set: true}, + {name: "typical port", port: 9002, set: true}, + {name: "upper bound", port: 65535, set: true}, + {name: "negative is rejected", port: -1, set: true, wantErr: true}, + {name: "above range is rejected", port: 70000, set: true, wantErr: true}, } - got := workflow.request.Workflow.Steps[0].Command.Args - want := []string{"ai", "inspector", "launch", "--port", "9090", "--silent"} - if !slices.Equal(got, want) { - t.Fatalf("workflow args = %v, want %v", got, want) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateInspectorPort(tt.port, tt.set) + if tt.wantErr { + if err == nil { + t.Fatalf("validateInspectorPort(%d, %t) = nil, want error", tt.port, tt.set) + } + if !strings.Contains(err.Error(), "--inspector-port") { + t.Fatalf("error should name the flag, got %q", err.Error()) + } + return + } + if err != nil { + t.Fatalf("validateInspectorPort(%d, %t) = %v, want nil", tt.port, tt.set, err) + } + }) } } @@ -309,6 +399,7 @@ func TestInspectorLaunchFailureOnlyWarns(t *testing.T) { ctx, workflow, ln.Addr().(*net.TCPAddr).Port, + 0, time.Millisecond, &stderr, ) @@ -364,7 +455,7 @@ func TestNoInspectorSkipsWorkflowLaunch(t *testing.T) { t.Parallel() workflow := &recordingWorkflowClient{called: make(chan struct{})} - handleInspectorAutoLaunch(t.Context(), workflow, 8088, true, true, nil, io.Discard) + handleInspectorAutoLaunch(t.Context(), workflow, 8088, 0, true, true, nil, io.Discard) select { case <-workflow.called: