diff --git a/README.md b/README.md index f86dc04..6060003 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/codeagent/retry.go b/codeagent/retry.go index f9b0c6e..9e5306d 100644 --- a/codeagent/retry.go +++ b/codeagent/retry.go @@ -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 @@ -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 @@ -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 diff --git a/codeagent/retry_test.go b/codeagent/retry_test.go new file mode 100644 index 0000000..6884c4b --- /dev/null +++ b/codeagent/retry_test.go @@ -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) + } +} diff --git a/storage/dag.go b/storage/dag.go index 39b9e6f..ab75bb9 100644 --- a/storage/dag.go +++ b/storage/dag.go @@ -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} } diff --git a/storage/sqlite.go b/storage/sqlite.go index fe2faaa..0e13e68 100644 --- a/storage/sqlite.go +++ b/storage/sqlite.go @@ -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() diff --git a/storage/sqlite_test.go b/storage/sqlite_test.go index 41fde1c..4dbdcda 100644 --- a/storage/sqlite_test.go +++ b/storage/sqlite_test.go @@ -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") diff --git a/storage/store.go b/storage/store.go index d953aed..70f8436 100644 --- a/storage/store.go +++ b/storage/store.go @@ -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) diff --git a/storage/store_test.go b/storage/store_test.go index 4cafc61..9b3dcdc 100644 --- a/storage/store_test.go +++ b/storage/store_test.go @@ -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 { diff --git a/storage/types.go b/storage/types.go index 7c1fdb4..e2e808f 100644 --- a/storage/types.go +++ b/storage/types.go @@ -5,6 +5,7 @@ import ( "time" ) +// NodeType identifies the role of a persisted conversation node. type NodeType string const ( @@ -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"` @@ -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"`