package agent import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "ollie/internal/backend" ) const maxRateLimitRetries = 3 type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, error) type loopConfig struct { Backend backend.Backend Model string Tools []backend.Tool Exec toolExecutor Confirm confirmFn MaxSteps int Output EventHandler systemPrompt string GenerationParams backend.GenerationParams } // confirmFn requests user confirmation for an action. Returns true if approved. type confirmFn func(prompt string) bool func run(ctx context.Context, cfg loopConfig, state state) error { maxSteps := cfg.MaxSteps if maxSteps <= 0 { maxSteps = 1 } var totalToolCalls int hitLimit := false for step := range maxSteps { history := state.history() if cfg.systemPrompt != "" { history = append([]backend.Message{{Role: "system", Content: cfg.systemPrompt}}, history...) } // Stream the assistant's response, retrying on HTTP 429. var ch <-chan backend.StreamEvent for attempt := range maxRateLimitRetries + 1 { var err error ch, err = cfg.Backend.ChatStream(ctx, cfg.Model, history, cfg.Tools, cfg.GenerationParams) if err == nil { break } var rlErr *backend.RateLimitError if !errors.As(err, &rlErr) || attempt >= maxRateLimitRetries { return fmt.Errorf("step %d: %w", step, err) } // Exponential backoff: 5s, 10s, 20s — unless the server told us exactly. wait := rlErr.RetryAfter if wait == 0 { wait = time.Duration(5<= maxSteps-1 { hitLimit = true break } } // Surface stall: only when the step limit was hit without completing. if hitLimit { emit(cfg, Event{Role: "stalled", Content: "max steps"}) } return nil } func emit(cfg loopConfig, msg Event) { if cfg.Output != nil { cfg.Output(msg) } } // retryCountdown emits one "retry" OutputMsg per second, counting down from // wait, so the UI can display a live countdown. Returns ctx.Err() if the // context is cancelled before the wait elapses. func retryCountdown(ctx context.Context, cfg loopConfig, wait time.Duration) error { deadline := time.Now().Add(wait) for { remaining := time.Until(deadline) if remaining <= 0 { return nil } secs := int(remaining.Seconds()) + 1 emit(cfg, Event{Role: "retry", Content: fmt.Sprintf("%d", secs)}) select { case <-ctx.Done(): return ctx.Err() case <-time.After(min(remaining, time.Second)): } } }