hataa c9fb9768d4
fix(subagents): unify guardrail caps on additive stop_reason + add token_budget (#3875 Phase 2) (#3980)
Phase 2 of #3875. Two guardrail axes can end a subagent run early — the turn
budget (GraphRecursionError) and the token budget (TokenBudgetMiddleware) —
and both now surface *why* through one additive `subagent_stop_reason` field
instead of a status enum.

This completes and course-corrects Phase 1 (#3949), which shipped the
turn-budget cap as a `max_turns_reached` status enum. The agreed Phase 2
design replaces that enum with an optional `stop_reason` field
(token_capped | turn_capped | loop_capped): a new enum value would break v1
consumers, while an additive field is ignored by older frontends and ledger
readers. `max_turns_reached` and SubagentStatus.MAX_TURNS_REACHED are removed.

- subagents.token_budget config (default enabled, 2,000,000 tokens, warn 0.7)
  with per-agent override; TokenBudgetMiddleware is now attached in
  build_subagent_runtime_middlewares so the cost-ceiling backstop engages for
  every subagent. The hard-stop does not raise — it strips tool_calls and
  lets the run finish with a final answer, recording the cap on a per-run
  consume_stop_reason() accessor.
- executor.py: on normal completion it reads consume_stop_reason() and stamps
  completed + token_capped when the budget fired; on GraphRecursionError it
  recovers the last AIMessage partial (completed + turn_capped) or, if nothing
  usable survived, failed + turn_capped. SubagentResult gains stop_reason.
- status_contract.py / contracts/subagent_status_contract.json (v2) /
  frontend subtask-result.ts: additive subagent_stop_reason field, pinned by
  test_status_values_match_contract / test_stop_reason_values_match_contract.
- task_tool.py + delegation_ledger.py: drop the max_turns_reached paths; the
  ledger captures stop_reason and renders model-facing "capped" guidance so
  the lead reuses a capped completion knowingly.

The 2,000,000-token default is deliberately loose (tighten to taste) — it
would have roughly halved the reported 4.4M burn while leaving legitimate
deep-research runs (max_turns=150) room. Subagent summarization is a follow-up.
2026-07-08 22:26:06 +08:00

33 lines
1.2 KiB
Python

"""A small bounded ``OrderedDict`` shared by guard middlewares.
Guard middlewares (``TokenBudgetMiddleware``, ``LoopDetectionMiddleware``) keep
per-``run_id`` state that must not grow without bound on abandoned or reused
runs. This module provides the single shared implementation so both middlewares
cap identically and a future guard does not reinvent it.
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
class BoundedDict(OrderedDict):
"""An ``OrderedDict`` that evicts the oldest entry once ``maxsize`` is reached.
Used for per-``run_id`` state (stop-reason flags, pending warnings, usage
accumulators) so a long-lived middleware instance on the lead agent cannot
leak memory across many runs. Insertion order is preserved, so the
least-recently-inserted key is evicted first.
"""
def __init__(self, maxsize: int = 1000, *args: Any, **kwds: Any) -> None:
self.maxsize = maxsize
super().__init__(*args, **kwds)
def __setitem__(self, key: Any, value: Any) -> None:
if key not in self:
if len(self) >= self.maxsize:
self.popitem(last=False)
super().__setitem__(key, value)