diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0445b5c4..436216bc 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -584,6 +584,21 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) messages = append(messages, providers.Message{Role: "user", Content: nudge}) } + // Iteration budget nudge: when model has used 75% of iterations without + // producing any text response, warn it to start summarizing. + if maxIter > 0 && iteration > 1 && iteration == maxIter*3/4 && finalContent == "" { + messages = append(messages, providers.Message{ + Role: "user", + Content: "[System] You have used 75% of your iteration budget without providing a text response. Start summarizing your findings and respond to the user within the next few iterations.", + }) + } + + // Inject iteration progress into context so tools can adapt (e.g. web_fetch reduces maxChars). + iterCtx := tools.WithIterationProgress(ctx, tools.IterationProgress{ + Current: iteration, + Max: maxIter, + }) + // Emit activity event: thinking phase emitRun(AgentEvent{ Type: protocol.AgentEventActivity, @@ -645,6 +660,16 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) toolDefs = filtered } + // Final iteration: strip all tools to force a text-only response. + // Without this the model may keep requesting tools and exit with "...". + if iteration == maxIter { + toolDefs = nil + messages = append(messages, providers.Message{ + Role: "user", + Content: "[System] Final iteration reached. Summarize all findings and respond to the user now. No more tool calls allowed.", + }) + } + // Use per-request model override if set (e.g. heartbeat uses cheaper model). model := l.model if req.ModelOverride != "" { @@ -924,7 +949,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } if result == nil { - result = l.tools.ExecuteWithContext(ctx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + result = l.tools.ExecuteWithContext(iterCtx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) } stopSlowTimer() @@ -1100,7 +1125,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } if result == nil { - result = l.tools.ExecuteWithContext(ctx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + result = l.tools.ExecuteWithContext(iterCtx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) } stopSlowTimer() l.emitToolSpanEnd(ctx, spanID, spanStart, result) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index c0cadb70..d9651f60 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,7 +7,7 @@ const ( DefaultContextWindow = 200000 DefaultMaxTokens = 8192 DefaultMaxMessageChars = 32000 - DefaultMaxIterations = 20 + DefaultMaxIterations = 30 DefaultTemperature = 0.7 DefaultHistoryShare = 0.75 ) diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index d9b885dd..89db61fb 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -412,6 +412,26 @@ func RunMediaNamesFromCtx(ctx context.Context) map[string]string { return v } +// --- Iteration progress (loop → tools) --- + +const ctxIterProgress toolContextKey = "tool_iter_progress" + +// IterationProgress carries the agent loop's current iteration state +// so tools can adapt behaviour (e.g. reduce output size) as the budget shrinks. +type IterationProgress struct { + Current int + Max int +} + +func WithIterationProgress(ctx context.Context, p IterationProgress) context.Context { + return context.WithValue(ctx, ctxIterProgress, p) +} + +func IterationProgressFromCtx(ctx context.Context) (IterationProgress, bool) { + v, ok := ctx.Value(ctxIterProgress).(IterationProgress) + return v, ok +} + // --- Per-agent sandbox config override --- const ctxSandboxCfg toolContextKey = "tool_sandbox_config" diff --git a/internal/tools/web_fetch.go b/internal/tools/web_fetch.go index 52044e2c..ab43c001 100644 --- a/internal/tools/web_fetch.go +++ b/internal/tools/web_fetch.go @@ -192,6 +192,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *Result maxChars = int(mc) } + // Adaptive maxChars: reduce as iterations progress to prevent context bloat. + if prog, ok := IterationProgressFromCtx(ctx); ok && prog.Max > 0 { + ratio := float64(prog.Current) / float64(prog.Max) + switch { + case ratio >= 0.75: + maxChars = min(maxChars, 10000) + case ratio >= 0.50: + maxChars = min(maxChars, 20000) + } + } + // Check cache (scoped per channel to prevent cross-channel cache poisoning) channel := ToolChannelFromCtx(ctx) cacheKey := fmt.Sprintf("fetch:%s:%s:%s:%d", channel, rawURL, extractMode, maxChars)