Files
zk-data-agent/src/token_budget.py
T
Abdelrahman Abdallah b5e5824a56 Implemented the next parity slice: prompt-budget preflight and context collapse.
Core changes:

  - Added claw-code/src/token_budget.py for projected prompt size, chat-framing overhead, output reserve, and soft/hard input limits.
  - Wired preflight prompt-length validation and auto-compact/context collapse into claw-code/src/agent_runtime.py.
  - Extended claw-code/src/compact.py so compaction reports usage back to the runtime.
  - Added inspection surfaces in claw-code/src/agent_slash_commands.py and claw-code/src/main.py:
      - /token-budget and /budget
      - token-budget
  - Hardened claw-code/src/tokenizer_runtime.py so arbitrary simple model names fall back cleanly instead of trying a slow Transformers
    lookup.
  - Exported the new helpers in claw-code/src/__init__.py.

  Docs and tracking:

  - Updated claw-code/PARITY_CHECKLIST.md to mark prompt-length validation, token-budget calculation, and auto-compact/context collapse as
    done.
  - Updated claw-code/README.md and claw-code/TESTING_GUIDE.md with the new commands and behavior.

  Tests:

  - Added claw-code/tests/test_token_budget.py.
  - Updated claw-code/tests/test_agent_runtime.py, claw-code/tests/test_agent_slash_commands.py, claw-code/tests/test_main.py, and claw-code/
    tests/test_agent_context_usage.py.
  - Verified with:
      - /data/fs201059/aa17626/miniconda3/bin/python3 -m compileall src tests
      - /data/fs201059/aa17626/miniconda3/bin/python3 -m unittest -v tests.test_token_budget
        tests.test_agent_runtime.AgentRuntimeTests.test_agent_rejects_prompt_before_backend_when_preflight_input_budget_is_exceeded
        tests.test_agent_runtime.AgentRuntimeTests.test_agent_auto_compacts_context_before_next_model_call tests.test_agent_slash_commands
        tests.test_main tests.test_compact tests.test_tokenizer_runtime tests.test_agent_context_usage
      - Result: 71 tests, OK
2026-04-11 01:38:19 +02:00

