Skip to content

Commit 7c227c1

Browse files
authored
Merge pull request #228 from krissetto/max-iterations-cagent-new
max iteration support for `cagent new`
2 parents d301e91 + ba150ae commit 7c227c1

9 files changed

Lines changed: 154 additions & 21 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,10 @@ export GOOGLE_API_KEY=your_api_key_here # if anthropic and openai keys are n
219219
`--max-tokens` can be specified to override the context limit used.
220220
When using DMR, the default is 16k to limit memory usage. With all other providers the default is 64k
221221

222-
Example of provider, model and context size overriding:
222+
`--max-iterations` can be specified to override how many times the agent is allowed to loop when doing tool calling etc.
223+
When using DMR, the default is set to 20 (small local models have the highest chance of getting confused and looping endlessly). For all other providers, the default is 0 (unlimited).
224+
225+
Example of provider, model, context size and max iterations overriding:
223226

224227
```sh
225228
# Use GPT-5 via OpenAI
@@ -230,6 +233,9 @@ cagent new --model dmr/ai/gemma3-qat:12B
230233
231234
# Override the max_tokens used during generation, default is 64k, 16k when using the dmr provider
232235
cagent new --model openai/gpt-5-mini --max-tokens 32000
236+
237+
# Override max_iterations to limit how much the model can loop autonomously
238+
cagent new --model dmr/ai/gemma3n:2B-F16 --max-iterations 15
233239
```
234240

235241
---

cmd/root/new.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import (
1414
)
1515

1616
var (
17-
modelParam string
18-
maxTokensParam int
17+
modelParam string
18+
maxTokensParam int
19+
maxIterationsParam int
1920
)
2021

2122
// Cmd creates a new command to create a new agent configuration
@@ -93,7 +94,7 @@ func NewNewCmd() *cobra.Command {
9394
fmt.Println()
9495
}
9596

96-
out, err := creator.StreamCreateAgent(ctx, ".", prompt, runConfig, modelProvider, model, maxTokensParam)
97+
out, rt, err := creator.StreamCreateAgent(ctx, ".", prompt, runConfig, modelProvider, model, maxTokensParam, maxIterationsParam)
9798
if err != nil {
9899
return err
99100
}
@@ -126,6 +127,22 @@ func NewNewCmd() *cobra.Command {
126127
llmIsTyping = false
127128
}
128129
printError(fmt.Errorf("%s", e.Error))
130+
case *runtime.MaxIterationsReachedEvent:
131+
if llmIsTyping {
132+
fmt.Println()
133+
llmIsTyping = false
134+
}
135+
136+
result := promptMaxIterationsContinue(e.MaxIterations)
137+
switch result {
138+
case ConfirmationApprove:
139+
rt.Resume(ctx, string(runtime.ResumeTypeApprove))
140+
case ConfirmationReject:
141+
rt.Resume(ctx, string(runtime.ResumeTypeReject))
142+
return nil
143+
case ConfirmationAbort:
144+
rt.Resume(ctx, string(runtime.ResumeTypeReject))
145+
}
129146
}
130147
}
131148
fmt.Print("\n\n")
@@ -135,6 +152,7 @@ func NewNewCmd() *cobra.Command {
135152
addGatewayFlags(cmd)
136153
cmd.PersistentFlags().StringVar(&modelParam, "model", "", "Model to use, optionally as provider/model where provider is one of: anthropic, openai, google, dmr. If omitted, provider is auto-selected based on available credentials or gateway")
137154
cmd.PersistentFlags().IntVar(&maxTokensParam, "max-tokens", 0, "Override max_tokens for the selected model (0 = default)")
155+
cmd.PersistentFlags().IntVar(&maxIterationsParam, "max-iterations", 0, "Maximum number of agentic loop iterations to prevent infinite loops (default: 20 for DMR, unlimited for other providers)")
138156

139157
return cmd
140158
}

