Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
34 changes: 34 additions & 0 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ See the shared [AI extension non-interactive input reference](../ai-non-interact
for every prompt's flag, environment/configuration input, or deterministic
no-prompt behavior.

## Exporting Agent Insights

Export the current insights for a hosted agent as machine-readable JSON:

```powershell
$export = azd ai agent insights export | ConvertFrom-Json
$export.insights | Where-Object severity -eq "high"
```

The command resolves the agent and `FOUNDRY_PROJECT_ENDPOINT` from the current
azd project and environment. Outside an azd project, pass the Foundry agent name
and project endpoint explicitly:

```powershell
azd ai agent insights export my-agent `
--project-endpoint "https://<account>.services.ai.azure.com/api/projects/<project>" `
--out-file ".\insights.json"

$export = Get-Content ".\insights.json" -Raw -Encoding utf8 | ConvertFrom-Json
```

Use `azd --environment <name> ai agent insights export` to export from a
non-current azd environment. When an agent is resolved from an azd project or
`--environment` is supplied, the endpoint must come from that environment's
`FOUNDRY_PROJECT_ENDPOINT` or an explicit `--project-endpoint`. A missing or
unreadable environment endpoint fails rather than falling back to the global
project context or shell endpoint.

Exports include highlighted traces, linked traces, and proposed fixes by default.
Use `--include-details=false` for the lightweight insight projection, or filter
the export with `--category`, `--severity`, and `--status`. Treat exported files
as sensitive because insight descriptions and trace details can contain
application or user data.

## Composing Agent Dependencies

Use the Agent command surface to attach existing Toolbox or Connection services
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/extensions/azure.ai.agents/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ examples:
- name: init
description: Initialize a new AI agent project.
usage: azd ai agent init
- name: export-insights
description: Export an agent's current insights as JSON.
usage: azd ai agent insights export --out-file ./insights.json
63 changes: 48 additions & 15 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"os"

"azureaiagent/internal/exterrors"
"azureaiagent/internal/pkg/agents/agent_api"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
Expand Down Expand Up @@ -62,6 +63,12 @@ type resolveProjectEndpointOpts struct {
// FlagValue is the value of the -p / --project-endpoint flag (level 1).
// Empty means the flag was not provided.
FlagValue string
// EnvName selects a specific azd environment for level 2. Empty uses the
// current environment.
EnvName string
// RequireEnvironmentEndpoint prevents pairing an environment-bound resource
// with an endpoint from global config or the shell.
RequireEnvironmentEndpoint bool
}

// resolvedEndpoint holds the result of resolveProjectEndpoint.
Expand All @@ -73,14 +80,14 @@ type resolvedEndpoint struct {
}

// azdHostedSources holds the values that the resolver reads from azd-managed
// sources (the active azd environment and ~/.azd/config.json). It is returned
// sources (the selected azd environment and ~/.azd/config.json). It is returned
// as a single struct so that tests can stub the whole lookup via
// readAzdHostedSourcesFunc.
type azdHostedSources struct {
// EnvValue is the FOUNDRY_PROJECT_ENDPOINT value from the active azd
// env, or "" if not set / no active env / no azd client available.
// EnvValue is the FOUNDRY_PROJECT_ENDPOINT value from the selected azd
// env, or "" if not set / no selected env / no azd client available.
EnvValue string
// EnvName is the active azd env name. Only meaningful when EnvValue != "".
// EnvName is the selected azd env name. Only meaningful when EnvValue != "".
EnvName string
// CfgState is the project context persisted in global config.
CfgState projectContextState
Expand All @@ -93,32 +100,49 @@ type azdHostedSources struct {
var readAzdHostedSourcesFunc = readAzdHostedSources

// readAzdHostedSources dials the azd daemon (if reachable) and reads both
// the active env's FOUNDRY_PROJECT_ENDPOINT and the global-config project
// context in a single client lifetime. Errors talking to the daemon are
// returned only for non-Unavailable cases on the config read — Unavailable
// is treated as "no daemon" and the caller falls through to subsequent levels.
func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) {
// the selected env's FOUNDRY_PROJECT_ENDPOINT and the global-config project
// context in a single client lifetime. Environment-bound resolution fails on
// missing values or read errors instead of consulting unrelated fallback sources.
func readAzdHostedSources(ctx context.Context, opts resolveProjectEndpointOpts) (azdHostedSources, error) {
var out azdHostedSources
envName := opts.EnvName
requireEnvironment := opts.RequireEnvironmentEndpoint || envName != ""

azdClient, err := azdext.NewAzdClient()
if err != nil {
if requireEnvironment {
return out, fmt.Errorf("creating azd client for environment resolution: %w", err)
}
// No azd client at all => no hosted sources, not an error.
return out, nil
}
defer azdClient.Close()

if envResp, err := azdClient.Environment().GetCurrent(
ctx, &azdext.EmptyRequest{},
); err == nil {
var envResp *azdext.EnvironmentResponse
if envName == "" {
envResp, err = azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
} else {
envResp, err = azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{Name: envName})
}
if err != nil && requireEnvironment {
return out, fmt.Errorf("getting azd environment %q: %w", envName, err)
}
if err == nil && envResp != nil && envResp.Environment != nil && envResp.Environment.Name != "" {
out.EnvName = envResp.Environment.Name
envVal, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: envResp.Environment.Name,
Key: "FOUNDRY_PROJECT_ENDPOINT",
})
if valErr == nil && envVal.Value != "" {
if valErr != nil && requireEnvironment {
return out, fmt.Errorf("reading FOUNDRY_PROJECT_ENDPOINT from environment %q: %w", out.EnvName, valErr)
}
if valErr == nil && envVal != nil && envVal.Value != "" {
out.EnvValue = envVal.Value
out.EnvName = envResp.Environment.Name
}
}
if requireEnvironment {
return out, nil
}

state, found, cfgErr := getProjectContext(ctx, azdClient)
if cfgErr != nil {
Expand Down Expand Up @@ -160,6 +184,8 @@ func containsGRPCCode(err error, code codes.Code) bool {
// 5. Structured error with actionable suggestion
//
// Invalid values at any level produce a hard validation error (no silent fallback).
// An explicit EnvName or RequireEnvironmentEndpoint restricts resolution to the
// selected environment unless FlagValue explicitly overrides it.
func resolveProjectEndpoint(
ctx context.Context,
opts resolveProjectEndpointOpts,
Expand All @@ -177,7 +203,7 @@ func resolveProjectEndpoint(
}

// Levels 2 + 3: azd-hosted sources (active env, then global config).
sources, err := readAzdHostedSourcesFunc(ctx)
sources, err := readAzdHostedSourcesFunc(ctx, opts)
if err != nil {
return nil, err
}
Expand All @@ -194,6 +220,13 @@ func resolveProjectEndpoint(
AzdEnvName: sources.EnvName,
}, nil
}
if opts.RequireEnvironmentEndpoint || opts.EnvName != "" {
return nil, exterrors.Dependency(
exterrors.CodeMissingProjectEndpoint,
"the selected azd environment has no FOUNDRY_PROJECT_ENDPOINT",
"set FOUNDRY_PROJECT_ENDPOINT in that azd environment, or pass --project-endpoint explicitly",
)
}

// Level 3: global config (~/.azd/config.json).
if sources.CfgFound && sources.CfgState.Endpoint != "" {
Expand Down
4 changes: 2 additions & 2 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ func addEvalContextFlags(cmd *cobra.Command, flags *evalContextFlags) {
"",
"Agent service name from azure.yaml, or Foundry agent name outside a project",
)
addEvalProjectEndpointFlag(cmd, &flags.projectEndpoint)
addProjectEndpointFlag(cmd, &flags.projectEndpoint)
}

func addEvalProjectEndpointFlag(cmd *cobra.Command, projectEndpoint *string) {
func addProjectEndpointFlag(cmd *cobra.Command, projectEndpoint *string) {
cmd.Flags().StringVarP(
projectEndpoint,
"project-endpoint",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func newEvalListCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
return runEvalList(ctx, flags, extCtx.NoPrompt)
},
}
addEvalProjectEndpointFlag(cmd, &flags.projectEndpoint)
addProjectEndpointFlag(cmd, &flags.projectEndpoint)
cmd.Flags().IntVar(&flags.limit, "limit", 10, "Maximum number of evals to return")
return cmd
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ If eval-id is omitted, the most recent eval from the current environment is used
return runEvalShow(ctx, evalID, flags, extCtx.NoPrompt)
},
}
addEvalProjectEndpointFlag(cmd, &flags.projectEndpoint)
addProjectEndpointFlag(cmd, &flags.projectEndpoint)
cmd.Flags().StringVar(&flags.evalRunID, "eval-run-id", "", "Show details for a specific eval run")
cmd.Flags().IntVar(&flags.limit, "limit", 20, "Maximum number of runs to show")
cmd.Flags().StringVarP(&flags.output, "out-file", "O", "", "Export full run results to a JSON file")
Expand Down
31 changes: 30 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ type brownfieldAgentExistenceResolver func(context.Context, string, string) (boo
type agentServiceResolutionOptions struct {
allowBrownfieldInlineName bool
brownfieldAgentExists brownfieldAgentExistenceResolver
environmentName string
}

type agentServiceResolutionOption func(*agentServiceResolutionOptions)
Expand Down Expand Up @@ -832,6 +833,12 @@ func withBrownfieldAgentExistenceResolver(
}
}

func withEnvironmentName(name string) agentServiceResolutionOption {
return func(options *agentServiceResolutionOptions) {
options.environmentName = name
}
}

// resolveAgentServiceFromProject finds the azure.ai.agent service in azure.yaml
// and resolves its deployed agent name and version from the azd environment.
// Callers may explicitly opt into the brownfield inline-name fallback; deployed
Expand All @@ -857,14 +864,36 @@ func resolveAgentServiceFromProject(

// Resolve deployed agent name and version from the azd environment. The
// deployed name wins because it reflects the resource actually created.
envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
var envResponse *azdext.EnvironmentResponse
if resolutionOptions.environmentName == "" {
envResponse, err = azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
} else {
envResponse, err = azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{
Name: resolutionOptions.environmentName,
})
}
if err != nil {
if resolutionOptions.environmentName != "" {
return info, fmt.Errorf(
"getting environment %q for agent service %q: %w",
resolutionOptions.environmentName,
svc.Name,
err,
)
}
if resolutionOptions.allowBrownfieldInlineName {
return info, fmt.Errorf("getting current environment for agent service %q: %w", svc.Name, err)
}
return info, nil
}
if envResponse == nil || envResponse.Environment == nil || envResponse.Environment.Name == "" {
if resolutionOptions.environmentName != "" {
return info, fmt.Errorf(
"environment %q is not available for agent service %q",
resolutionOptions.environmentName,
svc.Name,
)
}
if resolutionOptions.allowBrownfieldInlineName {
return info, fmt.Errorf("current environment is not available for agent service %q", svc.Name)
}
Expand Down
Loading
Loading