@@ -5,25 +5,40 @@ package cmd
55
66import (
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
2337type runFlags struct {
2438 port int
2539 name string
2640 startCommand string
41+ noInspector bool
2742}
2843
2944func newRunCommand (extCtx * azdext.ExtensionContext ) * cobra.Command {
@@ -43,7 +58,10 @@ positional argument. When omitted, the single agent service is used.
4358
4459The startup command is read from the startupCommand property of the
4560agent 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