diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 3f49b62ad..af9c853f6 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo125Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-qNsk3rEco0mzBIPodsp3GZpzgaIzthAPSIMmVCja50I="; + vendorHash = "sha256-eNm2ChvQ10xAYPJ6jN+Cm4CCnZfqk2e2MFGQ1Uj6T3w="; subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index d700e8605..515763a38 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -137,7 +137,7 @@ func makeComposeUpCmd() *cobra.Command { }) if err != nil { composeErr := err - debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack) + debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack, !global.NonInteractive) if err != nil { return err } @@ -166,7 +166,7 @@ func makeComposeUpCmd() *cobra.Command { serviceStates, err := cli.TailAndMonitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, tailOptions) if err != nil { deploymentErr := err - debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack) + debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack, !global.NonInteractive) if err != nil { term.Warn("Failed to initialize debugger:", err) return deploymentErr @@ -320,7 +320,11 @@ func handleComposeUpErr(ctx context.Context, debugger *debug.Debugger, project * return nil } - if global.NonInteractive || errors.Is(originalErr, byoc.ErrLocalPulumiStopped) { + if errors.Is(originalErr, byoc.ErrLocalPulumiStopped) { + return originalErr + } + // In CI only paid accounts auto-run the debugger; others just get the error. + if global.NonInteractive && !debugger.AutoApprove() { return originalErr } @@ -365,7 +369,9 @@ func handleTailAndMonitorErr(ctx context.Context, err error, debugger *debug.Deb debugConfig.FailedServices = []string{errDeploymentFailed.Service} } - if global.NonInteractive { + // In CI there is no one to prompt, so only paid accounts (which auto-approve) run the + // debugger automatically; everyone else gets a hint to debug interactively. + if global.NonInteractive && !debugger.AutoApprove() { printDefangHint("To debug the deployment, do:", debugConfig.String()) return } @@ -485,7 +491,7 @@ func makeComposeDownCmd() *cobra.Command { // A failed destroy (e.g. CodeBuild exit status) is when resources get orphaned, so prompt // the AI debugger just like `up` does; it can guide the user through cleanup. deploymentErr := err - debugger, dbgErr := debug.NewDebugger(cmd.Context(), global.FabricAddr, session.Stack) + debugger, dbgErr := debug.NewDebugger(cmd.Context(), global.FabricAddr, session.Stack, !global.NonInteractive) if dbgErr != nil { term.Warn("Failed to initialize debugger:", dbgErr) return deploymentErr diff --git a/src/cmd/cli/command/debug.go b/src/cmd/cli/command/debug.go index 22f6cd730..0a25b7362 100644 --- a/src/cmd/cli/command/debug.go +++ b/src/cmd/cli/command/debug.go @@ -35,7 +35,7 @@ var debugCmd = &cobra.Command{ return err } - debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack) + debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack, !global.NonInteractive) if err != nil { return err } diff --git a/src/cmd/cli/command/session.go b/src/cmd/cli/command/session.go index 4eb2d4f72..15efe2dba 100644 --- a/src/cmd/cli/command/session.go +++ b/src/cmd/cli/command/session.go @@ -174,7 +174,7 @@ func handleInvalidComposeFileErr(ctx context.Context, loadErr error) error { return fmt.Errorf("%w; original error: %w", err, loadErr) } - debugger, err := debug.NewDebugger(ctx, global.FabricAddr, &stacks.Parameters{}) + debugger, err := debug.NewDebugger(ctx, global.FabricAddr, &stacks.Parameters{}, !global.NonInteractive) if err != nil { return fmt.Errorf("%w; original error: %w", err, loadErr) } diff --git a/src/pkg/agent/agent.go b/src/pkg/agent/agent.go index 1a3c3c2da..ccd351d36 100644 --- a/src/pkg/agent/agent.go +++ b/src/pkg/agent/agent.go @@ -31,9 +31,31 @@ type Agent struct { generator *Generator printer Printer system string + // interactive controls whether the agent runs a REPL and prompts for input/elicitations. + // When false (e.g. CI), the agent handles the initial message once and returns. + interactive bool } -func New(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Agent, error) { +// Option configures an Agent created by New. +type Option func(*agentConfig) + +type agentConfig struct { + interactive bool +} + +// WithNonInteractive runs the agent in one-shot mode: it handles the initial message to +// completion and returns, without a REPL, continue-prompts, or tool elicitations. Suitable for +// non-interactive environments such as CI. +func WithNonInteractive() Option { + return func(c *agentConfig) { c.interactive = false } +} + +func New(ctx context.Context, fabricAddr string, stack *stacks.Parameters, opts ...Option) (*Agent, error) { + cfg := agentConfig{interactive: true} + for _, opt := range opts { + opt(&cfg) + } + accessToken := client.GetExistingToken(fabricAddr) aiProvider := "fabric" var providerPlugin api.Plugin @@ -62,8 +84,8 @@ func New(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Age genkit.WithPlugins(providerPlugin), ) - elicitationsClient := elicitations.NewSurveyClient(os.Stdin, os.Stdout, os.Stderr) - ec := elicitations.NewController(elicitationsClient) + ec := elicitations.NewController(elicitations.NewSurveyClient(os.Stdin, os.Stdout, os.Stderr)) + ec.SetSupported(cfg.interactive) printer := printer{outStream: os.Stdout} toolManager := NewToolManager(gk, printer) @@ -87,9 +109,10 @@ func New(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Age } a := &Agent{ - printer: printer, - generator: generator, - system: preparedSystemPrompt, + printer: printer, + generator: generator, + system: preparedSystemPrompt, + interactive: cfg.interactive, } return a, nil @@ -108,7 +131,9 @@ func (a *Agent) StartWithMessage(ctx context.Context, msg string) error { func (a *Agent) startSession(ctx context.Context, initialMessage string) error { signal.Reset(os.Interrupt) // unsubscribe the top-level signal handler - a.printer.Printf("Type '/exit' to quit.\n") + if a.interactive { + a.printer.Printf("Type '/exit' to quit.\n") + } if initialMessage != "" { if err := a.handleUserMessage(ctx, initialMessage); err != nil { @@ -116,6 +141,12 @@ func (a *Agent) startSession(ctx context.Context, initialMessage string) error { } } + // In non-interactive mode there is no one to prompt for further input: stop after the + // initial message has been handled to completion. + if !a.interactive { + return nil + } + for { var input string err := survey.AskOne( @@ -166,6 +197,11 @@ func (a *Agent) handleUserMessage(ctx context.Context, msg string) error { return err } + // Without a user to ask, stop at the turn limit instead of prompting to continue. + if !a.interactive { + return nil + } + var continueSession bool err = survey.AskOne(&survey.Confirm{ Message: "Defang is still working on this, would you like to continue?", diff --git a/src/pkg/debug/debug.go b/src/pkg/debug/debug.go index 81961ebf0..c7023718a 100644 --- a/src/pkg/debug/debug.go +++ b/src/pkg/debug/debug.go @@ -77,10 +77,17 @@ type Debugger struct { // defaultPermission is the default answer to the "debug with AI?" prompt. Paid accounts // default to yes so they flow into the agent (and its cleanup tooling) seamlessly. defaultPermission bool + // interactive is false in environments without a user to prompt (e.g. CI). When false the + // debugger only runs if defaultPermission is true (paid), and does so without prompting. + interactive bool } -func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Debugger, error) { - agent, err := agent.New(ctx, fabricAddr, stack) +func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameters, interactive bool) (*Debugger, error) { + var opts []agent.Option + if !interactive { + opts = append(opts, agent.WithNonInteractive()) + } + agent, err := agent.New(ctx, fabricAddr, stack, opts...) if err != nil { return nil, err } @@ -88,9 +95,17 @@ func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameter agent: agent, surveyor: &surveyor{}, defaultPermission: isPaidAccount(ctx, fabricAddr), + interactive: interactive, }, nil } +// AutoApprove reports whether the debugger will run without an interactive prompt. This is true +// for paid accounts and lets callers decide whether to invoke the debugger in non-interactive +// environments (CI) or just print a hint. +func (d *Debugger) AutoApprove() bool { + return d.defaultPermission +} + // isPaidAccount reports whether the signed-in account is on a paid plan. Any error falls back to // false so the prompt stays opt-in. func isPaidAccount(ctx context.Context, fabricAddr string) bool { @@ -146,16 +161,22 @@ func (d *Debugger) promptAndTrackDebugSession(fn func() error, eventName string, return err } - good, err := d.promptForFeedback() - if err != nil { - track.Evt(eventName+" Feedback Prompt Failed", append([]track.Property{P("reason", err)}, eventProperty...)...) - return err + if d.interactive { + good, err := d.promptForFeedback() + if err != nil { + track.Evt(eventName+" Feedback Prompt Failed", append([]track.Property{P("reason", err)}, eventProperty...)...) + return err + } + track.Evt(eventName+" Feedback Prompt Answered", append([]track.Property{P("feedback", good)}, eventProperty...)...) } - track.Evt(eventName+" Feedback Prompt Answered", append([]track.Property{P("feedback", good)}, eventProperty...)...) return nil } func (d *Debugger) promptForPermission() (bool, error) { + if !d.interactive { + // No user to prompt: proceed only if this account auto-approves (paid). + return d.defaultPermission, nil + } var aiDebug bool err := d.surveyor.AskOne(&survey.Confirm{ Message: "Would you like to debug this with the Defang AI Agent?", diff --git a/src/pkg/debug/debug_test.go b/src/pkg/debug/debug_test.go index 9abf216cf..e930db453 100644 --- a/src/pkg/debug/debug_test.go +++ b/src/pkg/debug/debug_test.go @@ -86,8 +86,9 @@ func TestDebugDeployment(t *testing.T) { response: tt.permission, } debugger := &Debugger{ - agent: mockAgent, - surveyor: mockSurveyor, + agent: mockAgent, + surveyor: mockSurveyor, + interactive: true, } t.Run(tt.name, func(t *testing.T) { mockAgent.ExpectedCalls = nil @@ -142,8 +143,9 @@ func TestDebugComposeLoadError(t *testing.T) { response: tt.permission, } debugger := &Debugger{ - agent: mockAgent, - surveyor: mockSurveyor, + agent: mockAgent, + surveyor: mockSurveyor, + interactive: true, } t.Run(tt.name, func(t *testing.T) { mockAgent.ExpectedCalls = nil @@ -182,3 +184,50 @@ func TestDebugComposeLoadError(t *testing.T) { }) } } + +// failSurveyor fails the test if any prompt is attempted; used to prove the non-interactive path +// never prompts. +type failSurveyor struct{ t *testing.T } + +func (s failSurveyor) AskOne(survey.Prompt, interface{}, ...survey.AskOpt) error { + s.t.Fatal("non-interactive debugger must not prompt") + return nil +} + +func TestDebugDeploymentNonInteractive(t *testing.T) { + ctx := t.Context() + const expectedPrompt = `An error occurred while deploying this project with Defang. Help troubleshoot and recommend a solution. Look at the logs to understand what happened. The deployment ID is "test-deployment".` + + tests := []struct { + name string + autoApprove bool + expectRun bool + }{ + {name: "paid account auto-runs without prompting", autoApprove: true, expectRun: true}, + {name: "free account does not run", autoApprove: false, expectRun: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAgent := &mockAgent{} + if tt.expectRun { + mockAgent.On("StartWithMessage", ctx, expectedPrompt).Return(nil) + } + debugger := &Debugger{ + agent: mockAgent, + surveyor: failSurveyor{t}, // must never be called when non-interactive + defaultPermission: tt.autoApprove, + interactive: false, + } + + err := debugger.DebugDeployment(ctx, DebugConfig{Deployment: "test-deployment"}) + assert.NoError(t, err) + + if tt.expectRun { + mockAgent.AssertCalled(t, "StartWithMessage", ctx, expectedPrompt) + } else { + mockAgent.AssertNotCalled(t, "StartWithMessage", mock.Anything, mock.Anything) + } + }) + } +} diff --git a/src/pkg/elicitations/elicitations.go b/src/pkg/elicitations/elicitations.go index 64bcd1284..2509c2dd6 100644 --- a/src/pkg/elicitations/elicitations.go +++ b/src/pkg/elicitations/elicitations.go @@ -2,6 +2,7 @@ package elicitations import ( "context" + "errors" "fmt" ) @@ -51,6 +52,11 @@ type Response struct { Content map[string]any } +// ErrNotSupported is returned by elicitation requests when the controller is marked unsupported +// (e.g. non-interactive/CI). Callers that want to degrade gracefully should check IsSupported() +// before requesting. +var ErrNotSupported = errors.New("elicitation is not supported in this environment") + func NewController(client Client) Controller { return &controller{ client: client, @@ -82,6 +88,11 @@ func (c *controller) RequestEnum(ctx context.Context, message, field string, opt } func (c *controller) requestField(ctx context.Context, message, field string, schema map[string]any, validator Validator) (string, error) { + // Enforce the supported flag here so it is authoritative: a caller that doesn't first check + // IsSupported() fails fast instead of blocking on a transport that has no user to answer. + if !c.supported { + return "", ErrNotSupported + } response, err := c.client.Request(ctx, Request{ Message: message, Schema: map[string]any{