cmd/root/run_text_utils.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var (
1818
yellow = color.New(color.FgYellow).SprintfFunc()
1919
red = color.New(color.FgRed).SprintfFunc()
2020
gray = color.New(color.FgHiBlack).SprintfFunc()
21+
green = color.New(color.FgGreen).SprintfFunc()
2122
)
2223

2324
// text styles
@@ -115,6 +116,28 @@ func printToolCallResponse(toolCall tools.ToolCall, response string) {
115116
fmt.Printf("\n%s\n", gray("%s response%s", bold(toolCall.Function.Name), formatToolCallResponse(response)))
116117
}
117118

119+
func promptMaxIterationsContinue(maxIterations int) ConfirmationResult {
120+
fmt.Printf("\n%s\n", yellow("⚠️ Maximum iterations (%d) reached. The agent may be stuck in a loop.", maxIterations))
121+
fmt.Printf("%s\n", gray("This can happen with smaller or less capable models."))
122+
fmt.Printf("\n%s (y/n): ", blue("Do you want to continue for 10 more iterations?"))
123+
124+
reader := bufio.NewReader(os.Stdin)
125+
response, err := reader.ReadString('\n')
126+
if err != nil {
127+
fmt.Printf("\n%s\n", red("Failed to read input, exiting..."))
128+
return ConfirmationAbort
129+
}
130+
131+
response = strings.TrimSpace(strings.ToLower(response))
132+
if response == "y" || response == "yes" {
133+
fmt.Printf("%s\n\n", green("✓ Continuing..."))
134+
return ConfirmationApprove
135+
} else {
136+
fmt.Printf("%s\n\n", gray("Exiting..."))
137+
return ConfirmationReject
138+
}
139+
}
140+
118141
func formatToolCallArguments(arguments string) string {
119142
if arguments == "" {
120143
return "()"

pkg/creator/agent.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ func CreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.R
121121
return messages[len(messages)-1].Message.Content, fsToolset.path, nil
122122
}
123123

124-
func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.RuntimeConfig, providerName, modelNameOverride string, maxTokensOverride int) (<-chan runtime.Event, error) {
124+
func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.RuntimeConfig, providerName, modelNameOverride string, maxTokensOverride, maxIterations int) (<-chan runtime.Event, runtime.Runtime, error) {
125+
// Apply default max iterations if not specified (0 means use defaults)
126+
if maxIterations == 0 {
127+
// Only when using DMR we set a default limit. Local models are more prone to loops
128+
if providerName == "dmr" {
129+
maxIterations = 20
130+
}
131+
}
125132
defaultModels := map[string]string{
126133
"openai": "gpt-5-mini",
127134
"anthropic": "claude-sonnet-4-0",
@@ -184,7 +191,7 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co
184191
options.WithGateway(runConfig.ModelsGateway),
185192
)
186193
if err != nil {
187-
return nil, fmt.Errorf("failed to create LLM client: %w", err)
194+
return nil, nil, fmt.Errorf("failed to create LLM client: %w", err)
188195
}
189196

190197
fmt.Println("Generating agent configuration....")
@@ -222,11 +229,14 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co
222229
)))
223230
rt, err := runtime.New(newTeam)
224231
if err != nil {
225-
return nil, fmt.Errorf("failed to create runtime: %w", err)
232+
return nil, nil, fmt.Errorf("failed to create runtime: %w", err)
226233
}
227234

228-
sess := session.New(session.WithUserMessage("", prompt))
235+
sess := session.New(
236+
session.WithUserMessage("", prompt),
237+
session.WithMaxIterations(maxIterations),
238+
)
229239
sess.ToolsApproved = true
230240

231-
return rt.RunStream(ctx, sess), nil
241+
return rt.RunStream(ctx, sess), rt, nil
232242
}

