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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,56 @@ 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 size caps and the token receipt

Dollars are one cost of a tool call. The other is the tokens its result
occupies in your model's context for the rest of the session, and MCP
clients enforce that ceiling blind: Claude Code rejects MCP results over
25,000 tokens by default and spills anything past 50,000 characters to a
file with a 2 KB preview, so a long page either fails the call or
arrives cut off with nothing telling the model the tail is missing.

Frugal moves that decision server-side and puts it on the receipt. Every
`frugal__extract`, `frugal__browse`, and `frugal__execute` response
reports `chars_returned`, `chars_total`, `est_tokens`, and (when they
differ) `truncated: true`; `frugal__search` reports `est_tokens` for the
result list. To cap what comes back, pass `max_chars` on the call or set
a default in `models.yaml`:

```yaml
limits:
max_chars: 40000 # default page-content budget per call (about 10k tokens)
```

```
frugal__extract {"url": "https://example.com/long-article", "max_chars": 8000}

result › {
"markdown": "...the first 8,000 characters, cut on a word boundary...
[frugal: output truncated to 7996 of 61230 chars; pass a larger max_chars to see more]",
"provider_used": "goreadability",
"cost_usd": 0,
"chars_returned": 7996,
"chars_total": 61230,
"truncated": true,
"est_tokens": 1999
}
```

- The budget is shared across the content fields in priority order
(markdown, then text, then html), so the readable rendering survives
and raw HTML is the first thing to go.
- The cut is rune-safe and backs off to the nearest word boundary. The
marker is appended to the shortened field so the truncation is
unmistakable even in clients that flatten structured output to text.
- A per-call `max_chars` overrides the configured default in either
direction. Zero or absent means no cap, which is also the default: an
existing config returns results byte-for-byte as before.
- Search results are measured, never truncated. `max_results` is the
size knob there, and `est_tokens` shows what a wide value costs.
- `est_tokens` is `chars / 4`, rounded up: a planning figure, not a
bill. Real tokenizers run higher on dense HTML and lower on prose.

## Describe the job: `frugal__execute`

Instead of picking a tool, an agent can state the intent and a priority.
Expand Down
17 changes: 13 additions & 4 deletions cmd/frugal/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,19 @@ func runMCPServe(args []string) int {
// rail, so there's no behavior difference for a budget-free config.
guard := buildGuard(cfg)

// Result-size default: the operator's character budget for page
// content (extract / browse / execute). Zero keeps results whole,
// exactly as before; a per-call max_chars always overrides it.
maxChars := 0
if cfg.Limits != nil && cfg.Limits.MaxChars > 0 {
maxChars = cfg.Limits.MaxChars
slog.Info("mcp serve: default result cap", "max_chars", maxChars)
}

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), tools.WithMaxChars(maxChars))
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,21 +175,21 @@ 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), tools.WithMaxChars(maxChars))
if len(extractors) > 0 {
slog.Info("mcp serve: frugal__extract registered", "providers", extractorNames(extractors))
}

