Skip to content

Commit 60d4343

Browse files
committed
max iteration support for cagent new
references #227 Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent d301e91 commit 60d4343

10 files changed

Lines changed: 110 additions & 19 deletions

File tree

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.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ func runWithoutTUI(ctx context.Context, agentFilename string, rt runtime.Runtime
349349
lastAgent := rt.CurrentAgent().Name()
350350
llmIsTyping := false
351351
var lastConfirmedToolCallID string
352-
for event := range rt.RunStream(loopCtx, sess) {
352+
for event := range rt.RunStream(loopCtx, sess, 0) {
353353
if event.GetAgentName() != "" && (firstLoop || lastAgent != event.GetAgentName()) {
354354
if !firstLoop {
355355
if llmIsTyping {

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 "()"

examples/golibrary/stream/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func main() {
4444

4545
sess := session.New(session.WithUserMessage("", "How are you doing?"))
4646

47-
events := rt.RunStream(ctx, sess)
47+
events := rt.RunStream(ctx, sess, 0)
4848
for event := range events {
4949
switch e := event.(type) {
5050
case *runtime.AgentChoiceEvent:

pkg/app/app.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func (a *App) Run(ctx context.Context, message string) {
5858

5959
// User message
6060
a.session.AddMessage(session.UserMessage(a.agentFilename, message))
61-
for event := range a.runtime.RunStream(ctx, a.session) {
61+
for event := range a.runtime.RunStream(ctx, a.session, 0) {
6262
a.events <- event
6363
}
6464
}()

pkg/creator/agent.go

Lines changed: 11 additions & 4 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,11 @@ 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

228235
sess := session.New(session.WithUserMessage("", prompt))
229236
sess.ToolsApproved = true
230237

231-
return rt.RunStream(ctx, sess), nil
238+
return rt.RunStream(ctx, sess, maxIterations), rt, nil
232239
}

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/remote_runtime.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ func (r *RemoteRuntime) CurrentAgent() *agent.Agent {
7272
return agent.New(r.currentAgent, fmt.Sprintf("Remote agent: %s", r.currentAgent))
7373
}
7474

75-
// RunStream starts the agent's interaction loop and returns a channel of events
76-
func (r *RemoteRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan Event {
75+
// RunStream starts the agent's interaction loop with an optional iteration limit
76+
// If maxIterations is 0, there is no limit
77+
func (r *RemoteRuntime) RunStream(ctx context.Context, sess *session.Session, maxIterations int) <-chan Event {
7778
slog.Debug("Starting remote runtime stream", "agent", r.currentAgent, "session_id", r.sessionID)
7879
events := make(chan Event, 128)
7980

@@ -125,7 +126,7 @@ func (r *RemoteRuntime) RunStream(ctx context.Context, sess *session.Session) <-
125126

126127
// Run starts the agent's interaction loop and returns the final messages
127128
func (r *RemoteRuntime) Run(ctx context.Context, sess *session.Session) ([]session.Message, error) {
128-
eventsChan := r.RunStream(ctx, sess)
129+
eventsChan := r.RunStream(ctx, sess, 0)
129130

130131
for event := range eventsChan {
131132
if errEvent, ok := event.(*ErrorEvent); ok {

pkg/runtime/runtime.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type Runtime interface {
4040
// CurrentAgent returns the currently active agent
4141
CurrentAgent() *agent.Agent
4242
// RunStream starts the agent's interaction loop and returns a channel of events
43-
RunStream(ctx context.Context, sess *session.Session) <-chan Event
43+
RunStream(ctx context.Context, sess *session.Session, maxIterations int) <-chan Event
4444
// Run starts the agent's interaction loop and returns the final messages
4545
Run(ctx context.Context, sess *session.Session) ([]session.Message, error)
4646
// Resume allows resuming execution after user confirmation
@@ -168,8 +168,9 @@ func (r *runtime) finalizeEventChannel(ctx context.Context, sess *session.Sessio
168168
}
169169
}
170170

171-
// Run starts the agent's interaction loop
172-
func (r *runtime) RunStream(ctx context.Context, sess *session.Session) <-chan Event {
171+
// RunStream starts the agent's interaction loop with an optional iteration limit
172+
// If maxIterations is 0, there is no limit
173+
func (r *runtime) RunStream(ctx context.Context, sess *session.Session, maxIterations int) <-chan Event {
173174
slog.Debug("Starting runtime stream", "agent", r.currentAgent, "session_id", sess.ID)
174175
events := make(chan Event, 128)
175176

@@ -206,7 +207,29 @@ func (r *runtime) RunStream(ctx context.Context, sess *session.Session) <-chan E
206207
slog.Debug("Failed to get model definition", "error", err)
207208
}
208209

210+
iteration := 0
209211
for {
212+
// Check iteration limit
213+
if maxIterations > 0 && iteration >= maxIterations {
214+
slog.Debug("Maximum iterations reached", "agent", a.Name(), "iterations", iteration, "max", maxIterations)
215+
events <- MaxIterationsReached(maxIterations)
216+
217+
// Wait for user decision
218+
select {
219+
case resumeType := <-r.resumeChan:
220+
if resumeType == ResumeTypeApprove {
221+
slog.Debug("User chose to continue after max iterations", "agent", a.Name())
222+
maxIterations = iteration + 10
223+
} else {
224+
slog.Debug("User chose to exit after max iterations", "agent", a.Name())
225+
return
226+
}
227+
case <-ctx.Done():
228+
slog.Debug("Context cancelled while waiting for max iterations decision", "agent", a.Name())
229+
return
230+
}
231+
}
232+
iteration++
210233
// Exit immediately if the stream context has been cancelled (e.g., Ctrl+C)
211234
if err := ctx.Err(); err != nil {
212235
slog.Debug("Runtime stream context cancelled, stopping loop", "agent", a.Name(), "session_id", sess.ID)
@@ -402,7 +425,7 @@ func (r *runtime) ResumeCodeReceived(_ context.Context, code string) error {
402425

403426
// Run starts the agent's interaction loop
404427
func (r *runtime) Run(ctx context.Context, sess *session.Session) ([]session.Message, error) {
405-
eventsChan := r.RunStream(ctx, sess)
428+
eventsChan := r.RunStream(ctx, sess, 0)
406429

407430
for event := range eventsChan {
408431
if errEvent, ok := event.(*ErrorEvent); ok {
@@ -821,7 +844,7 @@ func (r *runtime) handleTaskTransfer(ctx context.Context, sess *session.Session,
821844
s.Title = "Transferred task"
822845
s.ToolsApproved = sess.ToolsApproved
823846

824-
for event := range r.RunStream(ctx, s) {
847+
for event := range r.RunStream(ctx, s, 0) {
825848
evts <- event
826849
if errEvent, ok := event.(*ErrorEvent); ok {
827850
span.RecordError(fmt.Errorf("%s", errEvent.Error))

pkg/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ func (s *Server) runAgent(c echo.Context) error {
883883
c.Response().Header().Set("Connection", "keep-alive")
884884
c.Response().WriteHeader(http.StatusOK)
885885

886-
streamChan := rt.RunStream(c.Request().Context(), sess)
886+
streamChan := rt.RunStream(c.Request().Context(), sess, 0)
887887
for event := range streamChan {
888888
data, err := json.Marshal(event)
889889
if err != nil {

0 commit comments

Comments
 (0)