fix(agent): prevent "..." fallback when iteration budget exhausted

Add early detection and graceful degradation when the model exhausts its
iteration budget without producing a text response:

- Inject 75% budget nudge telling model to start summarizing
- Strip all tool schemas on final iteration to force text-only response
- Adaptive web_fetch maxChars (60K→20K→10K) via iteration progress context
- Bump DefaultMaxIterations 20→30 for web-research workloads
This commit is contained in:
viettranx
2026-03-20 17:55:51 +07:00
parent 84650c5c14
commit dba3f4e4e6
4 changed files with 59 additions and 3 deletions
+27 -2
View File
@@ -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)
+1 -1
View File
@@ -7,7 +7,7 @@ const (
DefaultContextWindow = 200000
DefaultMaxTokens = 8192
DefaultMaxMessageChars = 32000
DefaultMaxIterations = 20
DefaultMaxIterations = 30
DefaultTemperature = 0.7
DefaultHistoryShare = 0.75
)
+20
View File
@@ -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"
+11
View File
@@ -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)