feat: max_chars result caps with a token receipt on every routed read - #90
Open
brainsparker wants to merge 1 commit into
Open
feat: max_chars result caps with a token receipt on every routed read#90brainsparker wants to merge 1 commit into
brainsparker wants to merge 1 commit into
Conversation
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 <brianjsparker@gmail.com>
Contributor
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
frugal | bd0e4c3 | Commit Preview URL Branch Preview URL |
Sep 03 2026, 01:18 PM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Frugal prices every call in dollars. The other cost of a tool call is the tokens its result occupies in the model's context for the rest of the session, and until now that cost had no receipt and no knob. This PR adds both.
max_charsargument onfrugal__extract,frugal__browse, andfrugal__executecaps the page content returned per call (markdown + text + html, one shared budget, in that priority order so the readable rendering survives and raw HTML is the first thing dropped).limits.max_charsinmodels.yamlsets a server-wide default. A per-callmax_charsoverrides it in either direction. Zero or absent means no cap, which is also the default: an existing config returns results byte-for-byte as before.chars_returned,chars_total,est_tokens, andtruncated: truewhen the two counts differ.frugal__searchreportsest_tokensfor the result list (search hits are measured, never cut;max_resultsis the size knob there).[frugal: output truncated to 1490 of 157476 chars; pass a larger max_chars to see more]. Structured output carries the same numbers as fields, but clients that flatten results to text would otherwise lose the signal entirely.Captured from a live zero-key run against a real page:
Without the cap that page is roughly 39,000 tokens. Claude Code's default MCP ceiling is 25,000, so the call would have failed outright.
Why this, why now
Clients enforce result-size ceilings blind, and the failure modes are documented:
MAX_MCP_OUTPUT_TOKENS(default 25,000 tokens) with a plain error, and separately spills any result over 50,000 characters to a temp file with a 2 KB head preview. There is no env var for the second layer. Anthropic's own SDK tests document both layers: https://github.com/anthropics/claude-agent-sdk-python/blob/e41cbdd4/tests/test_mcp_large_output.pyFrugal already owns the interception point for every search, extract, and browse result and already ships the cost receipt. Adding the size receipt and the size knob is the same product promise applied to the second cost. It is independent of the open exact-match cache PR (#89). One note for whichever lands second: the cap should apply after the cache read, so that a hit with a larger
max_charsstill returns more; I kept the cap at the very end of each handler to make that rebase trivial.Runner-up candidates considered today and passed on:
package.json/go.mod/pyproject.tomlat startup and merge pinned dependency versions into compiled queries, closing the "wrong framework version" gap Context7 markets against. Strong fit, but the sandbox could not reach the npm registry today, so it could not be built or tested. Held for a day when it can be verified.Implementation notes
internal/limitpackage:Cap(maxChars, fields...)applies one budget across fields in order, rune-safe, with a 256-character whitespace back-off and a marker on the shortened field;EstTokensisceil(chars / 4)(a planning figure, documented as such);Countsums rune lengths. No dependencies.internal/config: new optionallimits:section withmax_chars. Validation rejects negative values with a clear message; zero is the documented "no cap" spelling.KnownFieldsstill enforced.internal/mcp/tools: one newToolOption(WithMaxChars) consumed by all four registrations,effectiveMaxCharsresolves per-call over default over unlimited. Extract caps markdown, text, html; browse caps text, html; execute applies the same cap to extract and browse intents (including the extract-to-browse fall-forward path) and measures search intents without cutting. Metadata fields (title, byline, links) and the routing receipt are never touched.cmd/frugal/mcp.go: readscfg.Limits, logs the default when set, passes it through.limits:block inconfig/models.yaml.Test status
internal/limit(rounding, rune safety, shared budget order, whitespace back-off, hard cut fallback, marker text, nil and empty fields, zero means unlimited).internal/mcp/tools/limits_test.godriving real in-memory MCP client sessions: no-cap path is byte-for-byte unchanged, per-call cap truncates and reports, configured default applies and per-call overrides it both ways, markdown preferred over html, negativemax_charserrors, browse caps text before html, execute honors the cap on extract intents and on the fall-forward render, search intents are measured but never truncated,frugal__searchreportsest_tokens.gofmt -lclean,go build ./...,go vet ./...,go test ./...all green locally on Go 1.25.1.tools/listshowsmax_charson extract and execute (not search), and the Wikipedia extract above returned the expected footer and marker.