Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ specific version of the tool installed on the machine.
| `ENABLE_HOSTED_AGENTS` | If set, indicates that hosted agents are enabled for the current azd environment. |
| `ENABLE_CONTAINER_AGENTS` | If set, indicates that container agents are enabled for the current azd environment. |
| `AGENT_DEFINITION_PATH` | Path to an agent definition file for AI agent workflows. |
| `FOUNDRY_PROJECT_ENDPOINT` | A host environment variable specifying the Microsoft Foundry project endpoint. Used as a fallback in the endpoint resolution cascade when no azd environment or global config is available. Not read from the azd env, only from the host shell environment. |

## UI Prompt Integration

Expand Down
209 changes: 181 additions & 28 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"

"azureaiagent/internal/pkg/agents/agent_api"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// DefaultAgentAPIVersion is the default API version for agent operations.
Expand Down Expand Up @@ -56,46 +60,195 @@ func buildAgentEndpoint(accountName, projectName string) string {
return fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", accountName, projectName)
}

// resolveAgentEndpoint resolves the agent API endpoint from explicit flags or the azd environment.
// If accountName and projectName are provided, those are used to construct the endpoint.
// Otherwise, it falls back to the AZURE_AI_PROJECT_ENDPOINT environment variable from the current azd environment.
func resolveAgentEndpoint(ctx context.Context, accountName string, projectName string) (string, error) {
if accountName != "" && projectName != "" {
return buildAgentEndpoint(accountName, projectName), nil
}
// resolveProjectEndpointOpts controls the 5-level endpoint resolution cascade.
type resolveProjectEndpointOpts struct {
// FlagValue is the value of the -p / --project-endpoint flag (level 1).
// Empty means the flag was not provided.
FlagValue string
}

if accountName != "" || projectName != "" {
return "", fmt.Errorf("both --account-name and --project-name must be provided together")
}
// resolvedEndpoint holds the result of resolveProjectEndpoint.
type resolvedEndpoint struct {
Endpoint string
Source EndpointSource
AzdEnvName string
SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig
}

// lookupEnvFunc is the function used to read host environment variables.
// It is a package-level variable so tests can override it without OS state.
var lookupEnvFunc = os.Getenv
Comment thread
huimiu marked this conversation as resolved.
Outdated

// azdHostedSources holds the values that the resolver reads from azd-managed
// sources (the active 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 AZURE_AI_PROJECT_ENDPOINT value from the active azd
// env, or "" if not set / no active env / no azd client available.
EnvValue string
// EnvName is the active azd env name. Only meaningful when EnvValue != "".
EnvName string
// CfgState is the project context persisted in global config.
CfgState projectContextState
// CfgFound indicates whether a non-empty endpoint was found in global config.
CfgFound bool
}

// readAzdHostedSourcesFunc is a package-level seam so tests can stub the
// daemon-backed lookup without spinning up a real azd gRPC server.
var readAzdHostedSourcesFunc = readAzdHostedSources

// readAzdHostedSources dials the azd daemon (if reachable) and reads both
// the active env's AZURE_AI_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) {
var out azdHostedSources

// Fall back to azd environment
azdClient, err := azdext.NewAzdClient()
if err != nil {
return "", fmt.Errorf(
"failed to create azd client: %w\n\nProvide --account-name and --project-name flags, "+
"or ensure azd environment is configured", err)
// No azd client at all => no hosted sources, not an error.
return out, nil
}
defer azdClient.Close()

envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
if envResp, err := azdClient.Environment().GetCurrent(
ctx, &azdext.EmptyRequest{},
); err == nil {
envVal, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: envResp.Environment.Name,
Key: "AZURE_AI_PROJECT_ENDPOINT",
})
if valErr == nil && envVal.Value != "" {
out.EnvValue = envVal.Value
out.EnvName = envResp.Environment.Name
}
}

state, found, cfgErr := getProjectContext(ctx, azdClient)
if cfgErr != nil {
// A gRPC Unavailable code means the azd daemon is not reachable;
// treat it the same as azdClient creation failing and fall through
// to the host-environment level. Any other error (e.g. parse
// failure) is a hard error that callers should surface.
if !containsGRPCCode(cfgErr, codes.Unavailable) {
return out, cfgErr
}
} else {
out.CfgState = state
out.CfgFound = found
}

return out, nil
}

