Skip to content
Merged
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
4 changes: 4 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ words:
- testdir
# Test infrastructure
- recordproxy
# Activity protocol local run (Playground)
- agentsplayground
- winget
- ECONNREFUSED
# Activity protocol / Teams bot terms
- armbotservice
- botservice
Expand Down
85 changes: 66 additions & 19 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ type runFlags struct {
name string
startCommand string
noInspector bool
noClient bool
channel string
}

func newRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
Expand All @@ -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

Expand All @@ -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"`,
Expand All @@ -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)")
Comment thread
v1212 marked this conversation as resolved.
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
}
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
189 changes: 189 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run_activity.go
Original file line number Diff line number Diff line change
@@ -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:<port>.
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:<port>
// 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
}
Loading
Loading