Files
gnoma/docs/essentials/patterns.md
vikingowl efcb5a2901 docs: add project essentials (12/12 complete)
Vision, domain model, architecture, patterns, process flows,
UML diagrams, API contracts, tech stack, constraints, milestones
(M1-M11), decision log (6 ADRs), and risk register.

Key decisions: single binary, pull-based streaming, Mistral as M1
reference provider, discriminated unions, multi-provider collaboration
as core identity.
2026-04-02 18:09:07 +02:00

4.8 KiB

essential, status, last_updated, project, depends_on
essential status last_updated project depends_on
patterns complete 2026-04-02 gnoma
architecture

Patterns

Discriminated Unions

  • What: Struct with a Type field discriminant; exactly one payload field is set per type value. Used instead of Go interfaces for data variants.
  • Where: message.Content, stream.Event
  • Why: Zero allocation (no interface boxing), cache-friendly, works with switch statements. Go lacks sum types — this is the pragmatic equivalent.
  • Example:
    type Content struct {
        Type       ContentType
        Text       string      // set when Type == ContentText
        ToolCall   *ToolCall   // set when Type == ContentToolCall
        ToolResult *ToolResult // set when Type == ContentToolResult
        Thinking   *Thinking   // set when Type == ContentThinking
    }
    
    switch c.Type {
    case ContentText:
        fmt.Print(c.Text)
    case ContentToolCall:
        execute(c.ToolCall)
    }
    

Pull-Based Stream Iterator

  • What: Next() / Current() / Err() / Close() interface for consuming streaming data.
  • Where: stream.Stream interface, all provider adapters
  • Why: Matches 3 of 4 SDKs (Anthropic, OpenAI, Mistral) natively. Gives consumer explicit backpressure control. Supports Close() for resource cleanup, unlike iter.Seq. Only Google needs a goroutine bridge.
  • Example:
    for s.Next() {
        event := s.Current()
        process(event)
    }
    if err := s.Err(); err != nil {
        handle(err)
    }
    s.Close()
    

Accumulator

  • What: Shared component that assembles a message.Response from a sequence of stream.Event values. Separated from provider-specific translation.
  • Where: stream.Accumulator, used by every provider adapter
  • Why: Provider adapters become thin translation layers. Accumulation logic (text building, tool call JSON fragment assembly, thinking blocks) is tested once, not per-provider.
  • Example:
    acc := stream.NewAccumulator()
    for s.Next() {
        acc.Apply(s.Current())
    }
    response := acc.Response()
    

Factory Registry

  • What: Map of names to factory functions. Creates instances on demand with config.
  • Where: provider.Registry, tool.Registry
  • Why: Decouples creation from usage. Makes testing easy — register mock factories. Enables dynamic provider switching.
  • Example:
    registry.Register("mistral", mistral.NewProvider)
    provider, err := registry.Create("mistral", cfg)
    

Functional Options

  • What: Variadic option functions for configuring complex objects.
  • Where: Session creation, provider construction
  • Why: Clean API for objects with many optional parameters. Self-documenting, extensible without breaking changes.
  • Example:
    session, err := manager.NewSession(
        WithProvider(mistral),
        WithModel("mistral-large-latest"),
        WithMaxTurns(20),
    )
    

Callback Event Propagation

  • What: The engine accepts a Callback func(stream.Event) and calls it for each event. The session wraps this to push events into a channel.
  • Where: engine.Submit()session/local.go
  • Why: Keeps the engine testable without concurrency. The engine knows nothing about channels, TUI, or goroutines. The session implementation decides how to propagate events.
  • Example:
    // In session/local.go:
    cb := func(evt stream.Event) {
        select {
        case s.events <- evt:
        case <-ctx.Done():
        }
    }
    turn, err := s.engine.Submit(ctx, input, cb)
    

Error Wrapping with errors.AsType

  • What: Provider adapters wrap SDK errors into typed ProviderError with classification. Consumers extract using Go 1.26's errors.AsType[E].
  • Where: All provider adapters, retry logic, engine error handling
  • Why: Enables error classification (transient vs auth vs bad request) for retry decisions. Type-safe extraction without pointer indirection.
  • Example:
    if pErr, ok := errors.AsType[*ProviderError](err); ok {
        if pErr.Retryable {
            // exponential backoff
        }
    }
    

Anti-Patterns

Patterns explicitly avoided in this project:

Anti-Pattern Why we avoid it What to do instead
Interface-based unions Heap allocation, type assertion overhead, no exhaustive matching Discriminated union structs with Type field
Channel-based streams Requires goroutine management, harder to control backpressure Pull-based iterator interface
Global state Untestable, race-prone, hidden dependencies Dependency injection via config structs
Shared mutable state between elfs Race conditions, complex synchronization Each elf owns its own engine; communicate via channels
Over-abstraction Premature generalization obscures intent Three similar lines > one premature abstraction

Changelog

  • 2026-04-02: Initial version