Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkgs/defang/cli.nix
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ buildGo125Module {
pname = "defang-cli";
version = "git";
src = lib.cleanSource ../../src;
vendorHash = "sha256-qNsk3rEco0mzBIPodsp3GZpzgaIzthAPSIMmVCja50I=";
vendorHash = "sha256-eNm2ChvQ10xAYPJ6jN+Cm4CCnZfqk2e2MFGQ1Uj6T3w=";

subPackages = [ "cmd/cli" ];

Expand Down
16 changes: 11 additions & 5 deletions src/cmd/cli/command/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/cli/command/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/cli/command/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
50 changes: 43 additions & 7 deletions src/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -108,14 +131,22 @@ 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 {
return fmt.Errorf("error handling initial message: %w", err)
}
}

// 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(
Expand Down Expand Up @@ -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?",
Expand Down
35 changes: 28 additions & 7 deletions src/pkg/debug/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,35 @@ 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
}
return &Debugger{
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 {
Expand Down Expand Up @@ -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?",
Expand Down
57 changes: 53 additions & 4 deletions src/pkg/debug/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
})
}
}
11 changes: 11 additions & 0 deletions src/pkg/elicitations/elicitations.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package elicitations

import (
"context"
"errors"
"fmt"
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand Down