{"_id":"@argszero/cordis-plugin-turn-budget-guard","_rev":"2-9e48a5fbe6bf3f79fa2e541515b595d9","name":"@argszero/cordis-plugin-turn-budget-guard","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.0":{"name":"@argszero/cordis-plugin-turn-budget-guard","version":"0.1.0","keywords":["cordis","deepseek-harness","dsh","plugin","guard","turn-budget","step-budget","unbounded-loop"],"license":"MIT","_id":"@argszero/cordis-plugin-turn-budget-guard@0.1.0","maintainers":[{"name":"argszero","email":"argszero.reg@gmail.com"}],"homepage":"https://github.com/argszero/cordis-plugin-turn-budget-guard#readme","bugs":{"url":"https://github.com/argszero/cordis-plugin-turn-budget-guard/issues"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dist":{"shasum":"a74d4c23675780ea9dcb87edf2f49e88ab4e751d","tarball":"https://registry.npmjs.org/@argszero/cordis-plugin-turn-budget-guard/-/cordis-plugin-turn-budget-guard-0.1.0.tgz","fileCount":8,"integrity":"sha512-8zi5HBEtu+PCrXLG875bQ4/2ZtlCMiLGWg3pYZ06vjVnMh9kRdWp272191PnsX1li3/Tcdz6bADLnjSTbW3z6w==","signatures":[{"sig":"MEUCIQDd4szKyAA0dC67xPj288OEBcZfhDoOlIOB6PZEDo8dywIgNPtGFuaxOG/0uyHBN9UaeZCQgm1uty2jd02R1mCC6hA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":32990},"main":"lib/index.js","type":"module","types":"lib/types/index.d.ts","engines":{"node":"^22.19 || >=24"},"exports":{".":{"types":"./lib/types/index.d.ts","default":"./lib/index.js"},"./src/*":"./src/*","./package.json":"./package.json"},"gitHead":"1bed0b3121a37fbd16bbdcde0bf03632a1f7a286","scripts":{"test":"node --test \"test/*.spec.mjs\"","build":"tsc","prepublishOnly":"tsc"},"_npmUser":{"name":"argszero","email":"argszero.reg@gmail.com"},"repository":{"url":"git+https://github.com/argszero/cordis-plugin-turn-budget-guard.git","type":"git"},"_npmVersion":"11.17.0","description":"Turn budget guard for dsh: caps the number of steps one agent turn may spend before the model is asked to wrap up, then stops the turn. Closes the 'no ceiling on action' gap measured in Discussion #6366 (33-50 turns / 32-70 tool calls for a one-field conf","directories":{},"_nodeVersion":"26.5.0","dependencies":{"@deepseek-ai/schemastery":"^3.18.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.5.0","@deepseek-ai/cordis":"^4.0.2","@deepseek-ai/dsh-llm":"0.1.2-rc.1","@deepseek-ai/dsh-agent":"0.1.2-rc.1"},"peerDependencies":{"@deepseek-ai/cordis":"^4.0.2","@deepseek-ai/dsh-llm":">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0","@deepseek-ai/dsh-agent":">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0"},"peerDependenciesMeta":{"@deepseek-ai/dsh-llm":{"optional":false},"@deepseek-ai/dsh-agent":{"optional":false}},"_npmOperationalInternal":{"tmp":"tmp/cordis-plugin-turn-budget-guard_0.1.0_1789167475031_0.8579489707894776","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"_id":"@argszero/cordis-plugin-turn-budget-guard@0.1.1","dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"bugs":{"url":"https://github.com/argszero/cordis-plugin-turn-budget-guard/issues"},"dist":{"shasum":"57abcf054f1ad5ae1e1c9c73de6cd334b4d31766","tarball":"https://registry.npmjs.org/@argszero/cordis-plugin-turn-budget-guard/-/cordis-plugin-turn-budget-guard-0.1.1.tgz","fileCount":8,"integrity":"sha512-Xc2tYk+ORKVGAKJqmftqipi7MgYXHyiiYCd3VjVe+TKGnZowhpg1DjilTSZItma8fes9ylD3eofih2VhHpd91A==","signatures":[{"sig":"MEUCIAvmDrA7ItCGx5asNnDqG6kv6z0rJ8cXIWIb6+7Vi4LBAiEA74o81F1hXFzda6HxBCcTyOihvmGe9CEQ2iMLxIoyS+w=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIEJkCBQ9pO8fppEQuGrH9SwLvUYo9nZn7N7GLDyU9CzMAiEA4ocx+TQXs8p5jtOao7+xOPXnKWAVLLaUpGVDTDy5xhM="}],"unpackedSize":35961},"main":"lib/index.js","name":"@argszero/cordis-plugin-turn-budget-guard","type":"module","types":"lib/types/index.d.ts","engines":{"node":"^22.19 || >=24"},"exports":{".":{"types":"./lib/types/index.d.ts","default":"./lib/index.js"},"./src/*":"./src/*","./package.json":"./package.json"},"gitHead":"52a8559ab5eabae9f000975418bed7cf9744c271","license":"MIT","scripts":{"test":"node --test \"test/*.spec.mjs\"","build":"tsc","prepublishOnly":"tsc"},"version":"0.1.1","_npmUser":{"name":"argszero","email":"argszero.reg@gmail.com"},"homepage":"https://github.com/argszero/cordis-plugin-turn-budget-guard#readme","keywords":["cordis","deepseek-harness","dsh","plugin","guard","turn-budget","step-budget","unbounded-loop"],"repository":{"url":"git+https://github.com/argszero/cordis-plugin-turn-budget-guard.git","type":"git"},"_npmVersion":"11.17.0","description":"Turn budget guard for dsh: caps the number of steps one agent turn may spend before the model is asked to wrap up, then stops the turn. Closes the 'no ceiling on action' gap measured in Discussion #6366 (33-50 turns / 32-70 tool calls for a one-field conf","directories":{},"maintainers":[{"name":"argszero","email":"argszero.reg@gmail.com"}],"_nodeVersion":"26.5.0","dependencies":{"@deepseek-ai/schemastery":"^3.18.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"semver":"^7.6.0","typescript":"^5.5.0","@deepseek-ai/cordis":"^4.0.2","@deepseek-ai/dsh-llm":"0.1.6-alpha.2","@deepseek-ai/dsh-agent":"0.1.6-alpha.2"},"peerDependencies":{"@deepseek-ai/cordis":"^4.0.2","@deepseek-ai/dsh-llm":">=0.1.2-rc.1 <0.1.3 || >=0.1.3-alpha.2 <0.1.4 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0","@deepseek-ai/dsh-agent":">=0.1.2-rc.1 <0.1.3 || >=0.1.3-alpha.2 <0.1.4 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0"},"peerDependenciesMeta":{"@deepseek-ai/dsh-llm":{"optional":false},"@deepseek-ai/dsh-agent":{"optional":false}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/cordis-plugin-turn-budget-guard_0.1.1_1789715571264_0.7806251830005442"}}},"time":{"created":"2026-09-11T22:57:54.879Z","modified":"2026-09-18T07:12:51.499Z","0.1.0":"2026-09-11T22:57:55.168Z","0.1.1":"2026-09-18T07:12:51.351Z"},"bugs":{"url":"https://github.com/argszero/cordis-plugin-turn-budget-guard/issues"},"license":"MIT","homepage":"https://github.com/argszero/cordis-plugin-turn-budget-guard#readme","keywords":["cordis","deepseek-harness","dsh","plugin","guard","turn-budget","step-budget","unbounded-loop"],"repository":{"url":"git+https://github.com/argszero/cordis-plugin-turn-budget-guard.git","type":"git"},"description":"Turn budget guard for dsh: caps the number of steps one agent turn may spend before the model is asked to wrap up, then stops the turn. Closes the 'no ceiling on action' gap measured in Discussion #6366 (33-50 turns / 32-70 tool calls for a one-field conf","maintainers":[{"name":"argszero","email":"argszero.reg@gmail.com"}],"readme":"# @argszero/cordis-plugin-turn-budget-guard\n\nA **turn budget guard** for the [dsh](https://github.com/deepseek-ai/deepseek-harness) harness.\n\nAn agent turn in dsh has no ceiling on the number of **actions** it may take. A model\nthat keeps calling tools never ends its turn: `turn()` is `while (true)`, and every exit\nrequires the step to complete without a tool call. The result is measured in\n[Discussion #6366](https://github.com/deepseek-ai/deepseek-harness/discussions/6366) —\nfour runs of the *same* prompt that only adds one configuration field:\n\n| run | rule file | mode | turns | tool calls |\n|---|---|---|---|---|\n| 1 | loaded | PTC | 39 (user interrupted) | — |\n| 2 | none | PTC | 50 | 59 |\n| 3 | none | PTC | 33 | 32 |\n| 4 | none | standard | 43 | 70 |\n\nThe write itself took 1–3 turns each time. One instrumented run spent **2,946,619 input\ntokens, 3% of them in the write step**. The reporter's own decomposition localises the\ninflation: the model has the answer by step 6–12 and then spends 14–28 further steps\nverifying facts that can only be known *after* the change is applied.\n\nThis plugin gives a turn a budget.\n\n## Install\n\n```sh\nnpm install @argszero/cordis-plugin-turn-budget-guard\n```\n\nMount it through a `dsh` profile layer (the package ships a `dsh.bundle` patch):\n\n```yaml\n- insert:\n    - id: turn-budget-guard\n      name: '@argszero/cordis-plugin-turn-budget-guard'\n      config:\n        maxSteps: 20\n        gracefulSteps: 8\n```\n\n## Configuration\n\n| field | default | meaning |\n|---|---|---|\n| `maxSteps` | `20` | Steps in one turn before the guard asks the model to wrap up. |\n| `gracefulSteps` | `8` | Further steps tolerated after the first wrap-up request before the turn is stopped. |\n| `maxFires` | `2` | Wrap-up requests per budget period, even while inside the grace window. |\n| `cancelCause` | `'turn-budget'` | Cause handed to `agent.cancel`. |\n\n## What it does, in order\n\n1. **Before `maxSteps`** — nothing. The listener delegates (`next()`) without touching\n   the admitted messages.\n2. **Past `maxSteps`** — appends one logged message asking the model to *reply now*:\n   what is done, what remains, what the next action is. This is the reporter's\n   suggestion #2 made concrete: forcing the \"what is still missing\" report instead of\n   silently continuing. The message is a normal `user/message` with\n   `source.kind:'plugin'` and `plugin:'turn-budget-guard'`, so it is attributable and\n   filterable (`session-audit`, a redactor) and can never be mistaken for the user.\n3. **`gracefulSteps` later, or `maxFires` spent** — `agent.cancel('turn-budget')` with\n   **`keepInbox: true`**, so anything the user typed while watching survives for the\n   next turn. The reason reaches the transcript as the turn's\n   `{ kind: 'aborted', reason }`.\n\nIt is a **watchdog, not a muzzle**: a turn that finishes inside the budget is never\ntouched, and a user who types mid-turn rebases the budget from that step — the human\nre-engaged, so the turn deserves fresh room.\n\n## How it works\n\nThe plugin subscribes to the public `agent/pre-step` waterfall, which runs once per\nproposed loop step and already carries the two numbers a ceiling needs:\n\n```ts\nctx.on('agent/pre-step', ({ agent, messages, turn, step }, next) => { … })\n```\n\nNo internal access and no extra bookkeeping: `step` *is* the loop's own counter.\n\nThe budget is measured **relative to a baseline** rather than against the raw step\nnumber. That detail is load-bearing: `step` grows monotonically within a turn, so a\nuser re-engaging at step 30 with `maxSteps: 20` would otherwise still be \"past budget\"\non the very next step, and the rebase would grant nothing.\n\n## Peer range\n\nThe plugin declares `@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-llm` as\npeer dependencies, both with the same range:\n\n```\n>=0.1.2-rc.1 <0.1.3 || >=0.1.3-alpha.2 <0.1.4 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0\n```\n\nEvery dsh release published today is a **prerelease** (`0.1.2-rc.1`, `0.1.5-alpha.1`,\n`0.1.6-alpha.2`, …), and a semver comparator admits a prerelease only when some\ncomparator **in the same group** shares its `major.minor.patch` tuple. Two\nconsequences follow, and both have already bitten this package:\n\n```jsonc\n// Matches nothing: 0.1.2-rc.1 sorts BELOW 0.1.2, and every other prerelease\n// carries a different tuple.  -> ETARGET, the package cannot be installed.\n\">=0.1.2\"\n\n// Only the 0.1.2-rc tuple. The `<0.2.0` upper bound is INERT for prereleases:\n// it excludes no later line, so every other line gets ERESOLVE.\n\">=0.1.2-rc.1 <0.2.0\"\n```\n\nThe second form is the dangerous one, because it *reads* as though it covered\neverything from `0.1.2-rc.1` onward. It does not — `<0.2.0` never excludes\n`0.1.6-alpha.2`, and no comparator names that tuple. Up to **v0.1.0** the\nshipped range was\n\n```\n>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0\n```\n\nwhich admitted the `0.1.2-rc` and `0.1.5` tuples and **nothing else** — 5 of the\n23 published versions. A user on the newest shipped dsh release\n(`0.1.6-alpha.2`) therefore could not install the plugin at all:\n\n```\nnpm error ERESOLVE unable to resolve dependency tree\nnpm error peer @deepseek-ai/dsh-llm@\">=0.1.2-rc.1 <0.2.0 || ...\" from\nnpm error   @argszero/cordis-plugin-turn-budget-guard@0.1.0\n```\n\nThe plugin's own suite passes on that line (16/16). The dsh packages are\n**peers**, so `--legacy-peer-deps` is not something a consumer can reasonably be\nasked to accept: the install simply fails.\n\n**What we ship:** one comparator per supported tuple, each with its own upper\nbound so the intended span is legible rather than implied.\n\n| clause | admits |\n| --- | --- |\n| `>=0.1.2-rc.1 <0.1.3` | `0.1.2-rc.1` |\n| `>=0.1.3-alpha.2 <0.1.4` | `0.1.3-alpha.2` |\n| `>=0.1.5-alpha.1 <0.2.0` | the whole 0.1.5 line (alpha.1, alpha.2, rc.1, rc.2) |\n| `>=0.1.6-alpha.1 <0.2.0` | the whole 0.1.6 line (alpha.1, alpha.2) |\n\n`test/peer-range` **computes** the admitted set with the real `semver` package\nand asserts it equals exactly the set the suite has been run against — 8\nversions — rather than pattern-matching the range string. An earlier guard only\nchecked that the string mentioned `0.1.2-rc.N` and `0.1.5-alpha.N`; that form\ncannot tell a correct range from an incorrect one, which is how the range above\nshipped green. Asserting the set exactly makes both directions loud: dropping a\nsupported line fails, and admitting an unverified line fails too. The same file\nalso asserts that this README quotes the manifest range verbatim.\n\n## Verified against\n\ndsh `0.1.5` sources at commit `c291e7961a`:\n\n- `packages/core/agent-loop/src/agent.ts` — `turn()` `while (true)` at `:286`; the exit\n  conditions at `:290-311`; the turn only ends when the step emits no tool call or its\n  calls conclude the turn (`:486-492`); admitted messages are appended as\n  `user/message` (`:374-376`).\n- `packages/core/agent-loop/src/index.ts:309` — `AgentLoopSettings` exposes only\n  `maxParallelToolCalls`; `grep` for `maxSteps` / `stepLimit` / `stepBudget` across\n  `packages/` returns nothing.\n- `packages/core/agent/src/runtime-types.ts:330` — the `agent/pre-step` contract;\n  `:112-119` — `PreStepDecision`; `:40-45` — `CancelOptions.keepInbox`.\n- `packages/core/session/src/index.ts:328-345` — a `user/message` must carry a non-empty\n  `id` and `role: 'user'`, which is why the notice is built with `createUserMessage`.\n- `packages/guard/repeat-tool-reminder/src/index.ts:57`, `:200-206` — the\n  `source.kind:'plugin'` + plugin-name injection convention this plugin follows.\n\n## Relationship to the in-tree guards\n\nNeither catches the shape #6366 measures:\n\n- `guard/timeout-policy` is a per-tool `tools/execute` deadline — the measured loop\n  calls tools that succeed.\n- `guard/repeat-tool-reminder` needs *identical* arguments across repeated calls; the\n  measured run varies its arguments every step.\n\nThe in-tree blueprint for a real fix already exists (a `maxSteps` setting plus a sticky\nturn-end reason), and this plugin is the community-side mitigation until it lands. If\nthe harness grows a step budget, set `maxSteps` above it or drop the plugin: it is a\npure watchdog and goes silent when the turn ends on its own.\n\n## Tests\n\n```sh\nnpm test\n```\n\n16 tests, no harness required: the decision rule (pure, in `src/guard.ts`) plus the\nlistener against a real Cordis context.\n","readmeFilename":"README.md"}