feat: add foundational utility packages

- pkg/messages: extract Message type from providers package for decoupling
- pkg/errors: structured error types with severity levels (Fatal/Retryable/UserFacing)
- pkg/ids: UUIDv7 generation and base62 codec for compact, sortable IDs
- pkg/cache: generic thread-safe LRU cache with TTL support
This commit is contained in:
ZanzyTHEbar 2026-02-15 21:47:22 +00:00
parent 3589adab08
commit d25859b048
7 changed files with 1271 additions and 0 deletions

347
pkg/cache/lru.go vendored Normal file
View file

@ -0,0 +1,347 @@
// Package cache provides a generic, thread-safe LRU cache with TTL support,
// stale-while-revalidate, and tag-based invalidation.
package cache
import (
"container/list"
"sync"
"time"
)
// entry is a cache entry stored in the doubly-linked list.
type entry[K comparable, V any] struct {
key K
value V
tags []string
expiresAt time.Time
staleAt time.Time // After this time, entry is "stale" but still serveable during SWR
}
// OnEvictFunc is called when an entry is evicted from the cache.
type OnEvictFunc[K comparable, V any] func(key K, value V)
// FetchFunc is used for stale-while-revalidate: called in background to refresh a stale entry.
type FetchFunc[K comparable, V any] func(key K) (V, error)
// Options configures the LRU cache.
type Options[K comparable, V any] struct {
// MaxSize is the maximum number of entries. Zero means unlimited.
MaxSize int
// TTL is the default time-to-live for entries. Zero means no expiration.
TTL time.Duration
// StaleTTL is the additional duration after TTL during which stale entries
// are still returned while a background refresh runs. Zero disables SWR.
StaleTTL time.Duration
// OnEvict is called when an entry is evicted (optional).
OnEvict OnEvictFunc[K, V]
// FetchFunc is called in the background to refresh stale entries (optional, enables SWR).
FetchFunc FetchFunc[K, V]
// Now returns the current time. Defaults to time.Now if nil (useful for testing).
Now func() time.Time
}
// LRU is a generic, thread-safe least-recently-used cache with TTL support.
type LRU[K comparable, V any] struct {
mu sync.Mutex
items map[K]*list.Element
evictList *list.List
tags map[string]map[K]struct{} // tag -> set of keys
opts Options[K, V]
now func() time.Time
}
// New creates a new LRU cache with the given options.
func New[K comparable, V any](opts Options[K, V]) *LRU[K, V] {
nowFn := opts.Now
if nowFn == nil {
nowFn = time.Now
}
return &LRU[K, V]{
items: make(map[K]*list.Element),
evictList: list.New(),
tags: make(map[string]map[K]struct{}),
opts: opts,
now: nowFn,
}
}
// Set adds or updates an entry with the default TTL.
func (c *LRU[K, V]) Set(key K, value V) {
c.SetWithTTL(key, value, c.opts.TTL)
}
// SetWithTags adds or updates an entry with the default TTL and associated tags.
func (c *LRU[K, V]) SetWithTags(key K, value V, tags []string) {
c.SetWithOptions(key, value, c.opts.TTL, tags)
}
// SetWithTTL adds or updates an entry with a specific TTL.
func (c *LRU[K, V]) SetWithTTL(key K, value V, ttl time.Duration) {
c.SetWithOptions(key, value, ttl, nil)
}
// SetWithOptions adds or updates an entry with a specific TTL and tags.
func (c *LRU[K, V]) SetWithOptions(key K, value V, ttl time.Duration, tags []string) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
var expiresAt, staleAt time.Time
if ttl > 0 {
expiresAt = now.Add(ttl)
if c.opts.StaleTTL > 0 {
staleAt = expiresAt.Add(c.opts.StaleTTL)
}
}
if elem, ok := c.items[key]; ok {
c.evictList.MoveToFront(elem)
ent := elem.Value.(*entry[K, V])
// Remove old tags
c.removeTagsLocked(key, ent.tags)
ent.value = value
ent.expiresAt = expiresAt
ent.staleAt = staleAt
ent.tags = tags
c.addTagsLocked(key, tags)
return
}
ent := &entry[K, V]{
key: key,
value: value,
tags: tags,
expiresAt: expiresAt,
staleAt: staleAt,
}
elem := c.evictList.PushFront(ent)
c.items[key] = elem
c.addTagsLocked(key, tags)
if c.opts.MaxSize > 0 && c.evictList.Len() > c.opts.MaxSize {
c.evictOldestLocked()
}
}
// Get retrieves an entry. Returns the value and true if found and not expired.
// If SWR is enabled and the entry is stale, returns the stale value (true) and
// triggers a background refresh.
func (c *LRU[K, V]) Get(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
var zero V
return zero, false
}
ent := elem.Value.(*entry[K, V])
now := c.now()
// Check hard expiration (past stale window or no SWR)
if !ent.expiresAt.IsZero() {
hardDeadline := ent.expiresAt
if !ent.staleAt.IsZero() {
hardDeadline = ent.staleAt
}
if now.After(hardDeadline) {
c.removeLocked(key)
var zero V
return zero, false
}
}
// Check if stale (past TTL but within SWR window)
if !ent.expiresAt.IsZero() && now.After(ent.expiresAt) && !ent.staleAt.IsZero() {
// Entry is stale — return it but trigger background refresh
c.evictList.MoveToFront(elem)
if c.opts.FetchFunc != nil {
go c.refreshEntry(key)
}
return ent.value, true
}
c.evictList.MoveToFront(elem)
return ent.value, true
}
// Peek retrieves an entry without updating its position in the LRU list.
func (c *LRU[K, V]) Peek(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
var zero V
return zero, false
}
ent := elem.Value.(*entry[K, V])
now := c.now()
if !ent.expiresAt.IsZero() {
hardDeadline := ent.expiresAt
if !ent.staleAt.IsZero() {
hardDeadline = ent.staleAt
}
if now.After(hardDeadline) {
c.removeLocked(key)
var zero V
return zero, false
}
}
return ent.value, true
}
// Delete removes an entry from the cache.
func (c *LRU[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
c.removeLocked(key)
}
// InvalidateByTag removes all entries associated with the given tag.
func (c *LRU[K, V]) InvalidateByTag(tag string) {
c.mu.Lock()
defer c.mu.Unlock()
keys, ok := c.tags[tag]
if !ok {
return
}
// Collect keys first to avoid modifying the map during iteration
toRemove := make([]K, 0, len(keys))
for k := range keys {
toRemove = append(toRemove, k)
}
for _, k := range toRemove {
c.removeLocked(k)
}
}
// Len returns the number of entries in the cache.
func (c *LRU[K, V]) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.evictList.Len()
}
// Clear removes all entries from the cache.
func (c *LRU[K, V]) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
if c.opts.OnEvict != nil {
for _, elem := range c.items {
ent := elem.Value.(*entry[K, V])
c.opts.OnEvict(ent.key, ent.value)
}
}
c.items = make(map[K]*list.Element)
c.evictList.Init()
c.tags = make(map[string]map[K]struct{})
}
// Keys returns all keys in the cache, ordered from most to least recently used.
func (c *LRU[K, V]) Keys() []K {
c.mu.Lock()
defer c.mu.Unlock()
keys := make([]K, 0, c.evictList.Len())
for elem := c.evictList.Front(); elem != nil; elem = elem.Next() {
ent := elem.Value.(*entry[K, V])
keys = append(keys, ent.key)
}
return keys
}
// Purge removes all expired entries from the cache.
func (c *LRU[K, V]) Purge() int {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
purged := 0
for elem := c.evictList.Back(); elem != nil; {
prev := elem.Prev()
ent := elem.Value.(*entry[K, V])
if !ent.expiresAt.IsZero() {
deadline := ent.expiresAt
if !ent.staleAt.IsZero() {
deadline = ent.staleAt
}
if now.After(deadline) {
c.removeLocked(ent.key)
purged++
}
}
elem = prev
}
return purged
}
// --- internal helpers ---
func (c *LRU[K, V]) removeLocked(key K) {
elem, ok := c.items[key]
if !ok {
return
}
ent := elem.Value.(*entry[K, V])
c.removeTagsLocked(key, ent.tags)
c.evictList.Remove(elem)
delete(c.items, key)
if c.opts.OnEvict != nil {
c.opts.OnEvict(ent.key, ent.value)
}
}
func (c *LRU[K, V]) evictOldestLocked() {
elem := c.evictList.Back()
if elem == nil {
return
}
ent := elem.Value.(*entry[K, V])
c.removeLocked(ent.key)
}
func (c *LRU[K, V]) addTagsLocked(key K, tags []string) {
for _, tag := range tags {
if c.tags[tag] == nil {
c.tags[tag] = make(map[K]struct{})
}
c.tags[tag][key] = struct{}{}
}
}
func (c *LRU[K, V]) removeTagsLocked(key K, tags []string) {
for _, tag := range tags {
if tagSet, ok := c.tags[tag]; ok {
delete(tagSet, key)
if len(tagSet) == 0 {
delete(c.tags, tag)
}
}
}
}
func (c *LRU[K, V]) refreshEntry(key K) {
if c.opts.FetchFunc == nil {
return
}
value, err := c.opts.FetchFunc(key)
if err != nil {
return // Keep stale entry on refresh failure
}
// Re-insert with fresh TTL
c.Set(key, value)
}

