Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,6 @@ config.SaveProviderConfig(cfg, "") // save changes

```
eyrie/
├── cmd/eyrie/ # CLI binary
├── client/ # Provider client & streaming interface
├── config/ # Provider configuration & routing
│ └── credential/ # Credential file management
Expand Down
52 changes: 40 additions & 12 deletions codeagent/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,43 @@ type RetryRecord struct {
}

// NewCodeAgentRetry creates a retry system with code-agent-specific strategies.
func NewCodeAgentRetry() *CodeAgentRetry {
// Default strategies do not pin fallback model IDs; configure fallbacks
// explicitly via WithFallback (or override a whole strategy via WithStrategy)
// so the catalog remains the single source of truth for model names.
func NewCodeAgentRetry(opts ...Option) *CodeAgentRetry {
cr := &CodeAgentRetry{
strategies: make(map[string]*RetryStrategy),
history: make([]RetryRecord, 0, 1000),
}
cr.registerDefaults()
for _, opt := range opts {
opt(cr)
}
return cr
}

// Option configures a CodeAgentRetry.
type Option func(*CodeAgentRetry)

// WithFallback configures a fallback model+provider for a given error type
// (e.g. "context_length", "budget_exceeded"). Model and provider names should
// be resolved from the catalog by the caller — no defaults are baked in.
func WithFallback(errorType, model, provider string) Option {
return func(cr *CodeAgentRetry) {
if s, ok := cr.strategies[errorType]; ok {
s.FallbackModel = model
s.FallbackProvider = provider
}
}
}

// WithStrategy overrides the retry strategy for a given error type.
func WithStrategy(errorType string, strategy RetryStrategy) Option {
return func(cr *CodeAgentRetry) {
cr.strategies[errorType] = &strategy
}
}

// registerDefaults sets up default retry strategies for common code agent failures.
func (cr *CodeAgentRetry) registerDefaults() {
// Rate limiting - wait and retry
Expand All @@ -65,13 +93,13 @@ func (cr *CodeAgentRetry) registerDefaults() {
Backoff: 2.0,
}

// Context length exceeded - switch to model with larger context
// Context length exceeded - switch to model with larger context.
// Fallback model is intentionally unset; configure it via WithFallback
// using a catalog-resolved model name so it never drifts from the catalog.
cr.strategies["context_length"] = &RetryStrategy{
Name: "Context Length",
MaxRetries: 2,
BaseDelay: 1 * time.Second,
FallbackModel: "claude-3-5-sonnet", // larger context
FallbackProvider: "anthropic",
Name: "Context Length",
MaxRetries: 2,
BaseDelay: 1 * time.Second,
}

// Tool execution failure - retry with different approach
Expand All @@ -82,12 +110,12 @@ func (cr *CodeAgentRetry) registerDefaults() {
Backoff: 1.5,
}

// Token budget exceeded - switch to cheaper model
// Token budget exceeded - switch to cheaper model.
// Fallback model is intentionally unset; configure it via WithFallback
// using a catalog-resolved model name so it never drifts from the catalog.
cr.strategies["budget_exceeded"] = &RetryStrategy{
Name: "Budget Exceeded",
MaxRetries: 1,
FallbackModel: "gpt-4o-mini", // cheaper
FallbackProvider: "openai",
Name: "Budget Exceeded",
MaxRetries: 1,
}

// Server error - retry with backoff
Expand Down
70 changes: 70 additions & 0 deletions codeagent/retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package codeagent

import (
"context"
"errors"
"testing"
"time"
)

func TestNewCodeAgentRetryDefaultsLeaveFallbacksUnset(t *testing.T) {
cr := NewCodeAgentRetry()

for _, errorType := range []string{"context_length", "budget_exceeded"} {
strategy, ok := cr.strategies[errorType]
if !ok {
t.Fatalf("missing default strategy for %q", errorType)
}
if strategy.FallbackModel != "" || strategy.FallbackProvider != "" {
t.Fatalf("%s fallback should be unset by default, got model=%q provider=%q", errorType, strategy.FallbackModel, strategy.FallbackProvider)
}
}
}

func TestWithFallbackConfiguresFallbackDecision(t *testing.T) {
cr := NewCodeAgentRetry(
WithFallback("context_length", "claude-sonnet", "anthropic"),
)
ctx := context.Background()
err := errors.New("context length exceeded")

first := cr.DecideRetry(ctx, err, "openai", "gpt-4o")
if first == nil || !first.ShouldRetry || first.FallbackModel != "" {
t.Fatalf("first retry = %+v, want normal retry without fallback", first)
}

second := cr.DecideRetry(ctx, err, "openai", "gpt-4o")
if second == nil || !second.ShouldRetry || second.FallbackModel != "" {
t.Fatalf("second retry = %+v, want normal retry without fallback", second)
}

third := cr.DecideRetry(ctx, err, "openai", "gpt-4o")
if third == nil {
t.Fatal("third retry decision is nil")
}
if !third.ShouldRetry {
t.Fatalf("third retry should switch to fallback, got %+v", third)
}
if third.FallbackModel != "claude-sonnet" || third.FallbackProvider != "anthropic" {
t.Fatalf("third retry fallback = %q/%q, want claude-sonnet/anthropic", third.FallbackModel, third.FallbackProvider)
}
}

func TestWithStrategyOverridesDefaultStrategy(t *testing.T) {
override := RetryStrategy{
Name: "Custom Timeout",
MaxRetries: 1,
BaseDelay: 25 * time.Millisecond,
MaxDelay: 25 * time.Millisecond,
Backoff: 1,
}
cr := NewCodeAgentRetry(WithStrategy("timeout", override))

got, ok := cr.strategies["timeout"]
if !ok {
t.Fatal("timeout strategy missing after override")
}
if got.Name != override.Name || got.MaxRetries != override.MaxRetries || got.BaseDelay != override.BaseDelay || got.MaxDelay != override.MaxDelay || got.Backoff != override.Backoff {
t.Fatalf("timeout strategy = %+v, want %+v", *got, override)
}
}
3 changes: 2 additions & 1 deletion storage/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ func NewDAG(dbPath string, sessionID string) (*DAG, error) {
return &DAG{store: store, sessionID: sessionID}, nil
}

// NewDAGFromStore creates a DAG using an existing store.
// NewDAGFromStore creates a DAG using an existing store, which is useful in
// tests and callers that manage store lifetime outside the DAG wrapper.
func NewDAGFromStore(store Store, sessionID string) *DAG {
return &DAG{store: store, sessionID: sessionID}
}
Expand Down
32 changes: 30 additions & 2 deletions storage/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,40 @@ type SQLiteStore struct {
db *sql.DB
}

func Open(path string) (*SQLiteStore, error) {
// Option configures a SQLiteStore at Open time.
type Option func(*openConfig)

type openConfig struct {
maxOpenConns int
}

// WithMaxOpenConns overrides the database connection pool size. The default
// (1) serializes all access, which is safe for single-agent use; raise it for
// concurrent (e.g. HTTP server) workloads. SQLite still serializes writes via
// the busy_timeout pragma, so concurrent access remains bounded by WAL readers
// plus a single writer.
func WithMaxOpenConns(n int) Option {
return func(c *openConfig) {
if n > 0 {
c.maxOpenConns = n
}
}
}

// Open opens the SQLite conversation DAG store at path. The store is opened in
// WAL mode with foreign keys enabled. Without options the connection pool is
// limited to a single connection (serialized access); pass WithMaxOpenConns to
// raise the pool size for concurrent workloads.
func Open(path string, opts ...Option) (*SQLiteStore, error) {
cfg := openConfig{maxOpenConns: 1}
for _, opt := range opts {
opt(&cfg)
}
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(on)")
if err != nil {
return nil, fmt.Errorf("storage: open %s: %w", path, err)
}
db.SetMaxOpenConns(1)
db.SetMaxOpenConns(cfg.maxOpenConns)
s := &SQLiteStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
Expand Down
2 changes: 2 additions & 0 deletions storage/sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"testing"
)

// testStore opens an isolated on-disk SQLite store for tests that need the
// real schema and pragmas rather than an in-memory stub.
func testStore(t *testing.T) *SQLiteStore {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
Expand Down
2 changes: 2 additions & 0 deletions storage/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package storage

import "context"

// Store captures the persistence operations used by the DAG wrapper and API
// layers so tests can swap implementations without changing call sites.
type Store interface {
CreateNode(ctx context.Context, node *Node) error
GetNode(ctx context.Context, id string) (*Node, error)
Expand Down
30 changes: 30 additions & 0 deletions storage/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ func TestOpenReusesExisting(t *testing.T) {
}
}

func TestOpenWithMaxOpenConns(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pool.db")
s, err := Open(path, WithMaxOpenConns(4))
if err != nil {
t.Fatal(err)
}
defer s.Close()

stats := s.db.Stats()
if stats.MaxOpenConnections != 4 {
t.Fatalf("MaxOpenConnections = %d, want 4", stats.MaxOpenConnections)
}
}

func TestOpenWithInvalidMaxOpenConnsKeepsDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "default-pool.db")
s, err := Open(path, WithMaxOpenConns(0))
if err != nil {
t.Fatal(err)
}
defer s.Close()

stats := s.db.Stats()
if stats.MaxOpenConnections != 1 {
t.Fatalf("MaxOpenConnections = %d, want default 1", stats.MaxOpenConnections)
}
}

func TestClose(t *testing.T) {
s := testStore(t)
if err := s.Close(); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions storage/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"time"
)

// NodeType identifies the role of a persisted conversation node.
type NodeType string

const (
Expand All @@ -15,6 +16,7 @@ const (
NodeTypeToolResult NodeType = "tool_result"
)

// Node is a single persisted entry in the conversation DAG.
type Node struct {
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
Expand All @@ -39,6 +41,7 @@ type Node struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
}

// Alias maps a stable name to a node ID.
type Alias struct {
Alias string `json:"alias"`
NodeID string `json:"node_id"`
Expand Down
Loading