fix(actors): llm token consumption undercounted #66

Merged
CoreRasurae merged 1 commits from bugfix/m2-lllm-token-consumption-undercounted into master 2026-07-01 14:44:39 +00:00
6 changed files with 792 additions and 159 deletions
+7 -4
View File
@@ -9,15 +9,17 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm
### Added
- **LLM Agent Token-Budget Awareness and Tool Output Pruning (issue #61)** (`llm.py`): Two complementary mechanisms to prevent context-window exhaustion in the multi-turn tool-call loop.
- **LLM Agent Token-Budget Awareness and Tool Output Pruning (issue #61, #65)** (`llm.py`): Two complementary mechanisms to prevent context-window exhaustion in the multi-turn tool-call loop, with accurate token tracking for billing.
**Token-budget awareness** (`token_budget_percent` config, default off): tracks estimated token consumption before each LLM invocation. Emits a warning at 75% budget consumption. When the budget ceiling is exceeded, injects a synthesis prompt, permits one final tool-call round, then forces a text-only response. Token estimation uses `chat_model.get_num_tokens()` when available, falling back to a `len(content)//4` heuristic.
**Token-budget awareness** (`token_budget_percent` config, default off): tracks actual token consumption from LLM response metadata before each invocation. Emits a warning at 75% budget consumption. When the budget ceiling is exceeded, injects a synthesis prompt, permits one final tool-call round, then forces a text-only response. Token counts from all rounds (including budget-exhaustion synthesis, stuck-model synthesis, and pruning passes) are accumulated for accurate billing.
**Tool output pruning** (`allow_tool_output_pruning` config, default `false`): an implicit LLM extraction pass that runs between tool execution and ToolMessage construction, scoped initially to `file_read` calls. The main model can opt-out per call via the `output_prune` meta-argument, or provide a focused search directive via `output_prune_context`. The pruning model responds with `[PRUNE_INFO_START/END]` and `[PRUNE_OUTPUT_START/END]` markers (§4.4.9) to separate pruning metadata from extracted content. An optional `pruning_model` config allows using a lighter model for extraction. On failure, the raw tool output is used as-is.
**Tool output pruning** (`allow_tool_output_pruning` config, default `false`): an implicit LLM extraction pass that runs between tool execution and ToolMessage construction, scoped initially to `file_read` calls. The main model can opt-out per call via the `output_prune` meta-argument, or provide a focused search directive via `output_prune_context`. The pruning model responds with `[PRUNE_INFO_START/END]` and `[PRUNE_OUTPUT_START/END]` markers (§4.4.9) to separate pruning metadata from extracted content. On failure, the raw tool output is used as-is.
**Token tracking:** `_extract_token_counts()` helper extracts token metadata via a three-tier fallback (`usage_metadata``response_metadata["token_usage"]``(0, 0, False)`), returning a `(prompt, completion, metadata_present)` tuple so callers can distinguish absent metadata from legitimate zero-count responses. Missing token-usage metadata on the pruning model raises `MissingUsageMetadataError` instead of silently zeroing billing data. Tokens from prior loop rounds are preserved in `ActorResult` even when a pruning-pass error occurs mid-loop.
**ADR:** `docs/adr/ADR-2031-tool-loop-token-budget-and-pruning.md` documents the specification extensions (three new §4.4 config fields, two runtime behaviors, one normative marker format).
**Module:** `src/cleveractors/agents/llm.py`. 1 source file changed, BDD: 21 new scenarios in `features/llm_agent_tool_calling.feature`, Robot: 5 new integration tests in `robot/llm_token_budget.robot`, ASV: 16 benchmarks in `benchmarks/token_budget_benchmark.py`.
**Module:** `src/cleveractors/agents/llm.py`, `src/cleveractors/core/exceptions.py`. BDD: scenarios in `features/llm_agent_tool_calling.feature`, Robot: integration tests in `robot/llm_token_budget.robot`, ASV: benchmarks in `benchmarks/token_budget_benchmark.py`.
- **Tool Schema Descriptions (`llm_tools.py`) (issue #59)** (`cleveractors.agents.llm_tools`): New module providing `normalize_tool_entry()` and `_BUILTIN_TOOL_SCHEMAS` — a registry of OpenAI function-calling parameter schemas for all built-in tools (`echo`, `math`, `json_parse`, `http_request`, `file_read`, `file_write`, `progress_bar`, `shell`, `python_exec`). Each schema includes targeted LLM guidance: `file_read` requires `max_chars` for files >10KB to prevent context overflow; `file_write` clarifies that response text is NOT persisted to disk; `shell` directs the LLM toward `file_read` for reading files; `python_exec` lists available sandbox builtins and instructs the LLM to use `file_read`/`file_write` instead of `open()`. String tool names in agent config (e.g. `"file_read"`) are normalized to full parameter schemas so the LLM knows about parameters like `max_chars`. Config dicts with a `"name"` key are also normalized; already-formatted OpenAI tool dicts pass through unchanged.
@@ -29,6 +31,7 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm
### Fixed
- **LLM Agent Synthesis-Round Tool Name Validation (issue #63)** (`llm.py`): When the model hallucinates a tool name not present in the declared tools list during the synthesis round, the invalid call is now rejected with a `ToolMessage` error stating `"Tool '<name>' is not available."` instead of crashing or silently discarding the response. A `_declared_names` set is built once per synthesis round from `self._lc_tools` for O(1) lookup.
- **ToolAgent Tool Name Dispatch Bug (issue #63)** (`tool.py`): `_execute_tool()` was checking `tool_name not in self.tools` where `self.tools` is a heterogeneous list of strings and dicts — a string tool name like `"file_read"` would never match the list because the list contains strings, not dicts with that name. Fixed by extracting `string_tool_names` and checking against that list instead.
+29 -29
View File
@@ -36,46 +36,45 @@ def _make_agent(**extra_config: Any) -> LLMAgent:
return agent
def _messages_with_size(chars: int) -> list[Any]:
"""Build a message list with approximately *chars* characters of content."""
content = "x" * max(1, chars - 70)
return [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=content),
]
# ── Token Extraction Benchmarks ───────────────────────────────────────
# ── Token Budget Benchmarks ────────────────────────────────────────────
class TokenBudgetBenchmark:
"""Benchmarks for token-budget awareness (§4.4.7)."""
class TokenExtractionBenchmark:
"""Benchmarks for _extract_token_counts (§4.4.7)."""
def setup(self) -> None:
self.agent = _make_agent()
self.resp_with_usage = MagicMock(spec=AIMessage)
self.resp_with_usage.usage_metadata = {
"input_tokens": 150,
"output_tokens": 50,
}
self.resp_via_metadata = MagicMock(spec=AIMessage)
self.resp_via_metadata.usage_metadata = None
self.resp_via_metadata.response_metadata = {
"token_usage": {"prompt_tokens": 200, "completion_tokens": 75},
}
self.resp_no_metadata = MagicMock(spec=AIMessage)
self.resp_no_metadata.usage_metadata = None
self.resp_no_metadata.response_metadata = None
def teardown(self) -> None:
self.agent = None
def time_estimate_small_messages(self) -> None:
"""Estimate tokens for a small (100 char) conversation."""
msgs = _messages_with_size(100)
self.agent._estimate_token_count(msgs)
def time_extract_via_usage_metadata(self) -> None:
"""Extract token counts via usage_metadata path."""
self.agent._extract_token_counts(self.resp_with_usage)
def time_estimate_medium_messages(self) -> None:
"""Estimate tokens for a 10K char conversation."""
msgs = _messages_with_size(10_000)
self.agent._estimate_token_count(msgs)
def time_extract_via_response_metadata(self) -> None:
"""Extract token counts via response_metadata path."""
self.agent._extract_token_counts(self.resp_via_metadata)
def time_estimate_large_messages(self) -> None:
"""Estimate tokens for a 50K char conversation."""
msgs = _messages_with_size(50_000)
self.agent._estimate_token_count(msgs)
def time_estimate_very_large_messages(self) -> None:
"""Estimate tokens for a 200K char conversation near context limit."""
msgs = _messages_with_size(200_000)
self.agent._estimate_token_count(msgs)
def time_extract_no_metadata(self) -> None:
"""Extract token counts when no metadata is available."""
self.agent._extract_token_counts(self.resp_no_metadata)
def time_context_window_lookup(self) -> None:
"""Look up the context window for the configured model."""
@@ -207,6 +206,7 @@ class PruningPassBenchmark:
f"[PRUNE_OUTPUT_START]\n{self.extracted}\n"
f"[PRUNE_OUTPUT_END]"
)
resp.usage_metadata = {"input_tokens": 50, "output_tokens": 20}
return resp
mock_prune_model.ainvoke = _mock_prune_ainvoke
+150 -9
View File
@@ -120,6 +120,19 @@ Feature: LLMAgent Tool Calling
When I create the LLM agent
Then the agent _pruning_model should be None
# ── pruning_model matching enforcement (issue #65) ──────────────────
Scenario: pruning_model mismatch raises ConfigurationError
Given I have an LLM agent config with model "gpt-4" and pruning_model "gpt-3.5-turbo"
When I attempt to create the LLM agent
Then ConfigurationError should be raised containing "must match"
Scenario: pruning_model same as model succeeds
Given I have an LLM agent config with model "gpt-4" and pruning_model "gpt-4"
When I create the LLM agent
Then the agent _pruning_model should be "gpt-4"
And the agent model should be "gpt-4"
# ── pruning_threshold (§4.4.8 D-2) ────────────────────────────────────
Scenario: LLMAgent stores pruning_threshold when configured
@@ -243,18 +256,11 @@ Feature: LLMAgent Tool Calling
When I create the LLM agent
Then _get_model_context_window should return 128000
# ── Token Estimation (§4.4.7) ───────────────────────────────────────
Scenario: _estimate_token_count uses heuristic fallback
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I set a mock chat model that returns a plain text response
And I estimate token count for messages with content "hello"
Then the estimated token count should be greater than 0
# ── _run_pruning_pass with mocked pruning model (§4.4.8) ────────────
# ── _run_pruning_pass with mocked pruning model (§4.4.8) ────────────
Scenario: _run_pruning_pass returns marked-up response
Scenario: _run_pruning_pass returns marked-up response with token counts
Given I have an LLM agent config with allow_tool_output_pruning true
When I create the LLM agent
And I set a mock pruning model that returns a marked-up response
@@ -262,6 +268,7 @@ Feature: LLMAgent Tool Calling
Then the pruned result should contain PRUNE_INFO_START
And the pruned result should contain PRUNE_OUTPUT_START
And the pruned result should contain "extracted content"
And the pruning token counts should be greater than zero
Scenario: _run_pruning_pass falls back to raw output on error
Given I have an LLM agent config with allow_tool_output_pruning true
@@ -276,6 +283,28 @@ Feature: LLMAgent Tool Calling
And I run the pruning pass on tool "echo" with empty output
Then the pruned result should be empty
# ── Token usage metadata enforcement (issue #65) ────────────────────
Scenario: _run_pruning_pass fails when response lacks usage_metadata
Given I have an LLM agent config with allow_tool_output_pruning true
When I create the LLM agent
And I set a mock pruning model that returns a response without usage_metadata
And I attempt to run the pruning pass on tool "file_read" with output "50KB of logs"
Then a MissingUsageMetadataError should be raised containing "token usage metadata"
Scenario: MissingUsageMetadataError in tool loop preserves accumulated tokens
Given I have an LLM agent configuration with tools [{"name": "file_read"}]
And I set allow_tool_output_pruning to true
And I set pruning_threshold to 1
And I set tool_max_rounds to 3
And I set unsafe_mode to true
When I create the LLM agent
And I set a mock chat model that returns one file_read tool call then text
And I set a mock pruning model that returns a response without usage_metadata
And I attempt to tool_calling process a message "Test billing preservation"
Then an ExecutionError should be raised containing "token usage metadata"
And the last token usage should have accumulated counts from prior rounds
# ── Token budget exhaustion in tool loop (§4.4.7) ──────────────────
Scenario: Token budget exhaustion triggers synthesis flow
@@ -355,3 +384,115 @@ Feature: LLMAgent Tool Calling
And I set a mock chat model that gets stuck then returns hallucinated tool name in synthesis
And I tool_calling process a message "Get stuck"
Then the synthesis messages should contain a ToolMessage with content "Tool 'nonexistent_tool' is not available."
# ── Additional coverage: token_budget_percent type validation ──────
Scenario: LLMAgent validates token_budget_percent must be numeric
Given I have a basic LLM agent configuration (no tools)
And I set token_budget_percent to string "not_a_number"
When I attempt to create the LLM agent
Then ConfigurationError should be raised containing "token_budget_percent must be a number"
# ── Additional coverage: pruning response missing end tag ──────────
Scenario: Parse pruning response with missing end tag falls back to full text
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I parse a pruning response that has start tag but no end tag
Then the parsed result should contain "Partial info without end tag"
# ── Additional coverage: tool_max_rounds parse error ──────────────
Scenario: LLMAgent rejects invalid tool_max_rounds on process_message
Given I have an LLM agent configuration with tools [{"name": "echo"}]
And I set tool_max_rounds to string "abc"
When I create the LLM agent
And I set a mock chat model that returns a plain text response
And I attempt to tool_calling process a message "Hello"
Then ConfigurationError should be raised containing "tool_max_rounds must be an integer"
# ── Additional coverage: direct-format tool calls ──────────────────
Scenario: LLMAgent handles tool_calls in direct format without function key
Given I have an LLM agent configuration with tools [{"name": "echo"}]
And I set tool_max_rounds to 2
When I create the LLM agent
And I set a mock chat model that returns a direct-format tool call then text
And I tool_calling process a message "Test direct format"
Then the tool_calling result should contain "Direct format answer"
# ── _extract_token_counts coverage: response_metadata fallback ─────
Scenario: _extract_token_counts extracts tokens via response_metadata fallback
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I extract token counts from a response using response_metadata fallback
Then the extracted token counts should be (30, 15, True)
Scenario: _extract_token_counts returns zero for empty usage_metadata
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I extract token counts from a response with empty usage_metadata
Then the extracted token counts should be (0, 0, False)
Scenario: _extract_token_counts returns zero when response_metadata has no token_usage key
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I extract token counts from a response with response_metadata without token_usage key
Then the extracted token counts should be (0, 0, False)
Scenario: _extract_token_counts returns zero for non-dict response_metadata
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I extract token counts from a response with non-dict response_metadata
Then the extracted token counts should be (0, 0, False)
Scenario: _extract_token_counts returns zero for missing response_metadata
Given I have a basic LLM agent configuration (no tools)
When I create the LLM agent
And I extract token counts from a response with missing response_metadata
Then the extracted token counts should be (0, 0, False)
# ── Budget synthesis with tool calls and pruning ──────────────────
Scenario: Budget synthesis tool calls trigger pruning pass
Given I have an LLM agent configuration with tools [{"name": "file_read"}]
And I set token_budget_percent to 0.000001
And I set allow_tool_output_pruning to true
And I set pruning_threshold to 1
And I set tool_max_rounds to 5
And I set unsafe_mode to true
When I create the LLM agent
And I set a mock chat model that triggers budget exhaustion then returns tool calls in synthesis
And I set a mock pruning model that returns a marked-up response
And I tool_calling process a message "Test budget synth pruning"
Then the tool_calling result should contain "Synthesis answer with pruning"
And the pruning pass should have been called
# ── MissingUsageMetadataError in budget synthesis ─────────────────
Scenario: MissingUsageMetadataError in budget synthesis preserves accumulated tokens
Given I have an LLM agent configuration with tools [{"name": "file_read"}]
And I set token_budget_percent to 0.000001
And I set allow_tool_output_pruning to true
And I set pruning_threshold to 1
And I set tool_max_rounds to 5
And I set unsafe_mode to true
When I create the LLM agent
And I set a mock chat model that triggers budget exhaustion then returns tool calls in synthesis
And I set a mock pruning model that returns a response without usage_metadata
And I attempt to tool_calling process a message "Test budget synth error"
Then an ExecutionError should be raised containing "token usage metadata"
And the last token usage should have accumulated counts from prior rounds
# ── Stuck-model synthesis tool execution error ─────────────────────
Scenario: Stuck-model synthesis tool error preserves accumulated tokens
Given I have an LLM agent configuration with tools [{"name": "echo"}]
And I set tool_max_rounds to 2
When I create the LLM agent
And I set a mock chat model that gets stuck then returns a tool call that raises ExecutionError
And I tool_calling process a message "Get stuck with error"
Then the tool_calling result should contain "Final answer after error"
And the accumulated prompt tokens should be greater than 0
And the accumulated completion tokens should be greater than 0
+410 -18
View File
@@ -17,7 +17,11 @@ else:
from langchain_core.language_models.chat_models import BaseChatModel
from cleveractors.agents.llm import LLMAgent
from cleveractors.core.exceptions import ConfigurationError
from cleveractors.core.exceptions import (
ConfigurationError,
ExecutionError,
MissingUsageMetadataError,
)
from cleveractors.templates.renderer import TemplateRenderer
@@ -253,6 +257,26 @@ def step_set_tool_max_rounds(context, n):
context.llm_config["tool_max_rounds"] = n
@given("I set pruning_threshold to {n:d}")
def step_set_pruning_threshold(context, n):
context.llm_config["pruning_threshold"] = n
@given("I set unsafe_mode to true")
def step_set_unsafe_mode_true(context):
context.llm_config["unsafe_mode"] = True
@given('I set tool_max_rounds to string "{bad_value}"')
def step_set_tool_max_rounds_bad(context, bad_value):
context.llm_config["tool_max_rounds"] = bad_value
@given('I set token_budget_percent to string "{bad_value}"')
def step_set_budget_percent_string(context, bad_value):
context.llm_config["token_budget_percent"] = bad_value
@when(
"I set a mock chat model that returns tool_calls with empty content "
"for {n:d} rounds then text"
@@ -422,6 +446,20 @@ def step_config_with_pruning_model(context, model):
}
@given(
'I have an LLM agent config with model "{agent_model}" '
'and pruning_model "{prune_model}"'
)
def step_config_with_model_and_pruning_model(context, agent_model, prune_model):
context.llm_config = {
"name": "pm_model_agent",
"provider": "openai",
"api_key": "test_key",
"model": agent_model,
"pruning_model": prune_model,
}
@given("I have an LLM agent config with pruning_threshold {n:d}")
def step_config_with_pruning_threshold(context, n):
context.llm_config = {
@@ -546,12 +584,11 @@ def step_parse_no_markers(context, text):
context._parsed_pruning = context.llm_agent._parse_pruning_response(text)
@when('I estimate token count for messages with content "{text}"')
def step_estimate_tokens(context, text):
from langchain_core.messages import HumanMessage as HM
msgs = [HM(content=text)]
context._estimated_tokens = context.llm_agent._estimate_token_count(msgs)
@when("I parse a pruning response that has start tag but no end tag")
def step_parse_missing_end_tag(context):
agent = context.llm_agent
response = agent._PRUNE_INFO_START + "\nPartial info without end tag"
context._parsed_pruning = agent._parse_pruning_response(response)
def _cleanup_prune_patch(context):
@@ -572,6 +609,7 @@ def step_mock_pruning_model(context):
"[PRUNE_INFO_START]\nRemoved 48K chars of logs\n[PRUNE_INFO_END]\n"
"[PRUNE_OUTPUT_START]\nextracted content\n[PRUNE_OUTPUT_END]"
)
response.usage_metadata = {"input_tokens": 50, "output_tokens": 20}
return response
mock_prune_model.ainvoke = _mock_prune_ainvoke
@@ -597,11 +635,39 @@ def step_mock_pruning_model_error(context):
context._prune_patch.start()
@when("I set a mock pruning model that returns a response without usage_metadata")
def step_mock_pruning_model_no_usage(context):
mock_prune_model = Mock(spec=BaseChatModel)
async def _mock_prune_ainvoke(messages, **kw):
context._pruning_was_called = True
response = Mock(spec=AIMessage)
response.content = (
"[PRUNE_INFO_START]\nRemoved 48K chars of logs\n[PRUNE_INFO_END]\n"
"[PRUNE_OUTPUT_START]\nextracted content\n[PRUNE_OUTPUT_END]"
)
# Deliberately omit usage_metadata to trigger the enforcement
# (issue #65 Gap 2 enforcement).
if hasattr(response, "usage_metadata"):
del response.usage_metadata
return response
mock_prune_model.ainvoke = _mock_prune_ainvoke
context._prune_patch = patch(
"cleveractors.agents.llm.build_chat_model",
return_value=mock_prune_model,
)
context._prune_patch.start()
@when('I run the pruning pass on tool "{tool_name}" with output "{output}"')
def step_run_pruning_pass(context, tool_name, output):
context._prune_result = asyncio.run(
_prune_str, _prune_pt, _prune_ct = asyncio.run(
context.llm_agent._run_pruning_pass(tool_name, output, [])
)
context._prune_result = _prune_str
context._prune_prompt_tokens = _prune_pt
context._prune_completion_tokens = _prune_ct
@when(
@@ -609,7 +675,7 @@ def step_run_pruning_pass(context, tool_name, output):
'"{output}" and prune_context "{prune_context}"'
)
def step_run_pruning_pass_with_context(context, tool_name, output, prune_context):
context._prune_result = asyncio.run(
_prune_str, _prune_pt, _prune_ct = asyncio.run(
context.llm_agent._run_pruning_pass(
tool_name,
output,
@@ -617,13 +683,28 @@ def step_run_pruning_pass_with_context(context, tool_name, output, prune_context
prune_context=prune_context,
)
)
context._prune_result = _prune_str
context._prune_prompt_tokens = _prune_pt
context._prune_completion_tokens = _prune_ct
@when('I run the pruning pass on tool "{tool_name}" with empty output')
def step_run_pruning_pass_empty(context, tool_name):
context._prune_result = asyncio.run(
_prune_str, _prune_pt, _prune_ct = asyncio.run(
context.llm_agent._run_pruning_pass(tool_name, "", [])
)
context._prune_result = _prune_str
context._prune_prompt_tokens = _prune_pt
context._prune_completion_tokens = _prune_ct
@when('I attempt to run the pruning pass on tool "{tool_name}" with output "{output}"')
def step_attempt_run_pruning_pass(context, tool_name, output):
try:
asyncio.run(context.llm_agent._run_pruning_pass(tool_name, output, []))
context._prune_error = None
except MissingUsageMetadataError as e:
context._prune_error = e
@when("I set a mock chat model that returns tool_calls then text for budget exhaustion")
@@ -681,6 +762,45 @@ def step_mock_one_tool_call(context):
context.llm_agent.chat_model = mock_model
@when("I set a mock chat model that returns one file_read tool call then text")
def step_mock_one_file_read_tool_call(context):
import os
cwd = os.getcwd()
file_path = os.path.join(cwd, "test_prune_file.txt")
with open(file_path, "w") as f:
f.write("A" * 600)
context._prune_file_path = file_path
if not hasattr(context, "_cleanup_files"):
context._cleanup_files = []
context._cleanup_files.append(file_path)
call_counter = [0]
context._pruning_was_called = False
async def _mock_ainvoke(messages, **invoke_kwargs):
call_counter[0] += 1
if call_counter[0] == 1:
return _create_mock_response(
"Thinking...",
tool_calls=[
{
"id": "call_prune_1",
"type": "function",
"function": {
"name": "file_read",
"arguments": _json.dumps({"file": file_path}),
},
}
],
)
return _create_mock_response("Final answer")
mock_model = Mock(spec=BaseChatModel)
mock_model.ainvoke = _mock_ainvoke
context.llm_agent.chat_model = mock_model
@when(
"I set a mock chat model that returns tool call with output_prune false then text"
)
@@ -727,18 +847,100 @@ def step_mock_output_prune_false(context):
context.llm_agent.chat_model = mock_model
@when("I set a mock chat model that returns a direct-format tool call then text")
def step_mock_direct_format_tool_call(context):
call_counter = [0]
async def _mock_ainvoke(messages, **invoke_kwargs):
call_counter[0] += 1
if call_counter[0] == 1:
return _create_mock_response(
"Thinking...",
tool_calls=[
{
"id": "call_direct_1",
"name": "echo",
"args": {"text": "hello"},
}
],
)
return _create_mock_response("Direct format answer")
mock_model = Mock(spec=BaseChatModel)
mock_model.ainvoke = _mock_ainvoke
context.llm_agent.chat_model = mock_model
@when('I attempt to tool_calling process a message "{message_txt}"')
def step_attempt_process_message(context, message_txt):
try:
context.result = asyncio.run(context.llm_agent.process_message(message_txt))
context._process_error = None
except (ConfigurationError, ExecutionError) as e:
context._process_error = e
# ─── Then steps (token-budget / pruning) ─────────────────────────────
@then('ConfigurationError should be raised containing "{text}"')
def step_config_error_raised(context, text):
err = context._create_error
err = getattr(context, "_process_error", None)
if err is None:
err = getattr(context, "_create_error", None)
assert err is not None, "Expected ConfigurationError but none was raised"
assert text in str(err), (
f"ConfigurationError message did not contain {text!r}: {err}"
)
@then('a MissingUsageMetadataError should be raised containing "{text}"')
def step_missing_metadata_error_raised(context, text):
err = context._prune_error
assert err is not None, "Expected MissingUsageMetadataError but none was raised"
assert isinstance(err, MissingUsageMetadataError), (
f"Expected MissingUsageMetadataError, got {type(err).__name__}: {err}"
)
assert text in str(err), (
f"MissingUsageMetadataError message did not contain {text!r}: {err}"
)
@then('an ExecutionError should be raised containing "{text}"')
def step_execution_error_raised(context, text):
err = context._process_error
assert err is not None, "Expected ExecutionError but none was raised"
assert isinstance(err, ExecutionError), (
f"Expected ExecutionError, got {type(err).__name__}: {err}"
)
assert text in str(err), f"ExecutionError message did not contain {text!r}: {err}"
@then("the last token usage should have accumulated counts from prior rounds")
def step_last_token_usage_accumulated(context):
pt, ct = context.llm_agent._last_token_usage
assert pt > 0, f"Expected prompt_tokens > 0, got {pt}"
assert ct > 0, f"Expected completion_tokens > 0, got {ct}"
_cleanup_prune_patch(context)
context._process_error = None
@then("the pruning token counts should be greater than zero")
def step_prune_token_counts_gt_zero(context):
pt = context._prune_prompt_tokens
ct = context._prune_completion_tokens
assert pt is not None, "prune_prompt_tokens was not set"
assert ct is not None, "prune_completion_tokens was not set"
assert pt > 0, f"Expected prompt_tokens > 0, got {pt}"
assert ct > 0, f"Expected completion_tokens > 0, got {ct}"
@then('the agent model should be "{expected}"')
def step_agent_model(context, expected):
actual = context.llm_agent.model
assert actual == expected, f"Agent model = {actual!r} != {expected!r}"
@then("the agent _token_budget_percent should be {value:g}")
def step_budget_stored(context, value):
actual = context.llm_agent._token_budget_percent
@@ -850,19 +1052,19 @@ def step_parsed_equals(context, text):
)
@then('the parsed result should contain "{text}"')
def step_parsed_contains(context, text):
assert text in context._parsed_pruning, (
f"Expected {text!r} in parsed result: {context._parsed_pruning!r}"
)
@then("_get_model_context_window should return {n:d}")
def step_context_window(context, n):
actual = context.llm_agent._get_model_context_window()
assert actual == n, f"_get_model_context_window = {actual} != {n}"
@then("the estimated token count should be greater than 0")
def step_estimate_positive(context):
assert context._estimated_tokens > 0, (
f"Expected >0 tokens, got {context._estimated_tokens}"
)
@then("the pruned result should contain PRUNE_INFO_START")
def step_pruned_has_info(context):
assert context.llm_agent._PRUNE_INFO_START in context._prune_result
@@ -1063,3 +1265,193 @@ def step_synthesis_messages_have_toolmessage(context, text):
f"No ToolMessage found with exact content {text!r}. "
f"ToolMessages: {[str(m.content) for m in tool_msgs]}"
)
# ─── _extract_token_counts direct testing steps ──────────────────────
@when("I extract token counts from a response with usage_metadata only")
def step_extract_via_usage_metadata(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = {"input_tokens": 50, "output_tokens": 25}
resp.response_metadata = None
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@when("I extract token counts from a response using response_metadata fallback")
def step_extract_via_response_metadata(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = None
resp.response_metadata = {
"token_usage": {"prompt_tokens": 30, "completion_tokens": 15}
}
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@when("I extract token counts from a response with empty usage_metadata")
def step_extract_empty_usage(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = {}
resp.response_metadata = None
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@when(
"I extract token counts from a response with response_metadata "
"without token_usage key"
)
def step_extract_no_token_usage(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = None
resp.response_metadata = {"other_key": "value"}
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@when("I extract token counts from a response with non-dict response_metadata")
def step_extract_non_dict_response_metadata(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = None
resp.response_metadata = [1, 2, 3]
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@when("I extract token counts from a response with missing response_metadata")
def step_extract_missing_response_metadata(context):
resp = Mock(spec=AIMessage)
resp.usage_metadata = None
resp.response_metadata = None
context._extracted_tokens = context.llm_agent._extract_token_counts(resp)
@then("the extracted token counts should be ({pt:d}, {ct:d}, {mp})")
def step_assert_extracted_tokens(context, pt, ct, mp):
expected_mp = mp == "True"
actual_pt, actual_ct, actual_mp = context._extracted_tokens
assert actual_pt == pt, f"Expected prompt_tokens={pt}, got {actual_pt}"
assert actual_ct == ct, f"Expected completion_tokens={ct}, got {actual_ct}"
assert actual_mp is expected_mp, (
f"Expected metadata_present={expected_mp}, got {actual_mp}"
)
# ─── Budget synthesis with tool calls and pruning ────────────────────
@when(
"I set a mock chat model that triggers budget exhaustion "
"then returns tool calls in synthesis"
)
def step_mock_budget_then_synth_tools(context):
import os
cwd = os.getcwd()
file_path = os.path.join(cwd, "test_budget_synth_prune.txt")
with open(file_path, "w") as f:
f.write("A" * 700)
if not hasattr(context, "_cleanup_files"):
context._cleanup_files = []
context._cleanup_files.append(file_path)
call_counter = [0]
context._pruning_was_called = False
async def _mock_ainvoke(messages, **invoke_kwargs):
call_counter[0] += 1
if call_counter[0] == 1:
# Use echo (not in pruning filter) to avoid pruning in main loop
return _create_mock_response(
"",
tool_calls=[
{
"id": "call_budget_synth_1",
"type": "function",
"function": {
"name": "echo",
"arguments": '{"text": "hello"}',
},
}
],
)
if call_counter[0] == 2:
return _create_mock_response(
"Let me read the file",
tool_calls=[
{
"id": "call_budget_synth_2",
"type": "function",
"function": {
"name": "file_read",
"arguments": _json.dumps({"file": file_path}),
},
}
],
)
return _create_mock_response("Synthesis answer with pruning")
mock_model = Mock(spec=BaseChatModel)
mock_model.ainvoke = _mock_ainvoke
context.llm_agent.chat_model = mock_model
# ─── Stuck-model synthesis tool error ────────────────────────────────
@when(
"I set a mock chat model that gets stuck then returns a tool call "
"that raises ExecutionError"
)
def step_mock_stuck_then_tool_error(context):
call_counter = [0]
async def _mock_ainvoke(messages, **invoke_kwargs):
call_counter[0] += 1
if call_counter[0] <= 2:
return _create_mock_response(
"",
tool_calls=[
{
"id": f"call_stuck_err_{call_counter[0]}",
"type": "function",
"function": {
"name": "echo",
"arguments": '{"text": "stuck"}',
},
}
],
)
if call_counter[0] == 3:
return _create_mock_response(
"Let me try a different tool",
tool_calls=[
{
"id": "call_synth_err",
"type": "function",
"function": {
"name": "nonexistent_tool",
"arguments": '{"text": "boom"}',
},
}
],
)
return _create_mock_response("Final answer after error")
mock_model = Mock(spec=BaseChatModel)
mock_model.ainvoke = _mock_ainvoke
context.llm_agent.chat_model = mock_model
@then("the accumulated prompt tokens should be greater than {n:d}")
def step_accumulated_prompt_gt(context, n):
pt, ct = context.llm_agent._last_token_usage
assert pt > n, f"Expected accumulated prompt_tokens > {n}, got {pt}"
@then("the accumulated completion tokens should be greater than {n:d}")
def step_accumulated_completion_gt(context, n):
pt, ct = context.llm_agent._last_token_usage
assert ct > n, f"Expected accumulated completion_tokens > {n}, got {ct}"
@then("the tool_calling result should be empty")
def step_result_empty(context):
assert not context.result, f"Expected empty result, got {context.result!r}"
+185 -99
View File
@@ -42,6 +42,7 @@ from cleveractors.core.exceptions import (
AgentCreationError,
ConfigurationError,
ExecutionError,
MissingUsageMetadataError,
)
from cleveractors.result import MAX_REASONABLE_TOKENS
from cleveractors.templates.renderer import TemplateRenderer
@@ -221,6 +222,17 @@ class LLMAgent(AgentWithMemory):
)
self._pruning_model: str | None = config.get("pruning_model") or None
# Temporary limitation: pruning_model must match the agent's model
# (issue #65). Support for a separate pruning model is disabled
# because multi-model token tracking across separate LangChain
# instances is not yet plumbed through NodeUsage.
if self._pruning_model is not None and self._pruning_model != self.model:
raise ConfigurationError(
f"pruning_model ({self._pruning_model}) must match the "
f"agent model ({self.model}). Using a different model "
f"for pruning is temporarily disabled (issue #65)."
)
# pruning_threshold: character length threshold for pruning (§4.4.8 D-2).
# When raw tool output length is at or below this value, no pruning
# occurs even when allow_tool_output_pruning is true and tool matches.
@@ -368,22 +380,54 @@ class LLMAgent(AgentWithMemory):
return self.max_tokens
return 128_000
def _estimate_token_count(self, messages: list[Any]) -> int:
"""Estimate total token count using get_num_tokens or heuristic fallback.
def _extract_token_counts(self, response: object) -> tuple[int, int, bool]:
"""Extract (prompt_tokens, completion_tokens, metadata_present) from an LLM response.
Note: the heuristic fallback (chars//4) does not count AIMessage.tool_calls
JSON. Per ADR D-6 this is a known limitation tool-call payloads can be
hundreds of characters and are excluded from the estimate."""
try:
_estimator = getattr(self.chat_model, "get_num_tokens", None)
if _estimator is not None:
return int(_estimator(messages))
except Exception: # nosec B110 — intentional fallback to heuristic
pass
total_chars = 0
for msg in messages:
total_chars += len(str(getattr(msg, "content", "")))
return max(1, total_chars // 4)
Uses the same three-tier fallback chain as the main token extraction
in process_message():
1. usage_metadata (LangChain standard field)
2. response_metadata["token_usage"] (provider-specific)
3. (0, 0, False) if neither is available
The third element (metadata_present) is True when token usage metadata
was found and parsed, allowing callers to distinguish "no metadata"
from "metadata that resolved to zero/zero".
"""
_prompt: int = 0
_completion: int = 0
_metadata_present: bool = False
_usage_raw: object = getattr(response, "usage_metadata", None)
_usage: dict[str, Any] | None = (
_usage_raw if isinstance(_usage_raw, dict) else None
)
if _usage is not None and _usage:
_prompt = self._safe_int(_usage.get("input_tokens"), "input_tokens")
_completion = self._safe_int(_usage.get("output_tokens"), "output_tokens")
_metadata_present = True
elif _usage is not None:
self._log_no_usage_metadata(_CAUSE_USAGE_METADATA_EMPTY)
elif (
hasattr(response, "response_metadata")
and response.response_metadata is not None
):
_rm: object = response.response_metadata
if isinstance(_rm, dict):
_token_usage: dict[str, Any] = _rm.get("token_usage", {})
if _token_usage:
_prompt = self._safe_int(
_token_usage.get("prompt_tokens"), "prompt_tokens"
)
_completion = self._safe_int(
_token_usage.get("completion_tokens"), "completion_tokens"
)
_metadata_present = True
else:
self._log_no_usage_metadata(_CAUSE_RESPONSE_METADATA_NO_TOKEN_USAGE)
else:
self._log_no_usage_metadata(_CAUSE_RESPONSE_METADATA_NOT_DICT)
else:
self._log_no_usage_metadata(_CAUSE_RESPONSE_METADATA_MISSING)
return (_prompt, _completion, _metadata_present)
def _augment_tool_schemas_for_pruning(
self, tools: list[dict[str, Any]]
@@ -471,14 +515,21 @@ class LLMAgent(AgentWithMemory):
raw_output: str,
task_context_msgs: list[Any],
prune_context: str | None = None,
) -> str:
) -> tuple[str, int, int]:
"""Execute the tool-output pruning extraction pass (§4.4.8).
When *prune_context* is a non-empty string, it replaces the
broader task context in the extraction prompt.
Returns:
Tuple of (parsed_content, prompt_tokens, completion_tokens)
from the pruning LLM call. On failure returns (raw_output, 0, 0).
"""
if not raw_output:
return raw_output
return (raw_output, 0, 0)
# Phase 1: Make the pruning LLM call. Failures here fall back to
# raw output (non-functional model, network error, missing API key,
# invalid model name, template-rendering exception, etc.).
try:
prune_model_name = self._pruning_model or self.model
prune_model = build_chat_model(
@@ -525,15 +576,34 @@ class LLMAgent(AgentWithMemory):
)
prune_messages = [_SM(content=system_content)] + user_msgs
prune_response = await prune_model.ainvoke(prune_messages)
return self._parse_pruning_response(str(prune_response.content))
except Exception:
logger.warning(
"Agent %s: pruning pass failed for tool %r; using raw output",
"Agent %s: pruning pass LLM call failed for tool %r; using raw output",
self.name,
tool_name,
exc_info=True,
)
return raw_output
return (raw_output, 0, 0)
# Phase 2: Parse and validate token counts. Missing token usage
# metadata is a hard failure so the caller is alerted that billing
# data would be incomplete (issue #65).
parsed = self._parse_pruning_response(str(prune_response.content))
_pp, _pc, _metadata_present = self._extract_token_counts(prune_response)
if not _metadata_present:
raise MissingUsageMetadataError(
f"Pruning model response for tool {tool_name!r} did not "
f"include token usage metadata. Token tracking is required "
f"when pruning is enabled. Use a model that reports "
f"usage_metadata or disable allow_tool_output_pruning."
)
logger.info(
"Agent %s: pruning pass for %r consumed %d prompt + %d completion tokens",
self.name,
tool_name,
_pp,
_pc,
)
return (parsed, _pp, _pc)
# ------------------------------------------------------------------
# Public credentials property
@@ -751,6 +821,8 @@ class LLMAgent(AgentWithMemory):
invoke_kwargs["tools"] = self._lc_tools
_budget_exhausted: bool = False
_accumulated_prompt: int = 0
_accumulated_completion: int = 0
_declared_names = {
t.get("function", {}).get("name", "") for t in (self._lc_tools or [])
@@ -761,6 +833,8 @@ class LLMAgent(AgentWithMemory):
break
# ── Token-budget check (§4.4.7) ─────────────────────────
# Uses accumulated actual token counts from prior rounds
# instead of heuristic estimation (issue #65).
if self._token_budget_percent is not None and _has_tools:
_budget_ceiling = max(
1,
@@ -769,7 +843,7 @@ class LLMAgent(AgentWithMemory):
* self._get_model_context_window()
),
)
_est = self._estimate_token_count(messages)
_est = _accumulated_prompt + _accumulated_completion
_remaining = max(0, _budget_ceiling - _est)
if _est > int(0.75 * _budget_ceiling):
logger.warning(
@@ -800,6 +874,9 @@ class LLMAgent(AgentWithMemory):
response = await self.chat_model.ainvoke(
messages, **invoke_kwargs
)
_bp, _bc, _ = self._extract_token_counts(response)
_accumulated_prompt += _bp
_accumulated_completion += _bc
_synth_tool_calls: list[dict[str, Any]] = (
getattr(response, "tool_calls", None) or []
)
@@ -885,12 +962,36 @@ class LLMAgent(AgentWithMemory):
and tool_name in self._pruning_tool_filter
and len(raw_out) > self._pruning_threshold
):
tool_output = await self._run_pruning_pass(
tool_name,
raw_out,
list(messages),
prune_context=_bq_prune_ctx,
)
try:
(
tool_output,
_bp_tok,
_bc_tok,
) = await self._run_pruning_pass(
tool_name,
raw_out,
list(messages),
prune_context=_bq_prune_ctx,
)
except MissingUsageMetadataError:
logger.warning(
"Agent %s: pruning pass for %r "
"raised MissingUsageMetadataError; "
"%d prompt + %d completion tokens "
"accumulated before failure will be "
"preserved for billing",
self.name,
tool_name,
_accumulated_prompt,
_accumulated_completion,
)
_captured_prompt = _accumulated_prompt
_captured_completion = (
_accumulated_completion
)
raise
_accumulated_prompt += _bp_tok
_accumulated_completion += _bc_tok
logger.info(
"Agent %s: pruning pass for %r: "
"%d -> %d chars",
@@ -924,11 +1025,17 @@ class LLMAgent(AgentWithMemory):
)
)
response = await self.chat_model.ainvoke(messages)
_bp3, _bc3, _ = self._extract_token_counts(response)
_accumulated_prompt += _bp3
_accumulated_completion += _bc3
response_text = str(response.content)
_budget_exhausted = True
break
response = await self.chat_model.ainvoke(messages, **invoke_kwargs)
_mp, _mc, _ = self._extract_token_counts(response)
_accumulated_prompt += _mp
_accumulated_completion += _mc
response_tool_calls: list[dict[str, Any]] = (
getattr(response, "tool_calls", None) or []
@@ -1016,12 +1123,34 @@ class LLMAgent(AgentWithMemory):
and len(raw_tool_output) > self._pruning_threshold
):
_task_context = list(messages)
tool_output = await self._run_pruning_pass(
tool_name,
raw_tool_output,
_task_context,
prune_context=_prune_context,
)
try:
(
tool_output,
_mlp_tok,
_mlc_tok,
) = await self._run_pruning_pass(
tool_name,
raw_tool_output,
_task_context,
prune_context=_prune_context,
)
except MissingUsageMetadataError:
logger.warning(
"Agent %s: pruning pass for %r "
"raised MissingUsageMetadataError; "
"%d prompt + %d completion tokens "
"accumulated before failure will be "
"preserved for billing",
self.name,
tool_name,
_accumulated_prompt,
_accumulated_completion,
)
_captured_prompt = _accumulated_prompt
_captured_completion = _accumulated_completion
raise
_accumulated_prompt += _mlp_tok
_accumulated_completion += _mlc_tok
logger.info(
"Agent %s: pruning pass for %r: %d -> %d chars",
self.name,
@@ -1072,6 +1201,9 @@ class LLMAgent(AgentWithMemory):
)
)
response = await self.chat_model.ainvoke(messages, tools=self._lc_tools)
_sp, _sc, _ = self._extract_token_counts(response)
_accumulated_prompt += _sp
_accumulated_completion += _sc
# Allow *one* final tool-call round so the model can
# write files or perform other last-minute operations
@@ -1165,77 +1297,31 @@ class LLMAgent(AgentWithMemory):
)
)
response = await self.chat_model.ainvoke(messages)
_sfp, _sfc, _ = self._extract_token_counts(response)
_accumulated_prompt += _sfp
_accumulated_completion += _sfc
response_text = str(response.content)
# Extract real token usage from LangChain response metadata (AC2).
# Primary source: usage_metadata (LangChain standard field).
# Fallback: response_metadata["token_usage"] (provider-specific).
# If neither is available: log a warning and use 0.
#
# Token variables are hoisted before the if/elif/else block so
# that the type checker sees a single consistent type regardless
# of which branch is taken (N1).
_prompt_tokens: int = 0
_completion_tokens: int = 0
# Use accumulated token counts from all ainvoke() calls
# (main model rounds + pruning passes) instead of only the
# final response (fix for issue #65 Gap 1 & Gap 2).
# If no ainvoke() calls were made (pre-ainvoke failure),
# _accumulated_prompt / _accumulated_completion remain 0
# which is correct (no tokens consumed).
_prompt_tokens = _accumulated_prompt
_completion_tokens = _accumulated_completion
_usage_raw: object = getattr(response, "usage_metadata", None)
# Guard: usage_metadata must be a dict (not just any truthy value)
# before calling .get() on it. A provider or test mock returning a
# non-dict truthy value (e.g. "1" or [42]) would otherwise raise
# AttributeError and discard the LLM response (issue #2).
_usage: dict[str, Any] | None = (
_usage_raw if isinstance(_usage_raw, dict) else None
)
if _usage is not None and _usage:
# usage_metadata is present and non-empty: extract token counts.
# Wrap int() in try/except so a non-numeric provider value
# (e.g. a string "abc" or a list) falls back to 0 and logs a
# warning instead of propagating an exception that would discard
# the LLM response (m1).
_prompt_tokens = self._safe_int(
_usage.get("input_tokens"), "input_tokens"
# Log a summary of accumulated token usage for observability.
if _accumulated_prompt > 0 or _accumulated_completion > 0:
logger.info(
"Agent %s: accumulated token usage: "
"%d prompt + %d completion tokens across all rounds",
self.name,
_accumulated_prompt,
_accumulated_completion,
)
_completion_tokens = self._safe_int(
_usage.get("output_tokens"), "output_tokens"
)
elif _usage is not None:
# usage_metadata is present but empty ({}) — treat as no data
# and log a warning (issue #7: empty dict is falsy but not None,
# so we must distinguish it from None to avoid silently falling
# through to the response_metadata branch).
self._log_no_usage_metadata(_CAUSE_USAGE_METADATA_EMPTY)
elif (
hasattr(response, "response_metadata")
and response.response_metadata is not None
):
# Guard against response_metadata being a non-dict truthy value
# (e.g. a list) — only call .get() when it is actually a dict
# (issue #2).
_rm: object = response.response_metadata
if not isinstance(_rm, dict):
# response_metadata is present but not a dict (e.g. a list).
# Log a distinct cause so operators can distinguish this from
# the "no token_usage key" case (review issue #6).
self._log_no_usage_metadata(_CAUSE_RESPONSE_METADATA_NOT_DICT)
else:
_token_usage: dict[str, Any] = _rm.get("token_usage", {})
if _token_usage:
# Wrap int() in try/except for non-numeric values (m1).
_prompt_tokens = self._safe_int(
_token_usage.get("prompt_tokens"), "prompt_tokens"
)
_completion_tokens = self._safe_int(
_token_usage.get("completion_tokens"), "completion_tokens"
)
else:
# response_metadata is a dict but has no token_usage key.
self._log_no_usage_metadata(
_CAUSE_RESPONSE_METADATA_NO_TOKEN_USAGE
)
else:
# response_metadata is either absent or explicitly None.
self._log_no_usage_metadata(_CAUSE_RESPONSE_METADATA_MISSING)
# Capture token counts immediately after ainvoke() succeeds.
# Setting the sentinel variables here marks the "ainvoke succeeded"
# boundary: any exception raised after this point is a post-ainvoke
+11
View File
@@ -65,5 +65,16 @@ class ApplicationError(CleverAgentsException):
"""Exception raised for application-level errors."""
class MissingUsageMetadataError(CleverAgentsException):
"""
Raised when an LLM response lacks token usage metadata and the
caller requires it for accurate billing/token tracking.
This is raised instead of a generic RuntimeError so that callers
can distinguish "no metadata was returned by the provider" from
other runtime failures.
"""
class StreamRoutingError(CleverAgentsException):
"""Exception raised for errors in stream routing."""