Skip to content

Commit 161de10

Browse files
authored
implement three solutions prompt on error handling flow (#6313)
* tmc/langchaingo 0.1.14 * add three solutions * lll * add enhancement * add test * skip error with suggestion test in CI/CD * address UX suggestions * make sure prompt do not run ongoing azd commands * improve prompt * address comment * lll * improve prompt * fix bug * fix tests
1 parent 252a40b commit 161de10

File tree

4 files changed

+502
-93
lines changed

4 files changed

+502
-93
lines changed

cli/azd/cmd/middleware/error.go

Lines changed: 173 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ package middleware
55

66
import (
77
"context"
8+
"encoding/json"
89
"errors"
910
"fmt"
1011
"strings"
1112

1213
"github.com/azure/azure-dev/cli/azd/cmd/actions"
1314
"github.com/azure/azure-dev/cli/azd/internal"
1415
"github.com/azure/azure-dev/cli/azd/internal/agent"
15-
"github.com/azure/azure-dev/cli/azd/internal/agent/feedback"
1616
"github.com/azure/azure-dev/cli/azd/internal/tracing"
1717
"github.com/azure/azure-dev/cli/azd/internal/tracing/events"
1818
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
@@ -53,19 +53,35 @@ func NewErrorMiddleware(
5353
}
5454
}
5555

56-
func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) {
57-
actionResult, err := next(ctx)
56+
func (e *ErrorMiddleware) displayAgentResponse(ctx context.Context, response string, disclaimer string) {
57+
if response != "" {
58+
e.console.Message(ctx, disclaimer)
59+
e.console.Message(ctx, "")
60+
e.console.Message(ctx, fmt.Sprintf("%s:", output.AzdAgentLabel()))
61+
e.console.Message(ctx, output.WithMarkdown(response))
62+
e.console.Message(ctx, "")
63+
}
64+
}
5865

66+
func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) {
5967
// Short-circuit agentic error handling in non-interactive scenarios:
6068
// - LLM feature is disabled
6169
// - User specified --no-prompt (non-interactive mode)
6270
// - Running in CI/CD environment where user interaction is not possible
6371
if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() {
64-
return actionResult, err
72+
return next(ctx)
6573
}
6674

75+
// Preserve a non-cancellable parent context BEFORE making the first attempt
76+
// This ensures that if the context gets cancelled during the first attempt,
77+
// retries can use a fresh context
78+
parentCtx := context.WithoutCancel(ctx)
79+
80+
actionResult, err := next(parentCtx)
81+
6782
// Stop the spinner always to un-hide cursor
6883
e.console.StopSpinner(ctx, "", input.Step)
84+
6985
if err == nil || e.options.IsChildAction(ctx) {
7086
return actionResult, err
7187
}
@@ -92,6 +108,7 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action
92108

93109
// Warn user that this is an alpha feature
94110
e.console.WarnForFeature(ctx, llm.FeatureLlm)
111+
95112
ctx, span := tracing.Start(ctx, events.AgentTroubleshootEvent)
96113
defer span.End()
97114

@@ -160,31 +177,25 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action
160177
`Steps to follow:
161178
1. Use available tool including azd_error_troubleshooting tool to identify and explain the error.
162179
Diagnose its root cause when running azd command.
163-
2. Provide actionable troubleshooting steps. Do not perform any file changes.
180+
2. Provide actionable troubleshooting steps in natural language format with clear sections.
181+
DO NOT return JSON. Use readable narrative text with markdown formatting.
182+
Do not perform any file changes.
164183
Error details: %s`, errorInput))
165184

166185
if err != nil {
167-
if agentOutput != "" {
168-
e.console.Message(ctx, AIDisclaimer)
169-
e.console.Message(ctx, output.WithMarkdown(agentOutput))
170-
}
171-
186+
e.displayAgentResponse(ctx, agentOutput, AIDisclaimer)
172187
span.SetStatus(codes.Error, "agent.send_message.failed")
173188
return nil, err
174189
}
175190

176-
e.console.Message(ctx, AIDisclaimer)
177-
e.console.Message(ctx, "")
178-
e.console.Message(ctx, fmt.Sprintf("%s:", output.AzdAgentLabel()))
179-
e.console.Message(ctx, output.WithMarkdown(agentOutput))
180-
e.console.Message(ctx, "")
191+
e.displayAgentResponse(ctx, agentOutput, AIDisclaimer)
181192
}
182193