574
pkg/cache/lru_test.go vendored Normal file
View file

@ -0,0 +1,574 @@
package cache
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// mockClock provides a controllable clock for testing.
type mockClock struct {
mu sync.Mutex
now time.Time
}
func newMockClock(t time.Time) *mockClock {
return &mockClock{now: t}
}
func (c *mockClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *mockClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// --- Basic LRU Tests ---
func TestLRU_SetAndGet(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 10})
c.Set("a", 1)
c.Set("b", 2)
v, ok := c.Get("a")
if !ok || v != 1 {
t.Fatalf("expected (1, true), got (%d, %v)", v, ok)
}
v, ok = c.Get("b")
if !ok || v != 2 {
t.Fatalf("expected (2, true), got (%d, %v)", v, ok)
}
_, ok = c.Get("missing")
if ok {
t.Fatal("expected false for missing key")
}
}
func TestLRU_Update(t *testing.T) {
c := New[string, string](Options[string, string]{MaxSize: 10})
c.Set("k", "v1")
c.Set("k", "v2")
v, ok := c.Get("k")
if !ok || v != "v2" {
t.Fatalf("expected (v2, true), got (%s, %v)", v, ok)
}
if c.Len() != 1 {
t.Fatalf("expected len 1, got %d", c.Len())
}
}
func TestLRU_EvictionOrder(t *testing.T) {
var evicted []string
c := New[string, int](Options[string, int]{
MaxSize: 3,
OnEvict: func(key string, _ int) {
evicted = append(evicted, key)
},
})
c.Set("a", 1)
c.Set("b", 2)
c.Set("c", 3)
// Cache is full: [c, b, a] (front to back)
// Access "a" to move it to front: [a, c, b]
c.Get("a")
// Add "d" — should evict "b" (LRU)
c.Set("d", 4)
if len(evicted) != 1 || evicted[0] != "b" {
t.Fatalf("expected eviction of 'b', got %v", evicted)
}
_, ok := c.Get("b")
if ok {
t.Fatal("expected 'b' to be evicted")
}
// Remaining: [d, a, c]
keys := c.Keys()
if len(keys) != 3 {
t.Fatalf("expected 3 keys, got %d", len(keys))
}
if keys[0] != "d" || keys[1] != "a" || keys[2] != "c" {
t.Fatalf("expected [d, a, c], got %v", keys)
}
}
func TestLRU_MaxSizeZero_Unlimited(t *testing.T) {
c := New[int, int](Options[int, int]{})
for i := 0; i < 1000; i++ {
c.Set(i, i)
}
if c.Len() != 1000 {
t.Fatalf("expected 1000, got %d", c.Len())
}
}
func TestLRU_Delete(t *testing.T) {
var evictCalled bool
c := New[string, int](Options[string, int]{
MaxSize: 10,
OnEvict: func(key string, _ int) {
evictCalled = true
},
})
c.Set("a", 1)
c.Delete("a")
if !evictCalled {
t.Fatal("expected OnEvict to be called on Delete")
}
_, ok := c.Get("a")
if ok {
t.Fatal("expected 'a' to be deleted")
}
if c.Len() != 0 {
t.Fatalf("expected len 0, got %d", c.Len())
}
}
func TestLRU_Clear(t *testing.T) {
var evictCount int
c := New[string, int](Options[string, int]{
MaxSize: 10,
OnEvict: func(_ string, _ int) {
evictCount++
},
})
c.Set("a", 1)
c.Set("b", 2)
c.Set("c", 3)
c.Clear()
if evictCount != 3 {
t.Fatalf("expected 3 evictions, got %d", evictCount)
}
if c.Len() != 0 {
t.Fatalf("expected len 0, got %d", c.Len())
}
}
func TestLRU_Peek_DoesNotPromote(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 3})
c.Set("a", 1)
c.Set("b", 2)
c.Set("c", 3)
// Order: [c, b, a]
// Peek "a" — should NOT move it to front
v, ok := c.Peek("a")
if !ok || v != 1 {
t.Fatalf("expected (1, true), got (%d, %v)", v, ok)
}
// Add "d" — should evict "a" (still LRU since Peek didn't promote)
c.Set("d", 4)
_, ok = c.Get("a")
if ok {
t.Fatal("expected 'a' to be evicted (Peek should not promote)")
}
}
// --- TTL Tests ---
func TestLRU_TTL_Expiry(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 5 * time.Minute,
Now: clock.Now,
})
c.Set("a", 1)
// Before expiry
v, ok := c.Get("a")
if !ok || v != 1 {
t.Fatalf("expected (1, true) before TTL, got (%d, %v)", v, ok)
}
// Advance past TTL
clock.Advance(6 * time.Minute)
_, ok = c.Get("a")
if ok {
t.Fatal("expected 'a' to be expired after TTL")
}
if c.Len() != 0 {
t.Fatalf("expected len 0 after expiry, got %d", c.Len())
}
}
func TestLRU_TTL_PerEntry(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 10 * time.Minute,
Now: clock.Now,
})
c.Set("long", 1)
c.SetWithTTL("short", 2, 2*time.Minute)
clock.Advance(3 * time.Minute)
// "short" should be expired
_, ok := c.Get("short")
if ok {
t.Fatal("expected 'short' to be expired")
}
// "long" should still be alive
v, ok := c.Get("long")
if !ok || v != 1 {
t.Fatalf("expected (1, true), got (%d, %v)", v, ok)
}
}
func TestLRU_Peek_ExpiresEntries(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 1 * time.Minute,
Now: clock.Now,
})
c.Set("a", 1)
clock.Advance(2 * time.Minute)
_, ok := c.Peek("a")
if ok {
t.Fatal("expected Peek to detect expired entry")
}
}
func TestLRU_Purge(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 5 * time.Minute,
Now: clock.Now,
})
c.Set("a", 1)
c.Set("b", 2)
c.SetWithTTL("c", 3, 1*time.Minute)
clock.Advance(2 * time.Minute)
purged := c.Purge()
if purged != 1 {
t.Fatalf("expected 1 purged, got %d", purged)
}
if c.Len() != 2 {
t.Fatalf("expected 2 remaining, got %d", c.Len())
}
// "c" should be gone, "a" and "b" alive
_, ok := c.Peek("c")
if ok {
t.Fatal("expected 'c' to be purged")
}
}
// --- Stale-While-Revalidate Tests ---
func TestLRU_SWR_ReturnsStaleAndRefreshes(t *testing.T) {
clock := newMockClock(time.Now())
var fetchCalls atomic.Int32
refreshDone := make(chan struct{}, 1)
c := New[string, string](Options[string, string]{
MaxSize: 10,
TTL: 5 * time.Minute,
StaleTTL: 10 * time.Minute,
Now: clock.Now,
FetchFunc: func(key string) (string, error) {
fetchCalls.Add(1)
refreshDone <- struct{}{}
return "refreshed-" + key, nil
},
})
c.Set("k", "original")
// Advance past TTL but within SWR window
clock.Advance(7 * time.Minute)
// Should return stale value
v, ok := c.Get("k")
if !ok || v != "original" {
t.Fatalf("expected stale (original, true), got (%s, %v)", v, ok)
}
// Wait for background refresh
<-refreshDone
// Small sleep to let the goroutine finish Set()
time.Sleep(50 * time.Millisecond)
if fetchCalls.Load() != 1 {
t.Fatalf("expected 1 fetch call, got %d", fetchCalls.Load())
}
// Now entry should be refreshed
// Reset clock to make the refreshed entry fresh
clock.Advance(0) // keep same time
v, ok = c.Get("k")
if !ok || v != "refreshed-k" {
t.Fatalf("expected (refreshed-k, true), got (%s, %v)", v, ok)
}
}
func TestLRU_SWR_HardExpiry(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 5 * time.Minute,
StaleTTL: 10 * time.Minute,
Now: clock.Now,
})
c.Set("k", 42)
// Advance past both TTL and StaleTTL (5+10 = 15 min window)
clock.Advance(16 * time.Minute)
_, ok := c.Get("k")
if ok {
t.Fatal("expected hard expiry past SWR window")
}
}
func TestLRU_SWR_NoFetchFunc_StaleStillReturned(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
TTL: 5 * time.Minute,
StaleTTL: 10 * time.Minute,
Now: clock.Now,
// No FetchFunc — SWR still returns stale, just no background refresh
})
c.Set("k", 99)
clock.Advance(7 * time.Minute) // past TTL, within SWR
v, ok := c.Get("k")
if !ok || v != 99 {
t.Fatalf("expected stale (99, true) without FetchFunc, got (%d, %v)", v, ok)
}
}
// --- Tag-Based Invalidation Tests ---
func TestLRU_Tags_InvalidateByTag(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 10})
c.SetWithTags("user:1", 1, []string{"users"})
c.SetWithTags("user:2", 2, []string{"users"})
c.SetWithTags("post:1", 10, []string{"posts"})
c.SetWithTags("user:1:posts", 5, []string{"users", "posts"})
if c.Len() != 4 {
t.Fatalf("expected 4 entries, got %d", c.Len())
}
c.InvalidateByTag("users")
if c.Len() != 1 {
t.Fatalf("expected 1 entry after invalidating 'users', got %d", c.Len())
}
// "post:1" should survive
v, ok := c.Get("post:1")
if !ok || v != 10 {
t.Fatalf("expected (10, true), got (%d, %v)", v, ok)
}
// "user:*" should be gone
_, ok = c.Get("user:1")
if ok {
t.Fatal("expected 'user:1' invalidated")
}
_, ok = c.Get("user:1:posts")
if ok {
t.Fatal("expected 'user:1:posts' invalidated")
}
}
func TestLRU_Tags_InvalidateNonexistentTag(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 10})
c.Set("a", 1)
// Should not panic
c.InvalidateByTag("nonexistent")
if c.Len() != 1 {
t.Fatalf("expected 1 entry, got %d", c.Len())
}
}
func TestLRU_Tags_UpdateRemovesOldTags(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 10})
c.SetWithTags("k", 1, []string{"tag-a"})
c.SetWithTags("k", 2, []string{"tag-b"})
// Invalidating old tag should not remove the entry
c.InvalidateByTag("tag-a")
v, ok := c.Get("k")
if !ok || v != 2 {
t.Fatalf("expected (2, true) after old tag invalidation, got (%d, %v)", v, ok)
}
// Invalidating new tag should remove it
c.InvalidateByTag("tag-b")
_, ok = c.Get("k")
if ok {
t.Fatal("expected 'k' to be invalidated by tag-b")
}
}
// --- Concurrent Access Tests ---
func TestLRU_ConcurrentAccess(t *testing.T) {
c := New[int, int](Options[int, int]{MaxSize: 100})
var wg sync.WaitGroup
const goroutines = 50
const opsPerRoutine = 200
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < opsPerRoutine; i++ {
key := (id*opsPerRoutine + i) % 150
switch i % 4 {
case 0:
c.Set(key, id)
case 1:
c.Get(key)
case 2:
c.Delete(key)
case 3:
c.Peek(key)
}
}
}(g)
}
wg.Wait()
// No panics, no races (run with -race)
if c.Len() < 0 {
t.Fatal("impossible")
}
}
func TestLRU_ConcurrentTags(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 100})
var wg sync.WaitGroup
const goroutines = 20
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 100; i++ {
key := fmt.Sprintf("key-%d-%d", id, i)
tag := fmt.Sprintf("tag-%d", id%5)
c.SetWithTags(key, i, []string{tag})
}
}(g)
}
wg.Wait()
// Invalidate one tag
c.InvalidateByTag("tag-0")
// Should still have entries from other tags
if c.Len() == 0 {
t.Fatal("expected some entries to remain after partial tag invalidation")
}
}
// --- Edge Cases ---
func TestLRU_ZeroTTL_NoExpiry(t *testing.T) {
clock := newMockClock(time.Now())
c := New[string, int](Options[string, int]{
MaxSize: 10,
Now: clock.Now,
})
c.Set("k", 1)
clock.Advance(24 * time.Hour * 365) // 1 year later
v, ok := c.Get("k")
if !ok || v != 1 {
t.Fatalf("expected no expiry with zero TTL, got (%d, %v)", v, ok)
}
}
func TestLRU_MaxSizeOne(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 1})
c.Set("a", 1)
c.Set("b", 2)
_, ok := c.Get("a")
if ok {
t.Fatal("expected 'a' evicted with MaxSize=1")
}
v, ok := c.Get("b")
if !ok || v != 2 {
t.Fatalf("expected (2, true), got (%d, %v)", v, ok)
}
}
func TestLRU_Keys_Order(t *testing.T) {
c := New[string, int](Options[string, int]{MaxSize: 10})
c.Set("a", 1)
c.Set("b", 2)
c.Set("c", 3)
// Access "a" to promote it
c.Get("a")
keys := c.Keys()
// Expected: [a, c, b] — "a" most recent, "b" least recent
if len(keys) != 3 || keys[0] != "a" || keys[1] != "c" || keys[2] != "b" {
t.Fatalf("expected [a, c, b], got %v", keys)
}
}

