Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
160 changes: 132 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,9 +60,131 @@ 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.
// 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
}

// 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

// 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
}

// Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT.
azdClient, azdErr := azdext.NewAzdClient()
if azdErr == nil {
defer azdClient.Close()

Comment thread
huimiu marked this conversation as resolved.
Outdated
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 != "" {
normalized, _, err := validateProjectEndpoint(envVal.Value)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceAzdEnv,
AzdEnvName: envResp.Environment.Name,
}, nil
}
}

// Level 3: global config (requires azd client).
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 nil, cfgErr
}
} else if found && state.Endpoint != "" {
normalized, _, err := validateProjectEndpoint(state.Endpoint)
if err != nil {
return nil, err
}
return &resolvedEndpoint{
Endpoint: normalized,
Source: SourceGlobalConfig,
SetAt: state.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
Expand All @@ -68,34 +194,12 @@ func resolveAgentEndpoint(ctx context.Context, accountName string, projectName s
return "", fmt.Errorf("both --account-name and --project-name must be provided together")
}

// Fall back to azd environment
azdClient, err := azdext.NewAzdClient()
result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{})
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)
}
defer azdClient.Close()

envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
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)
}

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)
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 @@
)

// 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,5 @@
}
return invURL
}

Check failure on line 187 in cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint (ubuntu-latest)

File is not properly formatted (gofmt)
// (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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"fmt"
"time"

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

// projectContextConfigPath is the UserConfig path for the persisted project context.
const projectContextConfigPath = configPathPrefix + ".context"
Comment thread
huimiu marked this conversation as resolved.
Outdated

// projectContextState is the JSON shape stored at extensions.ai-agents.context
// in ~/.azd/config.json.
type projectContextState struct {
Endpoint string `json:"endpoint"`
SetAt string `json:"setAt"`
}

// getProjectContext reads the persisted project context from global config.
// Returns (state, true, nil) when present, (zero, false, nil) when absent.
func getProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
) (projectContextState, bool, error) {
ch, err := azdext.NewConfigHelper(azdClient)
if err != nil {
return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err)
}

var state projectContextState
found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state)
if err != nil {
return projectContextState{}, false,
fmt.Errorf("getProjectContext: failed to read config: %w", err)
}

if !found || state.Endpoint == "" {
return projectContextState{}, false, nil
}

return state, true, nil
}

// setProjectContext persists a validated project endpoint to global config.
// The caller is responsible for validating the endpoint before calling this function.
// Returns the setAt timestamp that was written to config.
func setProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
endpoint string,
) (setAt string, err error) {
ch, chErr := azdext.NewConfigHelper(azdClient)
if chErr != nil {
return "", fmt.Errorf("setProjectContext: %w", chErr)
}

state := projectContextState{
Endpoint: endpoint,
SetAt: time.Now().UTC().Format(time.RFC3339),
}

if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil {
return "", fmt.Errorf("setProjectContext: failed to write config: %w", err)
}

return state.SetAt, nil
}

// clearProjectContext removes the context subtree from global config.
// Returns the previously stored endpoint (empty if none was set).
// The operation is idempotent — calling it when no context is set is not an error.
func clearProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
) (previousEndpoint string, err error) {
// Read existing state first so we can return the previous endpoint.
state, found, err := getProjectContext(ctx, azdClient)
if err != nil {
return "", err
}

if found {
previousEndpoint = state.Endpoint
}

ch, chErr := azdext.NewConfigHelper(azdClient)
if chErr != nil {
return "", fmt.Errorf("clearProjectContext: %w", chErr)
}

if err := ch.UnsetUser(ctx, projectContextConfigPath); err != nil {
return "", fmt.Errorf("clearProjectContext: failed to clear config: %w", err)
}

return previousEndpoint, nil
}
Loading
Loading