From bd0e4c354dc0f3d1e90099074e892a1e83e5fa93 Mon Sep 17 00:00:00 2001 From: Brian Sparker Date: Thu, 3 Sep 2026 06:18:11 -0700 Subject: [PATCH] feat: max_chars result caps with a token receipt on every routed read Add internal/limit (rune-safe budgeted truncation, est_tokens), a limits.max_chars config default, a max_chars argument on frugal__extract, frugal__browse, and frugal__execute, and chars_returned / chars_total / truncated / est_tokens on every tool output. Zero or absent keeps results whole, exactly as before. Signed-off-by: Brian Sparker --- README.md | 50 +++++ cmd/frugal/mcp.go | 17 +- config/models.yaml | 12 ++ internal/config/config.go | 34 +++- internal/config/config_test.go | 35 ++++ internal/limit/limit.go | 186 ++++++++++++++++++ internal/limit/limit_test.go | 139 ++++++++++++++ internal/mcp/tools/browse.go | 24 ++- internal/mcp/tools/execute.go | 33 ++++ internal/mcp/tools/extract.go | 32 +++- internal/mcp/tools/limits_test.go | 308 ++++++++++++++++++++++++++++++ internal/mcp/tools/options.go | 28 +++ internal/mcp/tools/search.go | 17 ++ 13 files changed, 904 insertions(+), 11 deletions(-) create mode 100644 internal/limit/limit.go create mode 100644 internal/limit/limit_test.go create mode 100644 internal/mcp/tools/limits_test.go diff --git a/README.md b/README.md index d6afaae..12838d7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/frugal/mcp.go b/cmd/frugal/mcp.go index 269fe92..d45c191 100644 --- a/cmd/frugal/mcp.go +++ b/cmd/frugal/mcp.go @@ -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.") @@ -166,7 +175,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), tools.WithMaxChars(maxChars)) if len(extractors) > 0 { slog.Info("mcp serve: frugal__extract registered", "providers", extractorNames(extractors)) } @@ -174,13 +183,13 @@ func runMCPServe(args []string) int { 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)) diff --git a/config/models.yaml b/config/models.yaml index 8873b7b..ce41db9 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -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: diff --git a/internal/config/config.go b/internal/config/config.go index 2b0f28d..82ba982 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e28e5be..a9c2cab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/limit/limit.go b/internal/limit/limit.go new file mode 100644 index 0000000..6edc3bc --- /dev/null +++ b/internal/limit/limit.go @@ -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) +} diff --git a/internal/limit/limit_test.go b/internal/limit/limit_test.go new file mode 100644 index 0000000..b3df373 --- /dev/null +++ b/internal/limit/limit_test.go @@ -0,0 +1,139 @@ +package limit + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestEstTokens_RoundsUp(t *testing.T) { + cases := map[int]int{0: 0, -5: 0, 1: 1, 4: 1, 5: 2, 400: 100, 401: 101} + for chars, want := range cases { + if got := EstTokens(chars); got != want { + t.Errorf("EstTokens(%d) = %d, want %d", chars, got, want) + } + } +} + +func TestCount_UsesRunesNotBytes(t *testing.T) { + if got := Count("héllo", "日本"); got != 7 { + t.Errorf("Count = %d, want 7 runes", got) + } +} + +func TestCap_ZeroMeansUnlimited(t *testing.T) { + md := strings.Repeat("a", 10_000) + html := strings.Repeat("b", 20_000) + rep := Cap(0, &md, &html) + if rep.Truncated { + t.Fatal("zero budget must not truncate") + } + if rep.CharsTotal != 30_000 || rep.CharsReturned != 30_000 { + t.Errorf("report = %+v, want 30000/30000", rep) + } + if len(md) != 10_000 || len(html) != 20_000 { + t.Error("fields must be untouched when unlimited") + } +} + +func TestCap_UnderBudgetIsANoOp(t *testing.T) { + md := "short body" + rep := Cap(1000, &md) + if rep.Truncated || md != "short body" { + t.Errorf("under-budget field changed: %q %+v", md, rep) + } + if rep.CharsTotal != 10 || rep.CharsReturned != 10 { + t.Errorf("report = %+v", rep) + } +} + +func TestCap_BudgetIsSharedAcrossFieldsInOrder(t *testing.T) { + md := strings.Repeat("m", 600) + text := strings.Repeat("t", 600) + html := strings.Repeat("h", 600) + rep := Cap(1000, &md, &text, &html) + if !rep.Truncated { + t.Fatal("expected truncation") + } + if rep.CharsTotal != 1800 { + t.Errorf("CharsTotal = %d, want 1800", rep.CharsTotal) + } + // markdown fits whole (600), text is cut to the remaining 400, + // html is dropped. + if utf8.RuneCountInString(md) != 600 { + t.Errorf("markdown should be intact, got %d runes", utf8.RuneCountInString(md)) + } + if !strings.HasPrefix(text, strings.Repeat("t", 400)) || strings.HasPrefix(text, strings.Repeat("t", 401)) { + t.Errorf("text should keep exactly 400 chars before the marker; got %d runes", utf8.RuneCountInString(text)) + } + if !strings.Contains(text, "[frugal: output truncated to 400 of 1800 chars") { + t.Errorf("marker missing or wrong: %q", text[400:]) + } + if html != "" { + t.Errorf("html should be dropped once the budget is spent, got %d runes", utf8.RuneCountInString(html)) + } + if rep.CharsReturned != 1000 { + t.Errorf("CharsReturned = %d, want 1000 (marker excluded)", rep.CharsReturned) + } +} + +func TestCap_BacksOffToWhitespace(t *testing.T) { + body := "alpha beta gamma delta epsilon zeta eta theta" + // A budget of 28 lands inside "epsilon" ("alpha beta gamma delta epsi"). + rep := Cap(28, &body) + if !rep.Truncated { + t.Fatal("expected truncation") + } + kept := strings.SplitN(body, "\n\n[frugal:", 2)[0] + if kept != "alpha beta gamma delta" { + t.Errorf("expected a clean word boundary, got %q", kept) + } + if rep.CharsReturned != len("alpha beta gamma delta") { + t.Errorf("CharsReturned = %d", rep.CharsReturned) + } +} + +func TestCap_HardCutsWhenNoWhitespaceNearby(t *testing.T) { + body := strings.Repeat("x", 5000) + rep := Cap(1000, &body) + kept := strings.SplitN(body, "\n\n[frugal:", 2)[0] + if len(kept) != 1000 { + t.Errorf("expected a hard cut at 1000, got %d", len(kept)) + } + if rep.CharsReturned != 1000 || rep.CharsTotal != 5000 { + t.Errorf("report = %+v", rep) + } +} + +func TestCap_IsRuneSafe(t *testing.T) { + // 3-byte runes with no whitespace: a byte-oriented cut would split one. + body := strings.Repeat("日", 100) + Cap(33, &body) + kept := strings.SplitN(body, "\n\n[frugal:", 2)[0] + if !utf8.ValidString(kept) { + t.Fatal("cut produced invalid UTF-8") + } + if utf8.RuneCountInString(kept) != 33 { + t.Errorf("kept %d runes, want 33", utf8.RuneCountInString(kept)) + } +} + +func TestCap_SkipsNilAndEmptyFields(t *testing.T) { + empty := "" + body := strings.Repeat("y", 50) + rep := Cap(20, nil, &empty, &body) + if empty != "" { + t.Error("empty field must stay empty") + } + if !rep.Truncated || rep.CharsTotal != 50 || rep.CharsReturned != 20 { + t.Errorf("report = %+v", rep) + } +} + +func TestCap_MarkerNamesKeptAndTotal(t *testing.T) { + body := strings.Repeat("z", 300) + Cap(100, &body) + if !strings.HasSuffix(body, "[frugal: output truncated to 100 of 300 chars; pass a larger max_chars to see more]") { + t.Errorf("unexpected marker: %q", body[100:]) + } +} diff --git a/internal/mcp/tools/browse.go b/internal/mcp/tools/browse.go index f7f78e6..764e028 100644 --- a/internal/mcp/tools/browse.go +++ b/internal/mcp/tools/browse.go @@ -9,6 +9,7 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/frugalsh/frugal/internal/browse" + "github.com/frugalsh/frugal/internal/limit" "github.com/frugalsh/frugal/internal/obs" "github.com/frugalsh/frugal/internal/routing" ) @@ -24,18 +25,26 @@ type BrowseInput struct { // Provider pins the browse provider for this call. Empty / "auto" // → the routing policy decides. Provider string `json:"provider,omitempty" jsonschema:"optional provider override: browserless | auto"` + // MaxChars caps the rendered content returned (text + html, shared). + // Zero falls back to the server's configured default. + MaxChars int `json:"max_chars,omitempty" jsonschema:"optional cap on returned content characters (text and html combined); the response reports truncated, chars_returned, and chars_total; 0 = server default"` } // BrowseOutput is the structured-content payload returned to the MCP // client. HTML is the primary read; Text is populated when Format == // "text". CostUSD + ProviderUsed + LatencyMS make the routing decision -// auditable. +// auditable; the size footer makes the context cost auditable. type BrowseOutput struct { HTML string `json:"html,omitempty"` Text string `json:"text,omitempty"` CostUSD float64 `json:"cost_usd"` ProviderUsed string `json:"provider_used"` LatencyMS int64 `json:"latency_ms"` + // See ExtractOutput for the meaning of the size footer. + CharsReturned int `json:"chars_returned"` + CharsTotal int `json:"chars_total"` + Truncated bool `json:"truncated,omitempty"` + EstTokens int `json:"est_tokens"` } // RegisterBrowse wires frugal__browse onto the given MCP server. @@ -83,6 +92,9 @@ func makeBrowseHandler(browsers []browse.Browser, metrics *obs.Metrics, o toolOp if in.URL == "" { return nil, BrowseOutput{}, fmt.Errorf("frugal__browse: url is required") } + if in.MaxChars < 0 { + return nil, BrowseOutput{}, fmt.Errorf("frugal__browse: max_chars must be zero (server default) or positive") + } q := browse.Query{URL: in.URL, WaitForMS: in.WaitMs, ReturnFormat: in.Format} logger := slog.Default() @@ -118,13 +130,19 @@ func makeBrowseHandler(browsers []browse.Browser, metrics *obs.Metrics, o toolOp return nil, BrowseOutput{}, fmt.Errorf("frugal__browse: %w", err) } - return nil, BrowseOutput{ + out := BrowseOutput{ HTML: res.HTML, Text: res.Text, CostUSD: res.CostUSD, ProviderUsed: used.Name(), LatencyMS: latency, - }, nil + } + // Text first when both are present: the stripped rendering is + // what a budget-conscious caller asked for, the DOM is the bulk. + rep := limit.Cap(o.effectiveMaxChars(in.MaxChars), &out.Text, &out.HTML) + out.CharsReturned, out.CharsTotal, out.Truncated = rep.CharsReturned, rep.CharsTotal, rep.Truncated + out.EstTokens = limit.EstTokens(rep.CharsReturned) + return nil, out, nil } } diff --git a/internal/mcp/tools/execute.go b/internal/mcp/tools/execute.go index 13679db..2adeac8 100644 --- a/internal/mcp/tools/execute.go +++ b/internal/mcp/tools/execute.go @@ -12,6 +12,7 @@ import ( "github.com/frugalsh/frugal/internal/browse" "github.com/frugalsh/frugal/internal/extract" + "github.com/frugalsh/frugal/internal/limit" "github.com/frugalsh/frugal/internal/obs" "github.com/frugalsh/frugal/internal/routing" "github.com/frugalsh/frugal/internal/search" @@ -24,6 +25,10 @@ type ExecuteInput struct { Intent string `json:"intent" jsonschema:"what you want done, in plain language; include the URL if you have one"` Priority string `json:"priority,omitempty" jsonschema:"routing preference: cheap | balanced | premium (default balanced — the server's configured policy)"` Provider string `json:"provider,omitempty" jsonschema:"optional provider pin within the chosen capability"` + // MaxChars caps page content when the intent resolves to an extract + // or a render. Search results are never truncated. Zero falls back + // to the server's configured default. + MaxChars int `json:"max_chars,omitempty" jsonschema:"optional cap on returned page content characters for extract and browse intents (markdown, text, and html combined); the response reports truncated, chars_returned, and chars_total; 0 = server default"` } // ExecuteOutput carries whichever capability's payload the intent @@ -42,6 +47,12 @@ type ExecuteOutput struct { LatencyMS int64 `json:"latency_ms"` Reason string `json:"reason"` Warnings []string `json:"warnings,omitempty"` + // Size footer, see ExtractOutput. For search intents CharsTotal and + // CharsReturned measure the result list and Truncated is never set. + CharsReturned int `json:"chars_returned"` + CharsTotal int `json:"chars_total"` + Truncated bool `json:"truncated,omitempty"` + EstTokens int `json:"est_tokens"` } // RegisterExecute wires frugal__execute onto the given MCP server. The @@ -114,6 +125,9 @@ func makeExecuteHandler(searchers []search.Searcher, extractors []extract.Extrac default: return nil, ExecuteOutput{}, fmt.Errorf("frugal__execute: priority must be one of: cheap, balanced, premium") } + if in.MaxChars < 0 { + return nil, ExecuteOutput{}, fmt.Errorf("frugal__execute: max_chars must be zero (server default) or positive") + } it := ClassifyIntent(in.Intent) // Priority overrides the strategy only; the operator's order and @@ -153,10 +167,29 @@ func makeExecuteHandler(searchers []search.Searcher, extractors []extract.Extrac return nil, ExecuteOutput{}, fmt.Errorf("frugal__execute: %w", err) } out.LatencyMS = time.Since(start).Milliseconds() + applySizeFooter(&out, o.effectiveMaxChars(in.MaxChars)) return nil, out, nil } } +// applySizeFooter caps page content and fills the size footer on an +// execute result. Search results are measured but never cut: the +// caller's size knob there is max_results, and dropping hits silently +// would misreport what the provider returned. The fall-forward render +// path lands here too, so a JS-rendered page gets the same budget as a +// plain extract. +func applySizeFooter(out *ExecuteOutput, maxChars int) { + if out.Capability == "search" { + n := itemChars(out.Results) + out.CharsReturned, out.CharsTotal = n, n + out.EstTokens = limit.EstTokens(n) + return + } + rep := limit.Cap(maxChars, &out.Markdown, &out.Text, &out.HTML) + out.CharsReturned, out.CharsTotal, out.Truncated = rep.CharsReturned, rep.CharsTotal, rep.Truncated + out.EstTokens = limit.EstTokens(rep.CharsReturned) +} + // hookFor adapts the metrics sink into an AttemptHook while counting // attempts, so the reason line can report which rung of the chain won. // capability is the capability the attempt runs under: execute routes diff --git a/internal/mcp/tools/extract.go b/internal/mcp/tools/extract.go index 8da111e..70a9882 100644 --- a/internal/mcp/tools/extract.go +++ b/internal/mcp/tools/extract.go @@ -9,6 +9,7 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/frugalsh/frugal/internal/extract" + "github.com/frugalsh/frugal/internal/limit" "github.com/frugalsh/frugal/internal/obs" "github.com/frugalsh/frugal/internal/routing" ) @@ -25,12 +26,18 @@ type ExtractInput struct { // Provider pins the extract provider for this call ("goreadability", // "firecrawl", …). Empty / "auto" → the routing policy decides. Provider string `json:"provider,omitempty" jsonschema:"optional provider override: goreadability | firecrawl | auto"` + // MaxChars caps the content returned (markdown + text + html, + // shared). Zero falls back to the server's configured default, which + // is unlimited unless the operator set limits.max_chars. + MaxChars int `json:"max_chars,omitempty" jsonschema:"optional cap on returned content characters (markdown, text, and html combined); the response reports truncated, chars_returned, and chars_total so you can re-call with a larger value; 0 = server default"` } // ExtractOutput is the structured-content payload returned to the MCP // client. Markdown is the primary read; HTML / Text / Title / Byline / // Links are populated when the driver supplies them. CostUSD + -// ProviderUsed + LatencyMS make the routing decision auditable. +// ProviderUsed + LatencyMS make the routing decision auditable, and +// the size footer (CharsReturned / CharsTotal / Truncated / EstTokens) +// makes the context cost auditable in the same breath. type ExtractOutput struct { Markdown string `json:"markdown,omitempty"` HTML string `json:"html,omitempty"` @@ -41,6 +48,15 @@ type ExtractOutput struct { CostUSD float64 `json:"cost_usd"` ProviderUsed string `json:"provider_used"` LatencyMS int64 `json:"latency_ms"` + // CharsReturned / CharsTotal / Truncated report what the max_chars + // budget did: how much content is in this response, how much the + // provider produced, and whether the two differ. EstTokens is the + // approximate context cost of the returned content + // (limit.CharsPerToken characters per token). + CharsReturned int `json:"chars_returned"` + CharsTotal int `json:"chars_total"` + Truncated bool `json:"truncated,omitempty"` + EstTokens int `json:"est_tokens"` } // RegisterExtract wires frugal__extract onto the given MCP server. @@ -90,6 +106,9 @@ func makeExtractHandler(extractors []extract.Extractor, metrics *obs.Metrics, o if in.URL == "" { return nil, ExtractOutput{}, fmt.Errorf("frugal__extract: url is required") } + if in.MaxChars < 0 { + return nil, ExtractOutput{}, fmt.Errorf("frugal__extract: max_chars must be zero (server default) or positive") + } q := extract.Query{URL: in.URL, Formats: in.Formats} logger := slog.Default() @@ -125,7 +144,7 @@ func makeExtractHandler(extractors []extract.Extractor, metrics *obs.Metrics, o return nil, ExtractOutput{}, fmt.Errorf("frugal__extract: %w", err) } - return nil, ExtractOutput{ + out := ExtractOutput{ Markdown: res.Markdown, HTML: res.HTML, Text: res.Text, @@ -135,7 +154,14 @@ func makeExtractHandler(extractors []extract.Extractor, metrics *obs.Metrics, o CostUSD: res.CostUSD, ProviderUsed: used.Name(), LatencyMS: latency, - }, nil + } + // Markdown first: it is the rendering agents actually read. Raw + // HTML is the bulkiest and least useful, so it is the first to + // be dropped when the budget is tight. + rep := limit.Cap(o.effectiveMaxChars(in.MaxChars), &out.Markdown, &out.Text, &out.HTML) + out.CharsReturned, out.CharsTotal, out.Truncated = rep.CharsReturned, rep.CharsTotal, rep.Truncated + out.EstTokens = limit.EstTokens(rep.CharsReturned) + return nil, out, nil } } diff --git a/internal/mcp/tools/limits_test.go b/internal/mcp/tools/limits_test.go new file mode 100644 index 0000000..77d309a --- /dev/null +++ b/internal/mcp/tools/limits_test.go @@ -0,0 +1,308 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/frugalsh/frugal/internal/browse" + "github.com/frugalsh/frugal/internal/extract" + "github.com/frugalsh/frugal/internal/search" +) + +// Integration tests for the max_chars result cap and the size footer, +// driving real in-memory MCP client sessions like the sibling tool tests. + +func decodeBrowseOutputT(t *testing.T, raw any) BrowseOutput { + t.Helper() + b, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out BrowseOutput + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal BrowseOutput: %v", err) + } + return out +} + +func callExtractWith(t *testing.T, srv *sdkmcp.Server, args map[string]any) *sdkmcp.CallToolResult { + t.Helper() + client, cleanup := dialExtractClient(t, srv) + defer cleanup() + res, err := client.CallTool(context.Background(), &sdkmcp.CallToolParams{Name: "frugal__extract", Arguments: args}) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + return res +} + +func TestExtract_NoCapReturnsWholeContentWithFooter(t *testing.T) { + body := strings.Repeat("word ", 2000) // 10,000 chars + srv := newExtractServer() + RegisterExtract(srv, []extract.Extractor{ + &fakeExtractor{name: "free", res: extract.Result{Markdown: body, HTML: "

x

"}}, + }, nil) + res := callExtractWith(t, srv, map[string]any{"url": "https://example.com"}) + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out, err := decodeExtractOutput(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + if out.Truncated { + t.Fatal("no cap configured: must not truncate") + } + if out.Markdown != body || out.HTML != "

x

" { + t.Error("content must be byte-for-byte unchanged without a cap") + } + if out.CharsTotal != 10_008 || out.CharsReturned != 10_008 { + t.Errorf("footer = %d/%d, want 10008/10008", out.CharsReturned, out.CharsTotal) + } + if out.EstTokens != 2502 { + t.Errorf("est_tokens = %d, want 2502", out.EstTokens) + } +} + +func TestExtract_PerCallMaxCharsTruncatesAndReports(t *testing.T) { + body := strings.Repeat("lorem ipsum ", 1000) // 12,000 chars + srv := newExtractServer() + RegisterExtract(srv, []extract.Extractor{ + &fakeExtractor{name: "free", res: extract.Result{Markdown: body, Title: "T"}}, + }, nil) + res := callExtractWith(t, srv, map[string]any{"url": "https://example.com", "max_chars": 1000}) + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out, err := decodeExtractOutput(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + if !out.Truncated { + t.Fatal("expected truncated: true") + } + if out.CharsTotal != 12_000 { + t.Errorf("chars_total = %d, want 12000", out.CharsTotal) + } + if out.CharsReturned > 1000 || out.CharsReturned < 900 { + t.Errorf("chars_returned = %d, want <= 1000 and near it", out.CharsReturned) + } + if !strings.Contains(out.Markdown, "[frugal: output truncated to") { + t.Error("marker missing from truncated markdown") + } + if strings.HasSuffix(strings.SplitN(out.Markdown, "\n\n[frugal:", 2)[0], " ") { + t.Error("kept content should not end in whitespace") + } + if out.Title != "T" { + t.Error("metadata fields must survive the cap") + } + if out.EstTokens != (out.CharsReturned+3)/4 { + t.Errorf("est_tokens = %d for %d chars", out.EstTokens, out.CharsReturned) + } +} + +func TestExtract_ConfiguredDefaultAppliesAndPerCallOverridesIt(t *testing.T) { + body := strings.Repeat("a", 5000) + srv := newExtractServer() + RegisterExtract(srv, []extract.Extractor{ + &fakeExtractor{name: "free", res: extract.Result{Markdown: body}}, + }, nil, WithMaxChars(500)) + + // Default from config. + out, err := decodeExtractOutput(callExtractWith(t, srv, map[string]any{"url": "https://example.com"}).StructuredContent) + if err != nil { + t.Fatal(err) + } + if !out.Truncated || out.CharsReturned != 500 { + t.Errorf("configured default not applied: %+v", out.CharsReturned) + } + + // Per-call raises it above the default. + out, err = decodeExtractOutput(callExtractWith(t, srv, map[string]any{"url": "https://example.com", "max_chars": 4000}).StructuredContent) + if err != nil { + t.Fatal(err) + } + if !out.Truncated || out.CharsReturned != 4000 { + t.Errorf("per-call raise not honored: got %d", out.CharsReturned) + } + + // Per-call larger than the content: whole thing, not truncated. + out, err = decodeExtractOutput(callExtractWith(t, srv, map[string]any{"url": "https://example.com", "max_chars": 10_000}).StructuredContent) + if err != nil { + t.Fatal(err) + } + if out.Truncated || out.CharsReturned != 5000 { + t.Errorf("generous per-call cap should return everything: %+v", out.CharsReturned) + } +} + +func TestExtract_BudgetPrefersMarkdownOverHTML(t *testing.T) { + srv := newExtractServer() + RegisterExtract(srv, []extract.Extractor{ + &fakeExtractor{name: "free", res: extract.Result{ + Markdown: strings.Repeat("m", 300), + Text: strings.Repeat("t", 300), + HTML: strings.Repeat("h", 300), + }}, + }, nil) + out, err := decodeExtractOutput(callExtractWith(t, srv, map[string]any{"url": "https://example.com", "max_chars": 450}).StructuredContent) + if err != nil { + t.Fatal(err) + } + if out.Markdown != strings.Repeat("m", 300) { + t.Error("markdown must be kept whole when it fits") + } + if !strings.HasPrefix(out.Text, strings.Repeat("t", 150)) || !strings.Contains(out.Text, "[frugal:") { + t.Errorf("text should carry the remaining 150 chars plus the marker, got %d chars", len(out.Text)) + } + if out.HTML != "" { + t.Error("html should be dropped once the budget is spent") + } + if out.CharsReturned != 450 || out.CharsTotal != 900 || !out.Truncated { + t.Errorf("footer = %+v", out) + } +} + +func TestExtract_NegativeMaxCharsErrors(t *testing.T) { + srv := newExtractServer() + RegisterExtract(srv, []extract.Extractor{&fakeExtractor{name: "free", res: extract.Result{Markdown: "x"}}}, nil) + res := callExtractWith(t, srv, map[string]any{"url": "https://example.com", "max_chars": -1}) + if !res.IsError || !strings.Contains(errorText(res), "max_chars") { + t.Errorf("expected a max_chars validation error, got %+v", res.Content) + } +} + +func TestBrowse_MaxCharsCapsTextBeforeHTML(t *testing.T) { + srv := newBrowseServer() + RegisterBrowse(srv, []browse.Browser{ + &fakeBrowser{name: "render", cost: 0.002, res: browse.Result{ + Text: strings.Repeat("t", 200), HTML: strings.Repeat("", 200), CostUSD: 0.002, + }}, + }, nil) + client, cleanup := dialBrowseClient(t, srv) + defer cleanup() + res, err := client.CallTool(context.Background(), &sdkmcp.CallToolParams{ + Name: "frugal__browse", + Arguments: map[string]any{"url": "https://example.com", "format": "text", "max_chars": 300}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out := decodeBrowseOutputT(t, res.StructuredContent) + if out.Text != strings.Repeat("t", 200) { + t.Error("text must survive whole when it fits the budget") + } + if !strings.HasPrefix(out.HTML, strings.Repeat("", 33)) || !strings.Contains(out.HTML, "[frugal:") { + t.Errorf("html should be cut to the remaining budget with a marker, got %q", out.HTML) + } + if !out.Truncated || out.CharsTotal != 800 || out.CharsReturned != 300 { + t.Errorf("footer = returned %d total %d truncated %v", out.CharsReturned, out.CharsTotal, out.Truncated) + } + if out.CostUSD != 0.002 || out.ProviderUsed != "render" { + t.Error("routing receipt must be unaffected by the cap") + } +} + +func TestExecute_ExtractIntentHonorsMaxChars(t *testing.T) { + srv := newServer() + RegisterExecute(srv, + []search.Searcher{&fakeSearcher{name: "free"}}, + []extract.Extractor{&fakeExtractor{name: "reader", res: extract.Result{Markdown: strings.Repeat("page ", 1000)}}}, + nil, nil) + res := callExecute(t, srv, map[string]any{"intent": "read https://example.com/post", "max_chars": 500}) + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out := decodeExecuteOutput(t, res.StructuredContent) + if out.Capability != "extract" { + t.Fatalf("capability = %q", out.Capability) + } + if !out.Truncated || out.CharsTotal != 5000 || out.CharsReturned > 500 { + t.Errorf("footer = returned %d total %d truncated %v", out.CharsReturned, out.CharsTotal, out.Truncated) + } + if !strings.Contains(out.Markdown, "[frugal: output truncated to") { + t.Error("marker missing") + } +} + +func TestExecute_FallForwardRenderIsCappedToo(t *testing.T) { + srv := newServer() + RegisterExecute(srv, + []search.Searcher{&fakeSearcher{name: "free"}}, + []extract.Extractor{&fakeExtractor{name: "reader", res: extract.Result{}}}, // empty: JS page + []browse.Browser{&fakeBrowser{name: "render", cost: 0.002, res: browse.Result{Text: strings.Repeat("dom ", 1000), CostUSD: 0.002}}}, + nil, WithMaxChars(400)) + res := callExecute(t, srv, map[string]any{"intent": "https://example.com/app"}) + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out := decodeExecuteOutput(t, res.StructuredContent) + if out.Capability != "browse" { + t.Fatalf("expected fall-forward to browse, got %q", out.Capability) + } + if !out.Truncated || out.CharsTotal != 4000 || out.CharsReturned > 400 { + t.Errorf("configured default not applied on fall-forward: returned %d total %d", out.CharsReturned, out.CharsTotal) + } +} + +func TestExecute_SearchIntentMeasuredNeverTruncated(t *testing.T) { + items := []search.Item{ + {Title: strings.Repeat("t", 50), URL: "https://a.example", Snippet: strings.Repeat("s", 400)}, + {Title: strings.Repeat("t", 50), URL: "https://b.example", Snippet: strings.Repeat("s", 400)}, + } + srv := newServer() + RegisterExecute(srv, + []search.Searcher{&fakeSearcher{name: "free", results: items}}, + nil, nil, nil, WithMaxChars(100)) + res := callExecute(t, srv, map[string]any{"intent": "search for something"}) + if res.IsError { + t.Fatalf("isError: %+v", res.Content) + } + out := decodeExecuteOutput(t, res.StructuredContent) + if out.Truncated { + t.Error("search results must never be truncated") + } + if len(out.Results) != 2 || out.Results[1].Snippet != items[1].Snippet { + t.Error("search results must be returned whole") + } + want := 2 * (50 + len("https://a.example") + 400) + if out.CharsTotal != want || out.CharsReturned != want { + t.Errorf("chars = %d/%d, want %d", out.CharsReturned, out.CharsTotal, want) + } + if out.EstTokens != (want+3)/4 { + t.Errorf("est_tokens = %d", out.EstTokens) + } +} + +func TestSearch_ReportsEstTokens(t *testing.T) { + items := []search.Item{{Title: "abcd", URL: "https://x.io", Snippet: strings.Repeat("z", 84)}} // 4+12+84 = 100 chars + srv := newServer() + RegisterSearch(srv, []search.Searcher{&fakeSearcher{name: "free", results: items}}, nil) + client, cleanup := dialClient(t, srv) + defer cleanup() + res, err := client.CallTool(context.Background(), &sdkmcp.CallToolParams{ + Name: "frugal__search", + Arguments: map[string]any{"query": "q"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + b, _ := json.Marshal(res.StructuredContent) + var out SearchOutput + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if out.EstTokens != 25 { + t.Errorf("est_tokens = %d, want 25 for 100 chars", out.EstTokens) + } + if len(out.Results) != 1 || out.Results[0].Snippet != items[0].Snippet { + t.Error("search results must be returned whole") + } +} diff --git a/internal/mcp/tools/options.go b/internal/mcp/tools/options.go index d3f862f..fab27f2 100644 --- a/internal/mcp/tools/options.go +++ b/internal/mcp/tools/options.go @@ -21,6 +21,12 @@ type toolOptions struct { // enforcement call sites need no conditionals and the zero value keeps // the historical behavior exactly. guard *routing.Guard + // maxChars is the operator's default character budget for the + // content fields of extract / browse / execute results (see + // internal/limit). Zero, the default, means unlimited, which keeps + // the historical payloads byte-for-byte. A per-call max_chars + // argument overrides it in either direction. + maxChars int } // ToolOption configures a routed tool at registration time. @@ -56,6 +62,28 @@ func WithGuard(g *routing.Guard) ToolOption { return func(o *toolOptions) { o.guard = g } } +// WithMaxChars sets the default character budget applied to the content +// of extract, browse, and execute results when the caller does not pass +// max_chars. Zero or negative disables the default cap. +func WithMaxChars(n int) ToolOption { + return func(o *toolOptions) { + if n < 0 { + n = 0 + } + o.maxChars = n + } +} + +// effectiveMaxChars resolves the budget for one call: the caller's +// max_chars wins when set, otherwise the operator default, otherwise +// unlimited (0). +func (o toolOptions) effectiveMaxChars(requested int) int { + if requested > 0 { + return requested + } + return o.maxChars +} + func buildToolOptions(opts []ToolOption) toolOptions { var o toolOptions for _, opt := range opts { diff --git a/internal/mcp/tools/search.go b/internal/mcp/tools/search.go index d83bff4..1acebab 100644 --- a/internal/mcp/tools/search.go +++ b/internal/mcp/tools/search.go @@ -19,6 +19,7 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/frugalsh/frugal/internal/limit" "github.com/frugalsh/frugal/internal/obs" "github.com/frugalsh/frugal/internal/routing" "github.com/frugalsh/frugal/internal/search" @@ -52,6 +53,11 @@ type SearchOutput struct { // so the agent can react (re-query pinned to serper/youcom) instead of // mistaking best-effort results for exact ones. Warnings []string `json:"warnings,omitempty" jsonschema:"degraded-service notes, e.g. a provider that ignored the freshness window"` + // EstTokens is the approximate context cost of the result list + // (titles, URLs, snippets), so the agent can see what a wide + // max_results actually buys. Search results are never truncated; + // max_results is the size knob for this tool. + EstTokens int `json:"est_tokens"` } // RegisterSearch wires frugal__search onto the given MCP server. searchers @@ -162,6 +168,7 @@ func makeSearchHandler(searchers []search.Searcher, metrics *obs.Metrics, o tool ProviderUsed: used.Name(), LatencyMS: latency, Warnings: res.Warnings, + EstTokens: limit.EstTokens(itemChars(res.Items)), } return nil, out, nil } @@ -188,6 +195,16 @@ func joinNames(searchers []search.Searcher) string { func boolPtr(b bool) *bool { return &b } +// itemChars counts the characters a result list puts into the agent's +// context: title, URL, and snippet per hit. +func itemChars(items []search.Item) int { + total := 0 + for _, it := range items { + total += limit.Count(it.Title, it.URL, it.Snippet) + } + return total +} + func normalizeFreshness(in string) (string, error) { v := strings.ToLower(strings.TrimSpace(in)) if v == "" {