Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 72 additions & 16 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"time"

"azureaiagent/internal/cmd/nextstep"
"azureaiagent/internal/exterrors"
"azureaiagent/internal/pkg/agents/agent_yaml"
"azureaiagent/internal/project"

Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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)")
Expand All @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateInspectorPort runs before we know whether the inspector will launch at all, so two paths accept --inspector-port and then drop it:

  • --no-client (or the deprecated --no-inspector) makes handleInspectorAutoLaunch return early, so the port never reaches the workflow.
  • Activity agent projects take the handlePlaygroundAutoLaunch branch instead, which has no inspector port at all.

The doc comment on validateInspectorPort says the reason to validate here is to stop a value from being silently dropped, so the suppressed-client case looks like it deserves the same treatment. init.go already uses exterrors.CodeConflictingArguments with cmd.Flags().Changed(...) for this shape of conflict.

The activity agent case is murkier, since the user can't always predict which branch they'll land on, so a stderr warning may fit better there than a hard error.

return err
}

azdClient, err := azdext.NewAzdClient()
if err != nil {
return fmt.Errorf("failed to create azd client: %w", err)
Expand Down Expand Up @@ -309,6 +332,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error {
ctx,
azdClient.Workflow(),
flags.port,
flags.inspectorPort,
suppressClient,
inspectorInstalled,
inspectorInstallErr,
Expand Down Expand Up @@ -366,6 +390,7 @@ func handleInspectorAutoLaunch(
ctx context.Context,
workflow azdext.WorkflowServiceClient,
agentPort int,
inspectorPort int,
noInspector bool,
inspectorInstalled bool,
inspectorInstallErr error,
Expand All @@ -386,6 +411,7 @@ func handleInspectorAutoLaunch(
ctx,
workflow,
agentPort,
inspectorPort,
agentInspectorReadyPollPeriod,
stderr,
)
Expand All @@ -395,6 +421,7 @@ func startInspectorAfterAgentReadyWithOptions(
ctx context.Context,
workflow azdext.WorkflowServiceClient,
agentPort int,
inspectorPort int,
pollPeriod time.Duration,
stderr io.Writer,
) {
Expand All @@ -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))
}
}()
Expand Down Expand Up @@ -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,
},
},
},
Expand All @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider rejecting inspectorPort == agentPort too. azd ai agent run --port 9091 --inspector-port 9091 gets through this check, then the agent binds 127.0.0.1:9091 and the inspector tries to bind the same address and fails. That's the collision this flag exists to prevent, and catching it up front gives the same clearer-error benefit this doc comment describes.

Passing the agent port into this function would change the signature the new tests use, so a separate check in runRun next to the existing call may be the smaller change.

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 {
Expand Down
111 changes: 101 additions & 10 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -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)
}
})
}
}

Expand Down Expand Up @@ -309,6 +399,7 @@ func TestInspectorLaunchFailureOnlyWarns(t *testing.T) {
ctx,
workflow,
ln.Addr().(*net.TCPAddr).Port,
0,
time.Millisecond,
&stderr,
)
Expand Down Expand Up @@ -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:
Expand Down