Enforce the token cap, and treat a spent budget as an interruption
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
Exceeding a LiteLLM spend limit takes external models away for the rest of the day, which makes three existing gaps consequential rather than untidy. - A13 calls the delegation caps hard on "turns, tokens, and wall clock", and the module docstring repeated it, but the token cap was decoration: nothing counted, and result.tokens was always zero. It is now metered from the usage the provider reports, so a confused delegate cannot spend a day's allowance. A5 keeps the *money* cap on the LiteLLM key, which is a different thing; this is a scope cap - The normalized route sent no ceiling at all, so truncation detection there had nothing to detect against. Both routes now send one - A budget refusal is recognised distinctly. It is not a rate limit: retrying cannot help, so the planner no longer burns a second call to fail identically And a delegation cut off by an exhausted budget is now "interrupted" rather than "failed", which preserves its branch and leaves its intent pending. The work on that branch may be perfectly good; discarding it because the budget ran out would throw away work for a reason that has nothing to do with the work. A3's resume rule already knows how to pick such a delegation back up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
249bcb10fc
commit
8cfb45aed3
5 files changed
+232
-6
No files matched your search
@@ -36,7 +36,7 @@ from nisse_dev.canonical import (
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_dev.git import GitError, GitRepo
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError
|
||||
from nisse_dev.reconcile import Reconciler
|
||||
from nisse_dev.state import Intent, StateStore
|
||||
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
|
||||
@@ -129,7 +129,7 @@ class Handback:
|
||||
|
||||
@dataclass
|
||||
class DelegationResult:
|
||||
status: Literal["handback", "capped", "failed"]
|
||||
status: Literal["handback", "capped", "failed", "interrupted"]
|
||||
note: str
|
||||
handback: Handback | None = None
|
||||
branch: str | None = None
|
||||
@@ -137,6 +137,17 @@ class DelegationResult:
|
||||
tokens: int = 0
|
||||
duration_s: float = 0.0
|
||||
|
||||
@property
|
||||
def interrupted(self) -> bool:
|
||||
"""Stopped by something external rather than by the delegate.
|
||||
|
||||
The work on the branch may be perfectly good, so it is preserved and
|
||||
the intent left pending: the next boot treats it exactly like a crash
|
||||
mid-delegation (A3) and resumes if the cut was clean. Discarding here
|
||||
would throw away work because the budget ran out.
|
||||
"""
|
||||
return self.status == "interrupted"
|
||||
|
||||
@property
|
||||
def usable(self) -> bool:
|
||||
"""Only a successful handback may be built upon. A cap breach or a
|
||||
@@ -212,6 +223,11 @@ class Delegation:
|
||||
exit_status=result.status,
|
||||
)
|
||||
)
|
||||
if result.interrupted:
|
||||
# Leave the branch and the intent alone: this delegation is not
|
||||
# finished, it was cut off, and A3's resume rule is exactly the
|
||||
# machinery for picking it back up.
|
||||
return result
|
||||
if not result.usable:
|
||||
# Abort discards the branch: the caller must never resume on top
|
||||
# of a partially-edited tree (A13).
|
||||
@@ -294,6 +310,7 @@ class Delegation:
|
||||
)
|
||||
touched: set[str] = set()
|
||||
did_work = False
|
||||
spent = 0
|
||||
|
||||
for turn in range(self._caps.turns):
|
||||
elapsed = self._now() - started_at
|
||||
@@ -304,6 +321,7 @@ class Delegation:
|
||||
f"after {turn} turns",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
try:
|
||||
produced = self._client.complete(
|
||||
@@ -312,9 +330,35 @@ class Delegation:
|
||||
model=self._model,
|
||||
system=system,
|
||||
)
|
||||
except BudgetExhausted as exc:
|
||||
return DelegationResult(
|
||||
"interrupted",
|
||||
f"external models are unavailable: {exc}",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
except LLMError as exc:
|
||||
return DelegationResult(
|
||||
"failed", f"delegate call failed: {exc}", branch=branch
|
||||
"failed",
|
||||
f"delegate call failed: {exc}",
|
||||
branch=branch,
|
||||
tokens=spent,
|
||||
)
|
||||
# Metered from what the provider reported, not estimated. The cap
|
||||
# is a scope limit (A13), distinct from the money cap A5 keeps on
|
||||
# the LiteLLM key — but an unbounded delegation can still exhaust
|
||||
# a daily budget and take external models away until it resets.
|
||||
usage = getattr(self._client, "last_usage", {}) or {}
|
||||
spent += int(usage.get("input", 0)) + int(usage.get("output", 0))
|
||||
if spent >= self._caps.tokens:
|
||||
return DelegationResult(
|
||||
"capped",
|
||||
f"token cap of {self._caps.tokens} reached ({spent} spent) "
|
||||
f"after {turn + 1} turns",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
|
||||
calls: list[ToolCall] = []
|
||||
@@ -336,6 +380,7 @@ class Delegation:
|
||||
"delegate produced neither a tool call nor text",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
|
||||
for call in calls:
|
||||
@@ -385,6 +430,7 @@ class Delegation:
|
||||
handback=handback if handback.succeeded else None,
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
|
||||
outcome = self._workspace.dispatch(call.tool, call.arguments)
|
||||
@@ -411,6 +457,7 @@ class Delegation:
|
||||
f"turn cap of {self._caps.turns} reached without a handback",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
|
||||
def _commit_turn(self, turn: int) -> None:
|
||||
|
||||
Reference in new issue
Block a user