mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-17 10:18:34 +00:00
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.
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""Middleware to enforce per-run token budget limits.
|
|
Tracks cumulative token usage (input, output, total) across model calls within
|
|
a single agent run and enforces configurable soft-warning and hard-stop
|
|
thresholds.
|
|
Detection strategy:
|
|
1. After each model response, sum the `usage_metadata` of all `AIMessage`s
|
|
in the current thread history. This automatically captures tokens from
|
|
subagents because `TokenUsageMiddleware` retroactively adds them to the
|
|
history.
|
|
2. If the highest fraction (input, output, or total) >= warn_threshold,
|
|
queue a warning.
|
|
3. If the highest fraction >= hard_stop_threshold, strip tool_calls.
|
|
Warning injection uses the deferred pattern:
|
|
- after_model queues the warning (does NOT mutate state).
|
|
- wrap_model_call injects it as a HumanMessage at the next model call.
|
|
This preserves AIMessage(tool_calls) → ToolMessage pairing.
|
|
|
|
Stop-reason surfacing (#3875 Phase 2):
|
|
The hard stop does NOT raise — it strips tool_calls so the agent loop
|
|
terminates naturally and produces a final answer. To let the caller (e.g.
|
|
the subagent executor) distinguish a budget-capped completion from a clean
|
|
one, the run that triggered the hard stop is recorded in ``_stop_reason``
|
|
and exposed via :meth:`consume_stop_reason`. That dict is intentionally NOT
|
|
cleared by ``after_agent``/``_clear_run_state`` so the executor can read it
|
|
after the run returns; the bounded dict prevents unbounded growth on
|
|
abandoned runs, and each subagent run builds a fresh middleware instance so
|
|
there is no cross-run contamination.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any, override
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.agents.middlewares._bounded_dict import BoundedDict
|
|
from deerflow.config.token_budget_config import TokenBudgetConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_BUDGET_WARNING_MSG = (
|
|
"[TOKEN BUDGET WARNING] You have used {used:,} of your {budget:,} {reason} token budget ({percent:.0f}%). Wrap up your current work and produce a final answer. Avoid starting new tool calls unless absolutely necessary."
|
|
)
|
|
_BUDGET_EXCEEDED_MSG = "[TOKEN BUDGET EXCEEDED] The {reason} token usage ({used:,}) has exceeded the safety limit ({budget:,}). Producing final answer with results collected so far."
|
|
|
|
|
|
@dataclass
|
|
class TokenUsage:
|
|
input: int = 0
|
|
output: int = 0
|
|
total: int = 0
|
|
|
|
|
|
class TokenBudgetMiddleware(AgentMiddleware[AgentState]):
|
|
"""Enforce per-run token budget limits."""
|
|
|
|
def __init__(self, config: TokenBudgetConfig) -> None:
|
|
super().__init__()
|
|
self._config = config
|
|
self._lock = threading.Lock()
|
|
|
|
# Keyed strictly by run_id (clobber-safe) and bounded (leak-safe)
|
|
self._warned: BoundedDict[str, bool] = BoundedDict(1000)
|
|
self._pending_warnings: BoundedDict[str, list[str]] = BoundedDict(1000)
|
|
self._seen_messages: BoundedDict[str, dict[str, tuple[int, int]]] = BoundedDict(1000)
|
|
self._cumulative_usage: BoundedDict[str, TokenUsage] = BoundedDict(1000)
|
|
# Stop reason set when the hard-stop fires. NOT cleared by
|
|
# ``_clear_run_state``/``after_agent`` so the executor can consume it
|
|
# after the run returns; bounded so abandoned runs cannot leak.
|
|
self._stop_reason: BoundedDict[str, str] = BoundedDict(1000)
|
|
|
|
@classmethod
|
|
def from_config(cls, config: TokenBudgetConfig) -> TokenBudgetMiddleware:
|
|
return cls(config=config)
|
|
|
|
def reset(self) -> None:
|
|
with self._lock:
|
|
self._warned.clear()
|
|
self._pending_warnings.clear()
|
|
self._seen_messages.clear()
|
|
self._cumulative_usage.clear()
|
|
self._stop_reason.clear()
|
|
|
|
def consume_stop_reason(self, run_id: str | None) -> str | None:
|
|
"""Pop and return the stop reason the hard-stop set for this run.
|
|
|
|
Returns ``"token_capped"`` when the budget hard-stop fired during the
|
|
run, otherwise ``None``. The executor calls this after the run returns
|
|
to decide whether a completed subagent was actually budget-capped
|
|
(and should carry ``stop_reason=token_capped`` to the lead). Popping
|
|
keeps the dict from accumulating across runs on a reused instance.
|
|
"""
|
|
with self._lock:
|
|
return self._stop_reason.pop(run_id, None)
|
|
|
|
@staticmethod
|
|
def _get_run_id(runtime: Runtime) -> str:
|
|
ctx = getattr(runtime, "context", None)
|
|
if isinstance(ctx, dict) and "run_id" in ctx:
|
|
return ctx["run_id"]
|
|
# Fallback to runtime object ID to prevent collisions across embedded client runs
|
|
return str(id(runtime))
|
|
|
|
def _clear_run_state(self, run_id: str) -> None:
|
|
with self._lock:
|
|
self._warned.pop(run_id, None)
|
|
self._pending_warnings.pop(run_id, None)
|
|
self._seen_messages.pop(run_id, None)
|
|
self._cumulative_usage.pop(run_id, None)
|
|
|
|
@override
|
|
def before_agent(self, state: AgentState, runtime: Runtime) -> None:
|
|
if not self._config.enabled:
|
|
return
|
|
|
|
# Mark all old messages from previous runs as 'seen' so they don't count toward THIS run's budget
|
|
messages = state.get("messages", [])
|
|
if not messages:
|
|
return
|
|
|
|
run_id = self._get_run_id(runtime)
|
|
with self._lock:
|
|
seen = self._seen_messages.setdefault(run_id, {})
|
|
self._cumulative_usage.setdefault(run_id, TokenUsage())
|
|
|
|
for msg in messages:
|
|
if isinstance(msg, AIMessage) and msg.id and hasattr(msg, "usage_metadata"):
|
|
usage = msg.usage_metadata or {}
|
|
input_tokens = usage.get("input_tokens", 0)
|
|
output_tokens = usage.get("output_tokens", 0)
|
|
seen[msg.id] = (input_tokens, output_tokens)
|
|
|
|
@override
|
|
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> None:
|
|
self.before_agent(state, runtime)
|
|
|
|
@override
|
|
def after_agent(self, state: AgentState, runtime: Runtime) -> None:
|
|
if not self._config.enabled:
|
|
return
|
|
self._clear_run_state(self._get_run_id(runtime))
|
|
|
|
@override
|
|
async def aafter_agent(self, state: AgentState, runtime: Runtime) -> None:
|
|
self.after_agent(state, runtime)
|
|
|
|
@staticmethod
|
|
def _append_text(content: str | list[dict | None] | None, stop_msg: str) -> str | list[dict | str]:
|
|
"""Append a stop message to an AIMessage.content field."""
|
|
if content is None:
|
|
return stop_msg
|
|
if isinstance(content, str):
|
|
if content:
|
|
return f"{content}\n\n{stop_msg}"
|
|
return f"\n\n{stop_msg}"
|
|
if isinstance(content, list):
|
|
new_content = list(content)
|
|
new_content.append({"type": "text", "text": f"\n\n{stop_msg}"})
|
|
return new_content
|
|
return f"{content}\n\n{stop_msg}"
|
|
|
|
def _build_hard_stop_update(self, msg: AIMessage, stop_msg: str) -> dict[str, Any]:
|
|
"""Build the state update dictionary for a hard stop."""
|
|
updated_content = self._append_text(msg.content, stop_msg)
|
|
kwargs = dict(msg.additional_kwargs) if msg.additional_kwargs else {}
|
|
if "tool_calls" in kwargs:
|
|
del kwargs["tool_calls"]
|
|
if "function_call" in kwargs:
|
|
del kwargs["function_call"]
|
|
|
|
response_metadata = dict(getattr(msg, "response_metadata", {}) or {})
|
|
|
|
if response_metadata.get("finish_reason") == "tool_calls":
|
|
response_metadata["finish_reason"] = "stop"
|
|
|
|
stopped_msg = msg.model_copy(update={"content": updated_content, "tool_calls": [], "additional_kwargs": kwargs, "response_metadata": response_metadata})
|
|
return {"messages": [stopped_msg]}
|
|
|
|
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
if not self._config.enabled:
|
|
return None
|
|
|
|
messages = state.get("messages", [])
|
|
if not messages:
|
|
return None
|
|
|
|
last_msg = messages[-1]
|
|
if not isinstance(last_msg, AIMessage):
|
|
return None
|
|
|
|
run_id = self._get_run_id(runtime)
|
|
|
|
with self._lock:
|
|
seen = self._seen_messages.setdefault(run_id, {})
|
|
usage_accum = self._cumulative_usage.setdefault(run_id, TokenUsage())
|
|
|
|
for msg in messages:
|
|
if isinstance(msg, AIMessage) and msg.id and hasattr(msg, "usage_metadata"):
|
|
usage = msg.usage_metadata or {}
|
|
|
|
input_tokens = usage.get("input_tokens", 0)
|
|
output_tokens = usage.get("output_tokens", 0)
|
|
|
|
# Check what previously recorded for this exact message
|
|
prev_input, prev_output = seen.get(msg.id, (0, 0))
|
|
|
|
# Calculate if any new tokens were added (handles retroactive subagent tokens)
|
|
diff_input = max(0, input_tokens - prev_input)
|
|
diff_output = max(0, output_tokens - prev_output)
|
|
|
|
if diff_input > 0 or diff_output > 0:
|
|
usage_accum.input += diff_input
|
|
usage_accum.output += diff_output
|
|
usage_accum.total += diff_input + diff_output
|
|
seen[msg.id] = (input_tokens, output_tokens)
|
|
|
|
if usage_accum.total <= 0:
|
|
return None
|
|
|
|
fractions = [("total", usage_accum.total, self._config.max_tokens)]
|
|
if self._config.max_input_tokens:
|
|
fractions.append(("input", usage_accum.input, self._config.max_input_tokens))
|
|
if self._config.max_output_tokens:
|
|
fractions.append(("output", usage_accum.output, self._config.max_output_tokens))
|
|
|
|
highest_fraction = 0.0
|
|
trigger_reason = ""
|
|
trigger_used = 0
|
|
trigger_budget = 0
|
|
|
|
for reason, used, limit in fractions:
|
|
frac = used / limit
|
|
if frac > highest_fraction:
|
|
highest_fraction = frac
|
|
trigger_reason = reason
|
|
trigger_used = used
|
|
trigger_budget = limit
|
|
|
|
if highest_fraction >= self._config.hard_stop_threshold:
|
|
logger.warning("Token budget hard stop triggered for run %s: %s limit exceeded", run_id, trigger_reason)
|
|
# Record the stop reason so the executor can surface
|
|
# ``stop_reason=token_capped`` to the lead after the run
|
|
# returns (the hard stop itself does not raise). See
|
|
# ``consume_stop_reason``.
|
|
self._stop_reason[run_id] = "token_capped"
|
|
stop_text = _BUDGET_EXCEEDED_MSG.format(reason=trigger_reason, used=trigger_used, budget=trigger_budget)
|
|
return self._build_hard_stop_update(last_msg, stop_text)
|
|
|
|
if highest_fraction >= self._config.warn_threshold and not self._warned.get(run_id, False):
|
|
self._warned[run_id] = True
|
|
percent = highest_fraction * 100
|
|
warn_text = _BUDGET_WARNING_MSG.format(reason=trigger_reason, used=trigger_used, budget=trigger_budget, percent=percent)
|
|
logger.info("Token budget warning triggered for run %s: %s limit at %.1f%%", run_id, trigger_reason, percent)
|
|
# queue warning for wrap_model_call
|
|
warnings = self._pending_warnings.setdefault(run_id, [])
|
|
warnings.append(warn_text)
|
|
return None
|
|
|
|
return None
|
|
|
|
@override
|
|
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
return self._apply(state, runtime)
|
|
|
|
@override
|
|
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
return self._apply(state, runtime)
|
|
|
|
def _drain_pending_warnings(self, runtime: Runtime) -> list[str]:
|
|
if not self._config.enabled:
|
|
return []
|
|
|
|
run_id = self._get_run_id(runtime)
|
|
with self._lock:
|
|
warnings = self._pending_warnings.pop(run_id, None)
|
|
return warnings or []
|
|
|
|
def _inject_warnings(self, request: ModelRequest, warnings: list[str]) -> ModelRequest:
|
|
if not warnings:
|
|
return request
|
|
|
|
merged_text = "\n\n".join(warnings)
|
|
warning_msg = HumanMessage(content=merged_text, name="budget_warning")
|
|
|
|
messages = getattr(request, "messages", [])
|
|
new_messages = list(messages) + [warning_msg]
|
|
return request.override(messages=new_messages)
|
|
|
|
@override
|
|
def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelCallResult:
|
|
|
|
warnings = self._drain_pending_warnings(request.runtime)
|
|
request = self._inject_warnings(request, warnings)
|
|
|
|
return handler(request)
|
|
|
|
@override
|
|
async def awrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]]) -> ModelCallResult:
|
|
warnings = self._drain_pending_warnings(request.runtime)
|
|
request = self._inject_warnings(request, warnings)
|
|
return await handler(request)
|