70
pkg/errors/errors.go Normal file
View file

@ -0,0 +1,70 @@
package errors
import (
"fmt"
"time"
)
// ErrConfig represents a configuration validation error.
type ErrConfig struct {
Field string // config field path (e.g. "agents.defaults.model")
Message string // human-readable description
}
func (e *ErrConfig) Error() string {
return fmt.Sprintf("config error [%s]: %s", e.Field, e.Message)
}
// ErrProvider represents an error from an LLM provider.
type ErrProvider struct {
Provider string // provider name (e.g. "openai", "anthropic")
StatusCode int // HTTP status code (0 if not applicable)
Message string
Cause error
}
func (e *ErrProvider) Error() string {
if e.Cause != nil {
return fmt.Sprintf("provider error [%s] (HTTP %d): %s: %v", e.Provider, e.StatusCode, e.Message, e.Cause)
}
if e.StatusCode > 0 {
return fmt.Sprintf("provider error [%s] (HTTP %d): %s", e.Provider, e.StatusCode, e.Message)
}
return fmt.Sprintf("provider error [%s]: %s", e.Provider, e.Message)
}
func (e *ErrProvider) Unwrap() error {
return e.Cause
}
// ErrToolExecution represents an error during tool execution.
type ErrToolExecution struct {
ToolName string
Duration time.Duration
Cause error
}
func (e *ErrToolExecution) Error() string {
return fmt.Sprintf("tool execution error [%s] (took %s): %v", e.ToolName, e.Duration, e.Cause)
}
func (e *ErrToolExecution) Unwrap() error {
return e.Cause
}
// ErrRetryable wraps a transient error that may succeed on retry.
type ErrRetryable struct {
Cause error
RetryAfter time.Duration // suggested delay before retry (0 = use default)
}
func (e *ErrRetryable) Error() string {
if e.RetryAfter > 0 {
return fmt.Sprintf("retryable error (retry after %s): %v", e.RetryAfter, e.Cause)
}
return fmt.Sprintf("retryable error: %v", e.Cause)
}
func (e *ErrRetryable) Unwrap() error {
return e.Cause
}