pkg/runtime/event.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,22 @@ func (e *AuthorizationRequiredEvent) isEvent() {}
289289
func (e *AuthorizationRequiredEvent) GetAgentName() string {
290290
return ""
291291
}
292+
293+
type MaxIterationsReachedEvent struct {
294+
Type string `json:"type"`
295+
MaxIterations int `json:"max_iterations"`
296+
AgentContext
297+
}
298+
299+
func MaxIterationsReached(maxIterations int) Event {
300+
return &MaxIterationsReachedEvent{
301+
Type: "max_iterations_reached",
302+
MaxIterations: maxIterations,
303+
}
304+
}
305+
306+
func (e *MaxIterationsReachedEvent) isEvent() {}
307+
308+
func (e *MaxIterationsReachedEvent) GetAgentName() string {
309+
return e.AgentName
310+
}

pkg/runtime/runtime.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func (r *runtime) finalizeEventChannel(ctx context.Context, sess *session.Sessio
168168
}
169169
}
170170

171-
// Run starts the agent's interaction loop
171+
// RunStream starts the agent's interaction loop and returns a channel of events
172172
func (r *runtime) RunStream(ctx context.Context, sess *session.Session) <-chan Event {
173173
slog.Debug("Starting runtime stream", "agent", r.currentAgent, "session_id", sess.ID)
174174
events := make(chan Event, 128)
@@ -206,7 +206,31 @@ func (r *runtime) RunStream(ctx context.Context, sess *session.Session) <-chan E
206206
slog.Debug("Failed to get model definition", "error", err)
207207
}
208208

209+
iteration := 0
210+
// Use a runtime copy of maxIterations so we don't modify the session's persistent config
211+
runtimeMaxIterations := sess.MaxIterations
209212
for {
213+
// Check iteration limit
214+
if runtimeMaxIterations > 0 && iteration >= runtimeMaxIterations {
215+
slog.Debug("Maximum iterations reached", "agent", a.Name(), "iterations", iteration, "max", runtimeMaxIterations)
216+
events <- MaxIterationsReached(runtimeMaxIterations)
217+
218+
// Wait for user decision
219+
select {
220+
case resumeType := <-r.resumeChan:
221+
if resumeType == ResumeTypeApprove {
222+
slog.Debug("User chose to continue after max iterations", "agent", a.Name())
223+
runtimeMaxIterations = iteration + 10
224+
} else {
225+
slog.Debug("User chose to exit after max iterations", "agent", a.Name())
226+
return
227+
}
228+
case <-ctx.Done():
229+
slog.Debug("Context cancelled while waiting for max iterations decision", "agent", a.Name())
230+
return
231+
}
232+
}
233+
iteration++
210234
// Exit immediately if the stream context has been cancelled (e.g., Ctrl+C)
211235
if err := ctx.Err(); err != nil {
212236
slog.Debug("Runtime stream context cancelled, stopping loop", "agent", a.Name(), "session_id", sess.ID)
@@ -816,7 +840,11 @@ func (r *runtime) handleTaskTransfer(ctx context.Context, sess *session.Session,
816840
}
817841

818842
slog.Debug("Creating new session with parent session", "parent_session_id", sess.ID, "tools_approved", sess.ToolsApproved)
819-
s := session.New(session.WithSystemMessage(memberAgentTask), session.WithUserMessage("", "Follow the default instructions"))
843+
s := session.New(
844+
session.WithSystemMessage(memberAgentTask),
845+
session.WithUserMessage("", "Follow the default instructions"),
846+
session.WithMaxIterations(sess.MaxIterations),
847+
)
820848
s.SendUserMessage = false
821849
s.Title = "Transferred task"
822850
s.ToolsApproved = sess.ToolsApproved

pkg/session/migrations.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ func getAllMigrations() []Migration {
193193
UpSQL: `ALTER TABLE sessions ADD COLUMN send_user_message BOOLEAN DEFAULT 1`,
194194
DownSQL: `ALTER TABLE sessions DROP COLUMN send_user_message`,
195195
},
196+
{
197+
ID: 7,
198+
Name: "007_add_max_iterations_column",
199+
Description: "Add max_iterations column to sessions table",
200+
UpSQL: `ALTER TABLE sessions ADD COLUMN max_iterations INTEGER DEFAULT 0`,
201+
DownSQL: `ALTER TABLE sessions DROP COLUMN max_iterations`,
202+
},
196203
// Add more migrations here as needed
197204
}
198205
}

