From 7a69f14277b27a3da7b2651cdc85568df7468559 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Fri, 31 Jul 2026 07:46:32 +0200 Subject: [PATCH] toolsrv: typed RateLimitedError, agent loop: don't count rate-limited calls as consecutive errors --- agent/loop.go | 19 +++++++++++++++++-- toolsrv/server.go | 12 ++++++++++++ toolsrv/shell.go | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/agent/loop.go b/agent/loop.go index 21d647c..54ec5b7 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -339,7 +339,12 @@ func run(ctx context.Context, cfg agentConfig, state state) error { emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result}) return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true } - result = fmt.Sprintf("error: %v", err) + var rlErr *toolsrv.RateLimitedError + if errors.As(err, &rlErr) { + result = fmt.Sprintf("error: shell is rate-limited — blocked for %v. Do not call shell() again until the block expires. Use other tools or wait.", rlErr.Remaining) + } else { + result = fmt.Sprintf("error: %v", err) + } } else { result = out resultBlocks = blocks @@ -516,14 +521,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error { } // Track consecutive rounds where every tool call errored. + // Rate-limited errors don't count — they're a temporary block, + // not a real failure. Reset consecutiveErrors so the hard abort + // doesn't fire. allErrors := len(results) > 0 + allRateLimited := allErrors for _, r := range results { if !r.IsError { allErrors = false + allRateLimited = false break } + if !strings.HasPrefix(r.Content, "error: shell is rate-limited") { + allRateLimited = false + } } - if allErrors { + if allRateLimited { + consecutiveErrors = 0 + } else if allErrors { consecutiveErrors++ } else { consecutiveErrors = 0 diff --git a/toolsrv/server.go b/toolsrv/server.go index f294e13..a648c69 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -20,6 +20,18 @@ const ( blockDuration = 5 * time.Minute ) +// RateLimitedError is returned by checkRateLimit when the shell is blocked +// due to too many validation failures. The agent loop uses this to detect +// the condition and inject a clear signal rather than counting it as a +// normal tool error. +type RateLimitedError struct { + Remaining time.Duration +} + +func (e *RateLimitedError) Error() string { + return fmt.Sprintf("rate limited: too many validation failures, blocked for %v", e.Remaining) +} + // Server runs code in a sandboxed environment. type Server struct { // cwd is the working directory for sandboxed commands. If empty, diff --git a/toolsrv/shell.go b/toolsrv/shell.go index f2de2e1..23dc347 100644 --- a/toolsrv/shell.go +++ b/toolsrv/shell.go @@ -383,7 +383,7 @@ func (e *Server) checkRateLimit() error { now := time.Now() if now.Before(e.blockedUntil) { remaining := e.blockedUntil.Sub(now).Round(time.Second) - return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining) + return &RateLimitedError{Remaining: remaining} } return nil }