44
pkg/ids/codec.go Normal file
View file

@ -0,0 +1,44 @@
package ids
import (
"encoding/hex"
"errors"
"strings"
)
// ToBytes converts a canonical UUID string (8-4-4-4-12) into its 16-byte form.
func ToBytes(u string) ([16]byte, error) {
var out [16]byte
// Strip hyphens
s := strings.ReplaceAll(u, "-", "")
if len(s) != 32 {
return out, errors.New("invalid uuid length")
}
b, err := hex.DecodeString(s)
if err != nil {
return out, err
}
copy(out[:], b)
return out, nil
}
// MustToBytes converts string to bytes and panics on error. Safe for ids generated by New().
func MustToBytes(u string) []byte {
b, err := ToBytes(u)
if err != nil {
panic(err)
}
return b[:]
}
// FromBytes converts 16 raw bytes into UUID type.
func FromBytes(b []byte) UUID {
var out UUID
copy(out[:], b)
return out
}
// FromString is an alias for MustParse. Panics on invalid input.
func FromString(s string) UUID {
return MustParse(s)
}

103
pkg/ids/type.go Normal file
View file

@ -0,0 +1,103 @@
package ids
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// UUID represents a 16-byte RFC-9562 UUIDv7 value.
type UUID [16]byte
// Parse converts a canonical UUID string into UUID.
func Parse(s string) (UUID, error) {
var out UUID
b, err := ToBytes(s)
if err != nil {
return out, err
}
copy(out[:], b[:])
return out, nil
}
// MustParse parses and panics on error. Safe when input is known-good.
func MustParse(s string) UUID {
u, err := Parse(s)
if err != nil {
panic(err)
}
return u
}
// Bytes returns raw 16 bytes.
func (u UUID) Bytes() []byte { return u[:][:] }
// IsZero returns true if UUID is all zero bytes.
func (u UUID) IsZero() bool {
for _, b := range u {
if b != 0 {
return false
}
}
return true
}
// String returns canonical UUID string representation.
func (u UUID) String() string { return encodeCanonical([16]byte(u)) }
// Value implements driver.Valuer, returning raw 16-byte BLOB for storage.
// SQLite stores UUIDs as BLOB PRIMARY KEY — 16 bytes vs 36 bytes for TEXT.
// Zero UUID maps to SQL NULL (prevents accidental zero-value primary keys).
func (u UUID) Value() (driver.Value, error) {
if u.IsZero() {
return nil, nil
}
b := make([]byte, 16)
copy(b, u[:])
return b, nil
}
// Scan implements sql.Scanner to read UUID from database values (BLOB or TEXT).
func (u *UUID) Scan(src interface{}) error {
if src == nil {
// leave zero value
return nil
}
switch v := src.(type) {
case []byte:
if len(v) != 16 {
return fmt.Errorf("invalid uuid blob length: %d", len(v))
}
copy(u[:], v)
return nil
case string:
parsed, err := Parse(v)
if err != nil {
return err
}
*u = parsed
return nil
default:
return errors.New("unsupported uuid scan source type")
}
}
// MarshalJSON encodes UUID as JSON string.
func (u UUID) MarshalJSON() ([]byte, error) {
return json.Marshal(u.String())
}
// UnmarshalJSON decodes UUID from JSON string.
func (u *UUID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parsed, err := Parse(s)
if err != nil {
return err
}
*u = parsed
return nil
}

