Bundle
dsh-tool-quota
DeepSeek Harness plugin: per-tool call-count and result-byte caps, enforced before the body runs and after it returns, failing loud rather than truncating
- Source
- jwilson411
- License
- MIT
- Updated
- Updated 5 days ago
Readme
# dsh-tool-quota
A [DeepSeek Harness][dsh] 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:
```yaml
- 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
```sh
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.
```yaml
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 `sessionId` a caller passes, else the calling agent's id from the
execution input, else `default`.
- Under `*`, each tool has its own allowance rather than sharing one pool:
`maxCalls: 40` means forty calls to *each* uncapped tool, not forty in total.
- A denied call is not recorded, so `used` never 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
```js
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'
```
```js
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:
```json
{
"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.register`** is patched, so every definition registered for the
lifetime of this fiber carries a metered `execute`. This is the mandatory path
and the only one this plugin assumes exists.
- **`ctx.tools.execute`** is 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:
```js
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
```sh
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.
[dsh]: https://www.npmjs.com/package/@deepseek-ai/dsh-tools
Install
dsh plugin --profile web add github:jwilson411/dsh-tool-quota#c6e3bd75c315e6e5896f59c9877d0117be932f39
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-tool-quota from the hub