browsers := buildBrowsers(cfg)
warnPolicyStrangers("browse", policies["browse"], browserNames(browsers))
tools.RegisterBrowse(srv.Inner, browsers, metrics,
tools.WithPolicy(policies["browse"]), tools.WithLatencyLookup(latFor("browse")), tools.WithGuard(guard))
tools.WithPolicy(policies["browse"]), tools.WithLatencyLookup(latFor("browse")), tools.WithGuard(guard), tools.WithMaxChars(maxChars))
if len(browsers) > 0 {
slog.Info("mcp serve: frugal__browse registered", "providers", browserNames(browsers))
}

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), tools.WithMaxChars(maxChars))
if len(searchers) > 0 {
slog.Info("mcp serve: frugal__execute registered",
"search", len(searchers), "extract", len(extractors), "browse", len(browsers))
Expand Down
12 changes: 12 additions & 0 deletions config/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@
# routing:
# cooldown: 90s
#
# Result size cap (optional). Set limits.max_chars to cap the page
# content (markdown + text + html, shared) that frugal__extract,
# frugal__browse, and frugal__execute return per call. Over the cap the
# tail is cut on a word boundary, a visible marker is appended, and the
# response reports truncated: true with chars_returned / chars_total so
# the agent can re-call with a larger per-call max_chars. Zero or absent
# means results are returned whole. 40000 chars is roughly 10k tokens,
# the point where Claude Code starts warning about MCP output size:
#
# limits:
# max_chars: 40000
#
# 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
34 changes: 33 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ type Config struct {
// Routing is the optional per-capability routing policy. Absent means
// every capability routes cheapest-first — the historical default.
Routing *RoutingConfig `yaml:"routing,omitempty"`
// Limits is the optional result-size section. Absent means results
// are returned whole, exactly as before.
Limits *LimitsConfig `yaml:"limits,omitempty"`
}

// LimitsConfig caps what a routed read returns to the agent.
//
// - max_chars: default character budget for the content of
// frugal__extract, frugal__browse, and frugal__execute results
// (markdown + text + html, shared, in that priority). When a result
// exceeds it the tail is cut on a word boundary, a visible marker is
// appended, and the response reports truncated: true with
// chars_returned / chars_total so the agent can re-call with a
// larger per-call max_chars. Zero or absent means no default cap.
// A per-call max_chars argument always overrides this value.
type LimitsConfig struct {
MaxChars int `yaml:"max_chars,omitempty"`
}

// RoutingConfig declares routing policy per capability. Each entry is
Expand Down Expand Up @@ -267,7 +284,22 @@ func validate(cfg *Config) error {
if err := validateProviders("browse_providers", cfg.BrowseProviders); err != nil {
return err
}
return validateRouting(cfg)
if err := validateRouting(cfg); err != nil {
return err
}
return validateLimits(cfg)
}

// validateLimits rejects a negative max_chars: zero is the documented
// "no cap" spelling, and a negative number is a typo, not an intent.
func validateLimits(cfg *Config) error {
if cfg.Limits == nil {
return nil
}
if cfg.Limits.MaxChars < 0 {
return fmt.Errorf("limits.max_chars must be zero (no cap) or positive, got %d", cfg.Limits.MaxChars)
}
return nil
}

// validRouteStrategies is the config-file spelling of the routing
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -644,3 +644,38 @@ func TestParse_CooldownRoundTrips(t *testing.T) {
t.Fatalf("routing.cooldown = %+v, want \"90s\"", cfg.Routing)
}
}

func TestParse_LimitsRoundTrips(t *testing.T) {
cfg, err := Parse([]byte("limits:\n max_chars: 40000\n"))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if cfg.Limits == nil || cfg.Limits.MaxChars != 40000 {
t.Fatalf("limits = %+v, want max_chars 40000", cfg.Limits)
}
}

func TestParse_LimitsRejectsNegativeMaxChars(t *testing.T) {
_, err := Parse([]byte("limits:\n max_chars: -1\n"))
if err == nil || !strings.Contains(err.Error(), "limits.max_chars") {
t.Fatalf("expected a limits.max_chars error, got %v", err)
}
}

func TestParse_ConfigWithoutLimitsIsUncapped(t *testing.T) {
cfg, err := Parse([]byte("search_providers:\n wikipedia:\n cost_per_call: 0\n"))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if cfg.Limits != nil {
t.Fatalf("limits should be nil when absent, got %+v", cfg.Limits)
}
// Zero is the documented "no cap" spelling and must load.
cfg, err = Parse([]byte("limits:\n max_chars: 0\n"))
if err != nil {
t.Fatalf("Parse zero: %v", err)
}
if cfg.Limits == nil || cfg.Limits.MaxChars != 0 {
t.Fatalf("limits = %+v", cfg.Limits)
}
}
186 changes: 186 additions & 0 deletions internal/limit/limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Package limit caps the size of tool results before they reach the
// agent's context window, and prices what does reach it in estimated
// tokens.
//
// Frugal prices every call in dollars. The other cost of a tool call is
// the tokens its result burns in the model on every later turn of the
// session, and that one has no receipt anywhere in the stack. Clients
// enforce their own ceilings blind (Claude Code refuses MCP results over
// 25,000 tokens by default and spills anything past 50,000 characters to
// a file with a 2 KB head preview), so a long page either fails the
// call or arrives cut mid-sentence with nothing telling the model that
// the tail is missing.
//
// This package moves that decision server-side and makes it explicit: a
// caller-chosen or operator-configured character budget is applied
// across the content fields of a result, the cut lands on a whitespace
// boundary when one is close, a visible marker is appended to the field
// that was cut, and a Report says exactly how much was kept. The MCP
// tools surface the Report as chars_returned, chars_total, truncated,
// and est_tokens so the agent can decide to re-call with a bigger
// budget instead of guessing at what it did not see.
package limit

import (
"fmt"
"unicode"
"unicode/utf8"
)

