dsh-tool-quota
DeepSeek Harness plugin: per-tool call and result-byte caps
- Stars
- 0
- Language
- JavaScript
- Created
- Aug 31, 2026
- Updated
- Aug 31, 2026
Introduction
dsh-tool-quota
A DeepSeek Harness function plugin: per-tool call and result-byte caps, enforced loudly.
An agent is given a search tool. Nothing about any single call is wrong, so nothing stops it from making eighty of them, or from pulling back a four-megabyte page and putting the whole thing in the conversation.
This plugin caps both, per tool, per session:
- id: tool-quota
config:
arxiv_search:
maxCalls: 8
maxResultBytes: 32000
"*":
maxCalls: 40
maxResultBytes: 64000
The ninth call to arxiv_search never runs:
ToolQuotaCallsError: tool quota: "arxiv_search" is capped at 8 calls per session
and has used 8; call 9 was denied before it ran. Use what the earlier calls
returned, call a different tool, or stop.
And a result over the byte cap is thrown away rather than handed on:
ToolQuotaBytesError: tool quota: "arxiv_search" returned 41231 bytes, over its
32000-byte cap; the result was discarded rather than truncated. Ask for less — a
narrower query, fewer items, or a smaller range.
Both carry a code — TOOL_QUOTA_CALLS, TOOL_QUOTA_BYTES — so a caller can
branch without parsing prose.
What it is not
Not a spend budget. There is no USD anywhere in this package, no price table, and no bill. A call is one call whether it cost a tenth of a cent or nothing.
Not token accounting. No tokenizer, no context-window arithmetic, no model call. 32000 bytes is 32000 bytes whatever a tokenizer would make of them. Money and tokens are a different package's job.
Not silent truncation. Nothing here shortens a result. A truncating cap returns something that looks like a whole answer with its tail quietly missing, and the reader downstream — usually a model — has no way to tell. Over-quota results are discarded and the call fails.
Not a rate limiter. There is no time in the model at all: no per-minute window, no burst, no sleeping and retrying. A quota is a total for a session, and when it is spent it stays spent until the session ends or the plugin reloads.
Not a permission system. A quota bounds how much a tool is used, not whether it should have been reachable at all.
Install
dsh plugin --profile default add github:jwilson411/dsh-tool-quota
The installer reads dsh.bundle.patch from the package manifest and appends this
package to the profile's ordered bundle list. Its cordis.patch.yml carries one
insert row, id: tool-quota, with empty config — caps belong to a
deployment, not to a package, so installing this plugin bounds nothing until a
profile says what to bound.
Pin the tools package at 0.1.1-rc.2; that is the release candidate this
plugin is developed and tested against.
Configure
The row's whole config block is the rules map. Its keys are tool names, plus
the reserved key *; each value is a rule of at most two fields.
| Key | Meaning |
|---|---|
maxCalls | How many times one session may call the tool. Inclusive of the denied call: at 8, eight calls run and the ninth throws. Omit for no call cap; 0 denies the tool outright. |
maxResultBytes | UTF-8 bytes one result may carry. Over the cap, the result is discarded. Omit for no byte cap. |
Both must be integers of at least 0. A numeric string ("8") is accepted,
because YAML quoting is an easy accident. Anything else — a float, a negative, a
word — is rejected when the plugin applies, not at the first call.
An unknown key inside a rule is rejected, not ignored. A misspelled
maxCall: 8 that quietly meant "no limit at all" is the exact failure this
plugin exists to prevent.
Which rule governs a tool
A named rule wins over * as a whole object. There is no field merge.
arxiv_search:
maxCalls: 100 # …and no byte cap, even though "*" sets one
"*":
maxCalls: 40
maxResultBytes: 64000
- Tool named in the map → its own rule, exactly as written.
- Tool not named → the
*rule. - Not named and no
*rule → no cap at all; the call is not even counted. {}as a named rule → matched, capped by nothing. This is how a tool is exempted from a*rule.
Merging would make a named rule impossible to loosen and would leave a reader unable to say what governs a tool without composing two places in their head.
An id-targeted patch replaces the row's whole config rather than merging into
it, so an override must restate every rule it means to keep.
Counting
Counts are per session and per tool name.
- Two agents sharing a process do not spend each other's allowance. A session is
the explicit
sessionIda caller passes, else the calling agent's id from the execution input, elsedefault. - Under
*, each tool has its own allowance rather than sharing one pool:maxCalls: 40means forty calls to each uncapped tool, not forty in total. - A denied call is not recorded, so
usednever climbs past the cap. - Counts live as long as the plugin does. A reload starts every session with a full allowance, which is the right default: a tracker that remembered across one would deny the first call of a fresh run for something the previous run spent.
Bytes
A string result is measured as itself with Buffer.byteLength(result, 'utf8').
Anything else is measured as its compact JSON, which is the form that actually
reaches the conversation.
A value JSON cannot represent — a BigInt, a cycle — is treated as over quota and
throws with reason: 'UNMEASURABLE'. A size that cannot be established is not a
size that fits, and guessing here would mean passing an unmeasured payload on.
The byte check runs after the tool body. The call has already been made and already been paid for; what the cap protects is the conversation, not the tool. The oversized value is never attached to the error — the whole point is that it travels no further.
The errors
error.code // 'TOOL_QUOTA_CALLS'
error.toolName // 'arxiv_search'
error.sessionId // the session whose allowance ran out
error.count // 9 — this call's ordinal, counting the denied one
error.used // 8 — calls that actually ran
error.maxCalls // 8
error.plugin // 'dsh-tool-quota'
error.code // 'TOOL_QUOTA_BYTES'
error.toolName // 'arxiv_search'
error.sessionId // the session the call belonged to
error.byteLength // 41231, or null when the value could not be measured
error.maxResultBytes // 32000
error.reason // 'OVER_LIMIT' | 'UNMEASURABLE'
error.plugin // 'dsh-tool-quota'
Both are thrown, not returned. A cap a caller can drive past by ignoring a return value is not a cap. Neither error carries the tool's arguments or the result body: those are the things most likely to hold a path, a query, or a credential, and an error message is the thing most likely to be logged.
The status tool
One model-facing tool is registered, tool_quota_status. It takes no arguments
and reports, for the calling session, every tool named in the configuration plus
every *-covered tool the session has already called:
{
"plugin": "dsh-tool-quota",
"sessionId": "agent-1",
"tools": [
{ "name": "arxiv_search", "rule": "tool", "used": 3, "maxCalls": 8,
"remaining": 5, "maxResultBytes": 32000 }
],
"star": { "maxCalls": 40, "maxResultBytes": 64000 }
}
remaining is max(0, maxCalls - used), and null for a tool whose rule sets
no maxCalls. The * limits apply to each uncapped tool separately, not to all
of them together. No money, no tokens, no time.
Calling it costs nothing against any quota — a status tool that could exhaust an allowance would be a strange way to spend one — and an agent that can ask what it has left does not have to discover the cap by hitting it.
How it attaches
apply patches the injected tool registry in two places and returns the
QuotaTracker, so a host that wants to inspect or reset it can.
ctx.tools.registeris patched, so every definition registered for the lifetime of this fiber carries a meteredexecute. This is the mandatory path and the only one this plugin assumes exists.ctx.tools.executeis patched when the runtime exposes it, which catches what the register patch cannot: a tool registered before this plugin applied.
One host call passing through both seams is counted once, keyed by its call id and tool name. It is also measured once, by the inner wrapper, which sees the canonical value the tool returned rather than whatever envelope the runtime built around it.
The config is validated before the registry is touched, so an unusable rule fails with nothing half-installed. Both patches are undone on dispose, leaving the registry exactly as it was found.
Using the library directly
The counting half is exported on its own for a host that owns its call sites:
import { QuotaTracker, decorateTool, wrapExecute } from 'dsh-tool-quota/quota'
const tracker = new QuotaTracker({
rules: { arxiv_search: { maxCalls: 8, maxResultBytes: 32000 } },
})
// Wrap one execute…
const metered = wrapExecute(execute, tracker, { tool: 'arxiv_search', sessionId: 's1' })
// …or copy a whole definition with its execute metered.
const capped = decorateTool(definition, tracker)
tracker.status('s1') // what that session has left
decorateTool returns a copy; the original definition is left alone. Also
exported: normalizeRules, normalizeLimit, ruleFor, resultByteLength,
sessionKey, ToolQuotaCallsError, ToolQuotaBytesError,
InvalidQuotaConfigError.
Dependencies
node:buffer and @deepseek-ai/dsh-tools — nothing else. The shipped source
opens no socket, reads no file, and spawns no process, all of which the test
suite asserts rather than promises. @deepseek-ai/cordis and
@deepseek-ai/dsh-tools are peer dependencies, provided by the harness.
Node >=22.14.0.
Tests
npm install
npm test
No network, no credentials, no model weights. CI runs the same suite on Node 22.x and 24.x.
License
MIT. Copyright (c) 2026 jwilson411.