Skip to content

Commit 444ec22

Browse files
huimiuCopilot
andauthored
feat: add azd ai agent project commands for Foundry endpoint management (#8162)
* feat(project): add commands to manage Foundry project endpoint configuration * address PR review comments - project_show.go: use errors.AsType[*azdext.LocalError] per AGENTS.md guidance - agent_context.go: extract azd-hosted source lookup (levels 2 + 3) into a stubbable seam (readAzdHostedSourcesFunc) for testability - project_resolver_test.go: add unit tests for AZURE_AI_PROJECT_ENDPOINT from azd env (success + invalid) and global config (success + invalid), plus a hosted-sources error propagation test; isolate non-azd tests from any developer-machine AZD_SERVER by clearing the env var and stubbing the seam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: apply gofmt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: adress comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 11a9c14 commit 444ec22

16 files changed

Lines changed: 1252 additions & 36 deletions

cli/azd/docs/environment-variables.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ specific version of the tool installed on the machine.
160160
| `ENABLE_HOSTED_AGENTS` | If set, indicates that hosted agents are enabled for the current azd environment. |
161161
| `ENABLE_CONTAINER_AGENTS` | If set, indicates that container agents are enabled for the current azd environment. |
162162
| `AGENT_DEFINITION_PATH` | Path to an agent definition file for AI agent workflows. |
163+
| `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. |
163164

164165
## UI Prompt Integration
165166

cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go

Lines changed: 178 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ package cmd
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
10+
"os"
911

1012
"azureaiagent/internal/pkg/agents/agent_api"
1113

1214
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1315
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
1416
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
17+
"google.golang.org/grpc/codes"
18+
"google.golang.org/grpc/status"
1519
)
1620

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

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

67-
if accountName != "" || projectName != "" {
68-
return "", fmt.Errorf("both --account-name and --project-name must be provided together")
69-
}
70+
// resolvedEndpoint holds the result of resolveProjectEndpoint.
71+
type resolvedEndpoint struct {
72+
Endpoint string
73+
Source EndpointSource
74+
AzdEnvName string
75+
SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig
76+
}
77+
78+
// azdHostedSources holds the values that the resolver reads from azd-managed
79+
// sources (the active azd environment and ~/.azd/config.json). It is returned
80+
// as a single struct so that tests can stub the whole lookup via
81+
// readAzdHostedSourcesFunc.
82+
type azdHostedSources struct {
83+
// EnvValue is the AZURE_AI_PROJECT_ENDPOINT value from the active azd
84+
// env, or "" if not set / no active env / no azd client available.
85+
EnvValue string
86+
// EnvName is the active azd env name. Only meaningful when EnvValue != "".
87+
EnvName string
88+
// CfgState is the project context persisted in global config.
89+
CfgState projectContextState
90+
// CfgFound indicates whether a non-empty endpoint was found in global config.
91+
CfgFound bool
92+
}
93+
94+
// readAzdHostedSourcesFunc is a package-level seam so tests can stub the
95+
// daemon-backed lookup without spinning up a real azd gRPC server.
96+
var readAzdHostedSourcesFunc = readAzdHostedSources
97+
98+
// readAzdHostedSources dials the azd daemon (if reachable) and reads both
99+
// the active env's AZURE_AI_PROJECT_ENDPOINT and the global-config project
100+
// context in a single client lifetime. Errors talking to the daemon are
101+
// returned only for non-Unavailable cases on the config read — Unavailable
102+
// is treated as "no daemon" and the caller falls through to subsequent levels.
103+
func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) {
104+
var out azdHostedSources
70105

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

80-
envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
113+
if envResp, err := azdClient.Environment().GetCurrent(
114+
ctx, &azdext.EmptyRequest{},
115+
); err == nil {
116+
envVal, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
117+
EnvName: envResp.Environment.Name,
118+
Key: "AZURE_AI_PROJECT_ENDPOINT",
119+
})
120+
if valErr == nil && envVal.Value != "" {
121+
out.EnvValue = envVal.Value
122+
out.EnvName = envResp.Environment.Name
123+
}
124+
}
125+
126+
state, found, cfgErr := getProjectContext(ctx, azdClient)
127+
if cfgErr != nil {
128+
// A gRPC Unavailable code means the azd daemon is not reachable;
129+
// treat it the same as azdClient creation failing and fall through
130+
// to the host-environment level. Any other error (e.g. parse
131+
// failure) is a hard error that callers should surface.
132+
if !containsGRPCCode(cfgErr, codes.Unavailable) {
133+
return out, cfgErr
134+
}
135+
} else {
136+
out.CfgState = state
137+
out.CfgFound = found
138+
}
139+
140+
return out, nil
141+
}
142+
143+
// containsGRPCCode walks the error chain looking for a gRPC status with the
144+
// specified code. Because fmt.Errorf("%w", ...) wraps errors without forwarding
145+
// the GRPCStatus() method, we must unwrap manually.
146+
// Note: only follows errors.Unwrap chains; errors.Join multi-wraps are not traversed.
147+
func containsGRPCCode(err error, code codes.Code) bool {
148+
for ; err != nil; err = errors.Unwrap(err) {
149+
if st, ok := status.FromError(err); ok && st.Code() == code {
150+
return true
151+
}
152+
}
153+
return false
154+
}
155+
156+
// resolveProjectEndpoint resolves a Foundry project endpoint using the 5-level
157+
// cascade defined in the design spec:
158+
//
159+
// 1. -p / --project-endpoint flag
160+
// 2. Active azd env value (AZURE_AI_PROJECT_ENDPOINT)
161+
// 3. Global config: extensions.ai-agents.context.endpoint in ~/.azd/config.json
162+
// 4. Host environment variable FOUNDRY_PROJECT_ENDPOINT
163+
// 5. Structured error with actionable suggestion
164+
//
165+
// Invalid values at any level produce a hard validation error (no silent fallback).
166+
func resolveProjectEndpoint(
167+
ctx context.Context,
168+
opts resolveProjectEndpointOpts,
169+
) (*resolvedEndpoint, error) {
170+
// Level 1: explicit flag.
171+
if opts.FlagValue != "" {
172+
normalized, _, err := validateProjectEndpoint(opts.FlagValue)
173+
if err != nil {
174+
return nil, err
175+
}
176+
return &resolvedEndpoint{
177+
Endpoint: normalized,
178+
Source: SourceFlag,
179+
}, nil
180+
}
181+
182+
// Levels 2 + 3: azd-hosted sources (active env, then global config).
183+
sources, err := readAzdHostedSourcesFunc(ctx)
81184
if err != nil {
82-
return "", fmt.Errorf(
83-
"failed to get current azd environment: %w\n\nProvide --account-name and --project-name flags, "+
84-
"or run 'azd init' to set up your environment", err)
185+
return nil, err
85186
}
86187

87-
envValue, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
88-
EnvName: envResponse.Environment.Name,
89-
Key: "AZURE_AI_PROJECT_ENDPOINT",
90-
})
91-
if err != nil || envValue.Value == "" {
92-
return "", fmt.Errorf(
93-
"AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'\n\n"+
94-
"Provide --account-name and --project-name flags, "+
95-
"or run 'azd ai agent init' to configure the endpoint", envResponse.Environment.Name)
188+
// Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT.
189+
if sources.EnvValue != "" {
190+
normalized, _, err := validateProjectEndpoint(sources.EnvValue)
191+
if err != nil {
192+
return nil, err
193+
}
194+
return &resolvedEndpoint{
195+
Endpoint: normalized,
196+
Source: SourceAzdEnv,
197+
AzdEnvName: sources.EnvName,
198+
}, nil
199+
}
200+
201+
// Level 3: global config (~/.azd/config.json).
202+
if sources.CfgFound && sources.CfgState.Endpoint != "" {
203+
normalized, _, err := validateProjectEndpoint(sources.CfgState.Endpoint)
204+
if err != nil {
205+
return nil, err
206+
}
207+
return &resolvedEndpoint{
208+
Endpoint: normalized,
209+
Source: SourceGlobalConfig,
210+
SetAt: sources.CfgState.SetAt,
211+
}, nil
212+
}
213+
214+
// Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT.
215+
if envVal := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" {
216+
normalized, _, err := validateProjectEndpoint(envVal)
217+
if err != nil {
218+
return nil, err
219+
}
220+
return &resolvedEndpoint{
221+
Endpoint: normalized,
222+
Source: SourceFoundryEnv,
223+
}, nil
224+
}
225+
226+
// Level 5: structured error.
227+
return nil, noProjectEndpointError()
228+
}
229+
230+
// resolveAgentEndpoint resolves the agent API endpoint from explicit flags or
231+
// the 5-level cascade. If accountName and projectName are provided, those are
232+
// used to construct the endpoint directly (existing behavior). Otherwise the
233+
// cascade is invoked with no flag value.
234+
func resolveAgentEndpoint(ctx context.Context, accountName string, projectName string) (string, error) {
235+
if accountName != "" && projectName != "" {
236+
return buildAgentEndpoint(accountName, projectName), nil
237+
}
238+
239+
if accountName != "" || projectName != "" {
240+
return "", fmt.Errorf("both --account-name and --project-name must be provided together")
241+
}
242+
243+
result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{})
244+
if err != nil {
245+
return "", err
96246
}
97247

98-
return envValue.Value, nil
248+
return result.Endpoint, nil
99249
}
100250

101251
// newAgentCredential creates a new Azure credential for agent API calls.

cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import (
1414
"azureaiagent/internal/pkg/agents/agent_yaml"
1515
)
1616

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

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

7980
host := strings.ToLower(u.Hostname())
80-
if host == "" || !strings.HasSuffix(host, agentEndpointHostSuffix) {
81+
if host == "" || !isFoundryHost(host) {
8182
return nil, exterrors.Validation(
8283
exterrors.CodeInvalidParameter,
83-
fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix),
84+
fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostHint),
8485
agentEndpointHint,
8586
)
8687
}
@@ -180,7 +181,3 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str
180181
}
181182
return invURL
182183
}
183-
184-
// (isValidAgentNameSegment was removed — agent name validation now delegates
185-
// to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same
186-
// deployable-name format as the rest of the extension.)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newProjectCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
12+
extCtx = ensureExtensionContext(extCtx)
13+
14+
cmd := &cobra.Command{
15+
Use: "project <command> [options]",
16+
Short: "Manage the default Microsoft Foundry project endpoint.",
17+
Long: `Manage the default Microsoft Foundry project endpoint.
18+
19+
These commands persist a workspace-level project endpoint in the azd global
20+
config (~/.azd/config.json) so that other agent commands can resolve it
21+
without requiring an azd environment or explicit flags.`,
22+
}
23+
24+
cmd.AddCommand(newProjectSetCommand(extCtx))
25+
cmd.AddCommand(newProjectUnsetCommand(extCtx))
26+
cmd.AddCommand(newProjectShowCommand(extCtx))
27+
28+
return cmd
29+
}

0 commit comments

Comments
 (0)