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
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
323 lines
11 KiB
Python
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
|