Skip to content
Merged
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
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
206 changes: 178 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,192 @@ 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
}

// 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.
// Note: only follows errors.Unwrap chains; errors.Join multi-wraps are not traversed.
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
}

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)
// 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 := os.Getenv("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
}

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 @@ -14,8 +14,9 @@ import (
"azureaiagent/internal/pkg/agents/agent_yaml"
)

// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs.
const agentEndpointHostSuffix = ".services.ai.azure.com"
// agentEndpointHostHint is the example Foundry host suffix shown in validation
// error messages. Actual host membership is checked via isFoundryHost (project_endpoint.go).
const agentEndpointHostHint = ".services.ai.azure.com"

// agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors.
// `azd ai agent show` persistently prints the agent endpoint URL, so it's the right
Expand Down Expand Up @@ -77,10 +78,10 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) {
}

host := strings.ToLower(u.Hostname())
if host == "" || !strings.HasSuffix(host, agentEndpointHostSuffix) {
if host == "" || !isFoundryHost(host) {
return nil, exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix),
fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostHint),
agentEndpointHint,
)
}
Expand Down Expand Up @@ -180,7 +181,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