pkg/session/session.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ type Session struct {
5858
// SendUserMessage is a flag to indicate if the user message should be sent
5959
SendUserMessage bool
6060

61+
// MaxIterations is the maximum number of agentic loop iterations to prevent infinite loops
62+
// If 0, there is no limit
63+
MaxIterations int `json:"max_iterations"`
64+
6165
InputTokens int `json:"input_tokens"`
6266
OutputTokens int `json:"output_tokens"`
6367
Cost float64 `json:"cost"`
@@ -165,6 +169,12 @@ func WithSystemMessage(content string) Opt {
165169
}
166170
}
167171

172+
func WithMaxIterations(maxIterations int) Opt {
173+
return func(s *Session) {
174+
s.MaxIterations = maxIterations
175+
}
176+
}
177+
168178
// New creates a new agent session
169179
func New(opts ...Opt) *Session {
170180
sessionID := uuid.New().String()

pkg/session/store.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ func (s *SQLiteSessionStore) AddSession(ctx context.Context, session *Session) e
7979
}
8080

8181
_, err = s.db.ExecContext(ctx,
82-
"INSERT INTO sessions (id, messages, tools_approved, input_tokens, output_tokens, title, send_user_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
83-
session.ID, string(itemsJSON), session.ToolsApproved, session.InputTokens, session.OutputTokens, session.Title, session.SendUserMessage, session.CreatedAt.Format(time.RFC3339))
82+
"INSERT INTO sessions (id, messages, tools_approved, input_tokens, output_tokens, title, send_user_message, max_iterations, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
83+
session.ID, string(itemsJSON), session.ToolsApproved, session.InputTokens, session.OutputTokens, session.Title, session.SendUserMessage, session.MaxIterations, session.CreatedAt.Format(time.RFC3339))
8484
return err
8585
}
8686

@@ -91,12 +91,12 @@ func (s *SQLiteSessionStore) GetSession(ctx context.Context, id string) (*Sessio
9191
}
9292

9393
row := s.db.QueryRowContext(ctx,
94-
"SELECT id, messages, tools_approved, input_tokens, output_tokens, title, cost, send_user_message, created_at FROM sessions WHERE id = ?", id)
94+
"SELECT id, messages, tools_approved, input_tokens, output_tokens, title, cost, send_user_message, max_iterations, created_at FROM sessions WHERE id = ?", id)
9595

96-
var messagesJSON, toolsApprovedStr, inputTokensStr, outputTokensStr, titleStr, costStr, sendUserMessageStr, createdAtStr string
96+
var messagesJSON, toolsApprovedStr, inputTokensStr, outputTokensStr, titleStr, costStr, sendUserMessageStr, maxIterationsStr, createdAtStr string
9797
var sessionID string
9898