145 lines
5.4 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from .agent_context_usage import collect_context_usage, infer_context_window
from .agent_session import AgentSessionState
from .agent_types import BudgetConfig, OutputSchemaConfig
from .compact import AUTOCOMPACT_BUFFER_TOKENS
DEFAULT_OUTPUT_RESERVE_TOKENS = 4096
MIN_OUTPUT_RESERVE_TOKENS = 1024
CHAT_MESSAGE_OVERHEAD_TOKENS = 5
CHAT_TOOL_CALL_OVERHEAD_TOKENS = 12
CHAT_NAME_OVERHEAD_TOKENS = 2
OUTPUT_SCHEMA_OVERHEAD_TOKENS = 256
@dataclass(frozen=True)
class TokenBudgetSnapshot:
model: str
context_window_tokens: int
projected_input_tokens: int
message_tokens: int
chat_overhead_tokens: int
reserved_output_tokens: int
reserved_compaction_buffer_tokens: int
reserved_schema_tokens: int
hard_input_limit_tokens: int
soft_input_limit_tokens: int
overflow_tokens: int
soft_overflow_tokens: int
exceeds_hard_limit: bool
exceeds_soft_limit: bool
token_counter_backend: str
token_counter_source: str
token_counter_accurate: bool
def calculate_token_budget(
*,
session: AgentSessionState,
model: str,
budget_config: BudgetConfig,
output_schema: OutputSchemaConfig | None = None,
) -> TokenBudgetSnapshot:
usage = collect_context_usage(
session=session,
model=model,
strategy='token_budget',
)
context_window_tokens = infer_context_window(model)
chat_overhead_tokens = estimate_chat_overhead(session)
reserved_output_tokens = _resolve_output_reserve(
context_window_tokens,
budget_config,
)
reserved_schema_tokens = OUTPUT_SCHEMA_OVERHEAD_TOKENS if output_schema is not None else 0
hard_input_limit_tokens = max(
context_window_tokens - reserved_output_tokens - reserved_schema_tokens,
0,
)
if budget_config.max_input_tokens is not None:
hard_input_limit_tokens = min(
hard_input_limit_tokens,
max(budget_config.max_input_tokens, 0),
)
reserved_compaction_buffer_tokens = min(
AUTOCOMPACT_BUFFER_TOKENS,
max(context_window_tokens // 10, MIN_OUTPUT_RESERVE_TOKENS),
)
soft_input_limit_tokens = max(
hard_input_limit_tokens - reserved_compaction_buffer_tokens,
0,
)
projected_input_tokens = usage.total_tokens + chat_overhead_tokens
overflow_tokens = max(projected_input_tokens - hard_input_limit_tokens, 0)
soft_overflow_tokens = max(projected_input_tokens - soft_input_limit_tokens, 0)
return TokenBudgetSnapshot(
model=model,
context_window_tokens=context_window_tokens,
projected_input_tokens=projected_input_tokens,
message_tokens=usage.total_tokens,
chat_overhead_tokens=chat_overhead_tokens,
reserved_output_tokens=reserved_output_tokens,
reserved_compaction_buffer_tokens=reserved_compaction_buffer_tokens,
reserved_schema_tokens=reserved_schema_tokens,
hard_input_limit_tokens=hard_input_limit_tokens,
soft_input_limit_tokens=soft_input_limit_tokens,
overflow_tokens=overflow_tokens,
soft_overflow_tokens=soft_overflow_tokens,
exceeds_hard_limit=overflow_tokens > 0,
exceeds_soft_limit=soft_overflow_tokens > 0,
token_counter_backend=usage.token_counter_backend,
token_counter_source=usage.token_counter_source,
token_counter_accurate=usage.token_counter_accurate,
)
def format_token_budget(snapshot: TokenBudgetSnapshot) -> str:
lines = [
'# Token Budget',
'',
f'- Model: {snapshot.model}',
f'- Context window: {snapshot.context_window_tokens:,}',
f'- Prompt tokens: {snapshot.projected_input_tokens:,}',
f'- Message/body tokens: {snapshot.message_tokens:,}',
f'- Chat framing overhead: {snapshot.chat_overhead_tokens:,}',
f'- Reserved output tokens: {snapshot.reserved_output_tokens:,}',
f'- Reserved schema tokens: {snapshot.reserved_schema_tokens:,}',
f'- Auto-compact buffer: {snapshot.reserved_compaction_buffer_tokens:,}',
f'- Hard input limit: {snapshot.hard_input_limit_tokens:,}',
f'- Soft input limit: {snapshot.soft_input_limit_tokens:,}',
f'- Token counter: {snapshot.token_counter_backend} ({snapshot.token_counter_source})'
+ (' [accurate]' if snapshot.token_counter_accurate else ' [fallback]'),
]
if snapshot.exceeds_hard_limit:
lines.append(f'- Hard overflow: {snapshot.overflow_tokens:,}')
elif snapshot.exceeds_soft_limit:
lines.append(f'- Soft overflow: {snapshot.soft_overflow_tokens:,}')
else:
remaining = max(snapshot.soft_input_limit_tokens - snapshot.projected_input_tokens, 0)
lines.append(f'- Remaining soft headroom: {remaining:,}')
return '\n'.join(lines)
def estimate_chat_overhead(session: AgentSessionState) -> int:
total = 0
for message in session.messages:
total += CHAT_MESSAGE_OVERHEAD_TOKENS
if message.name:
total += CHAT_NAME_OVERHEAD_TOKENS
total += len(message.tool_calls) * CHAT_TOOL_CALL_OVERHEAD_TOKENS
return total + 3
def _resolve_output_reserve(
context_window_tokens: int,
budget_config: BudgetConfig,
) -> int:
if budget_config.max_output_tokens is not None:
return max(budget_config.max_output_tokens, 0)
suggested = min(DEFAULT_OUTPUT_RESERVE_TOKENS, max(context_window_tokens // 16, MIN_OUTPUT_RESERVE_TOKENS))
return suggested