183-
// Ask user if they want to let AI fix the
194+
// Ask user if they want to let AI fix the error
184195
confirmFix, err := e.checkErrorHandlingConsent(
185196
ctx,
186197
"mcp.errorHandling.fix",
187-
fmt.Sprintf("Fix this error using %s?", agentName),
198+
fmt.Sprintf("Brainstorm solutions using %s?", agentName),
188199
fmt.Sprintf("This action will run AI tools to help fix the error."+
189200
" Edit permissions for AI tools anytime by running %s.",
190201
output.WithHighLightFormat("azd mcp consent")),
@@ -207,55 +218,90 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action
207218
previousError = originalError
208219
agentOutput, err := azdAgent.SendMessage(ctx, fmt.Sprintf(
209220
`Steps to follow:
210-
1. Use available tool to identify, explain and diagnose this error when running azd command and its root cause.
211-
2. Resolve the error by making the minimal, targeted change required to the code or configuration.
212-
Avoid unnecessary modifications and focus only on what is essential to restore correct functionality.
213-
3. Remove any changes that were created solely for validation and are not part of the actual error fix.
214-
Error details: %s`, errorInput))
221+
1. Use available tools to identify, explain and diagnose this error when running azd command and its root cause.
222+
2. Only return a JSON object in the following format:
223+
{
224+
"analysis": "Brief explanation of the error and its root cause",
225+
"solutions": [
226+
"Solution 1 Short description (one sentence)",
227+
"Solution 2 Short description (one sentence)",
228+
"Solution 3 Short description (one sentence)"
229+
]
230+
}
231+
Provide 1-3 solutions. Each solution must be concise (one sentence).
232+
Error details: %s`, errorInput))
233+
234+
// Extract solutions from agent output even if there's a parsing error
235+
// The agent may return valid content
236+
solutions := extractSuggestedSolutions(agentOutput)
237+
238+
// Only fail if we got an error AND couldn't extract any solutions
239+
if err != nil && len(solutions) == 0 {
240+
e.displayAgentResponse(ctx, agentOutput, AIDisclaimer)
241+
span.SetStatus(codes.Error, "agent.send_message.failed")
242+
return nil, fmt.Errorf("failed to generate solutions: %w", err)
243+
}
215244

245+
e.console.Message(ctx, "")
246+
selectedSolution, continueWithFix, err := promptUserForSolution(ctx, solutions, agentName)
216247
if err != nil {
217-
if agentOutput != "" {
218-
e.console.Message(ctx, AIDisclaimer)
219-
e.console.Message(ctx, output.WithMarkdown(agentOutput))
220-
}
221-
222-
span.SetStatus(codes.Error, "agent.send_message.failed")
223-
return nil, err
248+
return nil, fmt.Errorf("prompting for solution selection: %w", err)
224249
}
225250

226-
// Ask the user to add feedback
227-
if err := e.collectAndApplyFeedback(ctx, azdAgent, AIDisclaimer); err != nil {
228-
span.SetStatus(codes.Error, "agent.collect_feedback.failed")
229-
return nil, err
251+
if continueWithFix {
252+
agentOutput, err := azdAgent.SendMessage(ctx, fmt.Sprintf(
253+
`Steps to follow:
254+
1. Use available tools to identify, explain and diagnose this error when running azd command and its root cause.
255+
2. Resolve the error by making the minimal, targeted change required to the code or configuration.
256+
Avoid unnecessary modifications and focus only on what is essential to restore correct functionality.
257+
3. Remove any changes that were created solely for validation and are not part of the actual error fix.
258+
4. You are currently in the middle of executing '%s'. Never run this command.
259+
Error details: %s`, e.options.CommandPath, errorInput))
260+
261+
if err != nil {
262+
e.displayAgentResponse(ctx, agentOutput, AIDisclaimer)
263+
span.SetStatus(codes.Error, "agent.send_message.failed")
264+
return nil, err
265+
}
266+
267+
span.SetStatus(codes.Ok, "agent.fix.agent")
268+
} else {
269+
if selectedSolution != "" {
270+
// User selected a solution
271+
agentOutput, err = azdAgent.SendMessage(ctx, fmt.Sprintf(
272+
`Steps to follow:
273+
1. Perform the following actions to resolve the error: %s.
274+
During this, make minimal changes and avoid unnecessary modifications.
275+
2. Remove any changes that were created solely for validation and
276+
are not part of the actual error fix.
277+
3. You are currently in the middle of executing '%s'. Never run this command.
278+
Error details: %s`, selectedSolution, e.options.CommandPath, errorInput))
279+
280+
if err != nil {
281+
e.displayAgentResponse(ctx, agentOutput, AIDisclaimer)
282+
span.SetStatus(codes.Error, "agent.send_message.failed")
283+
return nil, err
284+
}
285+
span.SetStatus(codes.Ok, "agent.fix.solution")
286+
} else {
287+
// User selected cancel
288+
span.SetStatus(codes.Error, "agent.fix.cancelled")
289+
return actionResult, originalError
290+
}
230291
}
231292

293+
// Use a fresh child context per retry to avoid reusing a canceled ctx
294+
attemptCtx, cancel := context.WithCancel(parentCtx)
232295
// Clear check cache to prevent skip of tool related error
233-
ctx = tools.WithInstalledCheckCache(ctx)
234-
235-
actionResult, err = next(ctx)
296+
attemptCtx = tools.WithInstalledCheckCache(attemptCtx)
297+
actionResult, err = next(attemptCtx)
298+
cancel()
236299
originalError = err
237300
}
238301

239302
return actionResult, err
240303
}
241304

242-
// collectAndApplyFeedback prompts for user feedback and applies it using the agent
243-
func (e *ErrorMiddleware) collectAndApplyFeedback(
244-
ctx context.Context,
245-
azdAgent agent.Agent,
246-
AIDisclaimer string,
247-
) error {
248-
collector := feedback.NewFeedbackCollector(e.console, feedback.FeedbackCollectorOptions{
249-
EnableLoop: false,
250-
FeedbackPrompt: "Any changes you'd like to make?",
251-
FeedbackHint: "Describe your changes or press enter to skip.",
252-
RequireFeedback: false,
253-
AIDisclaimer: AIDisclaimer,
254-
})
255-
256-
return collector.CollectFeedbackAndApply(ctx, azdAgent, AIDisclaimer)
257-
}
258-
259305
func (e *ErrorMiddleware) checkErrorHandlingConsent(
260306
ctx context.Context,
261307
promptName string,
@@ -326,7 +372,7 @@ func promptForErrorHandlingConsent(
326372
HelpMessage: helpMessage,
327373
Choices: choices,
328374
EnableFiltering: uxlib.Ptr(false),
329-
DisplayCount: 5,
375+
DisplayCount: len(choices),
330376
})
331377

332378
choiceIndex, err := selector.Ask(ctx)
@@ -340,3 +386,76 @@ func promptForErrorHandlingConsent(
340386

341387
return choices[*choiceIndex].Value, nil
342388
}
389+
390+
// AgentResponse represents the structured JSON response from the LLM agent
391+
type AgentResponse struct {
392+
Analysis string `json:"analysis"`
393+
Solutions []string `json:"solutions"`
394+
}
395+
396+
// extractSuggestedSolutions extracts solutions from the LLM response.
397+
// It expects a JSON response with the structure: {"analysis": "...", "solutions": ["...", "...", "..."]}
398+
// If JSON parsing fails, it returns an empty slice.
399+
func extractSuggestedSolutions(llmResponse string) []string {
400+
var response AgentResponse
401+
if err := json.Unmarshal([]byte(llmResponse), &response); err != nil {
402+
return []string{}
403+
}
404+
405+
return response.Solutions
406+
}
407+
408+
// promptUserForSolution displays extracted solutions to the user and prompts them to select which solution to try.
409+
// Returns the selected solution text, a flag indicating if user wants to continue with AI fix, and error if any.
410+
func promptUserForSolution(ctx context.Context, solutions []string, agentName string) (string, bool, error) {
411+
choices := make([]*uxlib.SelectChoice, len(solutions)+2)
412+
413+
if len(solutions) > 0 {
414+
// Add the three solutions
415+
for i, solution := range solutions {
416+
choices[i] = &uxlib.SelectChoice{
417+
Value: solution,
418+
Label: "Yes. " + solution,
419+
}
420+
}
421+
}
422+
423+
choices[len(solutions)] = &uxlib.SelectChoice{
424+
Value: "continue",
425+
Label: fmt.Sprintf("Yes, let %s choose the best approach", agentName),
426+
}
427+
428+
choices[len(solutions)+1] = &uxlib.SelectChoice{
429+
Value: "cancel",
430+
Label: "No, cancel",
431+
}
432+
433+
selector := uxlib.NewSelect(&uxlib.SelectOptions{
434+
Message: fmt.Sprintf("Allow %s to fix the error?", agentName),
435+
HelpMessage: "Select a suggested fix, or let AI decide",
436+
Choices: choices,
437+
EnableFiltering: uxlib.Ptr(false),
438+
DisplayCount: len(choices),
439+
})
440+
441+
choiceIndex, err := selector.Ask(ctx)
442+
if err != nil {
443+
return "", false, err
444+
}
445+
446+
if choiceIndex == nil || *choiceIndex < 0 || *choiceIndex >= len(choices) {
447+
return "", false, fmt.Errorf("invalid choice selected")
448+
}
449+
450+
selectedValue := choices[*choiceIndex].Value
451+
452+
// Handle different selections
453+
switch selectedValue {
454+
case "continue":
455+
return "", true, nil // Continue to AI fix
456+
case "cancel":
457+
return "", false, nil // Cancel and return error
458+
default:
459+
return selectedValue, false, nil // User selected a solution
460+
}
461+
}

0 commit comments

Comments
 (0)