Files
cleveractors-core/robot/TokenBudgetTestLib.py
CoreRasurae 964e87cc0f
CI / lint (pull_request) Successful in 1m31s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m5s
CI / integration_tests (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 34s
CI / coverage (pull_request) Successful in 3m7s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 33s
CI / typecheck (push) Successful in 50s
CI / security (push) Successful in 50s
CI / quality (push) Successful in 33s
CI / integration_tests (push) Successful in 1m12s
CI / build (push) Successful in 33s
CI / unit_tests (push) Successful in 3m5s
CI / coverage (push) Successful in 3m6s
CI / status-check (push) Successful in 6s
feat(tool-loop): add token-budget awareness and tool output pruning
Implement token-budget awareness (§4.4.7) and tool output pruning
(§4.4.8-9) for the multi-turn tool-call loop to prevent context-window
exhaustion.

Token budget: tracks estimated token count before each ainvoke(), emits
warning at 75% budget, injects synthesis prompt at exhaustion with one
final tool-call round. Estimation via chat_model.get_num_tokens() with
len(content)//4 heuristic fallback. Config key: token_budget_percent.

Tool output pruning: implicit LLM extraction pass scoped to file_read
calls. Schema augmentation injects output_prune (bool) and
output_prune_context (string) meta-arguments. Pruning model responds
with [PRUNE_INFO_START/END] and [PRUNE_OUTPUT_START/END] markers.
Config keys: allow_tool_output_pruning, pruning_model.

- Capture output_prune value in budget synthesis flow with proper guard
- Fix ADR D-4: synthesis prompt is HumanMessage not SystemMessage
- Fix Robot tests: replace /dev/null with temp file path

ADR-2031 documents all specification extensions. 21 new BDD scenarios,
5 Robot integration tests, 16 ASV benchmarks.

ISSUES CLOSED: #61
2026-06-26 18:21:14 +01:00

382 lines
14 KiB
Python

"""Library for token-budget and pruning integration tests.
Provides keywords that create an Executor with a mock chat model that
exercises token-budget awareness and tool-output pruning without
requiring a real LLM API key.
"""
from __future__ import annotations
import asyncio
import json as _json
import tempfile
from typing import Any
from unittest.mock import MagicMock, patch
from langchain_core.messages import AIMessage
from cleveractors.result import ActorResult
from cleveractors.runtime import Executor, create_executor
class TokenBudgetTestLib:
"""Keywords for integration tests of token-budget and pruning."""
def __init__(self) -> None:
self._executor: Executor | None = None
self._last_result: ActorResult | None = None
self._mock_ainvoke_calls: list[dict[str, Any]] = []
self._patches: list[Any] = []
self._pruning_was_called: bool = False
_tf = tempfile.NamedTemporaryFile(delete=False, mode="w")
_tf.write("")
_tf.close()
self._temp_file = _tf.name
def _teardown_patches(self) -> None:
for p in self._patches:
p.stop()
self._patches.clear()
if hasattr(self, "_temp_file"):
import os
try:
os.unlink(self._temp_file)
except OSError:
pass
# ── Common helpers ──────────────────────────────────────────────────
def _make_basic_executor(self, extra_config: dict[str, Any] | None = None) -> None:
"""Create an Executor with a tool-configured LLM agent."""
self._teardown_patches()
self._mock_ainvoke_calls = []
self._pruning_was_called = False
config: dict[str, Any] = {
"type": "llm",
"name": "budget_test_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": "echo"}],
},
}
if extra_config:
config["config"].update(extra_config)
def _build_mock_model(*args, **kwargs):
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
return AIMessage(
content="Response text",
usage_metadata={
"input_tokens": 15,
"output_tokens": 10,
"total_tokens": 10,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = _mock_ainvoke
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_mock_model,
)
patcher.start()
self._patches.append(patcher)
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)
async def _execute_async(self, message: str) -> ActorResult:
return await self._executor.execute(message)
def _execute(self, message: str) -> None:
try:
self._last_result = asyncio.run(self._execute_async(message))
finally:
self._teardown_patches()
# ── Keywords: Token-Budget Awareness ────────────────────────────────
def create_executor_with_budget(self, budget_percent: str) -> None:
"""Create an Executor with token_budget_percent set."""
pct = float(budget_percent)
self._make_basic_executor({"token_budget_percent": pct})
def create_executor_with_low_budget_for_exhaustion(self) -> None:
"""Create an Executor with an extremely low budget that triggers
exhaustion on the first message."""
self._teardown_patches()
self._mock_ainvoke_calls = []
config: dict[str, Any] = {
"type": "llm",
"name": "budget_exhaust_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": "echo"}],
"token_budget_percent": 0.000001,
},
}
def _build_budget_model(*args, **kwargs):
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count == 1 and invoke_kwargs.get("tools"):
return AIMessage(
content="",
usage_metadata={
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 5,
},
tool_calls=[
{
"id": "call_budget_001",
"name": "echo",
"args": {"text": "hello"},
},
],
)
return AIMessage(
content="Synthesis completed with available information",
usage_metadata={
"input_tokens": 15,
"output_tokens": 10,
"total_tokens": 10,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = _mock_ainvoke
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_budget_model,
)
patcher.start()
self._patches.append(patcher)
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)
def create_executor_with_pruning_enabled(self) -> None:
"""Create an Executor with pruning enabled and a file_read tool."""
self._teardown_patches()
self._mock_ainvoke_calls = []
self._pruning_was_called = False
config: dict[str, Any] = {
"type": "llm",
"name": "prune_test_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": "file_read"}],
"allow_tool_output_pruning": True,
},
}
def _build_pruning_aware_model(*args, **kwargs):
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count == 1 and invoke_kwargs.get("tools"):
return AIMessage(
content="",
usage_metadata={
"input_tokens": 30,
"output_tokens": 15,
"total_tokens": 15,
},
tool_calls=[
{
"id": "call_prune_001",
"name": "file_read",
"args": {"file": self._temp_file},
},
],
)
return AIMessage(
content="Final response after pruning",
usage_metadata={
"input_tokens": 20,
"output_tokens": 12,
"total_tokens": 12,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = _mock_ainvoke
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_pruning_aware_model,
)
patcher.start()
self._patches.append(patcher)
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)
def create_executor_with_pruning_and_opt_out(self) -> None:
"""Create an Executor with pruning enabled; the mock returns
output_prune=false in the tool call arguments."""
self._teardown_patches()
self._mock_ainvoke_calls = []
self._pruning_was_called = False
config: dict[str, Any] = {
"type": "llm",
"name": "optout_test_agent",
"provider": "openai",
"model": "gpt-3.5-turbo",
"config": {
"tools": [{"name": "file_read"}],
"allow_tool_output_pruning": True,
},
}
def _build_optout_model(*args, **kwargs):
mock_model = MagicMock()
mock_model.temperature = 0.7
async def _mock_ainvoke(messages, **invoke_kwargs):
self._mock_ainvoke_calls.append({"kwargs": invoke_kwargs})
call_count = len(self._mock_ainvoke_calls)
if call_count == 1 and invoke_kwargs.get("tools"):
return AIMessage(
content="",
usage_metadata={
"input_tokens": 30,
"output_tokens": 15,
"total_tokens": 15,
},
tool_calls=[
{
"id": "call_optout_001",
"name": "file_read",
"args": {
"file": self._temp_file,
"output_prune": False,
},
},
],
)
return AIMessage(
content="Final answer without pruning",
usage_metadata={
"input_tokens": 20,
"output_tokens": 12,
"total_tokens": 12,
},
)
mock_model.ainvoke = _mock_ainvoke
mock_model.astream = _mock_ainvoke
return mock_model
patcher = patch(
"cleveractors.agents.llm.build_chat_model",
side_effect=_build_optout_model,
)
patcher.start()
self._patches.append(patcher)
self._executor = create_executor(
config_dict=config,
credentials={"openai": {"api_key": "mock-key"}},
limits={},
pricing={},
)
def create_executor_without_budget_or_pruning(self) -> None:
"""Create an Executor without any budget/pruning config (backward compat)."""
self._make_basic_executor()
# ── Keywords: Execution ─────────────────────────────────────────────
def execute_with_message(self, message: str) -> None:
self._execute(message)
def execute_stream_with_message(self, message: str) -> None:
async def _stream():
chunks = []
async for token in self._executor.execute_stream(message):
chunks.append(token)
return "".join(chunks)
try:
asyncio.run(_stream())
self._last_result = self._executor.last_result
finally:
self._teardown_patches()
# ── Keywords: Assertions ────────────────────────────────────────────
def result_is_valid_actor_result(self) -> None:
assert isinstance(self._last_result, ActorResult), (
f"Expected ActorResult, got {type(self._last_result)}"
)
def result_response_contains(self, text: str) -> None:
assert self._last_result is not None
assert text in self._last_result.response, (
f"Expected '{text}' in response, got: {self._last_result.response[:200]}"
)
def result_has_nodes(self) -> None:
assert self._last_result is not None
assert len(self._last_result.nodes) > 0
def result_prompt_tokens_greater_than_zero(self) -> None:
assert self._last_result is not None
assert self._last_result.prompt_tokens > 0
def agent_initialized_successfully(self) -> None:
assert self._executor is not None
assert self._executor._agent is not None
def chat_model_was_invoked(self) -> None:
assert len(self._mock_ainvoke_calls) > 0, "chat_model.ainvoke was never called"
def chat_model_was_invoked_with_tools(self) -> None:
for call in self._mock_ainvoke_calls:
if call.get("kwargs", {}).get("tools"):
return
raise AssertionError("chat_model.ainvoke was never called with tools kwarg")
def multiple_rounds_occurred(self) -> None:
assert len(self._mock_ainvoke_calls) >= 2, (
f"Expected >=2 ainvoke calls, got {len(self._mock_ainvoke_calls)}"
)