diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 84a77939c37..0a741c3ea65 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -100,6 +100,10 @@ words: - testdir # Test infrastructure - recordproxy + # Activity protocol local run (Playground) + - agentsplayground + - winget + - ECONNREFUSED # Activity protocol / Teams bot terms - armbotservice - botservice 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 a54436e6647..1cead41b4be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -43,6 +43,8 @@ type runFlags struct { name string startCommand string noInspector bool + noClient bool + channel string } func newRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { @@ -64,8 +66,9 @@ The startup command is read from the startupCommand property of the agent service in azure.yaml. If not set, it is auto-detected from the project type. Use --start-command to override both. -By default, this also opens Agent Inspector after the local agent starts -listening. Use --no-inspector to skip this.`, +By default, this also opens a local client after the agent starts listening: +Agent Inspector for responses/invocations agents, or the Microsoft 365 Agents +Playground for activity agents. Use --no-client to skip this.`, Example: ` # Start the agent in the current directory azd ai agent run @@ -75,8 +78,8 @@ listening. Use --no-inspector to skip this.`, # Start on a custom port azd ai agent run --port 9090 - # Start without opening Agent Inspector - azd ai agent run --no-inspector + # Start without opening a local client + azd ai agent run --no-client # Start with an explicit command azd ai agent run --start-command "python app.py"`, @@ -93,7 +96,15 @@ listening. Use --no-inspector to skip this.`, cmd.Flags().IntVarP(&flags.port, "port", "p", DefaultPort, "Port to listen on") 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 Agent Inspector") + cmd.Flags().BoolVar(&flags.noInspector, "no-inspector", false, "Do not open the local client (Agent Inspector or Playground)") + cmd.Flags().BoolVar(&flags.noClient, "no-client", false, + "Do not open the local client (Agent Inspector or Playground)") + // --no-inspector predates the Playground; --no-client is the canonical, + // protocol-neutral name. Keep --no-inspector working for back-compat but + // hide it from help and nudge users to --no-client. + _ = cmd.Flags().MarkDeprecated("no-inspector", "use --no-client instead") + cmd.Flags().StringVar(&flags.channel, "channel", defaultPlaygroundChannel, + "Channel for the Microsoft 365 Agents Playground (activity-protocol agents only)") return cmd } @@ -124,6 +135,13 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { // environment setup (e.g., setting ASPNETCORE_URLS for .NET). pt := detectProjectType(projectDir) + // Detect whether the target service is an activity agent. + // This is the single gate that keeps all activity-specific local behavior off + // the path of non-activity (responses/invocations) agents — they are entirely + // unaffected. Detection is self-contained (reads the agent definition), so + // this command has no dependency on the deploy-side activity work. + activityProfile := resolveActivityRunProfile(runCtx.Definition) + // Resolve start command: --start-command flag > azure.yaml startupCommand > detect startCmd := flags.startCommand if startCmd == "" { @@ -204,7 +222,27 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { } } - url := fmt.Sprintf("http://localhost:%d", flags.port) + // Activity agents bind IPv4 and are reached at 127.0.0.1 everywhere else + // (the port-readiness check and the Playground URL), because `localhost` + // can resolve to IPv6 ::1 first and fail the connection. Keep the display + // URL consistent so a user copying it hits the same address that works. + displayHost := "localhost" + if activityProfile.IsActivity { + displayHost = "127.0.0.1" + } + url := fmt.Sprintf("http://%s:%d", displayHost, flags.port) + + // Activity agents only round-trip locally in the anonymous + // "digital-worker" auth model. The default "simple" model needs a managed + // identity that doesn't exist off-box, so every message 500s locally. + // Force the toggle on for the local process only — deploy is unaffected + // because this env var is never set outside `run`. Appended last so it wins + // over any duplicate (Go exec uses the last value for a duplicate key). + if activityProfile.IsActivity { + env = append(env, fmt.Sprintf("%s=1", agentDigitalWorkerEnvVar)) + fmt.Printf( + "Activity agent detected: starting in digital-worker (anonymous) mode for local Playground testing.\n") + } // `run` holds the foreground TTY for the agent process and the // `Next:` block is a "wait + new terminal" sequence. Emitting it @@ -231,20 +269,29 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { return fmt.Errorf("failed to start agent: %w", err) } - inspectorInstalled := false - var inspectorInstallErr error - if !flags.noInspector { - inspectorInstalled, inspectorInstallErr = isInspectorExtensionInstalled(ctx, azdClient) + // Auto-launch the local client once the agent binds its port. Activity + // agents use the Microsoft 365 Agents Playground (the only local client that + // speaks the Activity protocol); everything else uses Agent Inspector. Both + // are suppressed by --no-inspector or its neutral alias --no-client. + suppressClient := flags.noInspector || flags.noClient + if activityProfile.IsActivity { + handlePlaygroundAutoLaunch(ctx, flags.port, flags.channel, suppressClient, os.Stderr) + } else { + inspectorInstalled := false + var inspectorInstallErr error + if !suppressClient { + inspectorInstalled, inspectorInstallErr = isInspectorExtensionInstalled(ctx, azdClient) + } + handleInspectorAutoLaunch( + ctx, + azdClient.Workflow(), + flags.port, + suppressClient, + inspectorInstalled, + inspectorInstallErr, + os.Stderr, + ) } - handleInspectorAutoLaunch( - ctx, - azdClient.Workflow(), - flags.port, - flags.noInspector, - inspectorInstalled, - inspectorInstallErr, - os.Stderr, - ) // Emit the `Next:` block once the agent's port is open. We don't // want users alt-tabbing to a fresh terminal and pasting the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_activity.go new file mode 100644 index 00000000000..61e03bc30fc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_activity.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +const ( + // agentsPlaygroundCommand is the Microsoft 365 Agents Playground CLI — the + // only local client that speaks the Activity protocol (/api/messages). + agentsPlaygroundCommand = "agentsplayground" + // defaultPlaygroundChannel is the channel the Playground uses to round-trip + // with a locally hosted activity agent without an Azure Bot registration. + defaultPlaygroundChannel = "emulator" + // agentDigitalWorkerEnvVar toggles the agent's anonymous (digital-worker) + // auth model. `run` sets it for the local process of an activity agent so the + // Playground's emulator channel can round-trip off-box. It is never set for + // deploy, so production keeps the default "simple" model. + agentDigitalWorkerEnvVar = "AGENT_DIGITAL_WORKER" + // playgroundReadyPollPeriod matches the inspector poll cadence. + playgroundReadyPollPeriod = agentInspectorReadyPollPeriod + // activityProtocolCanonicalName and activityProtocolLegacyName are the two + // container-level Activity protocol names accepted in an agent definition. + // These are matched as literals rather than via agent_api.AgentProtocol* + // constants on purpose: the canonical wire value was renamed upstream from + // "activity_protocol" to "activity", so binding to the constant would (a) + // collapse both switch arms onto the same value and (b) silently stop + // matching whichever name the constant no longer holds. Matching both + // literals keeps detection stable regardless of that rename. + activityProtocolCanonicalName = "activity" + activityProtocolLegacyName = "activity_protocol" +) + +// activityRunProfile captures the only activity-protocol fact `azd ai agent run` +// needs: whether the target agent speaks the Activity protocol (and therefore +// wants the Microsoft 365 Agents Playground as its local client instead of Agent +// Inspector). It is deliberately self-contained so local run support does not +// depend on the deploy-side activity provisioning work. +type activityRunProfile struct { + IsActivity bool +} + +// resolveActivityRunProfile returns the activity profile for the run target, or a +// zero profile (IsActivity=false) when the definition is missing or non-activity. +func resolveActivityRunProfile(def *agent_yaml.ContainerAgent) activityRunProfile { + if def == nil { + return activityRunProfile{} + } + return activityRunProfile{IsActivity: isActivityProtocolDefinition(*def)} +} + +// isActivityProtocolDefinition reports whether a hosted agent definition opts +// into the Activity protocol, either through a container-level protocol entry or +// an agent_endpoint advertising the "activity" protocol. Both the canonical +// "activity" and the legacy "activity_protocol" definition names are accepted. +func isActivityProtocolDefinition(ca agent_yaml.ContainerAgent) bool { + for _, p := range ca.Protocols { + switch strings.TrimSpace(p.Protocol) { + case activityProtocolCanonicalName, activityProtocolLegacyName: + return true + } + } + if ca.AgentEndpoint != nil { + for _, p := range ca.AgentEndpoint.Protocols { + if agent_api.AgentEndpointProtocol(strings.TrimSpace(p)) == agent_api.AgentEndpointProtocolActivity { + return true + } + } + } + return false +} + +// playgroundMessagesURL builds the /api/messages endpoint the Playground connects +// to. It always uses 127.0.0.1 (not localhost) because the agent binds IPv4; +// localhost resolves to IPv6 ::1 first and fails with ECONNREFUSED ::1:. +func playgroundMessagesURL(port int) string { + return fmt.Sprintf("http://127.0.0.1:%d/api/messages", port) +} + +// playgroundCommandArgs assembles the exact argv used to launch the Playground. +func playgroundCommandArgs(port int, channel string) []string { + if channel == "" { + channel = defaultPlaygroundChannel + } + return []string{agentsPlaygroundCommand, "-e", playgroundMessagesURL(port), "-c", channel} +} + +// isPlaygroundInstalled reports whether the agentsplayground CLI is on PATH. +func isPlaygroundInstalled() bool { + _, err := exec.LookPath(agentsPlaygroundCommand) + return err == nil +} + +// missingPlaygroundWarning mirrors missingInspectorExtensionWarning: it tells the +// user how to install the Playground and the exact command to run manually. The +// agent keeps running so the local endpoint is still usable (e.g. for a smoke +// test) even without the Playground. +func missingPlaygroundWarning(port int, channel string) string { + return fmt.Sprintf( + "Warning: the Microsoft 365 Agents Playground was not launched because the %q CLI is not installed.\n"+ + "Install it with: winget install agentsplayground\n"+ + "Then run: %s", + agentsPlaygroundCommand, + strings.Join(playgroundCommandArgs(port, channel), " "), + ) +} + +// handlePlaygroundAutoLaunch is the activity-agent analogue of +// handleInspectorAutoLaunch. When not suppressed, it waits for the agent's port +// to bind and then launches the Playground. A missing CLI only warns (with an +// install hint) and never fails the run. +func handlePlaygroundAutoLaunch(ctx context.Context, port int, channel string, suppress bool, stderr io.Writer) { + if suppress { + return + } + if !isPlaygroundInstalled() { + fmt.Fprintln(stderr, missingPlaygroundWarning(port, channel)) + return + } + startPlaygroundAfterAgentReady(ctx, port, channel, playgroundReadyPollPeriod, stderr) +} + +// startPlaygroundAfterAgentReady launches the Playground once localhost: +// accepts connections. It mirrors startInspectorAfterAgentReadyWithOptions: +// launch is deferred until the agent binds so the client doesn't connect before +// the server is ready. +func startPlaygroundAfterAgentReady( + ctx context.Context, + port int, + channel string, + pollPeriod time.Duration, + stderr io.Writer, +) { + go func() { + if err := waitForLocalPort(ctx, port, pollPeriod); err != nil { + if ctx.Err() == nil { + fmt.Fprintf( + stderr, + "Warning: the Microsoft 365 Agents Playground was not launched because localhost:%d was not ready: %v\n", + port, + err, + ) + } + return + } + + fmt.Fprintf( + stderr, + "Launching Microsoft 365 Agents Playground: %s\n", + strings.Join(playgroundCommandArgs(port, channel), " "), + ) + if err := launchPlayground(ctx, port, channel); err != nil && !isContextCancellation(err) { + fmt.Fprintf(stderr, "Warning: the Microsoft 365 Agents Playground was not launched: %v\n", err) + } + }() +} + +// launchPlayground starts the Playground CLI as a child process bound to ctx, so +// it is torn down together with the agent on Ctrl+C. The Playground runs its own +// server and opens a browser tab; its stdio is discarded so it doesn't fight the +// agent for the foreground TTY. +func launchPlayground(ctx context.Context, port int, channel string) error { + args := playgroundCommandArgs(port, channel) + //nolint:gosec // G204: args are fixed literals plus a validated int port and channel string. + proc := exec.CommandContext(ctx, args[0], args[1:]...) + proc.Stdout = io.Discard + proc.Stderr = io.Discard + if err := proc.Start(); err != nil { + return err + } + // exec.Cmd requires Wait to be called after a successful Start to release + // the associated OS resources and reap the child once it exits. The process + // is bound to ctx (killed on Ctrl+C together with the agent), so this Wait + // just reaps it — we don't care about the exit code. + go func() { + _ = proc.Wait() + }() + 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 8c8df93ae30..db2f1f70e0f 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 @@ -1064,3 +1064,135 @@ func TestCheckPythonVersion(t *testing.T) { } } } + +func TestPlaygroundMessagesURLUsesIPv4Loopback(t *testing.T) { + t.Parallel() + + got := playgroundMessagesURL(8088) + want := "http://127.0.0.1:8088/api/messages" + if got != want { + t.Fatalf("playgroundMessagesURL = %q, want %q", got, want) + } + if strings.Contains(got, "localhost") { + t.Fatalf("playground URL must use 127.0.0.1, not localhost: %q", got) + } +} + +func TestPlaygroundCommandArgs(t *testing.T) { + t.Parallel() + + t.Run("explicit channel", func(t *testing.T) { + t.Parallel() + + got := playgroundCommandArgs(9090, "emulator") + want := []string{"agentsplayground", "-e", "http://127.0.0.1:9090/api/messages", "-c", "emulator"} + if !slices.Equal(got, want) { + t.Fatalf("playgroundCommandArgs = %v, want %v", got, want) + } + }) + + t.Run("empty channel falls back to default", func(t *testing.T) { + t.Parallel() + + got := playgroundCommandArgs(8088, "") + want := []string{"agentsplayground", "-e", "http://127.0.0.1:8088/api/messages", "-c", "emulator"} + if !slices.Equal(got, want) { + t.Fatalf("playgroundCommandArgs = %v, want %v", got, want) + } + }) +} + +func TestResolveActivityRunProfile(t *testing.T) { + t.Parallel() + + t.Run("nil definition is not activity", func(t *testing.T) { + t.Parallel() + + if resolveActivityRunProfile(nil).IsActivity { + t.Fatal("nil definition should not resolve as activity") + } + }) + + t.Run("activity endpoint resolves as activity", func(t *testing.T) { + t.Parallel() + + def := &agent_yaml.ContainerAgent{ + AgentEndpoint: &agent_yaml.AgentEndpoint{Protocols: []string{"activity"}}, + } + if !resolveActivityRunProfile(def).IsActivity { + t.Fatal("activity endpoint should resolve as activity") + } + }) + + t.Run("container-level activity protocol resolves as activity", func(t *testing.T) { + t.Parallel() + + for _, name := range []string{"activity", "activity_protocol"} { + def := &agent_yaml.ContainerAgent{ + Protocols: []agent_yaml.ProtocolVersionRecord{{Protocol: name}}, + } + if !resolveActivityRunProfile(def).IsActivity { + t.Fatalf("protocol %q should resolve as activity", name) + } + } + }) + + t.Run("non-activity definition is not activity", func(t *testing.T) { + t.Parallel() + + if resolveActivityRunProfile(&agent_yaml.ContainerAgent{}).IsActivity { + t.Fatal("empty definition should not resolve as activity") + } + }) +} + +func TestRunCommandActivityFlags(t *testing.T) { + t.Parallel() + + cmd := newRunCommand(nil) + if cmd.Flags().Lookup("no-client") == nil { + t.Fatal("run command should expose --no-client") + } + channel := cmd.Flags().Lookup("channel") + if channel == nil { + t.Fatal("run command should expose --channel") + } + if channel.DefValue != defaultPlaygroundChannel { + t.Fatalf("--channel default = %q, want %q", channel.DefValue, defaultPlaygroundChannel) + } + // --no-inspector is kept for back-compat but deprecated in favor of + // --no-client: it must still resolve (so existing scripts work) yet carry + // a deprecation message (so cobra hides it from help and warns on use). + noInspector := cmd.Flags().Lookup("no-inspector") + if noInspector == nil { + t.Fatal("run command should still expose --no-inspector for back-compat") + } + if noInspector.Deprecated == "" { + t.Fatal("--no-inspector should be marked deprecated in favor of --no-client") + } +} + +func TestHandlePlaygroundAutoLaunchSuppressed(t *testing.T) { + t.Parallel() + + var buf lockedBuffer + // Suppressed: must not warn or attempt anything even if the CLI is missing. + handlePlaygroundAutoLaunch(t.Context(), 8088, "emulator", true, &buf) + if buf.String() != "" { + t.Fatalf("suppressed auto-launch should be silent, got: %q", buf.String()) + } +} + +func TestMissingPlaygroundWarning(t *testing.T) { + t.Parallel() + + warning := missingPlaygroundWarning(9090, "emulator") + for _, want := range []string{ + "winget install agentsplayground", + "agentsplayground -e http://127.0.0.1:9090/api/messages -c emulator", + } { + if !strings.Contains(warning, want) { + t.Fatalf("warning missing %q:\n%s", want, warning) + } + } +}