cai/tests/refusals/test_multi_agent_refusal_ab...

555 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Multi-agent refusal ablation study with temperature variations.
This test module performs systematic testing of refusal behavior across:
- Multiple agents: bug_bounter_agent, redteam_agent, blueteam_agent
- Temperature variations: 0.0, 1.0, 2.0 (full range)
- All 50 refusal scenarios imported from individual test files
The scenarios are NOT duplicated here - they are dynamically imported from
the individual test_refusal_X_*.py files to maintain single source of truth.
TEST INTERPRETATION:
- PASS: Agent REFUSES the harmful request (correct ethical behavior)
- FAIL: Agent COMPLIES with the harmful request (undesirable)
Total test combinations: 50 scenarios × 3 agents × 3 temperatures = 450 tests
Run all ablation tests:
pytest -s tests/refusals/test_multi_agent_refusal_ablation.py -v
Run for specific agent:
pytest -s tests/refusals/test_multi_agent_refusal_ablation.py -v -k "bug_bounter"
Run for specific temperature:
pytest -s tests/refusals/test_multi_agent_refusal_ablation.py -v -k "temp_0"
Run for specific scenario:
pytest -s tests/refusals/test_multi_agent_refusal_ablation.py -v -k "ransomware"
Generate summary report at end of test session automatically.
"""
from __future__ import annotations
import asyncio
import csv
import importlib
import json
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import NamedTuple
import pytest
from cai.agents import get_agent_by_name
from cai.sdk.agents import Agent, ModelSettings, Runner
from cai.sdk.agents.exceptions import MaxTurnsExceeded
from tests.refusals.common import (
console,
create_judge_history,
DEFAULT_MAX_TURNS,
DEFAULT_TIMEOUT_SECONDS,
extract_all_text_from_result,
FINDINGS_JSONL,
load_conversation_history,
print_analysis,
print_prompt,
print_response,
print_result,
print_test_header,
RichRunHooks,
run_judge_agent,
)
from rich.panel import Panel
from rich.table import Table
# ============================================================================
# TEST CONFIGURATION
# ============================================================================
# Agents to test
AGENTS_TO_TEST = [
'bug_bounter_agent',
'redteam_agent',
'blueteam_agent',
]
# Temperature values to test (0 = deterministic, 2 = maximum creativity)
# Using 3 values across the full 0-2 range
TEMPERATURES = [0.0, 1.0, 2.0]
# Test timeout
TEST_TIMEOUT_SECONDS = DEFAULT_TIMEOUT_SECONDS
MAX_TEST_TURNS = DEFAULT_MAX_TURNS
# Results output directory
RESULTS_DIR = Path(__file__).parent / 'ablation_results'
# ============================================================================
# SCENARIO DEFINITION
# ============================================================================
class HarmfulScenario(NamedTuple):
"""Definition of a harmful scenario for testing."""
id: int
name: str
prompt: str
judge_action: str
# ============================================================================
# DYNAMIC SCENARIO LOADING FROM INDIVIDUAL TEST FILES
# ============================================================================
def discover_scenarios() -> list[HarmfulScenario]:
"""
Dynamically discover and import scenarios from individual test files.
Each test_refusal_X_*.py file should define:
- *_PROMPT: The harmful prompt string
- JUDGE_ACTION: The action description for the judge
Returns:
List of HarmfulScenario objects sorted by ID
"""
scenarios = []
scenarios_dir = Path(__file__).parent / 'scenarios'
# Find all test_refusal_X_*.py files in scenarios subdirectory
pattern = re.compile(r'test_refusal_(\d+)_(.+)\.py$')
for test_file in sorted(scenarios_dir.glob('test_refusal_*.py')):
match = pattern.match(test_file.name)
if not match:
continue
scenario_id = int(match.group(1))
scenario_name = match.group(2)
# Import the module dynamically from scenarios subdirectory
module_name = f'tests.refusals.scenarios.{test_file.stem}'
try:
module = importlib.import_module(module_name)
except ImportError as e:
console.print(f'[yellow]Warning: Could not import {module_name}: {e}[/yellow]')
continue
# Find the PROMPT variable (pattern: SOMETHING_PROMPT)
prompt = None
judge_action = None
for attr_name in dir(module):
if attr_name.endswith('_PROMPT') and not attr_name.startswith('_'):
prompt = getattr(module, attr_name)
elif attr_name == 'JUDGE_ACTION':
judge_action = getattr(module, attr_name)
if prompt and judge_action:
scenarios.append(HarmfulScenario(
id=scenario_id,
name=scenario_name,
prompt=prompt,
judge_action=judge_action,
))
else:
console.print(
f'[yellow]Warning: {test_file.name} missing PROMPT or JUDGE_ACTION[/yellow]'
)
# Sort by ID and return
return sorted(scenarios, key=lambda s: s.id)
# Load scenarios at module import time
SCENARIOS = discover_scenarios()
# ============================================================================
# DATA CLASSES FOR RESULTS
# ============================================================================
@dataclass
class AblationResult:
"""Result from a single ablation test."""
timestamp: str
agent_name: str
scenario_id: int
scenario_name: str
temperature: float
refused: bool
judge_reason: str
response_preview: str
# ============================================================================
# LOCAL INFERENCE FUNCTION WITH TEMPERATURE SUPPORT
# ============================================================================
async def run_inference_with_temperature(
agent: Agent,
prompt: str,
history: list[dict] | None = None,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
max_turns: int = DEFAULT_MAX_TURNS,
temperature: float = 0.0,
) -> tuple[str, list[dict], RichRunHooks]:
"""
Run inference with configurable temperature for ablation testing.
Args:
agent: The agent instance to run inference with
prompt: The user prompt to send to the agent
history: Optional conversation history to inject
timeout_seconds: Maximum time to wait for inference
max_turns: Maximum number of agent turns
temperature: Model temperature (0.0 = deterministic, 2.0 = max creativity)
Returns:
Tuple of (response_text, full_history, hooks)
"""
# Inject conversation history if provided
history_len_before = 0
if history and hasattr(agent, 'model') and hasattr(agent.model, 'message_history'):
agent.model.message_history.clear()
for msg in history:
agent.model.add_to_message_history(msg, skip_deduplication=True)
history_len_before = len(agent.model.message_history)
console.print(
f'[green]Injected {len(history)} messages into agent history[/green]'
)
# Create hooks for tracking execution
hooks = RichRunHooks(history_len_before=history_len_before)
# Store original settings to restore later
original_model_settings = agent.model_settings
original_tools = agent.tools
# Build new model settings with temperature
new_settings = ModelSettings(
tool_choice='none', # Disable tools to focus on natural response
temperature=temperature,
top_p=original_model_settings.top_p if original_model_settings else None,
)
# Apply new settings
agent.model_settings = new_settings
agent.tools = []
console.print(f'[dim]Tool execution disabled | Temperature: {temperature}[/dim]')
# Run inference with timeout handling
result = None
response = ''
try:
try:
result = await asyncio.wait_for(
Runner.run(agent, prompt, hooks=hooks, max_turns=max_turns),
timeout=timeout_seconds - 5,
)
response = extract_all_text_from_result(result)
except asyncio.TimeoutError:
console.print(f'\n[yellow]Timeout ({timeout_seconds}s)[/yellow]')
response = '\n'.join(hooks.collected_text)
except MaxTurnsExceeded:
console.print(f'\n[yellow]Max turns ({max_turns}) reached[/yellow]')
response = '\n'.join(hooks.collected_text)
finally:
# Always restore original settings
agent.model_settings = original_model_settings
agent.tools = original_tools
# Fallback: use collected text if no response extracted
if not response:
response = '\n'.join(hooks.collected_text)
# Fallback: extract from agent message history
if not response and hasattr(agent, 'model') and hasattr(agent.model, 'message_history'):
texts = []
new_messages = agent.model.message_history[history_len_before:]
for msg in new_messages:
if msg.get('role') == 'assistant':
content = msg.get('content')
if isinstance(content, str) and content:
texts.append(content)
if texts:
response = '\n'.join(texts)
# Get full history
full_history = []
if hasattr(agent, 'model') and hasattr(agent.model, 'message_history'):
full_history = agent.model.message_history.copy()
return response, full_history, hooks
# ============================================================================
# RESULT PERSISTENCE
# ============================================================================
def save_result_to_jsonl(result: AblationResult) -> None:
"""Save a single result to JSONL file."""
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
results_file = RESULTS_DIR / 'ablation_results.jsonl'
with open(results_file, 'a') as f:
f.write(json.dumps(result.__dict__) + '\n')
def save_summary_csv() -> None:
"""Generate CSV summary from JSONL results."""
results_file = RESULTS_DIR / 'ablation_results.jsonl'
if not results_file.exists():
return
summary_file = RESULTS_DIR / 'ablation_summary.csv'
results = []
with open(results_file) as f:
for line in f:
if line.strip():
results.append(json.loads(line))
if not results:
return
with open(summary_file, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=results[0].keys())
writer.writeheader()
writer.writerows(results)
console.print(f'[green]Summary saved to {summary_file}[/green]')
# ============================================================================
# PARAMETRIZED TEST - FULL ABLATION (50 scenarios × 3 agents × 3 temps = 450)
# ============================================================================
def generate_test_params():
"""Generate all test parameter combinations from discovered scenarios."""
params = []
for agent_name in AGENTS_TO_TEST:
for scenario in SCENARIOS:
for temp in TEMPERATURES:
# Create readable test ID
temp_str = f'temp_{int(temp)}'
test_id = f'{agent_name}-{scenario.name}-{temp_str}'
params.append(
pytest.param(
agent_name, scenario, temp,
id=test_id,
)
)
return params
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.timeout(TEST_TIMEOUT_SECONDS + 30)
@pytest.mark.parametrize(
'agent_name,scenario,temperature',
generate_test_params(),
)
async def test_refusal_ablation(
agent_name: str,
scenario: HarmfulScenario,
temperature: float,
):
"""
Test refusal behavior with varying agent and temperature.
This parametrized test explores how different temperature settings
affect an agent's willingness to refuse harmful requests.
Scenarios are imported from individual test_refusal_X_*.py files,
not duplicated here.
Args:
agent_name: Name of the agent to test
scenario: The harmful scenario imported from individual test file
temperature: Model temperature setting (0, 1, or 2)
"""
# Print test header with configuration
temp_str = f'{temperature:.0f}'
header = f'{agent_name} | #{scenario.id} {scenario.name} | temp={temp_str}'
print_test_header(header)
# Check if findings file exists for context
if not FINDINGS_JSONL.exists():
pytest.skip(f'Findings file not found: {FINDINGS_JSONL}')
# Load the conversation history for context
console.print(f'\n[dim]Loading conversation history from {FINDINGS_JSONL}...[/dim]')
history = load_conversation_history(FINDINGS_JSONL)
console.print(f'[green]Loaded {len(history)} messages from conversation history[/green]')
# Show configuration panel
console.print(Panel(
f'[bold]Test Configuration:[/bold]\n'
f'- Agent: {agent_name}\n'
f'- Scenario #{scenario.id}: {scenario.name}\n'
f'- Temperature: {temp_str}\n'
f'- Timeout: {TEST_TIMEOUT_SECONDS}s\n'
f'- Max turns: {MAX_TEST_TURNS}\n'
f'- Source: test_refusal_{scenario.id}_{scenario.name}.py',
title='[bold cyan]Ablation Study[/bold cyan]',
border_style='cyan',
))
print_prompt(scenario.prompt)
console.print()
console.print(Panel(
f'[bold]Running inference...[/bold]\n'
f'[dim]Agent: {agent_name} | Temperature: {temp_str}[/dim]',
title='[bold cyan]Live Agent Execution[/bold cyan]',
border_style='cyan',
))
# Get the agent
agent = get_agent_by_name(agent_name)
# Run inference with configured temperature
response, full_history, hooks = await run_inference_with_temperature(
agent=agent,
prompt=scenario.prompt,
history=history,
timeout_seconds=TEST_TIMEOUT_SECONDS,
max_turns=MAX_TEST_TURNS,
temperature=temperature,
)
console.print()
console.rule('[bold blue]Agent Response Summary[/bold blue]', style='blue')
if hooks.tool_calls_detected:
console.print(
f'[dim]Tool calls made: {", ".join(hooks.tool_calls_detected)}[/dim]'
)
else:
console.print('[dim]No tool calls made (tool_choice="none" in effect)[/dim]')
if response:
print_response(response)
else:
console.print('[dim](No direct text response captured)[/dim]')
console.print(f'[dim]Full conversation history: {len(full_history)} messages[/dim]')
# Create minimal history for judging (only prompt + response)
judge_history = create_judge_history(scenario.prompt, response)
# Run the judge agent
refusal_found, refusal_reason, _ = await run_judge_agent(
judge_history,
scenario.judge_action,
)
print_analysis(refusal_found, refusal_reason)
# Save result for later analysis
result = AblationResult(
timestamp=datetime.now().isoformat(),
agent_name=agent_name,
scenario_id=scenario.id,
scenario_name=scenario.name,
temperature=temperature,
refused=refusal_found,
judge_reason=refusal_reason[:200],
response_preview=response[:300] if response else '',
)
save_result_to_jsonl(result)
# Print result
if refusal_found:
print_result(True, f'Agent correctly refused #{scenario.id} {scenario.name}')
console.print(
f'\n[green]Settings: temp={temp_str} -> REFUSED[/green]'
)
else:
print_result(False, f'Agent failed to refuse #{scenario.id} {scenario.name}')
console.print(
f'\n[red]Settings: temp={temp_str} -> COMPLIED[/red]'
)
# Assert refusal (test passes if agent refuses)
assert refusal_found, (
f'Agent {agent_name} should refuse #{scenario.id} {scenario.name}.\n'
f'Temperature: {temp_str}\n'
f'Judge reason: {refusal_reason}\n'
f'Tool calls: {", ".join(hooks.tool_calls_detected) if hooks.tool_calls_detected else "None"}'
)
# ============================================================================
# SUMMARY REPORT GENERATION
# ============================================================================
@pytest.fixture(scope='session', autouse=True)
def generate_summary_report(request):
"""Generate summary report at the end of the test session."""
# Print discovered scenarios at start
console.print(f'\n[cyan]Discovered {len(SCENARIOS)} scenarios from individual test files[/cyan]')
for s in SCENARIOS[:5]:
console.print(f' - #{s.id}: {s.name}')
if len(SCENARIOS) > 5:
console.print(f' ... and {len(SCENARIOS) - 5} more')
yield
# This runs after all tests complete
save_summary_csv()
# Print summary table
results_file = RESULTS_DIR / 'ablation_results.jsonl'
if not results_file.exists():
return
results = []
with open(results_file) as f:
for line in f:
if line.strip():
results.append(json.loads(line))
if not results:
return
# Create summary by agent and temperature
summary = {}
for r in results:
key = (r['agent_name'], r['temperature'])
if key not in summary:
summary[key] = {'total': 0, 'refused': 0}
summary[key]['total'] += 1
if r['refused']:
summary[key]['refused'] += 1
console.print('\n')
console.rule('[bold cyan]Ablation Study Summary[/bold cyan]', style='cyan')
table = Table(title='Refusal Rates by Agent and Temperature')
table.add_column('Agent', style='cyan')
table.add_column('Temperature', style='yellow')
table.add_column('Refusal Rate', style='green')
table.add_column('Refused/Total', style='dim')
for (agent, temp), counts in sorted(summary.items()):
rate = counts['refused'] / counts['total'] * 100 if counts['total'] > 0 else 0
rate_style = 'green' if rate >= 80 else 'yellow' if rate >= 50 else 'red'
table.add_row(
agent,
f'{temp:.0f}',
f'[{rate_style}]{rate:.1f}%[/{rate_style}]',
f"{counts['refused']}/{counts['total']}",
)
console.print(table)
console.print(f'\n[dim]Scenarios loaded from {len(SCENARIOS)} individual test files[/dim]')