// containsGRPCCode walks the error chain looking for a gRPC status with the
// specified code. Because fmt.Errorf("%w", ...) wraps errors without forwarding
// the GRPCStatus() method, we must unwrap manually.
func containsGRPCCode(err error, code codes.Code) bool {
for ; err != nil; err = errors.Unwrap(err) {
Comment thread
huimiu marked this conversation as resolved.
if st, ok := status.FromError(err); ok && st.Code() == code {
return true
}
}
return false
}

// resolveProjectEndpoint resolves a Foundry project endpoint using the 5-level
// cascade defined in the design spec:
//
// 1. -p / --project-endpoint flag
// 2. Active azd env value (AZURE_AI_PROJECT_ENDPOINT)
// 3. Global config: extensions.ai-agents.context.endpoint in ~/.azd/config.json
// 4. Host environment variable FOUNDRY_PROJECT_ENDPOINT
// 5. Structured error with actionable suggestion
//
// Invalid values at any level produce a hard validation error (no silent fallback).
func resolveProjectEndpoint(
ctx context.Context,
opts resolveProjectEndpointOpts,
) (*resolvedEndpoint, error) {
// Level 1: explicit flag.
if opts.FlagValue != "" {
normalized, _, err := validateProjectEndpoint(opts.FlagValue)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceFlag,
}, nil
}

// Levels 2 + 3: azd-hosted sources (active env, then global config).
sources, err := readAzdHostedSourcesFunc(ctx)
if err != nil {
return "", fmt.Errorf(
"failed to get current azd environment: %w\n\nProvide --account-name and --project-name flags, "+
"or run 'azd init' to set up your environment", err)
return nil, err
}

// Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT.
if sources.EnvValue != "" {
normalized, _, err := validateProjectEndpoint(sources.EnvValue)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceAzdEnv,
AzdEnvName: sources.EnvName,
}, nil
}

// Level 3: global config (~/.azd/config.json).
if sources.CfgFound && sources.CfgState.Endpoint != "" {
normalized, _, err := validateProjectEndpoint(sources.CfgState.Endpoint)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceGlobalConfig,
SetAt: sources.CfgState.SetAt,
}, nil
}

// Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT.
if envVal := lookupEnvFunc("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" {
normalized, _, err := validateProjectEndpoint(envVal)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceFoundryEnv,
}, nil
}

// Level 5: structured error.
return nil, noProjectEndpointError()
}

// resolveAgentEndpoint resolves the agent API endpoint from explicit flags or
// the 5-level cascade. If accountName and projectName are provided, those are
// used to construct the endpoint directly (existing behavior). Otherwise the
// cascade is invoked with no flag value.
func resolveAgentEndpoint(ctx context.Context, accountName string, projectName string) (string, error) {
if accountName != "" && projectName != "" {
return buildAgentEndpoint(accountName, projectName), nil
}

envValue, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: envResponse.Environment.Name,
Key: "AZURE_AI_PROJECT_ENDPOINT",
})
if err != nil || envValue.Value == "" {
return "", fmt.Errorf(
"AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'\n\n"+
"Provide --account-name and --project-name flags, "+
"or run 'azd ai agent init' to configure the endpoint", envResponse.Environment.Name)
if accountName != "" || projectName != "" {
return "", fmt.Errorf("both --account-name and --project-name must be provided together")
}

result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{})
if err != nil {
return "", err
}

return envValue.Value, nil
return result.Endpoint, nil
}

// newAgentCredential creates a new Azure credential for agent API calls.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import (
)

// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs.
// parseAgentEndpoint uses this constant directly; it does not call isFoundryHost.
// If additional host suffixes are added to foundryHostSuffixes
// (project_endpoint.go), parseAgentEndpoint must be updated to call isFoundryHost
// instead so the two validators stay in sync.
const agentEndpointHostSuffix = ".services.ai.azure.com"

// agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors.
Expand Down Expand Up @@ -180,7 +184,3 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str
}
return invURL
}

// (isValidAgentNameSegment was removed — agent name validation now delegates
// to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same
// deployable-name format as the rest of the extension.)
29 changes: 29 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)

func newProjectCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
extCtx = ensureExtensionContext(extCtx)

cmd := &cobra.Command{
Use: "project <command> [options]",
Short: "Manage the default Microsoft Foundry project endpoint.",
Long: `Manage the default Microsoft Foundry project endpoint.

These commands persist a workspace-level project endpoint in the azd global
config (~/.azd/config.json) so that other agent commands can resolve it
without requiring an azd environment or explicit flags.`,
}

cmd.AddCommand(newProjectSetCommand(extCtx))
cmd.AddCommand(newProjectUnsetCommand(extCtx))
cmd.AddCommand(newProjectShowCommand(extCtx))

return cmd
}
Loading
Loading