diff --git a/cmd/agent.go b/cmd/agent.go index d258d876..42cd2bd9 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -52,7 +52,12 @@ Examples: # Resume existing sessions infer agent "continue fixing the authentication bug" --session-id abc-123-def infer agent "analyze these new error logs" --session-id abc-123 --files error.log - infer agent "try a different approach" --session-id abc-123 --no-save`, + infer agent "try a different approach" --session-id abc-123 --no-save + +Exit Codes: + 0 task completed + 1 the run failed + 2 agent.max_turns was exhausted before the task completed`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { model, _ := cmd.Flags().GetString("model") @@ -263,13 +268,16 @@ For more information, visit: https://github.com/inference-gateway/inference-gate } // agentSessionOutcome maps a run error to the infer.run.outcome enum: a -// cancelled/timed-out context is "stopped_early", any other error "failed". +// cancelled/timed-out context or max-turns exhaustion is "stopped_early", +// any other error "failed". func agentSessionOutcome(err error) string { switch { case err == nil: return telemetry.RunSuccess case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): return telemetry.RunStoppedEarly + case errors.Is(err, domain.ErrMaxTurnsReached): + return telemetry.RunStoppedEarly default: return telemetry.RunFailed } @@ -507,6 +515,7 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { } consecutiveNoToolCalls := 0 + completedNormally := false for s.completedTurns < s.maxTurns { s.maybeRollover() @@ -541,18 +550,26 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { if consecutiveNoToolCalls >= 1 { logger.Info("task appears complete (no more tool calls)", "turns", s.completedTurns) + completedNormally = true break } } - if s.completedTurns >= s.maxTurns { - logger.Info("maximum turns reached", "turns", s.completedTurns) + var sessionErr error + + if !completedNormally && s.completedTurns >= s.maxTurns { + logger.Info("agent session stopped early (max turns reached)", "turns", s.completedTurns) + sessionErr = fmt.Errorf("%w: agent reached the maximum of %d turns without completing the task", domain.ErrMaxTurnsReached, s.maxTurns) } s.dispatchHooks(domain.HookPostSession, s.completedTurns) s.waitForBackgroundTasks(monitorCtx) + if sessionErr != nil { + return sessionErr + } + logger.Info("agent session completed", "turns", s.completedTurns) return nil } diff --git a/cmd/agent_test.go b/cmd/agent_test.go index a0f80c53..bab69e22 100644 --- a/cmd/agent_test.go +++ b/cmd/agent_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -22,6 +23,7 @@ import ( models "github.com/inference-gateway/cli/internal/models" services "github.com/inference-gateway/cli/internal/services" streamevent "github.com/inference-gateway/cli/internal/streamevent" + telemetry "github.com/inference-gateway/cli/internal/telemetry" ) // captureStdout redirects os.Stdout for the duration of fn and returns what was written. @@ -91,6 +93,27 @@ func TestOutputAgentError(t *testing.T) { }) } +func TestAgentSessionOutcome(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {name: "nil error returns success", err: nil, want: telemetry.RunSuccess}, + {name: "context canceled returns stopped_early", err: context.Canceled, want: telemetry.RunStoppedEarly}, + {name: "deadline exceeded returns stopped_early", err: context.DeadlineExceeded, want: telemetry.RunStoppedEarly}, + {name: "max turns reached returns stopped_early", err: fmt.Errorf("%w: agent reached max turns", domain.ErrMaxTurnsReached), want: telemetry.RunStoppedEarly}, + {name: "generic error returns failed", err: fmt.Errorf("something went wrong"), want: telemetry.RunFailed}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := agentSessionOutcome(tt.err); got != tt.want { + t.Errorf("agentSessionOutcome(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} + func TestFormatToolCallSummary(t *testing.T) { cases := []struct { name string diff --git a/cmd/root.go b/cmd/root.go index caa861bb..8e1ceadc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( viper "github.com/spf13/viper" config "github.com/inference-gateway/cli/config" + domain "github.com/inference-gateway/cli/internal/domain" logger "github.com/inference-gateway/cli/internal/logger" ) @@ -50,10 +52,18 @@ deployment, monitoring, and management of inference services.`, }, } +// ExitCodeMaxTurns is the process exit code when an agent run ends by +// exhausting agent.max_turns, so callers (e.g. infer-action) can tell +// turn exhaustion (2) apart from success (0) and failure (1). +const ExitCodeMaxTurns = 2 + func Execute() { defer logger.Close() if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version)); err != nil { + if errors.Is(err, domain.ErrMaxTurnsReached) { + os.Exit(ExitCodeMaxTurns) + } os.Exit(1) } } diff --git a/config/config.go b/config/config.go index 46b7218f..dcd60fb5 100644 --- a/config/config.go +++ b/config/config.go @@ -177,6 +177,7 @@ type ToolsConfig struct { Read ReadToolConfig `yaml:"read" mapstructure:"read"` Write WriteToolConfig `yaml:"write" mapstructure:"write"` Edit EditToolConfig `yaml:"edit" mapstructure:"edit"` + MultiEdit MultiEditToolConfig `yaml:"multi_edit" mapstructure:"multi_edit"` Delete DeleteToolConfig `yaml:"delete" mapstructure:"delete"` Grep GrepToolConfig `yaml:"grep" mapstructure:"grep"` Tree TreeToolConfig `yaml:"tree" mapstructure:"tree"` @@ -237,6 +238,11 @@ type EditToolConfig struct { StrictWhitespace bool `yaml:"strict_whitespace" mapstructure:"strict_whitespace"` } +// MultiEditToolConfig contains multi-edit-specific tool settings +type MultiEditToolConfig struct { + RequireApproval *bool `yaml:"require_approval,omitempty" mapstructure:"require_approval,omitempty"` +} + // DeleteToolConfig contains delete-specific tool settings type DeleteToolConfig struct { Enabled bool `yaml:"enabled" mapstructure:"enabled"` @@ -953,6 +959,9 @@ func DefaultConfig() *Config { //nolint:funlen RequireApproval: &[]bool{true}[0], StrictWhitespace: false, }, + MultiEdit: MultiEditToolConfig{ + RequireApproval: &[]bool{true}[0], + }, Delete: DeleteToolConfig{ Enabled: true, RequireApproval: &[]bool{true}[0], @@ -1223,6 +1232,10 @@ func (c *Config) IsApprovalRequired(toolName string) bool { // nolint:gocyclo,cy if c.Tools.Edit.RequireApproval != nil { return *c.Tools.Edit.RequireApproval } + case "MultiEdit": + if c.Tools.MultiEdit.RequireApproval != nil { + return *c.Tools.MultiEdit.RequireApproval + } case "Delete": if c.Tools.Delete.RequireApproval != nil { return *c.Tools.Delete.RequireApproval diff --git a/internal/domain/agent.go b/internal/domain/agent.go index bf5e9201..a3c921ad 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -2,12 +2,17 @@ package domain import ( "context" + "errors" "time" adk "github.com/inference-gateway/adk/types" sdk "github.com/inference-gateway/sdk" ) +// ErrMaxTurnsReached is returned when the agent reaches its maximum turn limit +// without completing the task. Callers should use errors.Is to check for it. +var ErrMaxTurnsReached = errors.New("max_turns_reached") + // AgentContext represents the execution context for the agent state machine type AgentContext struct { RequestID string