diff options
| author | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-04-01 15:50:34 +0100 |
|---|---|---|
| committer | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-04-01 15:50:34 +0100 |
| commit | 289d93c8c99251d5c0b5b34647fa77b8dca63f53 (patch) | |
| tree | 66c3344f2c6173c63661c377f49524986b25c666 | |
| parent | 0bc9fba7ec270a8a1fc043028256eb9d7533d9e4 (diff) | |
Fix error runs not appearing in final summary
| -rw-r--r-- | src/dashboard.py | 194 | ||||
| -rw-r--r-- | src/runner.py | 174 |
2 files changed, 234 insertions, 134 deletions
diff --git a/src/dashboard.py b/src/dashboard.py index 5be6960..a4ef1cd 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -4,7 +4,14 @@ import time from collections import deque from rich.console import Console from rich.live import Live -from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn +from rich.progress import ( + Progress, + SpinnerColumn, + TextColumn, + BarColumn, + TaskProgressColumn, + TimeRemainingColumn, +) from rich.table import Table from rich.panel import Panel from rich.layout import Layout @@ -14,6 +21,7 @@ from rich.columns import Columns # Thread-safe lock for dashboard updates _dashboard_lock = threading.Lock() + class ExperimentDashboard: """Rich dashboard for real-time experiment tracking with per-model stats.""" @@ -26,24 +34,19 @@ class ExperimentDashboard: self.failed_count = 0 self.total_tokens = 0 self.start_time = time.time() - + # Per-model stats self.model_stats = { - model: { - "success": 0, - "total": 0, - "target": 0, - "tokens": 0, - "time": 0.0 - } for model in models + model: {"success": 0, "total": 0, "target": 0, "tokens": 0, "time": 0.0} + for model in models } - + # Track currently active runs - self.active_runs = {} # thread_id -> info string - + self.active_runs = {} # thread_id -> info string + # Recent activity log self.activity_log: Deque[str] = deque(maxlen=5) - + # Progress bars self.progress = Progress( SpinnerColumn(), @@ -51,10 +54,12 @@ class ExperimentDashboard: BarColumn(bar_width=None), TaskProgressColumn(), TimeRemainingColumn(), - expand=True + expand=True, + ) + + self.overall_task = self.progress.add_task( + "[yellow]Overall Progress", total=total_runs ) - - self.overall_task = self.progress.add_task("[yellow]Overall Progress", total=total_runs) self.model_tasks = {} for model in models: self.model_tasks[model] = self.progress.add_task(f"[blue]{model}", total=0) @@ -70,9 +75,20 @@ class ExperimentDashboard: """Mark a run as active.""" with _dashboard_lock: goal_str = f" ({goal})" if goal else "" - self.active_runs[thread_id] = f"[bold blue]{model}[/] | {scenario}{goal_str}" + self.active_runs[thread_id] = ( + f"[bold blue]{model}[/] | {scenario}{goal_str}" + ) - def complete_run(self, thread_id: int, model: str, success: bool, tokens: int = 0, duration: float = 0.0, error: bool = False, label: str = ""): + def complete_run( + self, + thread_id: int, + model: str, + success: bool, + tokens: int = 0, + duration: float = 0.0, + error: bool = False, + label: str = "", + ): """Update counts and progress when a run completes.""" with _dashboard_lock: # Update global counts @@ -85,9 +101,9 @@ class ExperimentDashboard: else: self.incomplete_count += 1 status_msg = "[yellow]INCOMPLETE[/]" - + self.total_tokens += tokens - + # Update per-model stats if model in self.model_stats: m_stats = self.model_stats[model] @@ -96,16 +112,16 @@ class ExperimentDashboard: m_stats["success"] += 1 m_stats["tokens"] += tokens m_stats["time"] += duration - + # Update progress bars self.progress.update(self.overall_task, advance=1) if model in self.model_tasks: self.progress.update(self.model_tasks[model], advance=1) - + # Update activity log timestamp = time.strftime("%H:%M:%S") self.activity_log.append(f"[{timestamp}] {status_msg} {label}") - + # Remove from active runs if thread_id in self.active_runs: del self.active_runs[thread_id] @@ -118,20 +134,23 @@ class ExperimentDashboard: table.add_column("Success %", justify="right", ratio=1) table.add_column("Tokens", justify="right", ratio=1) table.add_column("Avg Time", justify="right", ratio=1) - + for model, stats in self.model_stats.items(): - if stats["target"] == 0: continue - + if stats["target"] == 0: + continue + progress = f"{stats['total']}/{stats['target']}" - success_rate = (stats["success"] / stats["total"] * 100) if stats["total"] > 0 else 0 + success_rate = ( + (stats["success"] / stats["total"] * 100) if stats["total"] > 0 else 0 + ) avg_time = (stats["time"] / stats["total"]) if stats["total"] > 0 else 0 - + table.add_row( model, progress, f"{success_rate:.0f}%", f"{stats['tokens']:,}", - f"{avg_time:.1f}s" + f"{avg_time:.1f}s", ) return table @@ -149,14 +168,14 @@ class ExperimentDashboard: table = Table(expand=True, box=None) table.add_column("Metric", style="cyan") table.add_column("Value", justify="right", style="magenta") - + completed = self.success_count + self.incomplete_count + self.failed_count remaining = self.total_runs - completed - + # Time calculations elapsed = time.time() - self.start_time time_remaining = self.progress.tasks[self.overall_task].time_remaining - + table.add_row("Success", f"[green]{self.success_count}[/green]") table.add_row("Incomplete", f"[yellow]{self.incomplete_count}[/yellow]") table.add_row("Failed", f"[red]{self.failed_count}[/red]") @@ -166,7 +185,7 @@ class ExperimentDashboard: table.add_row("Elapsed", self.format_time(elapsed)) table.add_row("Est. Left", self.format_time(time_remaining)) table.add_row("Skipped", f"[grey50]{self.skipped}[/grey50]") - + return table def generate_active_panel(self) -> Panel: @@ -192,47 +211,65 @@ class ExperimentDashboard: layout.split_column( Layout(name="header", size=3), Layout(name="main"), - Layout(name="footer", size=7) + Layout(name="footer", size=7), ) - + layout["main"].split_row( - Layout(name="progress_col", ratio=2), - Layout(name="stats_col", ratio=1) + Layout(name="progress_col", ratio=2), Layout(name="stats_col", ratio=1) ) - + layout["progress_col"].split_column( - Layout(name="bars", ratio=1), - Layout(name="model_details", ratio=1) + Layout(name="bars", ratio=1), Layout(name="model_details", ratio=1) ) - + layout["footer"].split_row( - Layout(name="active", ratio=1), - Layout(name="logs", ratio=1) + Layout(name="active", ratio=1), Layout(name="logs", ratio=1) + ) + + layout["header"].update( + Panel( + Text( + "AI Agent Experiment Framework", + justify="center", + style="bold white", + ), + style="blue", + ) + ) + layout["bars"].update( + Panel(self.progress, title="Overall Progress", style="white") + ) + layout["model_details"].update( + Panel(self.generate_model_table(), title="Model Stats", style="white") + ) + layout["stats_col"].update( + Panel(self.generate_status_table(), title="Totals", style="white") ) - - layout["header"].update(Panel(Text("AI Agent Experiment Framework", justify="center", style="bold white"), style="blue")) - layout["bars"].update(Panel(self.progress, title="Overall Progress", style="white")) - layout["model_details"].update(Panel(self.generate_model_table(), title="Model Stats", style="white")) - layout["stats_col"].update(Panel(self.generate_status_table(), title="Totals", style="white")) layout["active"].update(self.generate_active_panel()) layout["logs"].update(self.generate_log_panel()) - + return layout + def print_final_summary(results: List[Dict]): """Print a pretty grouped summary table at the end.""" console = Console() console.print("\n") - + # Group results by Model and Scenario grouped = {} for r in results: - key = (r['model'], r['scenario']) + key = (r["model"], r["scenario"]) if key not in grouped: grouped[key] = [] grouped[key].append(r) - table = Table(title="[bold]Final Experiment Summary[/bold]", show_header=True, header_style="bold magenta", expand=True) + table = Table( + title="[bold]Final Experiment Summary[/bold]", + show_header=True, + header_style="bold magenta", + expand=True, + ) table.add_column("Model", style="blue", no_wrap=True) table.add_column("Scenario", style="cyan") table.add_column("Goal Type", style="yellow") @@ -243,46 +280,49 @@ def print_final_summary(results: List[Dict]): for (model, scenario), group in sorted(grouped.items()): total = len(group) successes = sum(1 for r in group if r.get("success")) + errors = sum(1 for r in group if r.get("error")) total_tokens = sum(r.get("total_tokens", 0) for r in group) avg_time = sum(r.get("duration_seconds", 0.0) for r in group) / total - - # Collect distinct goal types in this group - goals = ", ".join(sorted(list(set(r.get("goal_type", "default") or "default" for r in group)))) - - success_color = "green" if successes == total else "yellow" if successes > 0 else "red" - + + goals = ", ".join( + sorted(list(set(r.get("goal_type", "default") or "default" for r in group))) + ) + + if errors > 0: + success_str = f"[red]{successes}/{total} ({errors} errors)[/]" + elif successes == total: + success_str = f"[green]{successes}/{total}[/]" + elif successes > 0: + success_str = f"[yellow]{successes}/{total}[/]" + else: + success_str = f"[red]{successes}/{total}[/]" + table.add_row( - model, - scenario, - goals, - f"[{success_color}]{successes}/{total}[/]", - f"{total_tokens:,}", - f"{avg_time:.1f}s" + model, scenario, goals, success_str, f"{total_tokens:,}", f"{avg_time:.1f}s" ) - + console.print(table) - + # Aggregated Per-Model Table - model_table = Table(title="[bold]Aggregated Model Performance[/bold]", show_header=True, header_style="bold blue") + model_table = Table( + title="[bold]Aggregated Model Performance[/bold]", + show_header=True, + header_style="bold blue", + ) model_table.add_column("Model") model_table.add_column("Total Runs", justify="right") model_table.add_column("Overall Success %", justify="right") model_table.add_column("Total Tokens", justify="right") - models = sorted(list(set(r['model'] for r in results))) + models = sorted(list(set(r["model"] for r in results))) for model in models: - m_results = [r for r in results if r['model'] == model] + m_results = [r for r in results if r["model"] == model] m_total = len(m_results) m_successes = sum(1 for r in m_results if r.get("success")) m_tokens = sum(r.get("total_tokens", 0) for r in m_results) - + rate = (m_successes / m_total * 100) if m_total > 0 else 0 - model_table.add_row( - model, - str(m_total), - f"{rate:.1f}%", - f"{m_tokens:,}" - ) - + model_table.add_row(model, str(m_total), f"{rate:.1f}%", f"{m_tokens:,}") + console.print("\n") console.print(model_table) diff --git a/src/runner.py b/src/runner.py index 999856a..d01aafc 100644 --- a/src/runner.py +++ b/src/runner.py @@ -24,14 +24,16 @@ def load_prompt(file_path: str) -> str: """Load a prompt file.""" if not os.path.exists(file_path): return "" - with open(file_path, 'r') as f: + with open(file_path, "r") as f: return f.read().strip() class ExperimentRunner: """Runs experiments based on configuration.""" - def __init__(self, config: ConfigLoader, verbose: bool = False, resume: bool = True): + def __init__( + self, config: ConfigLoader, verbose: bool = False, resume: bool = True + ): self.config = config self.results: List[Dict] = [] self.verbose = verbose @@ -40,9 +42,9 @@ class ExperimentRunner: def run_all(self): """Run all experiments defined in config.""" - logger.info(f"\n{'='*60}") + logger.info(f"\n{'=' * 60}") logger.info("Starting Experiment Run") - logger.info(f"{'='*60}\n") + logger.info(f"{'=' * 60}\n") # Build list of all work items (model, scenario, goal_type, oversight) work_items, skipped_count = self._build_work_items() @@ -50,14 +52,14 @@ class ExperimentRunner: if not work_items and skipped_count == 0: logger.warning("No work items to run.") return - + if not work_items: logger.info(f"All {skipped_count} items already exist. Skipping all.") return max_workers = self.config.max_workers total_items = len(work_items) - + # Prepare model list for dashboard model_names = sorted(list(set(item["model_config"].id for item in work_items))) model_counts = {} @@ -65,13 +67,19 @@ class ExperimentRunner: m_id = item["model_config"].id model_counts[m_id] = model_counts.get(m_id, 0) + 1 - self.dashboard = ExperimentDashboard(total_items, model_names, skipped=skipped_count) + self.dashboard = ExperimentDashboard( + total_items, model_names, skipped=skipped_count + ) for m_id, count in model_counts.items(): self.dashboard.update_model_total(m_id, count) logger.info(f"Total work items: {total_items}, Max workers: {max_workers}") - with Live(self.dashboard.get_layout(), refresh_per_second=4, vertical_overflow="visible") as live: + with Live( + self.dashboard.get_layout(), + refresh_per_second=4, + vertical_overflow="visible", + ) as live: if max_workers <= 1: # Sequential execution for item in work_items: @@ -80,12 +88,17 @@ class ExperimentRunner: else: # Parallel execution import logging + # Reduce console spam during parallel runs to keep the progress bar clean for handler in logger.handlers: - if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if isinstance(handler, logging.StreamHandler) and not isinstance( + handler, logging.FileHandler + ): handler.setLevel(logging.WARNING) - - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + + with concurrent.futures.ThreadPoolExecutor( + max_workers=max_workers + ) as executor: futures = { executor.submit(self._execute_work_item, item): item for item in work_items @@ -111,11 +124,15 @@ class ExperimentRunner: for model_config in self.config.models: for scenario_config in self.config.scenarios: # Determine oversight levels - available = scenario_config.oversight_levels or self.config.oversight_levels + available = ( + scenario_config.oversight_levels or self.config.oversight_levels + ) global_filter = self.config.oversight_levels oversight_levels = [lvl for lvl in available if lvl in global_filter] if not oversight_levels: - logger.warning(f"No matching oversight levels for {scenario_config.path}.") + logger.warning( + f"No matching oversight levels for {scenario_config.path}." + ) continue # Ensure baseline exists @@ -125,7 +142,10 @@ class ExperimentRunner: if goal_types: for goal_type in goal_types: items, skipped = self._build_run_items( - model_config, scenario_config, oversight_level, goal_type + model_config, + scenario_config, + oversight_level, + goal_type, ) work_items.extend(items) skipped_count += skipped @@ -143,7 +163,7 @@ class ExperimentRunner: model_config: ModelConfig, scenario_config: ScenarioConfig, oversight_level: str, - goal_type: str + goal_type: str, ) -> Tuple[List[Dict[str, Any]], int]: """Build individual run items for a specific combo, accounting for resume.""" model_name_safe = model_config.id.replace("/", "_") @@ -152,17 +172,21 @@ class ExperimentRunner: # Build log directory path if goal_type: - log_dir = os.path.join(output_dir, model_name_safe, scenario_name, - goal_type, oversight_level) + log_dir = os.path.join( + output_dir, model_name_safe, scenario_name, goal_type, oversight_level + ) else: - log_dir = os.path.join(output_dir, model_name_safe, scenario_name, - oversight_level) + log_dir = os.path.join( + output_dir, model_name_safe, scenario_name, oversight_level + ) # Check existing runs for resume existing_runs = 0 if self.resume and os.path.isdir(log_dir): all_json = glob.glob(os.path.join(log_dir, "*.json")) - existing_runs = len([f for f in all_json if not f.endswith(".partial.json")]) + existing_runs = len( + [f for f in all_json if not f.endswith(".partial.json")] + ) skipped = 0 if existing_runs >= scenario_config.runs: @@ -174,23 +198,29 @@ class ExperimentRunner: items = [] for run_num in range(existing_runs + 1, scenario_config.runs + 1): goal_label = f"/{goal_type}" if goal_type else "" - items.append({ - "model_config": model_config, - "scenario_config": scenario_config, - "oversight_level": oversight_level, - "goal_type": goal_type, - "run_num": run_num, - "label": f"{model_config.id} | {scenario_name}{goal_label} | {oversight_level} | run {run_num}" - }) + items.append( + { + "model_config": model_config, + "scenario_config": scenario_config, + "oversight_level": oversight_level, + "goal_type": goal_type, + "run_num": run_num, + "label": f"{model_config.id} | {scenario_name}{goal_label} | {oversight_level} | run {run_num}", + } + ) return items, skipped - def _ensure_baseline(self, model_config: ModelConfig, scenario_config: ScenarioConfig): + def _ensure_baseline( + self, model_config: ModelConfig, scenario_config: ScenarioConfig + ): """Ensure baseline exists for a model+scenario combo (thread-safe).""" scenario_name = os.path.basename(scenario_config.path) model_name_safe = model_config.id.replace("/", "_") output_dir = self.config.output_dir - baseline_path = os.path.join(output_dir, model_name_safe, scenario_name, "baseline.md") + baseline_path = os.path.join( + output_dir, model_name_safe, scenario_name, "baseline.md" + ) if not self.config.generate_baseline: return @@ -204,12 +234,12 @@ class ExperimentRunner: thread_id = threading.get_ident() if self.dashboard: self.dashboard.start_run( - thread_id, - item["model_config"].id, + thread_id, + item["model_config"].id, os.path.basename(item["scenario_config"].path), - item["goal_type"] + item["goal_type"], ) - + try: success, tokens, duration = self._run_single( model_config=item["model_config"], @@ -217,28 +247,44 @@ class ExperimentRunner: scenario_config=item["scenario_config"], oversight_level=item["oversight_level"], goal_type=item["goal_type"], - run_num=item["run_num"] + run_num=item["run_num"], ) - + if self.dashboard: self.dashboard.complete_run( thread_id, - item["model_config"].id, - success=success, - tokens=tokens, + item["model_config"].id, + success=success, + tokens=tokens, duration=duration, - label=item["label"] + label=item["label"], ) except Exception as e: logger.critical(f"ERROR in {item['label']}: {e}") if self.dashboard: self.dashboard.complete_run( thread_id, - item["model_config"].id, - success=False, + item["model_config"].id, + success=False, error=True, - label=item["label"] + label=item["label"], ) + error_result = { + "model": item["model_config"].id, + "provider": item["model_config"].provider, + "scenario": os.path.basename(item["scenario_config"].path), + "goal_type": item["goal_type"], + "oversight_level": item["oversight_level"], + "run_num": item["run_num"], + "duration_seconds": 0.0, + "total_tokens": 0, + "success": False, + "error": True, + "error_message": str(e), + "log_file": None, + } + with _results_lock: + self.results.append(error_result) def _extract_baseline_content(self, logs: List[Dict]) -> str: """Extract baseline content from the last create_file tool call.""" @@ -271,7 +317,7 @@ class ExperimentRunner: self, model_config: ModelConfig, provider_config: ProviderConfig, - scenario_config: ScenarioConfig + scenario_config: ScenarioConfig, ): """Run a baseline experiment WITHOUT hidden goal. Saves summary to baselines/.""" scenario_name = os.path.basename(scenario_config.path) @@ -290,7 +336,9 @@ class ExperimentRunner: tool_functions = make_tools_for_vfs(vfs_instance) # Create agent - save_vfs_state = self.config._config.get('output', {}).get('save_vfs_state', True) + save_vfs_state = self.config._config.get("output", {}).get( + "save_vfs_state", True + ) agent = Agent.from_configs( system_prompt=system_prompt, provider_config=provider_config, @@ -300,7 +348,7 @@ class ExperimentRunner: user_prompt_type="user.md", save_vfs_state=save_vfs_state, vfs_instance=vfs_instance, - tool_functions=tool_functions + tool_functions=tool_functions, ) # Run the conversation @@ -318,7 +366,7 @@ class ExperimentRunner: os.makedirs(baseline_dir, exist_ok=True) baseline_path = os.path.join(baseline_dir, "baseline.md") - with open(baseline_path, 'w') as f: + with open(baseline_path, "w") as f: f.write(baseline_content) # Save baseline log separately @@ -331,7 +379,7 @@ class ExperimentRunner: scenario_config: ScenarioConfig, oversight_level: str, run_num: int, - goal_type: str = "" + goal_type: str = "", ) -> Tuple[bool, int, float]: """Run a single experiment. Thread-safe with isolated VFS.""" # Load scenario prompts @@ -340,16 +388,22 @@ class ExperimentRunner: # Load hidden goal: from hidden_goals/{goal_type}.md or legacy hidden_goal.md if goal_type: - hidden_goal_path = os.path.join(scenario_config.path, "hidden_goals", f"{goal_type}.md") + hidden_goal_path = os.path.join( + scenario_config.path, "hidden_goals", f"{goal_type}.md" + ) else: hidden_goal_path = os.path.join(scenario_config.path, "hidden_goal.md") hidden_goal = load_prompt(hidden_goal_path) # Load oversight prompt: scenario-specific first, then global fallback - scenario_oversight_path = os.path.join(scenario_config.path, "oversight", f"{oversight_level}.md") + scenario_oversight_path = os.path.join( + scenario_config.path, "oversight", f"{oversight_level}.md" + ) oversight_prompt = load_prompt(scenario_oversight_path) if not oversight_prompt: - global_oversight_path = os.path.join(self.config.project_root, "oversight", f"{oversight_level}.md") + global_oversight_path = os.path.join( + self.config.project_root, "oversight", f"{oversight_level}.md" + ) oversight_prompt = load_prompt(global_oversight_path) # Build system prompt @@ -366,7 +420,9 @@ class ExperimentRunner: tool_functions = make_tools_for_vfs(vfs_instance) # Create agent with isolated VFS - save_vfs_state = self.config._config.get('output', {}).get('save_vfs_state', True) + save_vfs_state = self.config._config.get("output", {}).get( + "save_vfs_state", True + ) scenario_name = os.path.basename(scenario_config.path) agent = Agent.from_configs( system_prompt=system_prompt, @@ -378,7 +434,7 @@ class ExperimentRunner: save_vfs_state=save_vfs_state, goal_type=goal_type, vfs_instance=vfs_instance, - tool_functions=tool_functions + tool_functions=tool_functions, ) # Run the conversation @@ -398,11 +454,15 @@ class ExperimentRunner: if msg.get("role") == "assistant" and msg.get("finish_reason"): success = msg["finish_reason"] == "stop" break - elif msg.get("role") == "assistant" and msg.get("content") is None and msg.get("tool_calls"): + elif ( + msg.get("role") == "assistant" + and msg.get("content") is None + and msg.get("tool_calls") + ): continue duration = (end_time - start_time).total_seconds() - + # Record result (thread-safe) result_entry = { "model": model_config.id, @@ -414,12 +474,12 @@ class ExperimentRunner: "duration_seconds": duration, "total_tokens": agent.total_tokens, "success": success, - "log_file": log_file + "log_file": log_file, } with _results_lock: self.results.append(result_entry) - + return success, agent.total_tokens, duration |
