toolsrv: typed RateLimitedError, agent loop: don't count rate-limited calls as consecutive errors

This commit is contained in:
Ollie Agent 2026-07-31 07:46:32 +02:00
parent 5c27a948bd
commit 7a69f14277
3 changed files with 30 additions and 3 deletions

View File

@ -339,7 +339,12 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result}) emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result})
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true 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 { } else {
result = out result = out
resultBlocks = blocks resultBlocks = blocks
@ -516,14 +521,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
// Track consecutive rounds where every tool call errored. // 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 allErrors := len(results) > 0
allRateLimited := allErrors
for _, r := range results { for _, r := range results {
if !r.IsError { if !r.IsError {
allErrors = false allErrors = false
allRateLimited = false
break break
} }
if !strings.HasPrefix(r.Content, "error: shell is rate-limited") {
allRateLimited = false
}
} }
if allErrors { if allRateLimited {
consecutiveErrors = 0
} else if allErrors {
consecutiveErrors++ consecutiveErrors++
} else { } else {
consecutiveErrors = 0 consecutiveErrors = 0

View File

@ -20,6 +20,18 @@ const (
blockDuration = 5 * time.Minute 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. // Server runs code in a sandboxed environment.
type Server struct { type Server struct {
// cwd is the working directory for sandboxed commands. If empty, // cwd is the working directory for sandboxed commands. If empty,

View File

@ -383,7 +383,7 @@ func (e *Server) checkRateLimit() error {
now := time.Now() now := time.Now()
if now.Before(e.blockedUntil) { if now.Before(e.blockedUntil) {
remaining := e.blockedUntil.Sub(now).Round(time.Second) 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 return nil
} }