From 9a0ceca2dba05cbfd6de2380c0ad44ad27fe0465 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Thu, 30 Jul 2026 10:39:11 -0400 Subject: [PATCH 1/2] feat(ai-agents): add --inspector-port flag to agent run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fed9e97b-e79b-4889-ac76-0d9a428599cd --- cli/azd/extensions/azure.ai.agents/README.md | 8 ++ .../azure.ai.agents/internal/cmd/run.go | 82 ++++++++++--- .../azure.ai.agents/internal/cmd/run_test.go | 109 ++++++++++++++++-- 3 files changed, 173 insertions(+), 26 deletions(-) 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..b98592ecbcb 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,23 @@ 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. Zero means + // unset, in which case --inspector-port is not forwarded to the inspector. + inspectorPort int + name string + startCommand string + noInspector bool + noClient bool + channel string } type environmentEntry struct { @@ -83,6 +92,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 @@ -99,6 +111,8 @@ Playground for activity agents. Use --no-client to skip this.`, } 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 +129,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); err != nil { + return err + } + azdClient, err := azdext.NewAzdClient() if err != nil { return fmt.Errorf("failed to create azd client: %w", err) @@ -309,6 +327,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { ctx, azdClient.Workflow(), flags.port, + flags.inspectorPort, suppressClient, inspectorInstalled, inspectorInstallErr, @@ -366,6 +385,7 @@ func handleInspectorAutoLaunch( ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int, + inspectorPort int, noInspector bool, inspectorInstalled bool, inspectorInstallErr error, @@ -386,6 +406,7 @@ func handleInspectorAutoLaunch( ctx, workflow, agentPort, + inspectorPort, agentInspectorReadyPollPeriod, stderr, ) @@ -395,6 +416,7 @@ func startInspectorAfterAgentReadyWithOptions( ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int, + inspectorPort int, pollPeriod time.Duration, stderr io.Writer, ) { @@ -411,7 +433,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 +463,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 +498,22 @@ func launchInspector(ctx context.Context, workflow azdext.WorkflowServiceClient, return err } +// validateInspectorPort rejects out-of-range --inspector-port values. Zero means +// the flag was not set: the inspector extension then applies its own default UI +// port. 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) error { + if inspectorPort == 0 || (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..de066cdb02c 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,106 @@ 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 + wantErr bool + }{ + {name: "unset is allowed", port: 0}, + {name: "lower bound", port: 1}, + {name: "typical port", port: 9002}, + {name: "upper bound", port: 65535}, + {name: "negative is rejected", port: -1, wantErr: true}, + {name: "above range is rejected", port: 70000, 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) + if tt.wantErr { + if err == nil { + t.Fatalf("validateInspectorPort(%d) = nil, want error", tt.port) + } + 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) = %v, want nil", tt.port, err) + } + }) } } @@ -309,6 +397,7 @@ func TestInspectorLaunchFailureOnlyWarns(t *testing.T) { ctx, workflow, ln.Addr().(*net.TCPAddr).Port, + 0, time.Millisecond, &stderr, ) @@ -364,7 +453,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: From edcea336784cc73b67c182c25b66f4a58270743a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:50:18 +0000 Subject: [PATCH 2/2] fix(ai-agents): reject explicit --inspector-port 0 Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/run.go | 34 +++++++++++-------- .../azure.ai.agents/internal/cmd/run_test.go | 18 +++++----- 2 files changed, 30 insertions(+), 22 deletions(-) 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 b98592ecbcb..bfce324c30a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -46,14 +46,18 @@ const ( type runFlags struct { port int - // inspectorPort is the port the Agent Inspector UI listens on. Zero means - // unset, in which case --inspector-port is not forwarded to the inspector. + // 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 - name string - startCommand string - noInspector bool - noClient bool - channel string + // 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 { @@ -105,6 +109,7 @@ 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) }, @@ -129,7 +134,7 @@ 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); err != nil { + if err := validateInspectorPort(flags.inspectorPort, flags.inspectorPortSet); err != nil { return err } @@ -498,12 +503,13 @@ func launchInspector( return err } -// validateInspectorPort rejects out-of-range --inspector-port values. Zero means -// the flag was not set: the inspector extension then applies its own default UI -// port. 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) error { - if inspectorPort == 0 || (inspectorPort >= 1 && inspectorPort <= 65535) { +// 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 } 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 de066cdb02c..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 @@ -313,24 +313,26 @@ func TestValidateInspectorPort(t *testing.T) { tests := []struct { name string port int + set bool wantErr bool }{ {name: "unset is allowed", port: 0}, - {name: "lower bound", port: 1}, - {name: "typical port", port: 9002}, - {name: "upper bound", port: 65535}, - {name: "negative is rejected", port: -1, wantErr: true}, - {name: "above range is rejected", port: 70000, wantErr: true}, + {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}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateInspectorPort(tt.port) + err := validateInspectorPort(tt.port, tt.set) if tt.wantErr { if err == nil { - t.Fatalf("validateInspectorPort(%d) = nil, want error", tt.port) + 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()) @@ -338,7 +340,7 @@ func TestValidateInspectorPort(t *testing.T) { return } if err != nil { - t.Fatalf("validateInspectorPort(%d) = %v, want nil", tt.port, err) + t.Fatalf("validateInspectorPort(%d, %t) = %v, want nil", tt.port, tt.set, err) } }) }