@@ -5,13 +5,17 @@ package cmd
55
66import (
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 \n Provide --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 \n Provide --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.
0 commit comments