Skip to content

Commit f878993

Browse files
authored
feat: launch Agent Inspector from azd ai agent run (Azure#8264)
1 parent 52e2976 commit f878993

127 files changed

Lines changed: 6499 additions & 237 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/approval-ext-azure-ai-inspector.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
EXTENSION_PATH: "cli/azd/extensions/azure.ai.inspector/"
4949
WORKFLOW_PATH: ".github/workflows/approval-ext-azure-ai-inspector.yml"
5050
OVERRIDE_COMMAND: "/inspector-extension-approval override"
51+
REQUIRED_APPROVERS: '["anchenyi", "XiaofuHuang", "swatDong", "trangevi"]'
5152
with:
5253
script: |
5354
const script = require('./.github/scripts/pr-approval-foundry-extensions-shared.js');

cli/azd/extensions/azure.ai.agents/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Azure Developer CLI (azd) Agents Extension
22

3+
## Running Local Agents
4+
5+
`azd ai agent run` starts the selected agent locally and, by default, opens the
6+
Agent Inspector after the local agent port is accepting connections. The
7+
inspector launch is best-effort: if `azure.ai.inspector` is not installed or
8+
fails to start, the agent process keeps running and azd prints install guidance.
9+
10+
Use `--no-inspector` to run only the local agent process:
11+
12+
```bash
13+
azd ai agent run --no-inspector
14+
```
15+
316
## Local Development
417

518
### Prerequisites

cli/azd/extensions/azure.ai.agents/internal/cmd/run.go

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,40 @@ package cmd
55

66
import (
77
"context"
8+
"encoding/json"
9+
"errors"
810
"fmt"
11+
"io"
912
"log"
13+
"net"
1014
"os"
1115
"os/exec"
1216
"os/signal"
1317
"path/filepath"
1418
"runtime"
1519
"slices"
20+
"strconv"
1621
"strings"
1722
"syscall"
23+
"time"
1824

1925
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2026
"github.com/spf13/cobra"
27+
"google.golang.org/grpc/codes"
28+
"google.golang.org/grpc/status"
29+
)
30+
31+
const (
32+
agentInspectorExtensionID = "azure.ai.inspector"
33+
agentInspectorReadyTimeout = 30 * time.Second
34+
agentInspectorReadyPollPeriod = 250 * time.Millisecond
2135
)
2236

2337
type runFlags struct {
2438
port int
2539
name string
2640
startCommand string
41+
noInspector bool
2742
}
2843

2944
func newRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
@@ -43,7 +58,10 @@ positional argument. When omitted, the single agent service is used.
4358
4459
The startup command is read from the startupCommand property of the
4560
agent service in azure.yaml. If not set, it is auto-detected from the
46-
project type. Use --start-command to override both.`,
61+
project type. Use --start-command to override both.
62+
63+
By default, this also opens Agent Inspector after the local agent starts
64+
listening. Use --no-inspector to skip this.`,
4765
Example: ` # Start the agent in the current directory
4866
azd ai agent run
4967
@@ -53,6 +71,9 @@ project type. Use --start-command to override both.`,
5371
# Start on a custom port
5472
azd ai agent run --port 9090
5573
74+
# Start without opening Agent Inspector
75+
azd ai agent run --no-inspector
76+
5677
# Start with an explicit command
5778
azd ai agent run --start-command "python app.py"`,
5879
Args: cobra.MaximumNArgs(1),
@@ -68,6 +89,7 @@ project type. Use --start-command to override both.`,
6889
cmd.Flags().IntVarP(&flags.port, "port", "p", DefaultPort, "Port to listen on")
6990
cmd.Flags().StringVarP(&flags.startCommand, "start-command", "c", "",
7091
"Explicit startup command (overrides azure.yaml and auto-detection)")
92+
cmd.Flags().BoolVar(&flags.noInspector, "no-inspector", false, "Do not open Agent Inspector")
7193

7294
return cmd
7395
}
@@ -155,6 +177,8 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error {
155177
env = append(env, fmt.Sprintf("%s=%s", k, v))
156178
}
157179
env = appendFoundryEnvVars(env, azdEnvVars, runCtx.ServiceName)
180+
} else if shouldWarnLoadAzdEnvironmentFailure(err) {
181+
fmt.Fprintf(os.Stderr, "Warning: failed to load azd environment values: %s\n", err)
158182
}
159183

160184
// Resolve ${{connections.<name>.credentials.<key>}} references from the
@@ -187,6 +211,21 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error {
187211
return fmt.Errorf("failed to start agent: %w", err)
188212
}
189213

214+
inspectorInstalled := false
215+
var inspectorInstallErr error
216+
if !flags.noInspector {
217+
inspectorInstalled, inspectorInstallErr = isInspectorExtensionInstalled(ctx, azdClient)
218+
}
219+
handleInspectorAutoLaunch(
220+
ctx,
221+
azdClient.Workflow(),
222+
flags.port,
223+
flags.noInspector,
224+
inspectorInstalled,
225+
inspectorInstallErr,
226+
os.Stderr,
227+
)
228+
190229
// Handle Ctrl+C / SIGTERM: forward signal to child, then wait for it to exit.
191230
// The done channel is closed after proc.Wait returns so the goroutine can exit.
192231
sigCh := make(chan os.Signal, 1)
@@ -217,6 +256,174 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error {
217256
return nil
218257
}
219258

