diff --git a/cli/azd/docs/extensions/extensions-style-guide.md b/cli/azd/docs/extensions/extensions-style-guide.md index babf51d5785..c478688fda8 100644 --- a/cli/azd/docs/extensions/extensions-style-guide.md +++ b/cli/azd/docs/extensions/extensions-style-guide.md @@ -54,6 +54,71 @@ This framework enables: - Integration of new Azure services and capabilities - Third-party extension development +## Error Handling in Extensions + +Extensions communicate with the azd host over gRPC. When an extension returns an error, it must be +serialized into an `ExtensionError` proto message so that the host can display it to users and +classify it in telemetry. + +### Structured Error Types + +The `azdext` package provides two structured error types: + +- **`azdext.ServiceError`** — for HTTP/gRPC service failures (e.g., Azure API returned 429). + Fields: `Message`, `ErrorCode`, `StatusCode`, `ServiceName`, `Suggestion`. + +- **`azdext.LocalError`** — for local errors such as validation, auth, config, or internal failures. + Fields: `Message`, `Code`, `Category`, `Suggestion`. + +Both types implement `Error()`. They are detected via `errors.As` during serialization. + +### Telemetry Classification + +The host classifies extension errors into telemetry codes using the pattern: + +| Error type | Telemetry code pattern | +|-----------|----------------------| +| `ServiceError` with `ErrorCode` | `ext.service.` | +| `ServiceError` with `StatusCode` | `ext.service..` | +| `LocalError` | `ext..` | +| Unclassified | `ext.run.failed` | + +### Recommended Layering Pattern + +**Entry-point or orchestration layer**: Usually creates structured errors once it can confidently choose the final category, code, and suggestion. This often includes command handlers, top-level actions, or other user-facing coordination code. + +**Lower-level helpers, parsers, and clients**: Usually return plain Go errors with `fmt.Errorf("context: %w", err)` and let a higher layer classify the failure. + +Treat this as guidance, not a strict package boundary. The important part is that structured classification happens in a layer with enough context to produce the right telemetry and a useful suggestion. + +### Error Chain Precedence + +When `WrapError` serializes an error for gRPC, it checks the chain via `errors.As` and picks +the **first** match in this order: + +1. `ServiceError` (highest priority) +2. `LocalError` +3. `azcore.ResponseError` (auto-detected Azure SDK errors) +4. gRPC `Unauthenticated` (safety-net auth classification) +5. Fallback (unclassified) + +Because Go's `errors.As` walks from outermost to innermost, classifying near the outer orchestration layer naturally produces the intended classification. + +### Error Code Conventions + +Error codes should be: +- Lowercase `snake_case` (e.g., `missing_subscription_id`, `invalid_agent_manifest`) +- Descriptive of the specific failure, not the general category +- Unique within the extension +- Defined as `const` values (not inline strings) for consistency and grep-ability + +### Display and UX + +When a structured error has a non-empty `Suggestion`, the azd host displays it as a +formatted "ERROR + Suggestion" block. When there is no suggestion, only the error message +is shown. Extensions should provide suggestions for user-fixable errors (validation, +auth, dependency) and omit them for internal/unexpected errors. + --- *For core design principles that apply to all `azd` functionality, see [guiding-principles.md](../style-guidelines/guiding-principles.md).* diff --git a/cli/azd/extensions/azure.ai.agents/AGENTS.md b/cli/azd/extensions/azure.ai.agents/AGENTS.md new file mode 100644 index 00000000000..444bc04a663 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/AGENTS.md @@ -0,0 +1,126 @@ +# Azure AI Agents Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.agents` is a first-party azd extension under `cli/azd/extensions/azure.ai.agents/`. It runs as a separate Go binary and talks to the azd host over gRPC. + +Useful places to start: + +- `internal/cmd/`: Cobra commands and top-level orchestration +- `internal/project/`: project/service target integration and deployment flows +- `internal/pkg/`: lower-level helpers, parsers, and API-facing logic +- `internal/exterrors/`: structured error factories and extension-specific codes + +## Build and test + +From `cli/azd/extensions/azure.ai.agents`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of the version in `go.mod`. Do not merge the extension with that `replace` still present. + +## Error handling + +This extension uses `internal/exterrors` so the azd host can show a useful message, attach an optional suggestion, and emit stable telemetry. + +### Default rule + +Use plain Go errors by default. Switch to `exterrors.*` only when the current code can confidently answer all three of these: + +1. What category should telemetry see? +2. What stable error code should be recorded? +3. What suggestion, if any, should the user get? + +That usually means: + +- lower-level helpers return `fmt.Errorf("context: %w", err)` +- user-facing orchestration code classifies the failure with `exterrors.*` + +In this extension, that classification often happens in `internal/cmd/` and `internal/project/`, not only in Cobra `RunE` handlers. + +### Most important rule + +Create a structured error once, as close as possible to the place where you know the final category, code, and suggestion. + +If `err` is already a structured error, usually return it unchanged. + +Do **not** add context with `fmt.Errorf("context: %w", err)` after `err` is already structured. During gRPC serialization, azd preserves the structured error's own message/code/category, not the outer wrapper text. If you need extra context, include it in the structured error message when you create it. + +### Choosing an Error Type + +| Situation | Prefer | +| --- | --- | +| Invalid input, manifest, or option combination | `exterrors.Validation` | +| Missing environment value, missing resource, unavailable dependency | `exterrors.Dependency` | +| Auth or tenant/credential failure | `exterrors.Auth` | +| azd/extension version or capability mismatch | `exterrors.Compatibility` | +| User cancellation | `exterrors.Cancelled` | +| Azure SDK HTTP failure | `exterrors.ServiceFromAzure` | +| gRPC failure from azd host AI/prompt calls | `exterrors.FromAiService` / `exterrors.FromPrompt` | +| Unexpected bug or local failure with no better category | `exterrors.Internal` | + +### Recommended pattern + +```go +func loadThing(path string) error { + if err := parse(path); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + + return nil +} + +func runCommand() error { + if err := loadThing("agent.yaml"); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent manifest is invalid: %s", err), + "fix the manifest and retry", + ) + } + + return nil +} +``` + +### Azure and gRPC boundaries + +Prefer the dedicated helpers instead of hand-rolling conversions: + +- `exterrors.ServiceFromAzure(err, operation)` for `azcore.ResponseError` +- `exterrors.FromAiService(err, fallbackCode)` for azd host AI service calls +- `exterrors.FromPrompt(err, contextMessage)` for prompt failures + +These helpers keep telemetry and user-facing behavior consistent. + +### Error codes + +Define new codes in `internal/exterrors/codes.go`. + +- use lowercase `snake_case` +- describe the specific failure, not the general category +- keep them stable once introduced + +## Other extension conventions + +- Use modern Go 1.26 patterns where they help readability +- When using `PromptSubscription()`, create credentials with `Subscription.UserTenantId`, not `Subscription.TenantId` diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 1fe7899a61d..405b9218517 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -8,7 +8,10 @@ const ( CodeCancelled = "cancelled" ) -// Error codes for validation errors. +// Error codes commonly used for validation errors. +// +// These are usually paired with [Validation] when user input, manifests, +// or configuration values fail validation. const ( CodeInvalidAgentManifest = "invalid_agent_manifest" CodeInvalidManifestPointer = "invalid_manifest_pointer" @@ -25,11 +28,13 @@ const ( CodeLocationMismatch = "location_mismatch" CodeTenantMismatch = "tenant_mismatch" CodeMissingPublishedContainer = "missing_published_container_artifact" - CodeScaffoldTemplateFailed = "scaffold_template_failed" CodeModelDeploymentNotFound = "model_deployment_not_found" ) -// Error codes for dependency errors. +// Error codes commonly used for dependency errors. +// +// These are usually paired with [Dependency] when required external +// resources, services, or environment values are missing or unavailable. const ( CodeProjectNotFound = "project_not_found" CodeProjectInitFailed = "project_init_failed" @@ -41,10 +46,13 @@ const ( CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAgentEnvVars = "missing_agent_env_vars" CodeGitHubDownloadFailed = "github_download_failed" + CodeScaffoldTemplateFailed = "scaffold_template_failed" CodePromptFailed = "prompt_failed" ) -// Error codes for auth errors. +// Error codes commonly used for auth errors. +// +// These are usually paired with [Auth] for authentication/authorization failures. const ( //nolint:gosec // error code identifier, not a credential CodeCredentialCreationFailed = "credential_creation_failed" @@ -55,17 +63,25 @@ const ( ) // Error codes for compatibility errors. +// +// These are usually paired with [Compatibility] for version mismatches. const ( CodeIncompatibleAzdVersion = "incompatible_azd_version" ) // Error codes for azd host AI service errors. +// +// Used as fallback codes with [FromAiService] when the gRPC response +// doesn't include a more specific ErrorInfo reason. const ( CodeModelCatalogFailed = "model_catalog_failed" CodeModelResolutionFailed = "model_resolution_failed" ) -// Error codes for internal errors. +// Error codes commonly used for internal errors. +// +// These are usually paired with [Internal] for unexpected failures +// that are not directly caused by user input. const ( CodeAzdClientFailed = "azd_client_failed" CodeCognitiveServicesClientFailed = "cognitiveservices_client_failed" @@ -73,7 +89,7 @@ const ( CodeContainerStartTimeout = "container_start_timeout" ) -// Operation names for ServiceFromAzure errors. +// Operation names for [ServiceFromAzure] errors. // These are prefixed to the Azure error code (e.g., "create_agent.NotFound"). const ( OpGetFoundryProject = "get_foundry_project" diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/errors.go index 501a1c6065c..040bccd795a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/errors.go @@ -1,6 +1,16 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// Package exterrors provides structured error helpers for the azure.ai.agents extension. +// +// Use plain Go errors until the current code can confidently choose a final +// category, code, and suggestion. At that point, create a structured error with +// one of the helpers in this package or with one of the Azure/gRPC conversion +// helpers. +// +// Once an error is structured, usually return it unchanged. Avoid wrapping a +// structured error with [fmt.Errorf] and %w for extra context: azd serializes the +// structured error's own message and metadata, not the outer wrapper text. package exterrors import ( @@ -16,6 +26,11 @@ import ( "google.golang.org/grpc/status" ) +// --------------------------------------------------------------------------- +// Structured error factories +// --------------------------------------------------------------------------- + +// Validation returns a validation [azdext.LocalError] for user-input or manifest errors. func Validation(code, message, suggestion string) error { return &azdext.LocalError{ Message: message, @@ -25,6 +40,7 @@ func Validation(code, message, suggestion string) error { } } +// Dependency returns a dependency [azdext.LocalError] for missing resources or services. func Dependency(code, message, suggestion string) error { return &azdext.LocalError{ Message: message, @@ -34,6 +50,7 @@ func Dependency(code, message, suggestion string) error { } } +// Compatibility returns a compatibility [azdext.LocalError] for version/feature mismatches. func Compatibility(code, message, suggestion string) error { return &azdext.LocalError{ Message: message, @@ -43,6 +60,7 @@ func Compatibility(code, message, suggestion string) error { } } +// Auth returns an auth [azdext.LocalError] for authentication or authorization failures. func Auth(code, message, suggestion string) error { return &azdext.LocalError{ Message: message, @@ -52,6 +70,7 @@ func Auth(code, message, suggestion string) error { } } +// Configuration returns a local/configuration [azdext.LocalError]. func Configuration(code, message, suggestion string) error { return &azdext.LocalError{ Message: message, @@ -61,6 +80,7 @@ func Configuration(code, message, suggestion string) error { } } +// User returns a user-action [azdext.LocalError] (e.g. cancellation). No suggestion. func User(code, message string) error { return &azdext.LocalError{ Message: message, @@ -69,6 +89,7 @@ func User(code, message string) error { } } +// Internal returns an internal [azdext.LocalError] for unexpected extension failures. No suggestion. func Internal(code, message string) error { return &azdext.LocalError{ Message: message, @@ -77,8 +98,12 @@ func Internal(code, message string) error { } } -// ServiceFromAzure wraps an azcore.ResponseError into an azdext.ServiceError with operation context. -// If the error is not an azcore.ResponseError, it returns a generic internal LocalError. +// --------------------------------------------------------------------------- +// Azure / gRPC error converters +// --------------------------------------------------------------------------- + +// ServiceFromAzure wraps an [azcore.ResponseError] into an [azdext.ServiceError] with operation context. +// If the error is not an azcore.ResponseError, it returns a generic internal [azdext.LocalError]. func ServiceFromAzure(err error, operation string) error { var respErr *azcore.ResponseError if errors.As(err, &respErr) { @@ -104,7 +129,7 @@ func ServiceFromAzure(err error, operation string) error { } // FromAiService wraps a gRPC error returned by an azd host AI service call -// into a structured LocalError. It detects auth errors (codes.Unauthenticated) +// into a structured [azdext.LocalError]. It detects auth errors ([codes.Unauthenticated]) // and classifies them as Auth errors. For other errors, it preserves the server's // ErrorInfo reason code (from the azd.ai domain) when available, // falling back to the provided code. @@ -135,7 +160,7 @@ func FromAiService(err error, fallbackCode string) error { } // FromPrompt wraps a gRPC error from an azd host Prompt call into a structured error. -// Auth errors (codes.Unauthenticated) are classified as Auth errors with a suggestion +// Auth errors ([codes.Unauthenticated]) are classified as Auth errors with a suggestion // to re-authenticate. Other errors are returned with the provided context message. func FromPrompt(err error, contextMsg string) error { if err == nil { @@ -154,6 +179,10 @@ func FromPrompt(err error, contextMsg string) error { return fmt.Errorf("%s: %w", contextMsg, err) } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + // authFromGrpcMessage creates a structured Auth error from a gRPC Unauthenticated message. // It classifies the error as not_logged_in, login_expired, or a generic auth_failed // based on message content. @@ -167,7 +196,8 @@ func authFromGrpcMessage(msg string) error { return Auth(CodeAuthFailed, msg, "run `azd auth login` to authenticate") } -// IsCancellation checks if an error represents user cancellation (context.Canceled or gRPC Canceled). +// IsCancellation checks if an error represents user cancellation +// ([context.Canceled] or gRPC [codes.Canceled]). func IsCancellation(err error) bool { if errors.Is(err, context.Canceled) { return true @@ -184,7 +214,7 @@ func Cancelled(message string) error { } // aiErrorReason extracts the ErrorInfo.Reason from a gRPC status -// when the domain matches azdext.AiErrorDomain. +// when the domain matches [azdext.AiErrorDomain]. func aiErrorReason(st *status.Status) string { for _, detail := range st.Details() { info, ok := detail.(*errdetails.ErrorInfo)