b5e5824a56
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
145 lines
5.4 KiB
Python
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
|