diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index f0c53bfc82d..36558c2884b 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -23,8 +23,6 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/alpha" - "github.com/azure/azure-dev/cli/azd/pkg/auth" - "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" @@ -35,8 +33,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/pipeline" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/tools" - "github.com/azure/azure-dev/cli/azd/pkg/tools/github" - "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" "go.opentelemetry.io/otel/codes" @@ -71,56 +67,15 @@ type ErrorMiddleware struct { errorPipeline *errorhandler.ErrorHandlerPipeline } -// ErrorCategory represents the classification of an error for determining -// the appropriate agent mode behavior. -type ErrorCategory int - -const ( - // AzureContextAndOtherError represents errors originating from Azure service interactions - // or any other unclassified errors. These are eligible for full agentic analysis and automated fix. - AzureContextAndOtherError ErrorCategory = iota - - // MachineContextError represents errors caused by the local machine environment, - // such as missing tools, incompatible tool versions, extension failures, or build-tool issues. - MachineContextError - - // UserContextError represents errors caused by user actions or configuration, - // such as authentication failures, missing credentials, or invalid project/environment settings. - UserContextError -) - -// classifyError categorizes an error into one of three buckets: -// AzureContextAndOtherError, MachineContextError, or UserContextError -func classifyError(err error) ErrorCategory { +func fixableError(err error) bool { // --- Machine context: typed errors --- - _, toolCheckErr := errors.AsType[*tools.MissingToolErrors](err) - _, semverErr := errors.AsType[*tools.ErrSemver](err) _, extRunErr := errors.AsType[*extensions.ExtensionRunError](err) _, packStatusErr := errors.AsType[*pack.StatusCodeError](err) - if toolCheckErr || semverErr || extRunErr || packStatusErr { - return MachineContextError - } - - if errors.Is(err, maven.ErrPropertyNotFound) { - return MachineContextError - } - - // --- User context: typed errors --- - _, loginErr := errors.AsType[*auth.ReLoginRequiredError](err) - _, authFailedErr := errors.AsType[*auth.AuthFailedError](err) - - if loginErr || authFailedErr { - return UserContextError + if extRunErr || packStatusErr { + return false } - - if errors.Is(err, auth.ErrNoCurrentUser) || - errors.Is(err, azapi.ErrAzCliNotLoggedIn) || - errors.Is(err, azapi.ErrAzCliRefreshTokenExpired) || - errors.Is(err, github.ErrGitHubCliNotLoggedIn) || - errors.Is(err, github.ErrUserNotAuthorized) || - errors.Is(err, github.ErrRepositoryNameInUse) || - errors.Is(err, environment.ErrNotFound) || + if errors.Is(err, environment.ErrNotFound) || errors.Is(err, environment.ErrNameNotSpecified) || errors.Is(err, environment.ErrDefaultEnvironmentNotFound) || errors.Is(err, environment.ErrAccessDenied) || @@ -129,10 +84,10 @@ func classifyError(err error) ErrorCategory { errors.Is(err, pipeline.ErrSSHNotSupported) || errors.Is(err, pipeline.ErrRemoteHostIsNotGitHub) || errors.Is(err, project.ErrNoDefaultService) { - return UserContextError + return false } - return AzureContextAndOtherError + return true } // troubleshootCategory represents the user's chosen troubleshooting scope. @@ -145,6 +100,8 @@ const ( categoryGuidance troubleshootCategory = "guidance" // categoryTroubleshoot shows both explanation and guidance. categoryTroubleshoot troubleshootCategory = "troubleshoot" + // categoryFix skips explanation and jumps directly to agent-driven fix. + categoryFix troubleshootCategory = "fix" // categorySkip skips troubleshooting entirely. categorySkip troubleshootCategory = "skip" ) @@ -266,6 +223,11 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action e.console.Message(ctx, output.WithErrorFormat("TraceID: %s", errorWithTraceId.TraceId)) } + // Skip agent troubleshooting for errors that are not classified as fixable + if !fixableError(originalError) { + return actionResult, originalError + } + // Step 1: Category selection — user chooses the troubleshooting scope category, err := e.promptTroubleshootCategory(ctx) if err != nil { @@ -276,45 +238,49 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action if category == categorySkip { span.SetStatus(codes.Error, "agent.troubleshoot.skip") return actionResult, originalError - } else { - // Step 2: Execute the selected category prompt - categoryPrompt := e.buildPromptForCategory(category, originalError) - e.console.Message(ctx, output.WithHintFormat( - "Preparing %s to %s error...", agentcopilot.DisplayTitle, category)) - agentResult, err := azdAgent.SendMessage(ctx, categoryPrompt) - if err != nil { - span.SetStatus(codes.Error, "agent.send_message.failed") - return nil, err - } - - span.SetStatus(codes.Ok, fmt.Sprintf("agent.%s.completed", category)) - e.displayUsageMetrics(ctx, agentResult) } - // Step 3: Ask if user wants the agent to fix the error - wantFix, err := e.promptForFix(ctx) + // Step 2: Execute the selected category prompt + categoryPrompt := e.buildPromptForCategory(category, originalError) + e.console.Message(ctx, output.WithHintFormat( + "Preparing %s to %s error...", agentcopilot.DisplayTitle, category)) + agentResult, err := azdAgent.SendMessageWithRetry(ctx, categoryPrompt) if err != nil { - return nil, fmt.Errorf("prompting for fix: %w", err) + span.SetStatus(codes.Error, "agent.send_message.failed") + return nil, err } - if !wantFix { - span.SetStatus(codes.Ok, "agent.fix.declined") - return actionResult, originalError - } + span.SetStatus(codes.Ok, fmt.Sprintf("agent.%s.completed", category)) + e.displayUsageMetrics(ctx, agentResult) - // Step 4: Agent applies the fix - fixPrompt := e.buildFixPrompt(originalError) previousError = originalError - e.console.Message(ctx, output.WithHintFormat( - "Preparing %s to fix error...", agentcopilot.DisplayTitle)) - fixResult, err := azdAgent.SendMessage(ctx, fixPrompt) - if err != nil { - span.SetStatus(codes.Error, "agent.fix.failed") - return nil, err - } - span.SetStatus(codes.Ok, "agent.fix.completed") - e.displayUsageMetrics(ctx, fixResult) + if category != categoryFix { + // Step 3: Ask if user wants the agent to fix the error + // (only if they didn't already choose the fix category) + wantFix, err := e.promptForFix(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for fix: %w", err) + } + + if !wantFix { + span.SetStatus(codes.Ok, "agent.fix.declined") + return actionResult, originalError + } + + // Step 4: Agent applies the fix + fixPrompt := e.buildFixPrompt(originalError) + e.console.Message(ctx, output.WithHintFormat( + "Preparing %s to fix error...", agentcopilot.DisplayTitle)) + fixResult, err := azdAgent.SendMessageWithRetry(ctx, fixPrompt) + if err != nil { + span.SetStatus(codes.Error, "agent.fix.failed") + return nil, err + } + + span.SetStatus(codes.Ok, "agent.fix.completed") + e.displayUsageMetrics(ctx, fixResult) + } // Step 5: Ask user if they want to retry the command shouldRetry, err := e.promptRetryAfterFix(ctx) @@ -356,6 +322,8 @@ func (e *ErrorMiddleware) buildPromptForCategory(category troubleshootCategory, tmpl = guidanceTemplate case categoryTroubleshoot: tmpl = troubleshootManualTemplate + case categoryFix: + tmpl = fixTemplate default: tmpl = troubleshootManualTemplate } @@ -408,7 +376,7 @@ func (e *ErrorMiddleware) promptTroubleshootCategory(ctx context.Context) (troub if val, ok := userConfig.GetString(agentcopilot.ConfigKeyErrorHandlingCategory); ok && val != "" { saved := troubleshootCategory(val) switch saved { - case categoryExplain, categoryGuidance, categoryTroubleshoot, categorySkip: + case categoryExplain, categoryGuidance, categoryTroubleshoot, categoryFix, categorySkip: e.console.Message(ctx, output.WithWarningFormat( "\n%s troubleshooting is set to always use '%s'. To change, run %s.", agentcopilot.DisplayTitle, @@ -425,9 +393,9 @@ func (e *ErrorMiddleware) promptTroubleshootCategory(ctx context.Context) (troub {Value: string(categoryExplain), Label: "Explain this error"}, {Value: string(categoryGuidance), Label: "Show fix guidance"}, {Value: string(categoryTroubleshoot), Label: "Troubleshoot with explanation and guidance"}, + {Value: string(categoryFix), Label: "Fix this error"}, {Value: string(categorySkip), Label: "Skip"}, } - selector := uxlib.NewSelect(&uxlib.SelectOptions{ Message: fmt.Sprintf("How would you like %s to help?", agentcopilot.DisplayTitle), HelpMessage: fmt.Sprintf( diff --git a/cli/azd/cmd/middleware/error_test.go b/cli/azd/cmd/middleware/error_test.go index d18089e8327..49c71286485 100644 --- a/cli/azd/cmd/middleware/error_test.go +++ b/cli/azd/cmd/middleware/error_test.go @@ -263,29 +263,28 @@ func Test_ErrorMiddleware_NoPatternMatch(t *testing.T) { require.Equal(t, unknownError, err) } -func Test_ClassifyError(t *testing.T) { +func Test_FixableError(t *testing.T) { t.Parallel() - // --- Machine context: typed errors --- - t.Run("MissingToolErrors classifies as MachineContext", func(t *testing.T) { + t.Run("MissingToolErrors is fixable", func(t *testing.T) { t.Parallel() err := &tools.MissingToolErrors{ Errs: []error{errors.New("docker not found")}, ToolNames: []string{"docker"}, } - require.Equal(t, MachineContextError, classifyError(err)) + require.True(t, fixableError(err)) }) - t.Run("Wrapped MissingToolErrors classifies as MachineContext", func(t *testing.T) { + t.Run("Wrapped MissingToolErrors is fixable", func(t *testing.T) { t.Parallel() inner := &tools.MissingToolErrors{ Errs: []error{errors.New("node not found")}, ToolNames: []string{"node"}, } wrapped := fmt.Errorf("setup failed: %w", inner) - require.Equal(t, MachineContextError, classifyError(wrapped)) + require.True(t, fixableError(wrapped)) }) - t.Run("ErrSemver classifies as MachineContext", func(t *testing.T) { + t.Run("ErrSemver is fixable", func(t *testing.T) { t.Parallel() err := &tools.ErrSemver{ ToolName: "node", @@ -294,77 +293,77 @@ func Test_ClassifyError(t *testing.T) { UpdateCommand: "nvm install", }, } - require.Equal(t, MachineContextError, classifyError(err)) + require.True(t, fixableError(err)) }) - t.Run("ExtensionRunError classifies as MachineContext", func(t *testing.T) { + t.Run("ExtensionRunError is not fixable", func(t *testing.T) { t.Parallel() err := &extensions.ExtensionRunError{ ExtensionId: "my-extension", Err: errors.New("extension crashed"), } - require.Equal(t, MachineContextError, classifyError(err)) + require.False(t, fixableError(err)) }) - t.Run("StatusCodeError classifies as MachineContext", func(t *testing.T) { + t.Run("StatusCodeError is not fixable", func(t *testing.T) { t.Parallel() err := &pack.StatusCodeError{ Code: 1, Err: errors.New("pack build failed"), } - require.Equal(t, MachineContextError, classifyError(err)) + require.False(t, fixableError(err)) }) - // --- User context: typed errors --- - t.Run("ReLoginRequiredError classifies as UserContext", func(t *testing.T) { + t.Run("ReLoginRequiredError is fixable", func(t *testing.T) { t.Parallel() err := &auth.ReLoginRequiredError{} - require.Equal(t, UserContextError, classifyError(err)) + require.True(t, fixableError(err)) }) - t.Run("AuthFailedError classifies as UserContext", func(t *testing.T) { + t.Run("AuthFailedError is fixable", func(t *testing.T) { t.Parallel() err := &auth.AuthFailedError{} - require.Equal(t, UserContextError, classifyError(err)) + require.True(t, fixableError(err)) }) - userContextSentinels := []struct { - name string - err error + sentinels := []struct { + name string + err error + fixable bool }{ - // auth - {"auth.ErrNoCurrentUser", auth.ErrNoCurrentUser}, - // azapi - {"azapi.ErrAzCliNotLoggedIn", azapi.ErrAzCliNotLoggedIn}, - {"azapi.ErrAzCliRefreshTokenExpired", azapi.ErrAzCliRefreshTokenExpired}, - // github - {"github.ErrGitHubCliNotLoggedIn", github.ErrGitHubCliNotLoggedIn}, - {"github.ErrUserNotAuthorized", github.ErrUserNotAuthorized}, - {"github.ErrRepositoryNameInUse", github.ErrRepositoryNameInUse}, + {"auth.ErrNoCurrentUser", auth.ErrNoCurrentUser, true}, + {"azapi.ErrAzCliNotLoggedIn", azapi.ErrAzCliNotLoggedIn, true}, + {"azapi.ErrAzCliRefreshTokenExpired", + azapi.ErrAzCliRefreshTokenExpired, true}, + {"github.ErrGitHubCliNotLoggedIn", + github.ErrGitHubCliNotLoggedIn, true}, + {"github.ErrUserNotAuthorized", github.ErrUserNotAuthorized, true}, + {"github.ErrRepositoryNameInUse", github.ErrRepositoryNameInUse, true}, // environment - {"environment.ErrNotFound", environment.ErrNotFound}, - {"environment.ErrNameNotSpecified", environment.ErrNameNotSpecified}, - {"environment.ErrDefaultEnvironmentNotFound", environment.ErrDefaultEnvironmentNotFound}, - {"environment.ErrAccessDenied", environment.ErrAccessDenied}, + {"environment.ErrNotFound", environment.ErrNotFound, false}, + {"environment.ErrNameNotSpecified", environment.ErrNameNotSpecified, false}, + {"environment.ErrDefaultEnvironmentNotFound", + environment.ErrDefaultEnvironmentNotFound, false}, + {"environment.ErrAccessDenied", environment.ErrAccessDenied, false}, // pipeline - {"pipeline.ErrAuthNotSupported", pipeline.ErrAuthNotSupported}, - {"pipeline.ErrRemoteHostIsNotAzDo", pipeline.ErrRemoteHostIsNotAzDo}, - {"pipeline.ErrSSHNotSupported", pipeline.ErrSSHNotSupported}, - {"pipeline.ErrRemoteHostIsNotGitHub", pipeline.ErrRemoteHostIsNotGitHub}, + {"pipeline.ErrAuthNotSupported", pipeline.ErrAuthNotSupported, false}, + {"pipeline.ErrRemoteHostIsNotAzDo", pipeline.ErrRemoteHostIsNotAzDo, false}, + {"pipeline.ErrSSHNotSupported", pipeline.ErrSSHNotSupported, false}, + {"pipeline.ErrRemoteHostIsNotGitHub", pipeline.ErrRemoteHostIsNotGitHub, false}, // project - {"project.ErrNoDefaultService", project.ErrNoDefaultService}, + {"project.ErrNoDefaultService", project.ErrNoDefaultService, false}, } - for _, tc := range userContextSentinels { - t.Run(tc.name+" classifies as UserContext", func(t *testing.T) { + for _, tc := range sentinels { + t.Run(tc.name+" fixableError", func(t *testing.T) { t.Parallel() - require.Equal(t, UserContextError, classifyError(tc.err)) + require.Equal(t, tc.fixable, fixableError(tc.err)) }) - t.Run("Wrapped "+tc.name+" classifies as UserContext", func(t *testing.T) { + t.Run("Wrapped "+tc.name+" fixableError", func(t *testing.T) { t.Parallel() wrapped := fmt.Errorf("operation failed: %w", tc.err) - require.Equal(t, UserContextError, classifyError(wrapped)) + require.Equal(t, tc.fixable, fixableError(wrapped)) }) } @@ -372,7 +371,7 @@ func Test_ClassifyError(t *testing.T) { t.Run("Generic error defaults to AzureContext", func(t *testing.T) { t.Parallel() err := errors.New("deploying to Azure: InternalServerError") - require.Equal(t, AzureContextAndOtherError, classifyError(err)) + require.True(t, fixableError(err)) }) } @@ -408,6 +407,7 @@ func Test_TroubleshootCategory_Constants(t *testing.T) { require.Equal(t, troubleshootCategory("explain"), categoryExplain) require.Equal(t, troubleshootCategory("guidance"), categoryGuidance) require.Equal(t, troubleshootCategory("troubleshoot"), categoryTroubleshoot) + require.Equal(t, troubleshootCategory("fix"), categoryFix) require.Equal(t, troubleshootCategory("skip"), categorySkip) } @@ -438,6 +438,11 @@ func Test_BuildPromptForCategory(t *testing.T) { category: categoryTroubleshoot, contains: []string{"azd provision", "QuotaExceeded", "EXPLAIN TO THE USER", "RECOMMEND MANUAL STEPS"}, }, + { + name: "fix category", + category: categoryFix, + contains: []string{"azd provision", "QuotaExceeded", "FIX", "minimal change"}, + }, { name: "default falls back to troubleshoot manual", category: troubleshootCategory("unknown"), diff --git a/cli/azd/cmd/middleware/middleware_coverage_test.go b/cli/azd/cmd/middleware/middleware_coverage_test.go index f95c8704a9a..4d3a5c4555f 100644 --- a/cli/azd/cmd/middleware/middleware_coverage_test.go +++ b/cli/azd/cmd/middleware/middleware_coverage_test.go @@ -999,21 +999,21 @@ func TestErrorMiddleware_Run_ErrorWithSuggestion(t *testing.T) { } // --------------------------------------------------------------------------- -// classifyError — additional coverage +// fixableError — additional coverage // --------------------------------------------------------------------------- -func TestClassifyError_RegularError(t *testing.T) { +func TestFixableError_RegularError(t *testing.T) { t.Parallel() - result := classifyError(errors.New("some unknown error")) - require.Equal(t, AzureContextAndOtherError, result) + result := fixableError(errors.New("some unknown error")) + require.True(t, result) } -func TestClassifyError_ContextCanceled(t *testing.T) { +func TestFixableError_ContextCanceled(t *testing.T) { t.Parallel() // context.Canceled is not a typed auth/tool error, so it falls through to - // AzureContextAndOtherError (the catch-all default). - result := classifyError(context.Canceled) - require.Equal(t, AzureContextAndOtherError, result) + // the default fixable path. + result := fixableError(context.Canceled) + require.True(t, result) } // --------------------------------------------------------------------------- diff --git a/cli/azd/cmd/middleware/templates/explain.tmpl b/cli/azd/cmd/middleware/templates/explain.tmpl index c73155e4fe4..381179ee72b 100644 --- a/cli/azd/cmd/middleware/templates/explain.tmpl +++ b/cli/azd/cmd/middleware/templates/explain.tmpl @@ -20,5 +20,5 @@ One to two sentences describing what the error means. **Why it happened** One to three sentences explaining the root cause. -STOP here. Do NOT propose a fix. Do NOT make any file changes. Do NOT provide fix steps. -End your response after the explanation. +STOP here. End your response after the explanation. +Do NOT propose a fix. Do NOT make any file changes. Do NOT provide fix steps. diff --git a/cli/azd/cmd/middleware/templates/fix.tmpl b/cli/azd/cmd/middleware/templates/fix.tmpl index 68f2536e1aa..ba59b2b10c4 100644 --- a/cli/azd/cmd/middleware/templates/fix.tmpl +++ b/cli/azd/cmd/middleware/templates/fix.tmpl @@ -7,8 +7,8 @@ You MUST complete every step below in exact order. Do NOT skip ahead. ## STEP 1 — DIAGNOSE -Call the `azd_error_troubleshooting` tool now. Pass the full error message above as input. -Wait for the tool result before continuing. +Call the `azd_provision_common_error` tool and check if the error is recorded. If not, call the `azd_error_troubleshooting` tool now. Pass the full error message above as input. +Wait for the tool result before continuing. If tool is already called with same error, use the previous result. ## STEP 2 — FIX @@ -18,3 +18,6 @@ Using the tool result from Step 1: - Do NOT modify anything unrelated to this error. - Do NOT run `{{.Command}}`. - Remove any changes that were created solely for validation and are not part of the actual error fix. + +## STEP 3 — ASK FEEDBACK +Ask the user for feedback on the fix you applied. Make sure to address any concerns they have and be ready to iterate on the fix if necessary. diff --git a/cli/azd/cmd/middleware/templates/troubleshoot_fixable.tmpl b/cli/azd/cmd/middleware/templates/troubleshoot_fixable.tmpl deleted file mode 100644 index 452d60c985a..00000000000 --- a/cli/azd/cmd/middleware/templates/troubleshoot_fixable.tmpl +++ /dev/null @@ -1,53 +0,0 @@ -An error occurred while running `{{.Command}}`. - -Error message: -{{.ErrorMessage}} - -You MUST complete every step below in exact order. Do NOT skip ahead. - -## STEP 1 — DIAGNOSE - -Call the `azd_error_troubleshooting` tool now. Pass the full error message above as input. -Wait for the tool result before continuing. - -## STEP 2 — EXPLAIN TO THE USER - -Using the tool result from Step 1, respond to the user with two sections: - -**What happened** -One to two sentences describing what the error means. - -**Why it happened** -One to three sentences explaining the root cause. - -Do NOT propose a fix yet. Do NOT make any file changes. End your response after this explanation. - -## STEP 3 — ASK THE USER - -After the explanation, use the `ask_user` tool to ask the user: - -Question: "How would you like to proceed?" -Choices: - - "Fix this error for me" - - "Show me the steps to fix it myself" - - "Skip — I'll handle it" - -Wait for the user's response before continuing. - -## STEP 4 — ACT ON THE USER'S CHOICE - -Based on the user's answer from Step 3: - -If the user chose **"Fix this error for me"**: - - Describe the exact change you will make (file, setting, or command) and why it fixes the problem. - - Apply the minimal change required using available tools. - - Do NOT modify anything unrelated to this error. - - Do NOT run `{{.Command}}`. - -If the user chose **"Show me the steps to fix it myself"**: - - Provide a numbered list of manual steps (max 5). - - Each step must be one sentence. Include exact commands where possible. - - Do NOT make any file changes. - -If the user chose **"Skip"**: - - Acknowledge and stop. Do not take any action.