Files
CoreRasurae a8f68b8262
CI / lint (pull_request) Successful in 50s
CI / typecheck (pull_request) Successful in 55s
CI / security (pull_request) Successful in 53s
CI / quality (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 3m15s
CI / integration_tests (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 3m16s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 37s
CI / typecheck (push) Successful in 56s
CI / security (push) Successful in 54s
CI / quality (push) Successful in 37s
CI / unit_tests (push) Successful in 3m15s
CI / integration_tests (push) Successful in 1m14s
CI / build (push) Successful in 39s
CI / coverage (push) Successful in 3m16s
CI / status-check (push) Successful in 3s
fix(graph): enforce USD budget at each agent invocation with pre-flight gate
Enforces the USD budget (max_cost_usd) as a hard pre-flight gate before
every node execution and at each main-agent ainvoke round inside the
multi-turn tool loop, with pruning passes silently skipped when the
budget is exhausted.

Pre-flight gate (pure_graph.py):
  _check_budget_pre_flight() raises ExecutionError(kind='cost',
  reason='budget_exhausted') immediately before node execution in both
  _execute_from_node and all three branches of _stream_from_node.

In-node budget gating (llm.py):
  _check_budget_before_invoke() gates every _retry_ainvoke() call
  inside _execute_tool_loop, process_message, and stream_message.
  _should_skip_pruning() skips pruning passes when budget exhausted.
  _accumulate_cost_after_invoke() computes USD cost from token counts
  using the pricing table and advances current_accumulated_cost so
  subsequent budget checks see the updated cost.

ContextVar plumbing (retry.py, pure_graph.py):
  current_accumulated_cost, current_max_cost_usd, and current_pricing
  ContextVars carry per-node budget state from PureLangGraph into
  LLMAgent. Set before node.execute() via _set_budget_context_for_node,
  reset in finally blocks via _reset_budget_context.

Reviewer fixes (rui.hu #80):
  - Move cost accumulation inside try block in terminal streaming branch
    to prevent ContextVar leak (was set after finally reset).
  - Standardize log precision to $%.6f across all budget messages.
  - Remove orphaned 'already exhausted' step (criterion unreachable).
  - Add dedicated streaming budget enforcement scenario.
  - Remove redundant = None default on _reset_budget_context pricing_token.

ISSUES CLOSED: #76
2026-07-12 19:51:39 +01:00

323 lines
11 KiB
Python

"""
BDD test environment setup for reactive CleverAgents.
"""
import asyncio
import os
import shutil
import sys
import tempfile
from pathlib import Path
# Increase recursion limit to handle deep import chains in langchain/torch/transformers
sys.setrecursionlimit(5000)
def before_all(context):
"""Set up test environment before all tests."""
context.temp_dir = Path(tempfile.mkdtemp())
# Set dummy API keys for testing
os.environ["OPENAI_API_KEY"] = "test-key-openai"
os.environ["ANTHROPIC_API_KEY"] = "test-key-anthropic"
os.environ["GOOGLE_API_KEY"] = "test-key-google"
# Create async event loop for tests
context.loop = asyncio.new_event_loop()
asyncio.set_event_loop(context.loop)
def after_all(context):
"""Clean up test environment after all tests."""
# Clean up temp directory
shutil.rmtree(context.temp_dir, ignore_errors=True)
# Close async event loop with proper cleanup
if hasattr(context, "loop"):
# Cancel all remaining tasks
try:
pending = asyncio.all_tasks(context.loop)
for task in pending:
task.cancel()
except Exception:
pass
# Close the loop without waiting for tasks
try:
context.loop.close()
except Exception:
pass
def before_scenario(context, scenario):
"""Set up before each scenario."""
context.config_files = []
context.app = None
context.result = None
context.error = None
context.unsafe = False
context._active_patches = [] # Defensive init — after_scenario cleanup iterates this
context._llm_should_fail = (
False # Reset from prior scenarios that configure LLM failure mode
)
# Create scenario-specific temp directory
context.scenario_temp = (
context.temp_dir
/ f"scenario_{scenario.name.replace(' ', '_').replace('/', '_')}"
)
context.scenario_temp.mkdir(exist_ok=True)
# Create a fresh event loop per scenario to avoid "event loop is closed" errors
# from async_step decorators when the loop has been corrupted by prior cleanup
try:
old_loop = asyncio.get_event_loop()
if old_loop.is_closed():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
context.loop = new_loop
except RuntimeError:
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
context.loop = new_loop
# Always ensure API keys are set (some steps clear them and never restore)
os.environ.setdefault("OPENAI_API_KEY", "test-key-openai")
os.environ.setdefault("ANTHROPIC_API_KEY", "test-key-anthropic")
os.environ.setdefault("GOOGLE_API_KEY", "test-key-google")
def after_scenario(context, scenario):
"""Clean up after each scenario."""
# Clean up bridge resources if present
if hasattr(context, "bridge") and context.bridge:
try:
context.bridge.cleanup()
except Exception:
pass
# Dispose of app if created
if hasattr(context, "app") and context.app:
try:
# dispose() is async, so we need to run it in the event loop
if hasattr(context, "loop") and context.loop:
context.loop.run_until_complete(context.app.dispose())
else:
asyncio.run(context.app.dispose())
except Exception:
pass
# Cancel any remaining tasks without waiting
if hasattr(context, "loop"):
try:
pending = asyncio.all_tasks(context.loop)
for task in pending:
task.cancel()
# Drain cancellations so tasks get cleaned up and do not
# accumulate on the shared loop across scenarios.
_drain = context.loop.create_task(asyncio.sleep(0))
context.loop.run_until_complete(_drain)
except Exception:
pass
# Clean up event loops created by LangGraph tests
if hasattr(context, "event_loops"):
for loop in context.event_loops:
try:
if hasattr(loop, "is_closed") and not loop.is_closed():
# Cancel all tasks in this loop
tasks = asyncio.all_tasks(loop)
for task in tasks:
task.cancel()
loop.close()
except Exception:
pass
# Clean up temp directories from LangGraph tests
if hasattr(context, "temp_dirs"):
for temp_dir in context.temp_dirs:
try:
shutil.rmtree(temp_dir)
except Exception:
pass
# Clean up graphs and their schedulers
if hasattr(context, "graphs"):
for graph in context.graphs:
try:
if hasattr(graph, "scheduler") and hasattr(graph.scheduler, "_loop"):
loop = graph.scheduler._loop
if hasattr(loop, "is_closed") and not loop.is_closed():
# Cancel all tasks
tasks = asyncio.all_tasks(loop)
for task in tasks:
task.cancel()
loop.close()
except Exception:
pass
# Clean up template loader test files
if "template_loaders_coverage" in str(scenario.feature.filename):
# fmt: off
from tests.features.steps.template_loaders_coverage_steps import cleanup_test_files # isort: skip
# fmt: on
cleanup_test_files(context)
# Clean up files tracked for cleanup in tool_agent tests
if hasattr(context, "__dict__") and "_cleanup_files" in context.__dict__:
for filepath in context.__dict__["_cleanup_files"]:
try:
if os.path.exists(filepath):
os.remove(filepath)
except Exception:
pass
context.__dict__["_cleanup_files"] = []
# Stop any env_patch that wasn't stopped by a Then step (m-5).
if hasattr(context, "env_patch"):
try:
context.env_patch.stop()
except Exception:
pass
# Stop any _prune_patch from pruning model tests.
if hasattr(context, "_prune_patch") and context._prune_patch is not None:
try:
context._prune_patch.stop()
except Exception:
pass
context._prune_patch = None
# Remove log-capture handler installed by cleanup warning-log tests.
if hasattr(context, "_cleanup_log_handler"):
from cleveractors.agents.llm import logger as llm_logger
try:
llm_logger.removeHandler(context._cleanup_log_handler)
except Exception:
pass
if hasattr(context, "_cleanup_log_original_level"):
try:
llm_logger.setLevel(context._cleanup_log_original_level)
except Exception:
pass
# Stop any active patches used by test scenarios.
if hasattr(context, "_active_patches"):
for p in reversed(context._active_patches):
try:
p.stop()
except Exception:
pass
context._active_patches = []
# Clean up scenario temp directory
if hasattr(context, "scenario_temp") and context.scenario_temp.exists():
try:
shutil.rmtree(context.scenario_temp, ignore_errors=True)
except Exception:
pass
# Clean up _tmpdir from LocalPackageStore tests
if hasattr(context, "_tmpdir"):
try:
context._tmpdir.cleanup()
except Exception:
pass
# Reset budget ContextVars set by test steps (issue #76 test hygiene).
# Without this, budget ContextVars set in llm_agent_tool_loop scenarios
# leak across scenarios and can cause order-dependent flakiness
# (Graa review PR #80).
if hasattr(context, "_budget_cost_token"):
from cleveractors.agents.retry import (
current_accumulated_cost,
current_max_cost_usd,
current_pricing,
)
try:
current_accumulated_cost.reset(context._budget_cost_token)
if getattr(context, "_budget_max_token", None) is not None:
current_max_cost_usd.reset(context._budget_max_token)
if getattr(context, "_budget_pricing_token", None) is not None:
current_pricing.reset(context._budget_pricing_token)
except Exception:
pass
# Restore monkey-patched validation constants
if hasattr(context, "_original_max_dfs"):
import cleveractors.validation._limits as _limits
_limits._MAX_DFS_STEPS = context._original_max_dfs
if hasattr(context, "_original_max_subgraph"):
import cleveractors.validation._limits as _limits
_limits._MAX_SUBGRAPH_STEPS = context._original_max_subgraph
# Clean up log-capture handler from domain-pattern warning scenarios.
if hasattr(context, "_domain_warning_handler"):
try:
from cleveractors.agents.llm_providers import logger as llm_providers_logger
llm_providers_logger.removeHandler(context._domain_warning_handler)
except Exception:
pass
if hasattr(context, "_domain_warning_original_level"):
try:
from cleveractors.agents.llm_providers import logger as llm_providers_logger
llm_providers_logger.setLevel(context._domain_warning_original_level)
except Exception:
pass
# Clean up log-capture handler from llm_client warning scenarios.
if hasattr(context, "_llm_client_warning_handler"):
try:
from cleveractors.agents.llm_client import logger as llm_client_logger
llm_client_logger.removeHandler(context._llm_client_warning_handler)
except Exception:
pass
if hasattr(context, "_llm_client_warning_original_level"):
try:
from cleveractors.agents.llm_client import logger as llm_client_logger
llm_client_logger.setLevel(context._llm_client_warning_original_level)
except Exception:
pass
# Clean up log-capture handler from _execute_llm cleanup scenarios.
if hasattr(context, "_llm_cleanup_handler"):
try:
from cleveractors.runtime_dispatch import logger as dispatch_logger
dispatch_logger.removeHandler(context._llm_cleanup_handler)
except Exception:
pass
if hasattr(context, "_llm_cleanup_original_level"):
try:
from cleveractors.runtime_dispatch import logger as dispatch_logger
dispatch_logger.setLevel(context._llm_cleanup_original_level)
except Exception:
pass
# Clean up log-capture handler from _execute_graph cleanup scenarios.
if hasattr(context, "_graph_cleanup_handler"):
try:
from cleveractors.runtime_dispatch import logger as dispatch_logger
dispatch_logger.removeHandler(context._graph_cleanup_handler)
except Exception:
pass
if hasattr(context, "_graph_cleanup_original_level"):
try:
from cleveractors.runtime_dispatch import logger as dispatch_logger
dispatch_logger.setLevel(context._graph_cleanup_original_level)
except Exception:
pass