From f200fa0597013702024690ae0ee28c67ec6b8e5c Mon Sep 17 00:00:00 2001 From: "inference-gateway-maintainer[bot]" <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:54:24 +0000 Subject: [PATCH 1/5] fix: resolve #1006 --- cmd/agent.go | 9 ++++++++- config/config.go | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/agent.go b/cmd/agent.go index d258d876..121237ae 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -263,13 +263,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 strings.Contains(err.Error(), "max_turns_reached"): + return telemetry.RunStoppedEarly default: return telemetry.RunFailed } @@ -547,6 +550,10 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { if s.completedTurns >= s.maxTurns { logger.Info("maximum turns reached", "turns", s.completedTurns) + s.dispatchHooks(domain.HookPostSession, s.completedTurns) + s.waitForBackgroundTasks(monitorCtx) + logger.Info("agent session stopped early (max turns)", "turns", s.completedTurns) + return fmt.Errorf("max_turns_reached: agent reached the maximum of %d turns without completing the task", s.maxTurns) } s.dispatchHooks(domain.HookPostSession, s.completedTurns) diff --git a/config/config.go b/config/config.go index 46b7218f..abc02de3 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"` @@ -1223,6 +1224,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 From bf12efe2888d0c4fe4a415411cb9efd5e6655878 Mon Sep 17 00:00:00 2001 From: "inference-gateway-maintainer[bot]" <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:11:29 +0000 Subject: [PATCH 2/5] fix(config): add missing MultiEditToolConfig type definition The MultiEditToolConfig type was referenced in ToolsConfig and IsApprovalRequired but never defined, causing a typecheck compilation error. Add the minimal struct with Enabled and RequireApproval fields, matching the pattern of other tool config types. --- config/config.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/config.go b/config/config.go index abc02de3..cfe78d79 100644 --- a/config/config.go +++ b/config/config.go @@ -238,6 +238,12 @@ type EditToolConfig struct { StrictWhitespace bool `yaml:"strict_whitespace" mapstructure:"strict_whitespace"` } +// MultiEditToolConfig contains multi-edit-specific tool settings +type MultiEditToolConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + 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"` From 57f9f5ae8c28b434342a68ad0d5380d4cb25dd82 Mon Sep 17 00:00:00 2001 From: "inference-gateway-maintainer[bot]" <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:47:41 +0000 Subject: [PATCH 3/5] fix(config): register MultiEdit in DefaultConfig() for env var support Adds a default MultiEditToolConfig entry in DefaultConfig(), mirroring Edit, so Viper registers the key and INFER_TOOLS_MULTI_EDIT_REQUIRE_APPROVAL works through environment variables. --- cmd/agent.go | 8 +++++--- cmd/agent_test.go | 23 +++++++++++++++++++++++ config/config.go | 4 ++++ internal/domain/agent.go | 5 +++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 121237ae..32de6ff6 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -271,7 +271,7 @@ func agentSessionOutcome(err error) string { return telemetry.RunSuccess case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): return telemetry.RunStoppedEarly - case strings.Contains(err.Error(), "max_turns_reached"): + case errors.Is(err, domain.ErrMaxTurnsReached): return telemetry.RunStoppedEarly default: return telemetry.RunFailed @@ -510,6 +510,7 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { } consecutiveNoToolCalls := 0 + completedNormally := false for s.completedTurns < s.maxTurns { s.maybeRollover() @@ -544,16 +545,17 @@ 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 { + if !completedNormally && s.completedTurns >= s.maxTurns { logger.Info("maximum turns reached", "turns", s.completedTurns) s.dispatchHooks(domain.HookPostSession, s.completedTurns) s.waitForBackgroundTasks(monitorCtx) logger.Info("agent session stopped early (max turns)", "turns", s.completedTurns) - return fmt.Errorf("max_turns_reached: agent reached the maximum of %d turns without completing the task", s.maxTurns) + return fmt.Errorf("%w: agent reached the maximum of %d turns without completing the task", domain.ErrMaxTurnsReached, s.maxTurns) } s.dispatchHooks(domain.HookPostSession, s.completedTurns) 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/config/config.go b/config/config.go index cfe78d79..fc45a70b 100644 --- a/config/config.go +++ b/config/config.go @@ -960,6 +960,10 @@ func DefaultConfig() *Config { //nolint:funlen RequireApproval: &[]bool{true}[0], StrictWhitespace: false, }, + MultiEdit: MultiEditToolConfig{ + Enabled: true, + RequireApproval: &[]bool{true}[0], + }, Delete: DeleteToolConfig{ Enabled: true, RequireApproval: &[]bool{true}[0], 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 From de1f0a12d2daf12885a9a4e1875a86d90e1fa0ba Mon Sep 17 00:00:00 2001 From: "inference-gateway-maintainer[bot]" <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:42:14 +0000 Subject: [PATCH 4/5] refactor(agent): deduplicate dispatchHooks/waitForBackgroundTasks in max-turns path Set the error variable and fall through to the shared post-session block instead of duplicating dispatchHooks + waitForBackgroundTasks calls. Saves 4 lines. --- cmd/agent.go | 10 +++++++--- config/config.go | 2 -- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 32de6ff6..23e9102d 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -550,18 +550,22 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { } } + var sessionErr error + if !completedNormally && s.completedTurns >= s.maxTurns { logger.Info("maximum turns reached", "turns", s.completedTurns) - s.dispatchHooks(domain.HookPostSession, s.completedTurns) - s.waitForBackgroundTasks(monitorCtx) logger.Info("agent session stopped early (max turns)", "turns", s.completedTurns) - return fmt.Errorf("%w: agent reached the maximum of %d turns without completing the task", domain.ErrMaxTurnsReached, s.maxTurns) + 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/config/config.go b/config/config.go index fc45a70b..dcd60fb5 100644 --- a/config/config.go +++ b/config/config.go @@ -240,7 +240,6 @@ type EditToolConfig struct { // MultiEditToolConfig contains multi-edit-specific tool settings type MultiEditToolConfig struct { - Enabled bool `yaml:"enabled" mapstructure:"enabled"` RequireApproval *bool `yaml:"require_approval,omitempty" mapstructure:"require_approval,omitempty"` } @@ -961,7 +960,6 @@ func DefaultConfig() *Config { //nolint:funlen StrictWhitespace: false, }, MultiEdit: MultiEditToolConfig{ - Enabled: true, RequireApproval: &[]bool{true}[0], }, Delete: DeleteToolConfig{ From 132fcf93f5d1e056eedaaa65c643f8f34a1942fd Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Tue, 4 Aug 2026 16:01:40 +0200 Subject: [PATCH 5/5] feat(agent): exit with dedicated code 2 on max-turns exhaustion Callers (e.g. infer-action) can now distinguish turn exhaustion (2) from success (0) and failure (1). Documents the exit-code contract in the agent command help and collapses the duplicate max-turns log lines. --- cmd/agent.go | 10 +++++++--- cmd/root.go | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 23e9102d..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") @@ -553,8 +558,7 @@ func (s *AgentSession) execute(taskDescription string, files []string) error { var sessionErr error if !completedNormally && s.completedTurns >= s.maxTurns { - logger.Info("maximum turns reached", "turns", s.completedTurns) - logger.Info("agent session stopped early (max turns)", "turns", s.completedTurns) + 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) } 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) } }