99-
err := row.Scan(&sessionID, &messagesJSON, &toolsApprovedStr, &inputTokensStr, &outputTokensStr, &titleStr, &costStr, &sendUserMessageStr, &createdAtStr)
99+
err := row.Scan(&sessionID, &messagesJSON, &toolsApprovedStr, &inputTokensStr, &outputTokensStr, &titleStr, &costStr, &sendUserMessageStr, &maxIterationsStr, &createdAtStr)
100100
if err != nil {
101101
if errors.Is(err, sql.ErrNoRows) {
102102
return nil, ErrNotFound
@@ -146,6 +146,11 @@ func (s *SQLiteSessionStore) GetSession(ctx context.Context, id string) (*Sessio
146146
return nil, err
147147
}
148148

149+
maxIterations, err := strconv.Atoi(maxIterationsStr)
150+
if err != nil {
151+
return nil, err
152+
}
153+
149154
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
150155
if err != nil {
151156
return nil, err
@@ -160,25 +165,26 @@ func (s *SQLiteSessionStore) GetSession(ctx context.Context, id string) (*Sessio
160165
OutputTokens: outputTokens,
161166
Cost: cost,
162167
SendUserMessage: sendUserMessage,
168+
MaxIterations: maxIterations,
163169
CreatedAt: createdAt,
164170
}, nil
165171
}
166172

167173
// GetSessions retrieves all sessions
168174
func (s *SQLiteSessionStore) GetSessions(ctx context.Context) ([]*Session, error) {
169175
rows, err := s.db.QueryContext(ctx,
170-
"SELECT id, messages, tools_approved, input_tokens, output_tokens, title, cost, send_user_message, created_at FROM sessions ORDER BY created_at DESC")
176+
"SELECT id, messages, tools_approved, input_tokens, output_tokens, title, cost, send_user_message, max_iterations, created_at FROM sessions ORDER BY created_at DESC")
171177
if err != nil {
172178
return nil, err
173179
}
174180
defer rows.Close()
175181

176182
sessions := make([]*Session, 0)
177183
for rows.Next() {
178-
var messagesJSON, toolsApprovedStr, inputTokensStr, outputTokensStr, titleStr, costStr, sendUserMessageStr, createdAtStr string
184+
var messagesJSON, toolsApprovedStr, inputTokensStr, outputTokensStr, titleStr, costStr, sendUserMessageStr, maxIterationsStr, createdAtStr string
179185
var sessionID string
180186

181-
err := rows.Scan(&sessionID, &messagesJSON, &toolsApprovedStr, &inputTokensStr, &outputTokensStr, &titleStr, &costStr, &sendUserMessageStr, &createdAtStr)
187+
err := rows.Scan(&sessionID, &messagesJSON, &toolsApprovedStr, &inputTokensStr, &outputTokensStr, &titleStr, &costStr, &sendUserMessageStr, &maxIterationsStr, &createdAtStr)
182188
if err != nil {
183189
return nil, err
184190
}
@@ -225,6 +231,11 @@ func (s *SQLiteSessionStore) GetSessions(ctx context.Context) ([]*Session, error
225231
return nil, err
226232
}
227233

234+
maxIterations, err := strconv.Atoi(maxIterationsStr)
235+
if err != nil {
236+
return nil, err
237+
}
238+
228239
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
229240
if err != nil {
230241
return nil, err
@@ -239,6 +250,7 @@ func (s *SQLiteSessionStore) GetSessions(ctx context.Context) ([]*Session, error
239250
OutputTokens: outputTokens,
240251
Cost: cost,
241252
SendUserMessage: sendUserMessage,
253+
MaxIterations: maxIterations,
242254
CreatedAt: createdAt,
243255
}
244256

@@ -283,8 +295,8 @@ func (s *SQLiteSessionStore) UpdateSession(ctx context.Context, session *Session
283295
}
284296

285297
result, err := s.db.ExecContext(ctx,
286-
"UPDATE sessions SET messages = ?, title = ?, tools_approved = ?, input_tokens = ?, output_tokens = ?, cost = ?, send_user_message = ? WHERE id = ?",
287-
string(itemsJSON), session.Title, session.ToolsApproved, session.InputTokens, session.OutputTokens, session.Cost, session.SendUserMessage, session.ID)
298+
"UPDATE sessions SET messages = ?, title = ?, tools_approved = ?, input_tokens = ?, output_tokens = ?, cost = ?, send_user_message = ?, max_iterations = ? WHERE id = ?",
299+
string(itemsJSON), session.Title, session.ToolsApproved, session.InputTokens, session.OutputTokens, session.Cost, session.SendUserMessage, session.MaxIterations, session.ID)
288300
if err != nil {
289301
return err
290302
}

0 commit comments

Comments
 (0)