// CharsPerToken is the rule-of-thumb ratio used for EstTokens. Real
// tokenizers vary by model and by content (prose runs near 4 characters
// per token in English, dense HTML and code run lower), so this is a
// planning figure, not a bill. It matches the heuristic the major
// clients use for their own output warnings.
const CharsPerToken = 4

// backoffWindow is how far back from the hard cut Cap will look for a
// whitespace boundary before giving up and cutting mid-word. Large
// enough to reach the previous line break in ordinary prose, small
// enough that the caller still gets nearly the whole budget.
const backoffWindow = 256

// Report describes what Cap did to one result.
type Report struct {
// Truncated is true when at least one field was shortened.
Truncated bool
// CharsTotal is the content size before capping, in characters
// (Unicode code points, not bytes).
CharsTotal int
// CharsReturned is the content size after capping, in characters,
// not counting the truncation marker.
CharsReturned int
}

// EstTokens converts a character count into an estimated token count,
// rounding up so a one-character result still costs one token.
func EstTokens(chars int) int {
if chars <= 0 {
return 0
}
return (chars + CharsPerToken - 1) / CharsPerToken
}

// Count sums the character lengths of the given strings.
func Count(fields ...string) int {
total := 0
for _, f := range fields {
total += utf8.RuneCountInString(f)
}
return total
}

// Cap applies a total character budget across fields, in the order
// given. Fields are consumed until the budget runs out; the field that
// crosses the line is shortened to fit and every later field is
// emptied. A maxChars of zero or less means no limit: the fields are
// left untouched and the Report simply measures them.
//
// The order matters and callers should pass the most useful field
// first. frugal__extract passes markdown, then text, then html, so the
// human-readable rendering survives and the bulky raw HTML is the first
// thing to go.
//
// The cut is rune-safe (never splits a multi-byte character) and backs
// off to the nearest preceding whitespace when one sits within
// backoffWindow characters, so the agent sees a clean word boundary
// rather than half a token. A marker line naming the kept and total
// sizes is appended to the shortened field; the marker is not counted
// in CharsReturned.
func Cap(maxChars int, fields ...*string) Report {
var rep Report
for _, f := range fields {
if f != nil {
rep.CharsTotal += utf8.RuneCountInString(*f)
}
}
if maxChars <= 0 {
rep.CharsReturned = rep.CharsTotal
return rep
}

remaining := maxChars
for _, f := range fields {
if f == nil {
continue
}
n := utf8.RuneCountInString(*f)
switch {
case n == 0:
continue
case remaining <= 0:
// Budget already spent by an earlier field: drop this one
// entirely. The marker on the field that crossed the line
// carries the total, so the agent knows more existed.
*f = ""
rep.Truncated = true
case n <= remaining:
remaining -= n
rep.CharsReturned += n
default:
kept := cutAt(*f, remaining)
keptN := utf8.RuneCountInString(kept)
rep.CharsReturned += keptN
remaining = 0
rep.Truncated = true
*f = kept + marker(keptN, rep.CharsTotal)
}
}
return rep
}

// cutAt returns the first n runes of s, backed off to the nearest
// preceding whitespace when one falls within backoffWindow runes of the
// cut. Trailing whitespace is trimmed so the marker sits flush.
func cutAt(s string, n int) string {
if n <= 0 {
return ""
}
// Find the byte offset of the n-th rune.
byteEnd := len(s)
count := 0
for i := range s {
if count == n {
byteEnd = i
break
}
count++
}
if byteEnd >= len(s) {
return s
}
hard := s[:byteEnd]

// Back off to a whitespace boundary if one is close enough.
window := 0
for i := len(hard); i > 0 && window < backoffWindow; {
r, size := utf8.DecodeLastRuneInString(hard[:i])
if unicode.IsSpace(r) {
return trimRightSpace(hard[:i])
}
i -= size
window++
}
return trimRightSpace(hard)
}

func trimRightSpace(s string) string {
end := len(s)
for end > 0 {
r, size := utf8.DecodeLastRuneInString(s[:end])
if !unicode.IsSpace(r) {
break
}
end -= size
}
return s[:end]
}

// marker is the visible note appended to a shortened field. It is
// phrased for the model that reads it: what happened, how much is
// missing, and the one argument that changes it. Structured output
// carries the same numbers as fields, but clients that flatten results
// to text would otherwise lose the signal entirely.
func marker(kept, total int) string {
return fmt.Sprintf("\n\n[frugal: output truncated to %d of %d chars; pass a larger max_chars to see more]", kept, total)
}
Loading
Loading