259+
func handleInspectorAutoLaunch(
260+
ctx context.Context,
261+
workflow azdext.WorkflowServiceClient,
262+
agentPort int,
263+
noInspector bool,
264+
inspectorInstalled bool,
265+
inspectorInstallErr error,
266+
stderr io.Writer,
267+
) {
268+
if noInspector {
269+
return
270+
}
271+
if inspectorInstallErr != nil {
272+
fmt.Fprintf(stderr, "Warning: Agent Inspector was not launched: %v\n", inspectorInstallErr)
273+
return
274+
}
275+
if !inspectorInstalled {
276+
fmt.Fprintln(stderr, missingInspectorExtensionWarning())
277+
return
278+
}
279+
startInspectorAfterAgentReadyWithOptions(
280+
ctx,
281+
workflow,
282+
agentPort,
283+
agentInspectorReadyTimeout,
284+
agentInspectorReadyPollPeriod,
285+
stderr,
286+
)
287+
}
288+
289+
func startInspectorAfterAgentReadyWithOptions(
290+
ctx context.Context,
291+
workflow azdext.WorkflowServiceClient,
292+
agentPort int,
293+
readyTimeout time.Duration,
294+
pollPeriod time.Duration,
295+
stderr io.Writer,
296+
) {
297+
go func() {
298+
waitCtx, cancel := context.WithTimeout(ctx, readyTimeout)
299+
defer cancel()
300+
301+
if err := waitForLocalPort(waitCtx, agentPort, pollPeriod); err != nil {
302+
if ctx.Err() == nil {
303+
fmt.Fprintf(
304+
stderr,
305+
"Warning: Agent Inspector was not launched because localhost:%d was not ready: %v\n",
306+
agentPort,
307+
err,
308+
)
309+
}
310+
return
311+
}
312+
313+
if err := launchInspector(ctx, workflow, agentPort); err != nil && !isContextCancellation(err) {
314+
fmt.Fprintln(stderr, inspectorLaunchWarning(err))
315+
}
316+
}()
317+
}
318+
319+
func waitForLocalPort(ctx context.Context, port int, pollPeriod time.Duration) error {
320+
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
321+
dialer := net.Dialer{Timeout: pollPeriod}
322+
ticker := time.NewTicker(pollPeriod)
323+
defer ticker.Stop()
324+
325+
for {
326+
conn, err := dialer.DialContext(ctx, "tcp", address)
327+
if err == nil {
328+
_ = conn.Close()
329+
return nil
330+
}
331+
332+
select {
333+
case <-ctx.Done():
334+
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
335+
return fmt.Errorf("timed out waiting for %s to accept connections", address)
336+
}
337+
return ctx.Err()
338+
case <-ticker.C:
339+
}
340+
}
341+
}
342+
343+
func launchInspector(ctx context.Context, workflow azdext.WorkflowServiceClient, agentPort int) error {
344+
_, err := workflow.Run(ctx, &azdext.RunWorkflowRequest{
345+
Workflow: &azdext.Workflow{
346+
Name: "launch-agent-inspector",
347+
Steps: []*azdext.WorkflowStep{
348+
{
349+
Command: &azdext.WorkflowCommand{
350+
Args: []string{
351+
"ai",
352+
"inspector",
353+
"launch",
354+
"--port",
355+
strconv.Itoa(agentPort),
356+
"--silent",
357+
},
358+
},
359+
},
360+
},
361+
},
362+
})
363+
return err
364+
}
365+
366+
func isInspectorExtensionInstalled(ctx context.Context, azdClient *azdext.AzdClient) (bool, error) {
367+
configHelper, err := azdext.NewConfigHelper(azdClient)
368+
if err != nil {
369+
return false, err
370+
}
371+
372+
var installed map[string]json.RawMessage
373+
found, err := configHelper.GetUserJSON(ctx, "extension.installed", &installed)
374+
if err != nil {
375+
return false, fmt.Errorf("failed to check installed azd extensions: %w", err)
376+
}
377+
if !found {
378+
return false, nil
379+
}
380+
381+
_, ok := installed[agentInspectorExtensionID]
382+
return ok, nil
383+
}
384+
385+
func inspectorLaunchWarning(err error) string {
386+
msg := err.Error()
387+
if st, ok := status.FromError(err); ok {
388+
msg = st.Message()
389+
}
390+
391+
if isInspectorExtensionMissingMessage(msg) {
392+
return missingInspectorExtensionWarning()
393+
}
394+
395+
return fmt.Sprintf("Warning: Agent Inspector was not launched: %v", err)
396+
}
397+
398+
func missingInspectorExtensionWarning() string {
399+
return fmt.Sprintf(
400+
"Warning: Agent Inspector was not launched because the %s extension is not installed.\n"+
401+
"Install it with: azd extension install %s",
402+
agentInspectorExtensionID,
403+
agentInspectorExtensionID,
404+
)
405+
}
406+
407+
func isInspectorExtensionMissingMessage(message string) bool {
408+
message = strings.ToLower(message)
409+
return (strings.Contains(message, "unknown command") && strings.Contains(message, "inspector")) ||
410+
(strings.Contains(message, "ai inspector launch") && strings.Contains(message, "unknown flag: --port"))
411+
}
412+
413+
func isContextCancellation(err error) bool {
414+
return errors.Is(err, context.Canceled) ||
415+
errors.Is(err, context.DeadlineExceeded) ||
416+
status.Code(err) == codes.Canceled
417+
}
418+
419+
func shouldWarnLoadAzdEnvironmentFailure(err error) bool {
420+
msg := err.Error()
421+
if st, ok := status.FromError(err); ok {
422+
msg = st.Message()
423+
}
424+
return !strings.Contains(strings.ToLower(msg), "default environment not found")
425+
}
426+
220427
// appendPortEnvVars appends PORT and, for .NET projects, ASPNETCORE_URLS to the
221428
// environment slice so the agent listens on the correct port.
222429
// ASP.NET Core ignores PORT — it uses ASPNETCORE_URLS to configure Kestrel.

0 commit comments

Comments
 (0)