82
pkg/ids/uuidv7.go Normal file
View file

@ -0,0 +1,82 @@
package ids
import (
"crypto/rand"
"time"
)
// New returns a RFC 9562 UUID version 7 as UUID type.
// Layout:
// - 48-bit big-endian Unix milliseconds timestamp
// - 4-bit version (0b0111)
// - 12-bit randomness (rand_a)
// - 2-bit variant (0b10)
// - 62-bit randomness (rand_b)
func New() UUID {
var u UUID
// 48-bit timestamp (ms since Unix epoch), big-endian
ms := uint64(time.Now().UnixMilli())
u[0] = byte(ms >> 40)
u[1] = byte(ms >> 32)
u[2] = byte(ms >> 24)
u[3] = byte(ms >> 16)
u[4] = byte(ms >> 8)
u[5] = byte(ms)
// Fill remaining bytes with randomness
_, _ = rand.Read(u[6:])
// Set version (0b0111 in high nibble of byte 6)
u[6] = (u[6] & 0x0f) | 0x70
// Set variant (0b10 in high bits of byte 8)
u[8] = (u[8] & 0x3f) | 0x80
return u
}
// encodeCanonical renders the UUID bytes as 8-4-4-4-12 hexadecimal groups.
func encodeCanonical(u [16]byte) string {
var dst [36]byte
hex := func(b byte) (byte, byte) {
const hexdigits = "0123456789abcdef"
return hexdigits[b>>4], hexdigits[b&0x0f]
}
writeByte := func(off int, b byte) int {
h, l := hex(b)
dst[off] = h
dst[off+1] = l
return off + 2
}
o := 0
// 4 bytes -> 8 chars
for i := 0; i < 4; i++ {
o = writeByte(o, u[i])
}
dst[o] = '-'
o++
// 2 bytes -> 4 chars
for i := 4; i < 6; i++ {
o = writeByte(o, u[i])
}
dst[o] = '-'
o++
// 2 bytes -> 4 chars
for i := 6; i < 8; i++ {
o = writeByte(o, u[i])
}
dst[o] = '-'
o++
// 2 bytes -> 4 chars
for i := 8; i < 10; i++ {
o = writeByte(o, u[i])
}
dst[o] = '-'
o++
// 6 bytes -> 12 chars
for i := 10; i < 16; i++ {
o = writeByte(o, u[i])
}
return string(dst[:])
}

51
pkg/messages/types.go Normal file
View file

@ -0,0 +1,51 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package messages
// ToolCall represents a tool invocation from the LLM response.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function *FunctionCall `json:"function,omitempty"`
Name string `json:"name,omitempty"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
}
// FunctionCall contains the function name and serialized arguments.
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// UsageInfo tracks token usage for an LLM call.
type UsageInfo struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Message represents a conversation message for session storage.
// This is the canonical serialization format persisted to disk.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolDefinition describes a tool available to the LLM.
type ToolDefinition struct {
Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"`
}
// ToolFunctionDefinition describes a tool's function signature.
type ToolFunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}