Skip to content
Merged
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
25 changes: 21 additions & 4 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -507,6 +515,7 @@ func (s *AgentSession) execute(taskDescription string, files []string) error {
}

consecutiveNoToolCalls := 0
completedNormally := false

for s.completedTurns < s.maxTurns {
s.maybeRollover()
Expand Down Expand Up @@ -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
}
Expand Down
23 changes: 23 additions & 0 deletions cmd/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
Expand Down
13 changes: 13 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/domain/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down