package agent import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "ollie/backend" ) const maxRateLimitRetries = 3 type ToolExecutor func(name string, args json.RawMessage) (string, error) type OutputFn func(msg OutputMsg) type OutputMsg struct { Role string Name string Content string Usage backend.Usage } type Config struct { Backend backend.Backend Model string Tools []backend.Tool Exec ToolExecutor MaxSteps int Output OutputFn SystemPrompt string } func Run(ctx context.Context, cfg Config, state State) error { maxSteps := cfg.MaxSteps if maxSteps <= 0 { maxSteps = 1 } 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) 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< 0 || usage.OutputTokens > 0 { emit(cfg, OutputMsg{Role: "usage", Usage: usage}) } if err := state.Update(msg, results); err != nil { return fmt.Errorf("step %d update: %w", step, err) } if len(toolCalls) == 0 { if err := state.MarkComplete(); err != nil { return fmt.Errorf("mark complete: %w", err) } break } if step >= maxSteps-1 { break } } return nil } func emit(cfg Config, msg OutputMsg) { 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 Config, 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, OutputMsg{Role: "retry", Content: fmt.Sprintf("%d", secs)}) select { case <-ctx.Done(): return ctx.Err() case <-time.After(min(remaining, time.Second)): } } }