Skip to content
Draft
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
6 changes: 5 additions & 1 deletion cli/azd/extensions/azure.ai.agents/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ require (
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/cli/browser v1.3.0
github.com/gorilla/websocket v1.5.3
)

require (
dario.cat/mergo v1.0.2 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
Expand All @@ -54,7 +59,6 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/extensions/azure.ai.agents/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const (

// DefaultPort is the default port for local agent servers.
DefaultPort = 8088

// DefaultInspectorPort is the default port for the Agent Inspector UI.
DefaultInspectorPort = 8087
)

// AgentLocalContext holds local state persisted in UserConfig.
Expand Down
47 changes: 46 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type invokeFlags struct {
newConversation bool
protocol string
agentEndpoint string
inspector bool
inspectorPort int
}

type InvokeAction struct {
Expand Down Expand Up @@ -88,6 +90,9 @@ session automatically. Pass --new-session to force a reset.`,
# Invoke locally (agent must be running via 'azd ai agent run')
azd ai agent invoke --local "Hello!"

# Launch the Agent Inspector UI in a browser, pointed at the local agent
azd ai agent invoke --local --inspector

# Start a new session (discard conversation history)
azd ai agent invoke --new-session "Hello!"

Expand Down Expand Up @@ -138,7 +143,12 @@ session automatically. Pass --new-session to force a reset.`,
"provide either a message argument or --input-file, not both",
)
}
if flags.inputFile == "" && flags.message == "" {

if flags.inspector {
if err := validateInspectorFlags(flags); err != nil {
return err
}
} else if flags.inputFile == "" && flags.message == "" {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"a message argument or --input-file is required",
Expand Down Expand Up @@ -185,10 +195,41 @@ session automatically. Pass --new-session to force a reset.`,
"Full endpoint URL of a deployed agent (run 'azd ai agent show' to see it). "+
"Invokes without requiring an azd project; protocol is derived from the URL.",
)
cmd.Flags().BoolVar(
&flags.inspector,
"inspector",
false,
"Launch the Agent Inspector UI in a browser instead of streaming the response to the terminal. "+
"Only supported with --local in this preview.",
)
cmd.Flags().IntVar(
&flags.inspectorPort,
"inspector-port",
DefaultInspectorPort,
fmt.Sprintf("Port the Agent Inspector UI listens on (default: %d)", DefaultInspectorPort),
)

return cmd
}

func validateInspectorFlags(flags *invokeFlags) error {
if !flags.local {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--inspector currently only supports --local",
"add --local to launch the inspector against a local agent (start it with: azd ai agent run)",
)
}
if flags.inputFile != "" || flags.message != "" {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--inspector cannot be combined with a message or --input-file",
"the inspector UI provides its own input; remove the message argument and --input-file",
)
}
return nil
}

// validateAgentEndpointFlags rejects flags that have no effect (or conflict) when --agent-endpoint
// is used. Ephemeral mode has no project, no local persistence, and no localhost target.
func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error {
Expand Down Expand Up @@ -222,6 +263,10 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error {
}

func (a *InvokeAction) Run(ctx context.Context) error {
if a.flags.inspector {
return a.runInspector(ctx)
}

protocol, err := a.resolveProtocol(ctx)
if err != nil {
return err
Expand Down
121 changes: 121 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_inspector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"

"azureaiagent/internal/inspector"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/cli/browser"
)

// runInspector launches the inspector UI against the local agent.
func (a *InvokeAction) runInspector(ctx context.Context) error {
logger := log.New(log.Writer(), "[inspector] ", log.LstdFlags)

// Resolve persisted session/conversation IDs the same way `azd ai agent
// invoke` does, so the inspector continues whatever chat the CLI was
// using. --new-session / --new-conversation flags flow through.
sessionID, conversationID := a.resolveInspectorSeedIDs(ctx, logger)

srv := inspector.New(inspector.Config{
Port: a.flags.inspectorPort,
AgentPort: a.flags.port,
Logger: logger,
SessionID: sessionID,
ConversationID: conversationID,
SSESink: func(r io.Reader) {
if err := readSSEStream(injectSSEEvents(r), "local"); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
}
},
})

url := srv.URL()
fmt.Printf("Inspector: %s\n", url)
fmt.Printf("Target: localhost:%d (local)\n", a.flags.port)
if sessionID != "" || conversationID != "" {
printSessionStatus("Session: ", sessionID)
fmt.Printf("Conversation: %s\n", conversationID)
}
fmt.Println("\nPress Ctrl+C to stop the inspector.")

ready := make(chan struct{})
go func() {
<-ready
if err := browser.OpenURL(url); err != nil {
logger.Printf("failed to open browser: %v", err)
}
}()

return srv.Start(ctx, ready)
}

// resolveInspectorSeedIDs reads the persisted per-agent session/conversation
// IDs from azd UserConfig (same path as `azd ai agent invoke`). Returns
// empty strings if azd is unavailable — the SPA falls back to a fresh UUID.
func (a *InvokeAction) resolveInspectorSeedIDs(ctx context.Context, logger *log.Logger) (string, string) {
azdClient, err := azdext.NewAzdClient()
if err != nil {
return "", ""
}
defer azdClient.Close()

agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, a.noPrompt)

sid, err := resolveStoredID(
ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", true,
)
if err != nil {
logger.Printf("seed session ID: %v", err)
}
convID, err := resolveStoredID(
ctx, azdClient, agentKey, a.flags.conversation, a.flags.newConversation, "conversations", true,
)
if err != nil {
logger.Printf("seed conversation ID: %v", err)
}
return sid, convID
}

// injectSSEEvents wraps the local agentserver SSE stream so it matches the
// Foundry SSE shape that readSSEStream expects. agentserver discriminates
// chunks via a JSON `type` field on each `data:` line and omits the
// `event:` line that readSSEStream switches on; this helper synthesises it.
// `response.failed` is mapped to `response.completed` so the failed-status
// branch in readSSEStream catches it.
func injectSSEEvents(r io.Reader) io.Reader {
pr, pw := io.Pipe()
go func() {
defer pw.Close()
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if data, ok := strings.CutPrefix(line, "data: "); ok {
var typed struct {
Type string `json:"type"`
}
if json.Unmarshal([]byte(data), &typed) == nil && typed.Type != "" {
event := typed.Type
if event == "response.failed" {
event = "response.completed"
}
fmt.Fprintf(pw, "event: %s\n", event)
}
}
fmt.Fprintln(pw, line)
}
}()
return pr
}
23 changes: 23 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/inspector/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// Package inspector serves the embedded Agent Inspector SPA and proxies
// its localhost calls over a JSON-RPC WebSocket.
package inspector

import (
"embed"
"io/fs"
)

//go:embed all:assets
var embeddedAssets embed.FS

// Assets returns the SPA bundle rooted at the assets directory.
func Assets() fs.FS {
sub, err := fs.Sub(embeddedAssets, "assets")
if err != nil {
panic(err)
}
return sub
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading