Skip to content
Open
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,41 @@ When a guardrail skips a provider it is noted in the routing trace
in a chain is over budget or cooling down, the call fails with a clear
message rather than silently doing nothing.

## Result cache

Off by default. When enabled, a repeated identical `frugal__search` or
`frugal__extract` call inside the TTL is answered from process memory
instead of a provider, at zero cost. Agents repeat themselves
constantly: retry loops, sibling subagents issuing the same query,
follow-up questions about the same page. With the cache on, only the
first call pays.

```yaml
cache:
enabled: true
search_ttl: 5m # default 5m; "0" turns search caching off
extract_ttl: 15m # default 15m; "0" turns extract caching off
max_entries: 512 # LRU eviction past this bound (default 512)
```

- Hits are labeled in the response: `cached: true`, `cache_age_ms`, and
`cost_usd: 0`, with `provider_used` still naming the provider that
produced the original result. Nothing is silently stale: the agent
can always see it got a cached answer and how old it is.
- Exact-match by design: the key covers the query or URL plus every
argument that changes what a provider would return (`max_results`,
`freshness`, `formats`, a provider pin). The Phase 3 semantic cache
builds on top of this layer; it does not replace it.
- `frugal__execute` shares entries with the direct tools, so
`frugal__execute("search python docs")` is a hit after
`frugal__search("python docs")` and vice versa. An explicit
`priority: cheap` or `premium` on execute bypasses the cache, since
the caller asked for a specific routing outcome.
- `frugal__browse` is never cached: rendering a page is exactly the
case where the caller wants the live DOM.
- In-memory only, per process. Cached provider payloads never touch
disk and never outlive the server.

## Describe the job: `frugal__execute`

Instead of picking a tool, an agent can state the intent and a priority.
Expand Down
56 changes: 53 additions & 3 deletions cmd/frugal/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/frugalsh/frugal/internal/browse"
"github.com/frugalsh/frugal/internal/cache"
"github.com/frugalsh/frugal/internal/config"
"github.com/frugalsh/frugal/internal/extract"
"github.com/frugalsh/frugal/internal/install"
Expand Down Expand Up @@ -152,10 +153,17 @@ func runMCPServe(args []string) int {
// rail, so there's no behavior difference for a budget-free config.
guard := buildGuard(cfg)

// Result cache: one instance shared by every read tool so
// frugal__execute and the direct tools hit the same entries. Nil
// when the config leaves it off; every cache method is nil-safe,
// so the tool wiring below is unconditional.
resultCache, searchTTL, extractTTL := buildResultCache(cfg)
withCache := tools.WithResultCache(resultCache, searchTTL, extractTTL)

searchers := buildSearchers(cfg)
warnPolicyStrangers("search", policies["search"], searcherNames(searchers))
tools.RegisterSearch(srv.Inner, searchers, metrics,
tools.WithPolicy(policies["search"]), tools.WithLatencyLookup(latFor("search")), tools.WithGuard(guard))
tools.WithPolicy(policies["search"]), tools.WithLatencyLookup(latFor("search")), tools.WithGuard(guard), withCache)
if len(searchers) == 0 {
slog.Warn("mcp serve: no search providers configured — frugal__search will not be advertised. " +
"Set SEARXNG_URL (free, self-hosted), SERPER_API_KEY, or YDC_API_KEY to enable.")
Expand All @@ -166,7 +174,7 @@ func runMCPServe(args []string) int {
extractors := buildExtractors(cfg)
warnPolicyStrangers("extract", policies["extract"], extractorNames(extractors))
tools.RegisterExtract(srv.Inner, extractors, metrics,
tools.WithPolicy(policies["extract"]), tools.WithLatencyLookup(latFor("extract")), tools.WithGuard(guard))
tools.WithPolicy(policies["extract"]), tools.WithLatencyLookup(latFor("extract")), tools.WithGuard(guard), withCache)
if len(extractors) > 0 {
slog.Info("mcp serve: frugal__extract registered", "providers", extractorNames(extractors))
}
Expand All @@ -180,7 +188,7 @@ func runMCPServe(args []string) int {
}

tools.RegisterExecute(srv.Inner, searchers, extractors, browsers, metrics,
tools.WithPolicies(policies), tools.WithLatencyLookupFor(latFor), tools.WithGuard(guard))
tools.WithPolicies(policies), tools.WithLatencyLookupFor(latFor), tools.WithGuard(guard), withCache)
if len(searchers) > 0 {
slog.Info("mcp serve: frugal__execute registered",
"search", len(searchers), "extract", len(extractors), "browse", len(browsers))
Expand Down Expand Up @@ -387,6 +395,48 @@ func policyFor(rc *config.RoutingConfig, capability string) routing.Policy {
// the same style as the "routing policy" lines. An invalid cooldown
// string warns and falls back to the routing package's default rather
// than failing startup: a mistyped duration shouldn't stop the server.
// Result-cache TTL defaults. Search results shift faster than article
// bodies, so search gets the shorter window.
const (
defaultSearchCacheTTL = 5 * time.Minute
defaultExtractCacheTTL = 15 * time.Minute
)

// buildResultCache turns the optional cache config section into the
// shared result cache plus per-capability TTLs. Returns a nil cache
// when the section is absent or disabled; nil is a safe no-op for the
// tool layer. Invalid TTLs warn and fall back to the defaults, same as
// the routing cooldown: a typo should not brick a working config. An
// explicit "0" is valid and disables that capability's caching only.
func buildResultCache(cfg *config.Config) (*cache.Cache, time.Duration, time.Duration) {
if cfg.Cache == nil || !cfg.Cache.Enabled {
return nil, 0, 0
}
parseTTL := func(name, raw string, fallback time.Duration) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback
}
if raw == "0" {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil || d < 0 {
slog.Warn("mcp serve: invalid cache ttl; using default",
"field", name, "value", raw, "default", fallback)
return fallback
}
return d
}
searchTTL := parseTTL("search_ttl", cfg.Cache.SearchTTL, defaultSearchCacheTTL)
extractTTL := parseTTL("extract_ttl", cfg.Cache.ExtractTTL, defaultExtractCacheTTL)
c := cache.New(cfg.Cache.MaxEntries)
slog.Info("mcp serve: result cache enabled",
"search_ttl", searchTTL, "extract_ttl", extractTTL,
"max_entries", cfg.Cache.MaxEntries)
return c, searchTTL, extractTTL
}

func buildGuard(cfg *config.Config) *routing.Guard {
caps := map[string]float64{}
add := func(capability string, providers map[string]config.SearchProviderConfig) {
Expand Down
14 changes: 14 additions & 0 deletions config/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@
# routing:
# cooldown: 90s
#
# Result cache (optional, off by default). When enabled, repeated
# identical search / extract calls inside the TTL are answered from
# process memory at zero cost and marked cached: true in the response.
# Browse is never cached. In-memory only: entries never touch disk and
# never outlive the process. TTLs are Go durations; "0" disables that
# capability's caching only. Invalid values warn and use the defaults
# (search 5m, extract 15m, max_entries 512):
#
# cache:
# enabled: true
# search_ttl: 5m
# extract_ttl: 15m
# max_entries: 512
#
# v1.0 ships only the search-tool layer. Chat-model routing and its
# pricing tables come back in Phase 2 with the frugal__chat MCP tool.
search_providers:
Expand Down
231 changes: 231 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Package cache implements Frugal's exact-match result cache: a
// bounded, in-memory TTL store that lets the routed read tools
// (frugal__search, frugal__extract, and frugal__execute when it lands
// on those capabilities) answer a repeated call without paying a
// provider again.
//
// Scope is deliberately narrow for v1:
//
// - Exact-match only. The key is a canonical string of the call's
// routing-relevant arguments. "python docs" and "Python docs" are
// different entries. The roadmap's semantic cache builds on top of
// this layer later; it does not replace it.
// - Read capabilities only. Browse is never cached: rendering a page
// is exactly the case where the caller wants the live DOM.
// - In-memory, per-process. Nothing is written to disk, so cached
// provider payloads never outlive the server process.
//
// Thread-safety: one mutex around a map plus an LRU list. The routed
// tools are network-bound; a single lock on the memory path is not the
// bottleneck and keeps eviction logic obviously correct.
package cache

import (
"container/list"
"strconv"
"strings"
"sync"
"time"
)

// DefaultMaxEntries bounds the cache when the operator enables caching
// without setting a size. Entries hold search snippets or extracted
// article text, so hundreds (not millions) is the sane default order.
const DefaultMaxEntries = 512

// Cache is a bounded TTL + LRU store. Construct with New; the zero
// value is not usable, and a nil *Cache is a safe no-op on every
// method, mirroring the routing.Guard convention so call sites need no
// conditionals.
type Cache struct {
mu sync.Mutex
entries map[string]*list.Element
lru *list.List // front = most recently used
maxEntries int
now func() time.Time

hits int64
misses int64
savedMicro int64 // micro-USD saved by hits (original call cost, x1e6)
}

type entry struct {
key string
value any
costUSD float64
storedAt time.Time
expires time.Time
}

// New builds a cache holding at most maxEntries entries. Values at or
// below zero fall back to DefaultMaxEntries: a cache the operator
// enabled should never be silently disabled by a config typo.
func New(maxEntries int) *Cache {
if maxEntries <= 0 {
maxEntries = DefaultMaxEntries
}
return &Cache{
entries: make(map[string]*list.Element),
lru: list.New(),
maxEntries: maxEntries,
now: time.Now,
}
}

// SetClock overrides the time source. Test hook only.
func (c *Cache) SetClock(now func() time.Time) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.now = now
}

// Get returns the stored value and its age when key is present and not
// expired. Expired entries are removed on the spot, so a steady stream
// of misses cannot hold dead payloads alive until eviction. A hit books
// the original call's cost as savings.
func (c *Cache) Get(key string) (value any, age time.Duration, ok bool) {
if c == nil {
return nil, 0, false
}
c.mu.Lock()
defer c.mu.Unlock()
el, found := c.entries[key]
if !found {
c.misses++
return nil, 0, false
}
en := el.Value.(*entry)
now := c.now()
if now.After(en.expires) {
c.removeLocked(el)
c.misses++
return nil, 0, false
}
c.lru.MoveToFront(el)
c.hits++
c.savedMicro += int64(en.costUSD * 1e6)
return en.value, now.Sub(en.storedAt), true
}

// Put stores value under key for ttl. costUSD is what the original call
// paid; it is what a later hit records as saved. A ttl at or below zero
// stores nothing: the operator turned that capability's cache off.
func (c *Cache) Put(key string, value any, costUSD float64, ttl time.Duration) {
if c == nil || ttl <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
if el, found := c.entries[key]; found {
en := el.Value.(*entry)
en.value = value
en.costUSD = costUSD
en.storedAt = now
en.expires = now.Add(ttl)
c.lru.MoveToFront(el)
return
}
el := c.lru.PushFront(&entry{
key: key,
value: value,
costUSD: costUSD,
storedAt: now,
expires: now.Add(ttl),
})
c.entries[key] = el
for c.lru.Len() > c.maxEntries {
c.removeLocked(c.lru.Back())
}
}

func (c *Cache) removeLocked(el *list.Element) {
if el == nil {
return
}
en := el.Value.(*entry)
delete(c.entries, en.key)
c.lru.Remove(el)
}

// Stats is a point-in-time snapshot of cache effectiveness.
type Stats struct {
Hits int64
Misses int64
SavedUSD float64
Entries int
}

// Snapshot returns the current counters. Safe on a nil cache (all
// zeros), so status surfaces can render unconditionally.
func (c *Cache) Snapshot() Stats {
if c == nil {
return Stats{}
}
c.mu.Lock()
defer c.mu.Unlock()
return Stats{
Hits: c.hits,
Misses: c.misses,
SavedUSD: float64(c.savedMicro) / 1e6,
Entries: c.lru.Len(),
}
}

// SearchKey canonicalizes a search call into a cache key. provider is
// the caller's pin ("" or "auto" both mean auto-routing and must map to
// the same key). The query text is trimmed but case is preserved:
// exact-match means exact.
func SearchKey(provider, query string, maxResults int, freshness string) string {
return strings.Join([]string{
"search",
normalizeProvider(provider),
strconv.Itoa(maxResults),
strings.ToLower(strings.TrimSpace(freshness)),
strings.TrimSpace(query),
}, "\x1f")
}

// ExtractKey canonicalizes an extract call into a cache key. Formats
// are lowercased, deduplicated, and sorted so ["markdown","html"] and
// ["HTML","markdown"] share an entry: drivers treat formats as a set.
func ExtractKey(provider, url string, formats []string) string {
canon := make([]string, 0, len(formats))
seen := make(map[string]bool, len(formats))
for _, f := range formats {
f = strings.ToLower(strings.TrimSpace(f))
if f == "" || seen[f] {
continue
}
seen[f] = true
canon = append(canon, f)
}
sortStrings(canon)
return strings.Join([]string{
"extract",
normalizeProvider(provider),
strings.Join(canon, ","),
strings.TrimSpace(url),
}, "\x1f")
}

func normalizeProvider(p string) string {
p = strings.ToLower(strings.TrimSpace(p))
if p == "auto" {
return ""
}
return p
}

// sortStrings is insertion sort: format lists have at most three
// entries, so pulling in sort for this would be noise.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
Loading
Loading