From 657f5a15417b5f1e8878fdca11b8c0241c472e27 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 15 Dec 2025 18:08:29 +1300 Subject: [PATCH 01/44] First crack at using skill --- .../claude_code/claude_code_agent.py | 2 +- ade_bench/setup/agent_setup.py | 15 +++++++ shared/config/CLAUDE.md | 11 ++++- shared/config/skills/dbt-debug/SKILL.md | 43 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 shared/config/skills/dbt-debug/SKILL.md diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index b50b7601..4f8ed7e7 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -15,7 +15,7 @@ class ClaudeCodeAgent(AbstractInstalledAgent): NAME = AgentName.CLAUDE_CODE - ALLOWED_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "mcp__dbt"] + ALLOWED_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "mcp__dbt", "Skill"] def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/ade_bench/setup/agent_setup.py b/ade_bench/setup/agent_setup.py index c274c62b..ed2198c1 100644 --- a/ade_bench/setup/agent_setup.py +++ b/ade_bench/setup/agent_setup.py @@ -23,6 +23,20 @@ def _copy_config_file(terminal, trial_handler, config_filename: str, container_f logger.warning(f"Configuration file not found at {config_path}") +def _copy_skills_directory(terminal, trial_handler) -> None: + """Helper to copy the skills directory to the container's .claude/skills directory.""" + skills_path = trial_handler.shared_config_path / "skills" + if skills_path.exists() and skills_path.is_dir(): + # Create .claude/skills directory in the container first + claude_skills_dir = DockerComposeManager.CONTAINER_APP_DIR / ".claude/skills" + terminal.container.exec_run(["mkdir", "-p", str(claude_skills_dir)]) + + # Copy skills directory contents to .claude/skills in the container + terminal.copy_to_container( + paths=skills_path, + container_dir=str(claude_skills_dir) + ) + def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: """Setup agent-specific configuration files and resources.""" @@ -32,6 +46,7 @@ def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: if agent_name == AgentName.CLAUDE_CODE: _copy_config_file(terminal, trial_handler, "CLAUDE.md") + _copy_skills_directory(terminal, trial_handler) elif agent_name == AgentName.GEMINI_CLI: _copy_config_file(terminal, trial_handler, "GEMINI.md") elif agent_name == AgentName.OPENAI_CODEX: diff --git a/shared/config/CLAUDE.md b/shared/config/CLAUDE.md index 7d41ec5d..1bea3806 100644 --- a/shared/config/CLAUDE.md +++ b/shared/config/CLAUDE.md @@ -2,12 +2,20 @@ You are acting as an expert analyst and data engineer who is taksed with solving analytics and data engineering problems. Follow the requests given to you—do exactly what is asked, nothing more. +## Available Skills + +YOU MUST USE THIS SKILL, DEFINED IN `.claude/skills/dbt-debug` + +- **dbt-debug**: A comprehensive debugging toolkit for dbt projects. Use this skill when encountering dbt errors, test failures, unexpected results, or when needing to investigate model behavior, trace dependencies, examine compiled SQL, or troubleshoot any aspect of a dbt project's execution. The skill provides systematic debugging workflows, documentation of available dbt commands, and guidance on dbt Core vs dbt Fusion engine differences. + ## Available Tools + - dbt: You have access to a dbt project, and its configuration files. The project may use dbt Fusion or standard dbt. - Snowflake: Each dbt project is connected to a Snowflake database. - dbt's MCP server: In some cases, you may have access to the dbt MCP server, which you should use when appropriate. ## Key Responsibilities + - Create a model → create the model file with appropriate config and SQL - Fix a bug → identify and fix the issue - Update a model → make the requested changes @@ -15,7 +23,8 @@ You are acting as an expert analyst and data engineer who is taksed with solving - Do not add extra work like creating tests, adding documentation, or refactoring code unless explicitly asked. ## Key Principles + - Do what's asked: Follow the specific request, no extras - Inspect data: Look at actual data to understand problems or find issues - Check your work: When necessary, validate your work by querying data or compiling the dbt models -- Use the MCP server: Consider using the MCP server to speed up or improve your work. \ No newline at end of file +- Use the MCP server: Consider using the MCP server to speed up or improve your work. diff --git a/shared/config/skills/dbt-debug/SKILL.md b/shared/config/skills/dbt-debug/SKILL.md new file mode 100644 index 00000000..98aec209 --- /dev/null +++ b/shared/config/skills/dbt-debug/SKILL.md @@ -0,0 +1,43 @@ +--- +name: dbt-debug +description: Toolkit for debugging dbt projects. Use when encountering dbt errors, test failures, unexpected results, or when needing to investigate model behavior, trace dependencies, examine compiled SQL, or troubleshoot any aspect of a dbt project's execution. +--- + +# dbt Debugging Skill + +## Debugging Workflow + +When debugging any dbt issue, follow this systematic approach: + +1. Read the error message. The error message dbt produces will normally contain the type of error, and the file where the error occurred. +2. Inspect the file that was known to cause the issue, and see if there's an immediate fix. +3. Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. +4. Review compiled files and the logs. + - The target/compiled directory contains select statements that you can run in any query editor. + - The target/run directory contains the SQL dbt executes to build your models. + - The logs/dbt.log file contains all the queries that dbt runs, and additional logging. Recent errors will be at the bottom of the file. + +## Available Commands + +- `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` +- `dbt --version` will return the currently installed version of dbt (and adapters, for dbt Core). This is the best way to check whether the project is using dbt Core or the new dbt Fusion engine. +- `dbt parse` will validate that the dbt project is correctly structured (valid YAML and configs). It will not run queries against the remote warehouse. +- `dbt compile` will render Jinja templates. dbt Core's engine does not understand SQL, so a successful compile step does not mean the SQL is valid. By contrast, the dbt Fusion engine produces and statically analyze a logical plan (as long as the `static_analysis` config is not set to `off` for a model or any of its parents) during a `dbt compile` step, which is a cheap way to verify a project's content. +- `dbt show` returns the first 5 rows of a single node (when used with `--select`) or of an arbitrary query (when used with `--inline`). Use `--limit` to fetch a different number of rows. When writing an `--inline` query, do not provide a limit statement inside the query text. +- `dbt run` will attempt to materialize the selected models into the warehouse. For dbt Core, the rendered SQL can only be verified by running it against the warehouse. +- `dbt test` runs selected tests without verifying that the resources being tested already exist in the warehouse. +- `dbt build` runs all selected resources (tests, models, snapshots, seeds) in DAG order. It is the best command to use to pinpoint where in the project an issue exists, and to validate that an issue has been resolved. +- `dbt run-operation` allows invocation of arbitrary macros. This is unlikely to be relevant unless instructed by the user. +- `dbt clean` will delete the paths specified in `clean-targets` list of `dbt_project.yml`. It is normally used to delete the target directory and any installed packages. +- `dbt deps` will install the exact package versions specified in `package-lock.yml` if specified. If there is no `package-lock.yml`, dbt will resolve the requested dependencies (including transitive dependencies) in `packages.yml` or `dependencies.yml`. + +## dbt Core vs the dbt Fusion engine + +The dbt Fusion engine is the next-generation engine to execute dbt projects. If the dbt Fusion engine is installed (check with `dbt --version`), then try validating SQL using `dbt compile` before consuming warehouse resources with a `dbt run` or `dbt build`. + +## Common misconceptions and mistakes + +- Do not attempt to use `dbt run-query` or `dbt run-operation run_query`, they do not exist. +- Do not attempt to modify database tables directly. Instead, write models which transform the data as required. +- You should not just filter out data that doesn't match expectations, even if you don't think it's being used. +- Always use `{{ref()}}` or `{{source()}}` to point to objects. Do not guess at a table's schema or database. From 30dd733ebb14b6f8cc1653e9bc4e0e2cbae0c659 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 16 Dec 2025 15:51:39 +1300 Subject: [PATCH 02/44] Rewriting skill text and adding prompt to force claude to use it --- shared/config/CLAUDE.md | 4 +- shared/config/skills/dbt-debug/SKILL.md | 43 ---------- shared/config/skills/dbt-skill/SKILL.md | 101 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 45 deletions(-) delete mode 100644 shared/config/skills/dbt-debug/SKILL.md create mode 100644 shared/config/skills/dbt-skill/SKILL.md diff --git a/shared/config/CLAUDE.md b/shared/config/CLAUDE.md index 1bea3806..20a42ae8 100644 --- a/shared/config/CLAUDE.md +++ b/shared/config/CLAUDE.md @@ -4,9 +4,9 @@ You are acting as an expert analyst and data engineer who is taksed with solving ## Available Skills -YOU MUST USE THIS SKILL, DEFINED IN `.claude/skills/dbt-debug` +YOU MUST USE THIS SKILL, DEFINED IN `.claude/skills/dbt-skill` -- **dbt-debug**: A comprehensive debugging toolkit for dbt projects. Use this skill when encountering dbt errors, test failures, unexpected results, or when needing to investigate model behavior, trace dependencies, examine compiled SQL, or troubleshoot any aspect of a dbt project's execution. The skill provides systematic debugging workflows, documentation of available dbt commands, and guidance on dbt Core vs dbt Fusion engine differences. +- **dbt-skill**: A comprehensive guide to working with dbt projects. ## Available Tools diff --git a/shared/config/skills/dbt-debug/SKILL.md b/shared/config/skills/dbt-debug/SKILL.md deleted file mode 100644 index 98aec209..00000000 --- a/shared/config/skills/dbt-debug/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: dbt-debug -description: Toolkit for debugging dbt projects. Use when encountering dbt errors, test failures, unexpected results, or when needing to investigate model behavior, trace dependencies, examine compiled SQL, or troubleshoot any aspect of a dbt project's execution. ---- - -# dbt Debugging Skill - -## Debugging Workflow - -When debugging any dbt issue, follow this systematic approach: - -1. Read the error message. The error message dbt produces will normally contain the type of error, and the file where the error occurred. -2. Inspect the file that was known to cause the issue, and see if there's an immediate fix. -3. Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. -4. Review compiled files and the logs. - - The target/compiled directory contains select statements that you can run in any query editor. - - The target/run directory contains the SQL dbt executes to build your models. - - The logs/dbt.log file contains all the queries that dbt runs, and additional logging. Recent errors will be at the bottom of the file. - -## Available Commands - -- `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` -- `dbt --version` will return the currently installed version of dbt (and adapters, for dbt Core). This is the best way to check whether the project is using dbt Core or the new dbt Fusion engine. -- `dbt parse` will validate that the dbt project is correctly structured (valid YAML and configs). It will not run queries against the remote warehouse. -- `dbt compile` will render Jinja templates. dbt Core's engine does not understand SQL, so a successful compile step does not mean the SQL is valid. By contrast, the dbt Fusion engine produces and statically analyze a logical plan (as long as the `static_analysis` config is not set to `off` for a model or any of its parents) during a `dbt compile` step, which is a cheap way to verify a project's content. -- `dbt show` returns the first 5 rows of a single node (when used with `--select`) or of an arbitrary query (when used with `--inline`). Use `--limit` to fetch a different number of rows. When writing an `--inline` query, do not provide a limit statement inside the query text. -- `dbt run` will attempt to materialize the selected models into the warehouse. For dbt Core, the rendered SQL can only be verified by running it against the warehouse. -- `dbt test` runs selected tests without verifying that the resources being tested already exist in the warehouse. -- `dbt build` runs all selected resources (tests, models, snapshots, seeds) in DAG order. It is the best command to use to pinpoint where in the project an issue exists, and to validate that an issue has been resolved. -- `dbt run-operation` allows invocation of arbitrary macros. This is unlikely to be relevant unless instructed by the user. -- `dbt clean` will delete the paths specified in `clean-targets` list of `dbt_project.yml`. It is normally used to delete the target directory and any installed packages. -- `dbt deps` will install the exact package versions specified in `package-lock.yml` if specified. If there is no `package-lock.yml`, dbt will resolve the requested dependencies (including transitive dependencies) in `packages.yml` or `dependencies.yml`. - -## dbt Core vs the dbt Fusion engine - -The dbt Fusion engine is the next-generation engine to execute dbt projects. If the dbt Fusion engine is installed (check with `dbt --version`), then try validating SQL using `dbt compile` before consuming warehouse resources with a `dbt run` or `dbt build`. - -## Common misconceptions and mistakes - -- Do not attempt to use `dbt run-query` or `dbt run-operation run_query`, they do not exist. -- Do not attempt to modify database tables directly. Instead, write models which transform the data as required. -- You should not just filter out data that doesn't match expectations, even if you don't think it's being used. -- Always use `{{ref()}}` or `{{source()}}` to point to objects. Do not guess at a table's schema or database. diff --git a/shared/config/skills/dbt-skill/SKILL.md b/shared/config/skills/dbt-skill/SKILL.md new file mode 100644 index 00000000..efbf0530 --- /dev/null +++ b/shared/config/skills/dbt-skill/SKILL.md @@ -0,0 +1,101 @@ +--- +name: dbt-skill +description: Interact with dbt projects. dbt is a data transformation tool. +--- + +# Using dbt for analytics and data engineering + +## Key terminology + +- A **[model](https://docs.getdbt.com/docs/build/models)** is a select statement which will be persisted to the database as a view, table, materialized view, iceberg table, etc. Ephemeral models are inlined CTEs. +- A **[dbt project](https://docs.getdbt.com/docs/build/projects)** is a collection of models, tests, seeds, snapshots, macros, and configuration files that define data transformations and their dependencies. +- A **[source](https://docs.getdbt.com/docs/build/sources)** is a reference to raw data tables in your warehouse that exist outside of dbt. Sources are defined in YAML files and used as the starting point for your transformations. +- A **[seed](https://docs.getdbt.com/docs/build/seeds)** is a CSV file in your dbt project that gets loaded into your warehouse as a table. Seeds are typically used for small reference or lookup tables. +- A **[snapshot](https://docs.getdbt.com/docs/build/snapshots)** captures the state of a mutable table at a point in time, enabling Type 2 Slowly Changing Dimension tracking. +- A **[test](https://docs.getdbt.com/docs/build/data-tests)** is an assertion about your data. Tests can be generic (e.g., `unique`, `not_null`) or singular (custom SQL queries that return failing rows). +- A **[macro](https://docs.getdbt.com/docs/build/jinja-macros)** is a reusable Jinja template function that generates SQL or performs other logic. Macros enable code reuse and abstraction. +- A **[package](https://docs.getdbt.com/docs/build/packages)** is a collection of dbt resources (models, macros, etc.) that can be installed and reused across projects. +- A **[materialization](https://docs.getdbt.com/docs/build/materializations)** is the strategy dbt uses to persist a model in the warehouse (e.g., table, view, incremental, ephemeral). +- The **[DAG](https://docs.getdbt.com/terms/dag)** (Directed Acyclic Graph) is the dependency structure of your dbt project, showing how models depend on each other. +- A **[node](https://docs.getdbt.com/reference/node-selection/syntax)** is any object in the DAG (model, test, seed, snapshot, source, etc.) that dbt can select and execute. +- A **[selector](https://docs.getdbt.com/reference/node-selection/syntax)** is a pattern used to specify which nodes to run (e.g., `model_name`, `+model_name` for upstream, `model_name+` for downstream). +- A **[resource](https://docs.getdbt.com/reference/configs-and-properties)** is any dbt object defined in your project (models, sources, tests, etc.). Resources are nodes that can be referenced and built. +- An **[adapter](https://docs.getdbt.com/docs/connect-adapters)** is a plugin that enables dbt to work with different data warehouses (e.g., Snowflake, BigQuery, DuckDB). + +## Core loop + +I have: + +- a dbt project +- a CLI with dbt installed +- a database connection + +You are being asked to make changes that will affect the dbt models and resources in the DAG, in the service of producing data artifacts for use in other downstream tools. + +This could look like: + +- creating a new dbt model based off of sources or models that currently exist in your project +- modifying an existing model in your dbt project +- adding a new source of data to your dbt project +- changing the settings or configurations of your dbt project, for example adding descriptions or tests to a column, or changing a model's materialization strategy. +- running CLI commands to apply the state defined in your dbt project to your database (for example to build a new table in the database or refresh the existing data) + +These are the primitives you will use when fulfilling a user request. Many requests will require you to do some combination of tasks in that list, in a specific order. + +Much of the complexity of this work will boil down to the following: + +- Ensuring that you have an understanding of the tables, their columns and their data types +- Taking actions in the correct order, and validating that you have done the correct thing at each step of the process, instead of waiting until the end to check + +The best way to ensure you are correctly following your instructions is to adhere to the below workflow. + +When asked to make changes to a dbt project, you should: + +1. Understand the problem you are trying to solve. Do you need to add new models or modify existing models? +2. Gather context on what you have available to you: + - the dbt project (models, seeds, YAML files, etc) + - the objects in the warehouse (databases, schemas, tables, UDFs) +3. Plan how to perform the desired transformations: + - Determine which models will need to be modified. + - Decide which order to modify the models in, working in DAG order from parents to children. +4. For each model you modify: + - Plan out what will change. Which columns need to be added, modified or deleted. Which rows need to be added or removed based on modifications to joins or filters? + - Define success criteria you can use to validate whether your changes were correct. + - Apply the changes you planned, by writing dbt code into the model file. + - Build the changed model with `dbt build`. + - Run `dbt show` to validate that the success criteria you defined have been met. + - Carefully validate that there are no subtle issues in the data such as type mismatches. + - Repeat with the next model. +5. If you encounter compilation errors or warehouse errors during iteration, use the debugging guide. + +## Debugging + +- Read the error message. The error message dbt produces will normally contain the type of error, and the file where the error occurred. +- Inspect the file that was known to cause the issue, and see if there's an immediate fix. +- Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. +- Review compiled files and the logs: + - The `target/compiled` directory contains the rendered model code as a select statement. + - The `target/run` directory contains that rendered code inside of DDL statements such as `CREATE TABLE AS SELECT`. + - The `logs/dbt.log` file contains all the queries that dbt rans, and additional logging. Recent errors will be at the bottom of the file. + +## Available Commands + +- `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` +- `dbt --version` will return the currently installed version of dbt (and adapters, for dbt Core). This is the best way to check whether the project is using dbt Core or the new dbt Fusion engine. +- `dbt parse` will validate that the dbt project is correctly structured (valid YAML and configs). It will not run queries against the remote warehouse. It is implicitly run during the following commands, so does not need to be explicitly invoked. +- `dbt compile` will render Jinja templates. dbt Core's engine does not understand SQL, so a successful compile step does not mean the SQL is valid. By contrast, the dbt Fusion engine produces and statically analyze a logical plan (as long as the `static_analysis` config is not set to `off` for a model or any of its parents) during a `dbt compile` step, which is a cheap way to verify a project's content. +- `dbt show` returns the first 5 rows of a single node (when used with `--select`) or of an arbitrary query (when used with `--inline`). Use `--limit` to fetch a different number of rows. When writing an `--inline` query, do not provide a limit statement inside the query text. +- `dbt run` will attempt to materialize the selected models into the warehouse. For dbt Core, the rendered SQL can only be verified by running it against the warehouse. +- `dbt test` runs selected tests without verifying that the resources being tested already exist in the warehouse. +- `dbt build` runs all selected resources (tests, models, snapshots, seeds) in DAG order. It is the best command to use to pinpoint where in the project an issue exists, and to validate that an issue has been resolved. +- `dbt run-operation` allows invocation of arbitrary macros. This is unlikely to be relevant unless instructed by the user. +- `dbt clean` will delete the paths specified in `clean-targets` list of `dbt_project.yml`. It is normally used to delete the target directory and any installed packages. +- `dbt deps` will install the exact package versions specified in `package-lock.yml` if specified. If there is no `package-lock.yml`, dbt will resolve the requested dependencies (including transitive dependencies) in `packages.yml` or `dependencies.yml`. + +## Other warnings + +- You should NEVER write DDL or DML directly to the warehouse; always use dbt for this. +- Do not filter data out of models unless asked to. + + From 6c8cc7abff851c6cca4f7dc248cab94183e77777 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 16 Dec 2025 16:45:02 +1300 Subject: [PATCH 03/44] toggle use-skill on and off (very ugly) --- ade_bench/cli/ab/main.py | 6 +++ ade_bench/harness.py | 6 ++- ade_bench/setup/agent_setup.py | 64 +++++++++++++++++++++++++-- ade_bench/setup/setup_orchestrator.py | 5 ++- scripts_python/create_sandbox.py | 10 ++++- 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/ade_bench/cli/ab/main.py b/ade_bench/cli/ab/main.py index 25859879..5c5eef16 100644 --- a/ade_bench/cli/ab/main.py +++ b/ade_bench/cli/ab/main.py @@ -153,6 +153,11 @@ def run( "--use-mcp", help="Enable MCP (Model Context Protocol) for the agent" ), + use_skills: bool = typer.Option( + False, + "--use-skills", + help="Enable skills for the agent (e.g., dbt-debugging skill)" + ), with_profiling: bool = typer.Option( False, "--with-profiling", @@ -228,6 +233,7 @@ def run( project_type=project_type, keep_alive=persist, use_mcp=use_mcp, + use_skills=use_skills, with_profiling=with_profiling ) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index c4a07390..8f0ee91d 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -66,6 +66,7 @@ def __init__( project_type: str | None = None, keep_alive: bool = False, use_mcp: bool = False, + use_skills: bool = False, with_profiling: bool = False, ): """ @@ -95,6 +96,7 @@ def __init__( project_type: Project type to filter variants (e.g., dbt, other). keep_alive: If True, keep containers alive when tasks fail for debugging. use_mcp: If True, start a dbt MCP server after setup completes. + use_skills: If True, copy skills directory to container for agent use. with_profiling: If True, will enable the cProfiler. """ self._run_uuid = None @@ -109,6 +111,7 @@ def __init__( self._project_type_filter = project_type self._keep_alive = keep_alive self._use_mcp = use_mcp + self._use_skills = use_skills self._with_profiling = with_profiling # Initialize setup orchestrator for variant-specific setup @@ -559,7 +562,8 @@ def _run_setup( terminal=terminal, session=session, file_diff_handler=file_diff_handler, - trial_handler=trial_handler + trial_handler=trial_handler, + use_skills=self._use_skills ) # Run setup with timeout using asyncio diff --git a/ade_bench/setup/agent_setup.py b/ade_bench/setup/agent_setup.py index ed2198c1..9a4ae954 100644 --- a/ade_bench/setup/agent_setup.py +++ b/ade_bench/setup/agent_setup.py @@ -2,6 +2,8 @@ Agent-specific setup functions for copying configuration files and other agent resources. """ +import tempfile +from pathlib import Path from ..utils.logger import logger from ..terminal.docker_compose_manager import DockerComposeManager from ..agents.agent_name import AgentName @@ -23,6 +25,59 @@ def _copy_config_file(terminal, trial_handler, config_filename: str, container_f logger.warning(f"Configuration file not found at {config_path}") +def _copy_claude_config(terminal, trial_handler, use_skills: bool) -> None: + """Helper to copy CLAUDE.md config file, optionally removing skills section.""" + config_path = trial_handler.shared_config_path / "CLAUDE.md" + if not config_path.exists(): + logger.warning(f"Configuration file not found at {config_path}") + return + + # Read the config file + with open(config_path, 'r') as f: + content = f.read() + + # If skills are disabled, remove the "Available Skills" section + if not use_skills: + lines = content.split('\n') + filtered_lines = [] + skip_section = False + + for i, line in enumerate(lines): + # Check if we're at the start of the Available Skills section + if line.strip() == "## Available Skills": + skip_section = True + continue + + # Check if we've reached the next section (starts with ##) + if skip_section and line.strip().startswith("## ") and "Available Skills" not in line: + skip_section = False + + # Only add lines if we're not in the skills section + if not skip_section: + filtered_lines.append(line) + + content = '\n'.join(filtered_lines) + # Remove any extra blank lines that might have been left + while '\n\n\n' in content: + content = content.replace('\n\n\n', '\n\n') + + # Write to a temporary file and copy to container + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as tmp_file: + tmp_file.write(content) + tmp_file.flush() + tmp_path = Path(tmp_file.name) + + try: + terminal.copy_to_container( + paths=tmp_path, + container_dir=str(DockerComposeManager.CONTAINER_APP_DIR), + container_filename="CLAUDE.md" + ) + finally: + # Clean up temp file + tmp_path.unlink() + + def _copy_skills_directory(terminal, trial_handler) -> None: """Helper to copy the skills directory to the container's .claude/skills directory.""" skills_path = trial_handler.shared_config_path / "skills" @@ -36,8 +91,10 @@ def _copy_skills_directory(terminal, trial_handler) -> None: paths=skills_path, container_dir=str(claude_skills_dir) ) + else: + logger.debug(f"Skills directory not found at {skills_path}, skipping skill setup") -def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: +def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills: bool = False) -> None: """Setup agent-specific configuration files and resources.""" agent_name = trial_handler.agent_name @@ -45,8 +102,9 @@ def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: log_harness_info(logger, task_id, "setup", "Migrating agent config files...") if agent_name == AgentName.CLAUDE_CODE: - _copy_config_file(terminal, trial_handler, "CLAUDE.md") - _copy_skills_directory(terminal, trial_handler) + _copy_claude_config(terminal, trial_handler, use_skills) + if use_skills: + _copy_skills_directory(terminal, trial_handler) elif agent_name == AgentName.GEMINI_CLI: _copy_config_file(terminal, trial_handler, "GEMINI.md") elif agent_name == AgentName.OPENAI_CODEX: diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index 7178a45e..3177a2cb 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -15,12 +15,13 @@ class SetupOrchestrator: """Simple orchestrator that calls setup functions directly.""" - def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None): + def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, use_skills=False): self.logger = logger self.terminal = terminal self.session = session self.file_diff_handler = file_diff_handler self.trial_handler = trial_handler + self.use_skills = use_skills def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: """Setup a task for the given variant.""" @@ -36,7 +37,7 @@ def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: # Setup agent-specific configuration files # Logging is in the setup_agent_config function - setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) + setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger, self.use_skills) # Set up the database diff --git a/scripts_python/create_sandbox.py b/scripts_python/create_sandbox.py index f1b0f1fe..1ea04e99 100644 --- a/scripts_python/create_sandbox.py +++ b/scripts_python/create_sandbox.py @@ -312,6 +312,7 @@ def main(): parser.add_argument("--project-type", required=True, help="Project type (dbt, other)") parser.add_argument("--agent", required=False, help="Ignored") parser.add_argument("--use-mcp", required=False, action="store_true", help="Ignored") + parser.add_argument("--use-skills", required=False, action="store_true", help="Copy skills to sandbox") parser.add_argument("--persist", required=False, action="store_true", help="Ignored") parser.add_argument("--no-diffs", required=False, action="store_true", help="Ignored") parser.add_argument("--seed", required=False, action="store_true", help="Ignored") @@ -366,7 +367,14 @@ def main(): if not copy_shared_scripts(): sys.exit(1) - # Step 9: Update dbt configuration files + # Step 9: Copy skills directory (if enabled) + if args.use_skills: + if not copy_skills(): + sys.exit(1) + else: + print(f"✓ Skipping skills directory (--use-skills not specified)") + + # Step 10: Update dbt configuration files print(f"✓ Updating dbt configuration files...") if not update_dbt_config(variant, task_name): print(f"❌ Failed to update dbt configuration files") From 3f02d71fe92d63df4eb58f44a3c4f742d9893d0f Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 16 Dec 2025 16:45:20 +1300 Subject: [PATCH 04/44] Add demo for profiling tables --- shared/config/skills/dbt-skill/SKILL.md | 11 ++++++++++- .../skills/dbt-skill/scripts/profiling.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 shared/config/skills/dbt-skill/scripts/profiling.md diff --git a/shared/config/skills/dbt-skill/SKILL.md b/shared/config/skills/dbt-skill/SKILL.md index efbf0530..cb4467d3 100644 --- a/shared/config/skills/dbt-skill/SKILL.md +++ b/shared/config/skills/dbt-skill/SKILL.md @@ -71,13 +71,22 @@ When asked to make changes to a dbt project, you should: ## Debugging - Read the error message. The error message dbt produces will normally contain the type of error, and the file where the error occurred. -- Inspect the file that was known to cause the issue, and see if there's an immediate fix. +- Work out whether the problem is in the code or the data. Recent code changes imply a code problem, otherwise the underlying data from an upstream source is likely to be the cause. - Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. - Review compiled files and the logs: - The `target/compiled` directory contains the rendered model code as a select statement. - The `target/run` directory contains that rendered code inside of DDL statements such as `CREATE TABLE AS SELECT`. - The `logs/dbt.log` file contains all the queries that dbt rans, and additional logging. Recent errors will be at the bottom of the file. +If the issue is in the code: + +- Inspect the file that was known to cause the issue, and see if there's an immediate fix. + +If the issue is in the data: + +- Identify the problematic column(s) with bisection or by reading the error message +- Profile suspect columns using the sample code in `scripts/profiling.md` to identify the underlying cause. + ## Available Commands - `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` diff --git a/shared/config/skills/dbt-skill/scripts/profiling.md b/shared/config/skills/dbt-skill/scripts/profiling.md new file mode 100644 index 00000000..9d2dac24 --- /dev/null +++ b/shared/config/skills/dbt-skill/scripts/profiling.md @@ -0,0 +1,17 @@ +# Profiling Data + +## Get metadata about a single column + +Investigate a single column using a query like this. Be mindful of warehouse compute consumption when deciding how many of these queries to run. + +```sql +select + min(product_id) as min_value, + max(product_id) as max_value, + count(product_id) as count_not_null, + count(distinct product_id) as count_unique_values, + count(*) as count_rows, + min(length(product_id)) as shortest_value, + max(length(product_id)) as longest_value, +from {{ ref('my_model') }} +``` From 991a0c0ccf30f4e764bdb668dd6bd346655c3299 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 17 Dec 2025 15:13:22 +1300 Subject: [PATCH 05/44] More iteration on skill, add review run results example script --- shared/config/skills/dbt-skill/SKILL.md | 82 +++++++++++-------- .../dbt-skill/scripts/review_run_results.md | 80 ++++++++++++++++++ 2 files changed, 127 insertions(+), 35 deletions(-) create mode 100644 shared/config/skills/dbt-skill/scripts/review_run_results.md diff --git a/shared/config/skills/dbt-skill/SKILL.md b/shared/config/skills/dbt-skill/SKILL.md index cb4467d3..5493610c 100644 --- a/shared/config/skills/dbt-skill/SKILL.md +++ b/shared/config/skills/dbt-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: dbt-skill -description: Interact with dbt projects. dbt is a data transformation tool. +description: Interact with dbt projects. dbt is a data transformation tool. This skill helps with creating and modifying dbt resources and their metadata, as well as running CLI commands to retrieve information about the project or apply the state defined in your project to the database. --- # Using dbt for analytics and data engineering @@ -21,33 +21,29 @@ description: Interact with dbt projects. dbt is a data transformation tool. - A **[selector](https://docs.getdbt.com/reference/node-selection/syntax)** is a pattern used to specify which nodes to run (e.g., `model_name`, `+model_name` for upstream, `model_name+` for downstream). - A **[resource](https://docs.getdbt.com/reference/configs-and-properties)** is any dbt object defined in your project (models, sources, tests, etc.). Resources are nodes that can be referenced and built. - An **[adapter](https://docs.getdbt.com/docs/connect-adapters)** is a plugin that enables dbt to work with different data warehouses (e.g., Snowflake, BigQuery, DuckDB). +- An **[analysis](https://docs.getdbt.com/docs/build/analyses)** is a SQL file which will not be built as a model. Anything included in this directory will be compiled but not run. -## Core loop +## Common tasks when fulfilling to a dbt-related request -I have: +The following list is the primitives you will use when fulfilling a user request. Many requests will require you to do a combination of tasks from this list, in a specific order: -- a dbt project -- a CLI with dbt installed -- a database connection +- work out the cause of an error message and an effective solution +- create a new dbt model based off of sources or models that currently exist in your project +- modify an existing model in your dbt project +- add a new source of data to your dbt project +- change the settings or configurations of your dbt project, for example adding descriptions or tests to a column, or changing a model's materialization strategy +- run CLI commands to apply the state defined in your dbt project to your database (for example to build a new table in the database or refresh the existing data) -You are being asked to make changes that will affect the dbt models and resources in the DAG, in the service of producing data artifacts for use in other downstream tools. +To perform these tasks effectively, you will need to: -This could look like: +- Build an understanding of the state of the project: + - relevant models/sources, their columns and their data types + - pre-existing errors and warnings in the project +- Decide what actions are necessary +- Take actions in the correct order +- Validate that your changes are correct after each step of the process -- creating a new dbt model based off of sources or models that currently exist in your project -- modifying an existing model in your dbt project -- adding a new source of data to your dbt project -- changing the settings or configurations of your dbt project, for example adding descriptions or tests to a column, or changing a model's materialization strategy. -- running CLI commands to apply the state defined in your dbt project to your database (for example to build a new table in the database or refresh the existing data) - -These are the primitives you will use when fulfilling a user request. Many requests will require you to do some combination of tasks in that list, in a specific order. - -Much of the complexity of this work will boil down to the following: - -- Ensuring that you have an understanding of the tables, their columns and their data types -- Taking actions in the correct order, and validating that you have done the correct thing at each step of the process, instead of waiting until the end to check - -The best way to ensure you are correctly following your instructions is to adhere to the below workflow. +## How to modify existing models When asked to make changes to a dbt project, you should: @@ -68,15 +64,34 @@ When asked to make changes to a dbt project, you should: - Repeat with the next model. 5. If you encounter compilation errors or warehouse errors during iteration, use the debugging guide. -## Debugging +## How to create new models + +When asked to create new models, you should: -- Read the error message. The error message dbt produces will normally contain the type of error, and the file where the error occurred. +1. Ensure that there are not pre-existing models that would achieve the goals +2. Decide what columns will need to exist in the final model +3. Work backwards from the end state, deciding from where each required column will come. Use as few models as possible and avoid redundant transformations +4. For each model you create: + - Plan out your changes. Which columns need to be brought through as they are, transformed or aggregated? Which rows need to be included or excluded based on joins or filters? + - Define success criteria you can use to validate whether your changes were correct. + - Apply the changes you planned, by writing dbt code into the model file. + - Build the changed model with `dbt build`. + - Run `dbt show` to validate that the success criteria you defined have been met. + - Carefully validate that there are no subtle issues in the data such as type mismatches. + - Repeat with the next model. +5. If you encounter compilation errors or warehouse errors during iteration, use the debugging guide. + +## How to debug errors + +- If you are prompted to fix a bug start by reviewing the logs and artifacts. See `scripts/review_run_results.md` for an example. + - The `logs/dbt.log` file contains all the queries that dbt rans, and additional logging. Recent errors will be at the bottom of the file. + - The `run_results.json` file contains each model which ran in the most recent invocation, and whether they succeeded or not. +- If the error came from the console, read the error message. +- The error messages dbt produces will normally contain the type of error, and the file where the error occurred. - Work out whether the problem is in the code or the data. Recent code changes imply a code problem, otherwise the underlying data from an upstream source is likely to be the cause. - Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. -- Review compiled files and the logs: - The `target/compiled` directory contains the rendered model code as a select statement. - The `target/run` directory contains that rendered code inside of DDL statements such as `CREATE TABLE AS SELECT`. - - The `logs/dbt.log` file contains all the queries that dbt rans, and additional logging. Recent errors will be at the bottom of the file. If the issue is in the code: @@ -87,7 +102,12 @@ If the issue is in the data: - Identify the problematic column(s) with bisection or by reading the error message - Profile suspect columns using the sample code in `scripts/profiling.md` to identify the underlying cause. -## Available Commands +## How to run dbt commands + +- When running commands like `dbt run`, `dbt build`, `dbt compile`, `dbt test`, you must always use dbt's selection syntax to precisely identify resources to be processed. This increases iteration speed and minimises warehouse costs. +- NEVER write DDL or DML directly to the warehouse; always use dbt for this. + +### Available Commands - `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` - `dbt --version` will return the currently installed version of dbt (and adapters, for dbt Core). This is the best way to check whether the project is using dbt Core or the new dbt Fusion engine. @@ -100,11 +120,3 @@ If the issue is in the data: - `dbt run-operation` allows invocation of arbitrary macros. This is unlikely to be relevant unless instructed by the user. - `dbt clean` will delete the paths specified in `clean-targets` list of `dbt_project.yml`. It is normally used to delete the target directory and any installed packages. - `dbt deps` will install the exact package versions specified in `package-lock.yml` if specified. If there is no `package-lock.yml`, dbt will resolve the requested dependencies (including transitive dependencies) in `packages.yml` or `dependencies.yml`. - -## Other warnings - -- You should NEVER write DDL or DML directly to the warehouse; always use dbt for this. -- Do not filter data out of models unless asked to. - - diff --git a/shared/config/skills/dbt-skill/scripts/review_run_results.md b/shared/config/skills/dbt-skill/scripts/review_run_results.md new file mode 100644 index 00000000..79140258 --- /dev/null +++ b/shared/config/skills/dbt-skill/scripts/review_run_results.md @@ -0,0 +1,80 @@ +# Review dbt Run Results + +## When to Use + +If a user tells you there is a problem with the project, review the `target/run_results.json` file to identify which resources failed and why. + +Review the completion date to ensure the information is fresh. + +## Python Script + +```python +import json + +def review_dbt_run_results(run_results_path: str): + """Review dbt run_results.json and identify failures.""" + with open(run_results_path) as f: + data = json.load(f) + + results = data.get('results', []) + failed = [r for r in results if r.get('status') == 'error'] + + print(f"Total: {len(results)} | Failed: {len(failed)}") + + if failed: + print("\nFailed Resources:") + for r in failed: + # Extract resource name from unique_id (e.g., "model.project.name" -> "name") + resource_name = r['unique_id'].split('.')[-1] + resource_type = r['unique_id'].split('.')[0] + + print(f"\n- {resource_type}: {resource_name}") + + # Parse error message for key details + message = r.get('message', '') + if message: + # Extract the main error line + error_lines = [line for line in message.split('\n') if line.strip()] + print(f" Error: {error_lines[0] if error_lines else message}") + + # Show compiled SQL if available + compiled = r.get('compiled_code', '').strip() + if compiled and len(compiled) < 200: + print(f" SQL: {compiled}") + elif compiled: + print(f" SQL: {compiled[:200]}...") + + return failed + +# Usage +failed_resources = review_dbt_run_results('target/run_results.json') +``` + +## What to Do with Results + +Once you identify failed resources: + +1. **Read the error message** - Understand what went wrong (syntax error, missing column, type mismatch, etc.) + +2. **Check the compiled SQL** - The `compiled_code` shows the actual SQL that was executed, which helps identify: + - Typos in column names + - Missing or incorrect joins + - Invalid SQL syntax + - Logic errors + +3. **Locate the source file** - Find the model file using the `unique_id`: + - `model.project_name.model_name` → `models/model_name.sql` + - Check the error message for the file path + +4. **Fix the issue** - Common fixes: + - **Syntax errors**: Correct SQL syntax in the model file + - **Missing columns**: Add missing columns or fix column references + - **Duplicate columns**: Remove duplicate column names in SELECT statements + - **Missing dependencies**: Ensure upstream models/sources exist and are materialized + - **Schema mismatches**: Update column names to match source schema + +5. **Re-run the specific model** - Test your fix: + + ```bash + dbt build -s model_name + ``` From 1a105e84a4a40b60a21bcab71e07db3f0fa2783e Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 17 Dec 2025 15:48:06 +1300 Subject: [PATCH 06/44] begging claude to use the resources available to it --- shared/config/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/config/CLAUDE.md b/shared/config/CLAUDE.md index 20a42ae8..d7810a93 100644 --- a/shared/config/CLAUDE.md +++ b/shared/config/CLAUDE.md @@ -6,7 +6,7 @@ You are acting as an expert analyst and data engineer who is taksed with solving YOU MUST USE THIS SKILL, DEFINED IN `.claude/skills/dbt-skill` -- **dbt-skill**: A comprehensive guide to working with dbt projects. +- **dbt-skill**: A comprehensive guide to working with dbt projects, including making changes to a project and debugging issues. ## Available Tools From 48aeac12615f359ce5a16342460a48267590eb0c Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Fri, 30 Jan 2026 16:02:23 +1300 Subject: [PATCH 07/44] Clean up to use vercel skills cli instead --- ade_bench/setup/agent_setup.py | 95 ++++---------- shared/config/skills/dbt-skill/SKILL.md | 122 ------------------ .../skills/dbt-skill/scripts/profiling.md | 17 --- .../dbt-skill/scripts/review_run_results.md | 80 ------------ 4 files changed, 25 insertions(+), 289 deletions(-) delete mode 100644 shared/config/skills/dbt-skill/SKILL.md delete mode 100644 shared/config/skills/dbt-skill/scripts/profiling.md delete mode 100644 shared/config/skills/dbt-skill/scripts/review_run_results.md diff --git a/ade_bench/setup/agent_setup.py b/ade_bench/setup/agent_setup.py index 9a4ae954..ecdfcc13 100644 --- a/ade_bench/setup/agent_setup.py +++ b/ade_bench/setup/agent_setup.py @@ -2,7 +2,6 @@ Agent-specific setup functions for copying configuration files and other agent resources. """ -import tempfile from pathlib import Path from ..utils.logger import logger from ..terminal.docker_compose_manager import DockerComposeManager @@ -25,74 +24,27 @@ def _copy_config_file(terminal, trial_handler, config_filename: str, container_f logger.warning(f"Configuration file not found at {config_path}") -def _copy_claude_config(terminal, trial_handler, use_skills: bool) -> None: - """Helper to copy CLAUDE.md config file, optionally removing skills section.""" - config_path = trial_handler.shared_config_path / "CLAUDE.md" - if not config_path.exists(): - logger.warning(f"Configuration file not found at {config_path}") - return - - # Read the config file - with open(config_path, 'r') as f: - content = f.read() - - # If skills are disabled, remove the "Available Skills" section - if not use_skills: - lines = content.split('\n') - filtered_lines = [] - skip_section = False - - for i, line in enumerate(lines): - # Check if we're at the start of the Available Skills section - if line.strip() == "## Available Skills": - skip_section = True - continue - - # Check if we've reached the next section (starts with ##) - if skip_section and line.strip().startswith("## ") and "Available Skills" not in line: - skip_section = False - - # Only add lines if we're not in the skills section - if not skip_section: - filtered_lines.append(line) - - content = '\n'.join(filtered_lines) - # Remove any extra blank lines that might have been left - while '\n\n\n' in content: - content = content.replace('\n\n\n', '\n\n') - - # Write to a temporary file and copy to container - with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as tmp_file: - tmp_file.write(content) - tmp_file.flush() - tmp_path = Path(tmp_file.name) +def _install_skills_via_cli(terminal, trial_handler) -> None: + """Install dbt skills using the Vercel Skills CLI. - try: - terminal.copy_to_container( - paths=tmp_path, - container_dir=str(DockerComposeManager.CONTAINER_APP_DIR), - container_filename="CLAUDE.md" - ) - finally: - # Clean up temp file - tmp_path.unlink() - - -def _copy_skills_directory(terminal, trial_handler) -> None: - """Helper to copy the skills directory to the container's .claude/skills directory.""" - skills_path = trial_handler.shared_config_path / "skills" - if skills_path.exists() and skills_path.is_dir(): - # Create .claude/skills directory in the container first - claude_skills_dir = DockerComposeManager.CONTAINER_APP_DIR / ".claude/skills" - terminal.container.exec_run(["mkdir", "-p", str(claude_skills_dir)]) - - # Copy skills directory contents to .claude/skills in the container - terminal.copy_to_container( - paths=skills_path, - container_dir=str(claude_skills_dir) - ) + The CLI automatically detects which agents are available in the container + and installs skills to the appropriate directories (.claude/skills/, + .cursor/skills/, .codex/skills/, etc.). + """ + skills_repo = "dbt-labs/dbt-agent-skills" + install_cmd = f"npx --yes skills add {skills_repo} --all" + + logger.info(f"Installing skills from {skills_repo} (supports all agent types)...") + + result = terminal.container.exec_run( + ["sh", "-c", install_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning(f"Skills installation failed: {result.output.decode('utf-8')}") else: - logger.debug(f"Skills directory not found at {skills_path}, skipping skill setup") + logger.info(f"Skills installed successfully from {skills_repo}") def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills: bool = False) -> None: """Setup agent-specific configuration files and resources.""" @@ -101,13 +53,16 @@ def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills log_harness_info(logger, task_id, "setup", "Migrating agent config files...") + # Copy agent-specific config files if agent_name == AgentName.CLAUDE_CODE: - _copy_claude_config(terminal, trial_handler, use_skills) - if use_skills: - _copy_skills_directory(terminal, trial_handler) + _copy_config_file(terminal, trial_handler, "CLAUDE.md") elif agent_name == AgentName.GEMINI_CLI: _copy_config_file(terminal, trial_handler, "GEMINI.md") elif agent_name == AgentName.OPENAI_CODEX: _copy_config_file(terminal, trial_handler, "AGENTS.md") elif agent_name == AgentName.MACRO: _copy_config_file(terminal, trial_handler, "MACRO.md") + + # Install skills for any agent type when --use-skills is enabled + if use_skills: + _install_skills_via_cli(terminal, trial_handler) diff --git a/shared/config/skills/dbt-skill/SKILL.md b/shared/config/skills/dbt-skill/SKILL.md deleted file mode 100644 index 5493610c..00000000 --- a/shared/config/skills/dbt-skill/SKILL.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -name: dbt-skill -description: Interact with dbt projects. dbt is a data transformation tool. This skill helps with creating and modifying dbt resources and their metadata, as well as running CLI commands to retrieve information about the project or apply the state defined in your project to the database. ---- - -# Using dbt for analytics and data engineering - -## Key terminology - -- A **[model](https://docs.getdbt.com/docs/build/models)** is a select statement which will be persisted to the database as a view, table, materialized view, iceberg table, etc. Ephemeral models are inlined CTEs. -- A **[dbt project](https://docs.getdbt.com/docs/build/projects)** is a collection of models, tests, seeds, snapshots, macros, and configuration files that define data transformations and their dependencies. -- A **[source](https://docs.getdbt.com/docs/build/sources)** is a reference to raw data tables in your warehouse that exist outside of dbt. Sources are defined in YAML files and used as the starting point for your transformations. -- A **[seed](https://docs.getdbt.com/docs/build/seeds)** is a CSV file in your dbt project that gets loaded into your warehouse as a table. Seeds are typically used for small reference or lookup tables. -- A **[snapshot](https://docs.getdbt.com/docs/build/snapshots)** captures the state of a mutable table at a point in time, enabling Type 2 Slowly Changing Dimension tracking. -- A **[test](https://docs.getdbt.com/docs/build/data-tests)** is an assertion about your data. Tests can be generic (e.g., `unique`, `not_null`) or singular (custom SQL queries that return failing rows). -- A **[macro](https://docs.getdbt.com/docs/build/jinja-macros)** is a reusable Jinja template function that generates SQL or performs other logic. Macros enable code reuse and abstraction. -- A **[package](https://docs.getdbt.com/docs/build/packages)** is a collection of dbt resources (models, macros, etc.) that can be installed and reused across projects. -- A **[materialization](https://docs.getdbt.com/docs/build/materializations)** is the strategy dbt uses to persist a model in the warehouse (e.g., table, view, incremental, ephemeral). -- The **[DAG](https://docs.getdbt.com/terms/dag)** (Directed Acyclic Graph) is the dependency structure of your dbt project, showing how models depend on each other. -- A **[node](https://docs.getdbt.com/reference/node-selection/syntax)** is any object in the DAG (model, test, seed, snapshot, source, etc.) that dbt can select and execute. -- A **[selector](https://docs.getdbt.com/reference/node-selection/syntax)** is a pattern used to specify which nodes to run (e.g., `model_name`, `+model_name` for upstream, `model_name+` for downstream). -- A **[resource](https://docs.getdbt.com/reference/configs-and-properties)** is any dbt object defined in your project (models, sources, tests, etc.). Resources are nodes that can be referenced and built. -- An **[adapter](https://docs.getdbt.com/docs/connect-adapters)** is a plugin that enables dbt to work with different data warehouses (e.g., Snowflake, BigQuery, DuckDB). -- An **[analysis](https://docs.getdbt.com/docs/build/analyses)** is a SQL file which will not be built as a model. Anything included in this directory will be compiled but not run. - -## Common tasks when fulfilling to a dbt-related request - -The following list is the primitives you will use when fulfilling a user request. Many requests will require you to do a combination of tasks from this list, in a specific order: - -- work out the cause of an error message and an effective solution -- create a new dbt model based off of sources or models that currently exist in your project -- modify an existing model in your dbt project -- add a new source of data to your dbt project -- change the settings or configurations of your dbt project, for example adding descriptions or tests to a column, or changing a model's materialization strategy -- run CLI commands to apply the state defined in your dbt project to your database (for example to build a new table in the database or refresh the existing data) - -To perform these tasks effectively, you will need to: - -- Build an understanding of the state of the project: - - relevant models/sources, their columns and their data types - - pre-existing errors and warnings in the project -- Decide what actions are necessary -- Take actions in the correct order -- Validate that your changes are correct after each step of the process - -## How to modify existing models - -When asked to make changes to a dbt project, you should: - -1. Understand the problem you are trying to solve. Do you need to add new models or modify existing models? -2. Gather context on what you have available to you: - - the dbt project (models, seeds, YAML files, etc) - - the objects in the warehouse (databases, schemas, tables, UDFs) -3. Plan how to perform the desired transformations: - - Determine which models will need to be modified. - - Decide which order to modify the models in, working in DAG order from parents to children. -4. For each model you modify: - - Plan out what will change. Which columns need to be added, modified or deleted. Which rows need to be added or removed based on modifications to joins or filters? - - Define success criteria you can use to validate whether your changes were correct. - - Apply the changes you planned, by writing dbt code into the model file. - - Build the changed model with `dbt build`. - - Run `dbt show` to validate that the success criteria you defined have been met. - - Carefully validate that there are no subtle issues in the data such as type mismatches. - - Repeat with the next model. -5. If you encounter compilation errors or warehouse errors during iteration, use the debugging guide. - -## How to create new models - -When asked to create new models, you should: - -1. Ensure that there are not pre-existing models that would achieve the goals -2. Decide what columns will need to exist in the final model -3. Work backwards from the end state, deciding from where each required column will come. Use as few models as possible and avoid redundant transformations -4. For each model you create: - - Plan out your changes. Which columns need to be brought through as they are, transformed or aggregated? Which rows need to be included or excluded based on joins or filters? - - Define success criteria you can use to validate whether your changes were correct. - - Apply the changes you planned, by writing dbt code into the model file. - - Build the changed model with `dbt build`. - - Run `dbt show` to validate that the success criteria you defined have been met. - - Carefully validate that there are no subtle issues in the data such as type mismatches. - - Repeat with the next model. -5. If you encounter compilation errors or warehouse errors during iteration, use the debugging guide. - -## How to debug errors - -- If you are prompted to fix a bug start by reviewing the logs and artifacts. See `scripts/review_run_results.md` for an example. - - The `logs/dbt.log` file contains all the queries that dbt rans, and additional logging. Recent errors will be at the bottom of the file. - - The `run_results.json` file contains each model which ran in the most recent invocation, and whether they succeeded or not. -- If the error came from the console, read the error message. -- The error messages dbt produces will normally contain the type of error, and the file where the error occurred. -- Work out whether the problem is in the code or the data. Recent code changes imply a code problem, otherwise the underlying data from an upstream source is likely to be the cause. -- Isolate the problem — for example, by running one model a time, or by undoing the code that broke things. - - The `target/compiled` directory contains the rendered model code as a select statement. - - The `target/run` directory contains that rendered code inside of DDL statements such as `CREATE TABLE AS SELECT`. - -If the issue is in the code: - -- Inspect the file that was known to cause the issue, and see if there's an immediate fix. - -If the issue is in the data: - -- Identify the problematic column(s) with bisection or by reading the error message -- Profile suspect columns using the sample code in `scripts/profiling.md` to identify the underlying cause. - -## How to run dbt commands - -- When running commands like `dbt run`, `dbt build`, `dbt compile`, `dbt test`, you must always use dbt's selection syntax to precisely identify resources to be processed. This increases iteration speed and minimises warehouse costs. -- NEVER write DDL or DML directly to the warehouse; always use dbt for this. - -### Available Commands - -- `dbt debug` checks that dbt is correctly installed, and can successfully connect to the configured data warehouse. It will not take any project content into consideration beyond core pieces of `profiles.yml` and `dbt_project.yml` -- `dbt --version` will return the currently installed version of dbt (and adapters, for dbt Core). This is the best way to check whether the project is using dbt Core or the new dbt Fusion engine. -- `dbt parse` will validate that the dbt project is correctly structured (valid YAML and configs). It will not run queries against the remote warehouse. It is implicitly run during the following commands, so does not need to be explicitly invoked. -- `dbt compile` will render Jinja templates. dbt Core's engine does not understand SQL, so a successful compile step does not mean the SQL is valid. By contrast, the dbt Fusion engine produces and statically analyze a logical plan (as long as the `static_analysis` config is not set to `off` for a model or any of its parents) during a `dbt compile` step, which is a cheap way to verify a project's content. -- `dbt show` returns the first 5 rows of a single node (when used with `--select`) or of an arbitrary query (when used with `--inline`). Use `--limit` to fetch a different number of rows. When writing an `--inline` query, do not provide a limit statement inside the query text. -- `dbt run` will attempt to materialize the selected models into the warehouse. For dbt Core, the rendered SQL can only be verified by running it against the warehouse. -- `dbt test` runs selected tests without verifying that the resources being tested already exist in the warehouse. -- `dbt build` runs all selected resources (tests, models, snapshots, seeds) in DAG order. It is the best command to use to pinpoint where in the project an issue exists, and to validate that an issue has been resolved. -- `dbt run-operation` allows invocation of arbitrary macros. This is unlikely to be relevant unless instructed by the user. -- `dbt clean` will delete the paths specified in `clean-targets` list of `dbt_project.yml`. It is normally used to delete the target directory and any installed packages. -- `dbt deps` will install the exact package versions specified in `package-lock.yml` if specified. If there is no `package-lock.yml`, dbt will resolve the requested dependencies (including transitive dependencies) in `packages.yml` or `dependencies.yml`. diff --git a/shared/config/skills/dbt-skill/scripts/profiling.md b/shared/config/skills/dbt-skill/scripts/profiling.md deleted file mode 100644 index 9d2dac24..00000000 --- a/shared/config/skills/dbt-skill/scripts/profiling.md +++ /dev/null @@ -1,17 +0,0 @@ -# Profiling Data - -## Get metadata about a single column - -Investigate a single column using a query like this. Be mindful of warehouse compute consumption when deciding how many of these queries to run. - -```sql -select - min(product_id) as min_value, - max(product_id) as max_value, - count(product_id) as count_not_null, - count(distinct product_id) as count_unique_values, - count(*) as count_rows, - min(length(product_id)) as shortest_value, - max(length(product_id)) as longest_value, -from {{ ref('my_model') }} -``` diff --git a/shared/config/skills/dbt-skill/scripts/review_run_results.md b/shared/config/skills/dbt-skill/scripts/review_run_results.md deleted file mode 100644 index 79140258..00000000 --- a/shared/config/skills/dbt-skill/scripts/review_run_results.md +++ /dev/null @@ -1,80 +0,0 @@ -# Review dbt Run Results - -## When to Use - -If a user tells you there is a problem with the project, review the `target/run_results.json` file to identify which resources failed and why. - -Review the completion date to ensure the information is fresh. - -## Python Script - -```python -import json - -def review_dbt_run_results(run_results_path: str): - """Review dbt run_results.json and identify failures.""" - with open(run_results_path) as f: - data = json.load(f) - - results = data.get('results', []) - failed = [r for r in results if r.get('status') == 'error'] - - print(f"Total: {len(results)} | Failed: {len(failed)}") - - if failed: - print("\nFailed Resources:") - for r in failed: - # Extract resource name from unique_id (e.g., "model.project.name" -> "name") - resource_name = r['unique_id'].split('.')[-1] - resource_type = r['unique_id'].split('.')[0] - - print(f"\n- {resource_type}: {resource_name}") - - # Parse error message for key details - message = r.get('message', '') - if message: - # Extract the main error line - error_lines = [line for line in message.split('\n') if line.strip()] - print(f" Error: {error_lines[0] if error_lines else message}") - - # Show compiled SQL if available - compiled = r.get('compiled_code', '').strip() - if compiled and len(compiled) < 200: - print(f" SQL: {compiled}") - elif compiled: - print(f" SQL: {compiled[:200]}...") - - return failed - -# Usage -failed_resources = review_dbt_run_results('target/run_results.json') -``` - -## What to Do with Results - -Once you identify failed resources: - -1. **Read the error message** - Understand what went wrong (syntax error, missing column, type mismatch, etc.) - -2. **Check the compiled SQL** - The `compiled_code` shows the actual SQL that was executed, which helps identify: - - Typos in column names - - Missing or incorrect joins - - Invalid SQL syntax - - Logic errors - -3. **Locate the source file** - Find the model file using the `unique_id`: - - `model.project_name.model_name` → `models/model_name.sql` - - Check the error message for the file path - -4. **Fix the issue** - Common fixes: - - **Syntax errors**: Correct SQL syntax in the model file - - **Missing columns**: Add missing columns or fix column references - - **Duplicate columns**: Remove duplicate column names in SELECT statements - - **Missing dependencies**: Ensure upstream models/sources exist and are materialized - - **Schema mismatches**: Update column names to match source schema - -5. **Re-run the specific model** - Test your fix: - - ```bash - dbt build -s model_name - ``` From 19083698bb0e5962aa3bda07b3a966c02099fa91 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Fri, 30 Jan 2026 17:26:57 +1300 Subject: [PATCH 08/44] add git to dockerfiles so that skill installation works --- docker/base/Dockerfile.duckdb-dbt | 1 + docker/base/Dockerfile.snowflake-dbt | 1 + docker/base/Dockerfile.snowflake-dbtf | 1 + 3 files changed, 3 insertions(+) diff --git a/docker/base/Dockerfile.duckdb-dbt b/docker/base/Dockerfile.duckdb-dbt index 9dcab047..cd6b0a1c 100644 --- a/docker/base/Dockerfile.duckdb-dbt +++ b/docker/base/Dockerfile.duckdb-dbt @@ -1,6 +1,7 @@ FROM python:3.11-slim RUN apt-get update && apt-get install -y \ + git \ tmux asciinema \ curl \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ diff --git a/docker/base/Dockerfile.snowflake-dbt b/docker/base/Dockerfile.snowflake-dbt index 7de7187b..2b72e832 100644 --- a/docker/base/Dockerfile.snowflake-dbt +++ b/docker/base/Dockerfile.snowflake-dbt @@ -1,6 +1,7 @@ FROM python:3.11-slim RUN apt-get update && apt-get install -y \ + git \ tmux asciinema \ curl \ && curl -sSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \ diff --git a/docker/base/Dockerfile.snowflake-dbtf b/docker/base/Dockerfile.snowflake-dbtf index 4fd08b6b..d3d53269 100644 --- a/docker/base/Dockerfile.snowflake-dbtf +++ b/docker/base/Dockerfile.snowflake-dbtf @@ -1,6 +1,7 @@ FROM python:3.11-slim RUN apt-get update && apt-get install -y \ + git \ tmux asciinema \ curl \ && curl -sSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \ From b897dbbd59e1ed734a7e7dbc9ccebe2725e5ca83 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:12:39 +1300 Subject: [PATCH 09/44] Add plugin sets design document Replaces --use-mcp and --use-skills flags with YAML-configured plugin sets for A/B comparison of agent performance. Co-Authored-By: Claude Opus 4.5 --- docs/plans/2026-02-03-plugin-sets-design.md | 279 ++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/plans/2026-02-03-plugin-sets-design.md diff --git a/docs/plans/2026-02-03-plugin-sets-design.md b/docs/plans/2026-02-03-plugin-sets-design.md new file mode 100644 index 00000000..dc2dd63b --- /dev/null +++ b/docs/plans/2026-02-03-plugin-sets-design.md @@ -0,0 +1,279 @@ +# Design: Plugin Sets for ADE-Bench + +**Status**: Ready for implementation +**Date**: 2026-02-03 + +## Overview + +Replace the current `--use-mcp` and `--use-skills` flags with a YAML-configured plugin set system. This enables: + +- A/B comparison of agent performance with different skill/MCP configurations +- Declarative configuration instead of CLI flags +- Reusable plugin types (skills, MCP) across multiple vendors + +## Goals + +1. Configure skill sets in YAML, reference by name from CLI +2. Support multiple default skill sets for automatic A/B comparison +3. Generic handlers for skills and MCP servers (not hardcoded per vendor) +4. Capture skill set metadata in results for analysis + +## Non-Goals + +- Transcript generation (separate feature, not in scope) +- Non-Claude agents for skills (skills via Vercel CLI are agent-agnostic, but some may only work with certain agents) + +--- + +## Schema + +**File:** `experiment_sets/skill-sets.yaml` + +```yaml +sets: + - name: no-plugins + description: Baseline - no skills or MCP + default: true + # agents omitted = all agents compatible + skills: [] + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] + + - name: dbt-skills + description: dbt skills via Vercel Skills CLI + agents: [claude] # Optional - restricts to specified agents + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] + + - name: dbt-mcp + description: dbt MCP server + default: true + skills: [] + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] + + - name: dbt-full + description: Both skills and MCP + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] +``` + +**Pydantic models** (`ade_bench/models/skill_set.py`): + +```python +from pydantic import BaseModel + +class McpServerConfig(BaseModel): + command: str + args: list[str] = [] + env: dict[str, str] = {} + +class SkillSet(BaseModel): + name: str + description: str = "" + default: bool = False + agents: list[str] | None = None # None = all agents compatible + skills: list[str] = [] + mcp_servers: dict[str, McpServerConfig] = {} + allowed_tools: list[str] = [] + +class SkillSetsConfig(BaseModel): + sets: list[SkillSet] +``` + +--- + +## CLI Changes + +**Remove:** +- `--use-mcp` +- `--use-skills` + +**Add:** +- `--plugin-set` (space-separated list of skill set names) + +**Behavior:** + +```bash +# No flag: runs all default skill sets (A/B comparison) +ab run task001 --db duckdb --project-type dbt --agent claude +# Runs: no-plugins, dbt-mcp (both marked default: true) + +# Explicit single set +ab run task001 --db duckdb --project-type dbt --agent claude --plugin-set dbt-skills + +# Explicit multiple sets (space-separated) +ab run task001 --db duckdb --project-type dbt --agent claude --plugin-set no-plugins dbt-mcp +``` + +**Validation at startup:** +1. Load `experiment_sets/skill-sets.yaml` +2. If `--plugin-set` specified, validate names exist; otherwise use defaults +3. Filter to skill sets compatible with `--agent` +4. Error and exit if no compatible skill sets remain +5. Run separate trials for each skill set + +--- + +## Plugin Type Handlers + +Two generic handlers read from skill set config: + +### SkillsHandler + +Installs skills via Vercel Skills CLI. Refactored from existing `_install_skills_via_cli()`. + +```python +class SkillsHandler: + def install(self, skill_set: SkillSet, terminal) -> None: + for repo in skill_set.skills: + cmd = f"npx --yes skills add {repo} --all" + result = terminal.container.exec_run( + ["sh", "-c", cmd], + workdir="/app" + ) + if result.exit_code != 0: + raise RuntimeError(f"Skills installation failed: {result.output}") +``` + +### McpHandler + +Configures MCP servers in agent config. Static env vars from YAML; dynamic vars (`DBT_PROJECT_DIR`, `DBT_PATH`) set during container setup. + +```python +class McpHandler: + def configure(self, skill_set: SkillSet, agent_name: str, terminal) -> None: + for name, config in skill_set.mcp_servers.items(): + # Write env file + env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) + env_path = f"/tmp/{name}.env" + terminal.container.exec_run(["sh", "-c", f"cat > {env_path} << 'EOF'\n{env_content}\nEOF"]) + + # Register with agent + args_str = " ".join(config.args) + cmd = f"{agent_name} mcp add {name} -- {config.command} --env-file {env_path} {args_str}" + terminal.container.exec_run(["sh", "-c", cmd], workdir="/app") +``` + +Both handlers run in the `pre_agent` phase (after setup, before agent starts). + +--- + +## Output Structure + +Each skill set produces a separate run with suffixed run_id: + +``` +experiments/ +├── 2026-02-03__14-30-00__no-plugins/ +│ ├── run_config.yaml +│ ├── results.json +│ └── task_001.duckdb_dbt/ +│ ├── result.json +│ └── agent-logs/ +│ +└── 2026-02-03__14-30-00__dbt-mcp/ + ├── run_config.yaml + ├── results.json + └── task_001.duckdb_dbt/ + ├── result.json + └── agent-logs/ +``` + +### Result Metadata + +**result.json** (per task): +```json +{ + "task_id": "task_001.duckdb_dbt", + "agent": "claude", + "pass": true, + "runtime_ms": 45000, + "skill_set": { + "name": "dbt-mcp", + "skills": [], + "mcp_servers": ["dbt"] + } +} +``` + +**results.json** (aggregated, at run level): +```json +{ + "run_id": "2026-02-03__14-30-00__dbt-mcp", + "skill_set": { + "name": "dbt-mcp", + "skills": [], + "mcp_servers": { + "dbt": { + "command": "uvx", + "args": ["dbt-mcp@latest"], + "env": { + "DISABLE_SEMANTIC_LAYER": "true" + } + } + } + }, + "trials": [...] +} +``` + +--- + +## Implementation Plan + +### New Files + +| File | Purpose | +|------|---------| +| `experiment_sets/skill-sets.yaml` | Skill set definitions | +| `ade_bench/models/skill_set.py` | Pydantic models for schema | +| `ade_bench/plugins/skills_handler.py` | Installs skills via `npx skills add` | +| `ade_bench/plugins/mcp_handler.py` | Configures MCP servers | +| `ade_bench/plugins/skill_set_loader.py` | Loads and validates YAML config | + +### Files to Modify + +| File | Changes | +|------|---------| +| `ade_bench/cli/ab/main.py` | Remove `--use-mcp`, `--use-skills`; add `--plugin-set` | +| `ade_bench/harness.py` | Loop over skill sets, suffix run_id | +| `ade_bench/setup/agent_setup.py` | Remove `_install_skills_via_cli()`, `use_skills` param | +| `ade_bench/setup/setup_orchestrator.py` | Call handlers based on skill set config | +| Container setup scripts | Set `DBT_PROJECT_DIR`, `DBT_PATH` env vars | +| `ade_bench/models/results.py` | Add skill_set field to result models | + +### Files to Delete + +| File | Reason | +|------|--------| +| `shared/scripts/setup-dbt-mcp.sh` | Logic moves to `McpHandler` | + +--- + +## Open Questions + +None - design is ready for implementation. From 87c3f42ae7d9026c15a8d7fc22176cb8b0b60255 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:16:08 +1300 Subject: [PATCH 10/44] docs: add detailed implementation plan for plugin sets 12 tasks with TDD approach, exact file paths, and code snippets. Co-Authored-By: Claude Opus 4.5 --- .../2026-02-03-plugin-sets-implementation.md | 1396 +++++++++++++++++ 1 file changed, 1396 insertions(+) create mode 100644 docs/plans/2026-02-03-plugin-sets-implementation.md diff --git a/docs/plans/2026-02-03-plugin-sets-implementation.md b/docs/plans/2026-02-03-plugin-sets-implementation.md new file mode 100644 index 00000000..674c2939 --- /dev/null +++ b/docs/plans/2026-02-03-plugin-sets-implementation.md @@ -0,0 +1,1396 @@ +# Plugin Sets Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace `--use-mcp` and `--use-skills` flags with YAML-configured plugin sets for A/B comparison. + +**Architecture:** Define skill sets in `experiment_sets/skill-sets.yaml`, load via Pydantic models, apply via generic handlers (SkillsHandler, McpHandler) in pre-agent phase. Multiple default sets run as separate trials with suffixed run IDs. + +**Tech Stack:** Python 3.11+, Pydantic, PyYAML, typer CLI + +--- + +## Task 1: Create Pydantic Models for Skill Sets + +**Files:** +- Create: `ade_bench/models/__init__.py` +- Create: `ade_bench/models/skill_set.py` +- Test: `tests/models/test_skill_set.py` + +**Step 1: Create models directory** + +```bash +mkdir -p ade_bench/models tests/models +touch ade_bench/models/__init__.py tests/models/__init__.py +``` + +**Step 2: Write the failing test** + +Create `tests/models/test_skill_set.py`: + +```python +import pytest +from ade_bench.models.skill_set import SkillSet, McpServerConfig, SkillSetsConfig + + +def test_mcp_server_config_minimal(): + config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + assert config.command == "uvx" + assert config.args == ["dbt-mcp@latest"] + assert config.env == {} + + +def test_mcp_server_config_with_env(): + config = McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true"} + ) + assert config.env == {"DISABLE_SQL": "true"} + + +def test_skill_set_minimal(): + skill_set = SkillSet(name="test", allowed_tools=["Bash"]) + assert skill_set.name == "test" + assert skill_set.description == "" + assert skill_set.default is False + assert skill_set.agents is None + assert skill_set.skills == [] + assert skill_set.mcp_servers == {} + assert skill_set.allowed_tools == ["Bash"] + + +def test_skill_set_full(): + skill_set = SkillSet( + name="dbt-full", + description="Full dbt setup", + default=True, + agents=["claude"], + skills=["dbt-labs/dbt-agent-skills"], + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + }, + allowed_tools=["Bash", "Skill", "mcp__dbt__*"] + ) + assert skill_set.default is True + assert skill_set.agents == ["claude"] + assert len(skill_set.mcp_servers) == 1 + + +def test_skill_set_is_compatible_with_agent_all(): + """When agents is None, compatible with all agents.""" + skill_set = SkillSet(name="test", allowed_tools=["Bash"]) + assert skill_set.is_compatible_with_agent("claude") is True + assert skill_set.is_compatible_with_agent("gemini") is True + + +def test_skill_set_is_compatible_with_agent_restricted(): + """When agents is set, only compatible with listed agents.""" + skill_set = SkillSet(name="test", agents=["claude"], allowed_tools=["Bash"]) + assert skill_set.is_compatible_with_agent("claude") is True + assert skill_set.is_compatible_with_agent("gemini") is False + + +def test_skill_sets_config_from_yaml(): + yaml_content = """ +sets: + - name: no-plugins + default: true + skills: [] + allowed_tools: [Bash, Read] + - name: dbt-skills + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + allowed_tools: [Bash, Skill] +""" + import yaml + data = yaml.safe_load(yaml_content) + config = SkillSetsConfig(**data) + assert len(config.sets) == 2 + assert config.sets[0].name == "no-plugins" + assert config.sets[0].default is True + + +def test_skill_sets_config_get_defaults(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", default=True, allowed_tools=["Bash"]), + SkillSet(name="b", default=False, allowed_tools=["Bash"]), + SkillSet(name="c", default=True, allowed_tools=["Bash"]), + ]) + defaults = config.get_defaults() + assert len(defaults) == 2 + assert defaults[0].name == "a" + assert defaults[1].name == "c" + + +def test_skill_sets_config_get_by_name(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + SkillSet(name="b", allowed_tools=["Bash"]), + ]) + assert config.get_by_name("a").name == "a" + assert config.get_by_name("b").name == "b" + assert config.get_by_name("nonexistent") is None + + +def test_skill_sets_config_get_by_names(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + SkillSet(name="b", allowed_tools=["Bash"]), + SkillSet(name="c", allowed_tools=["Bash"]), + ]) + result = config.get_by_names(["a", "c"]) + assert len(result) == 2 + assert result[0].name == "a" + assert result[1].name == "c" + + +def test_skill_sets_config_get_by_names_unknown_raises(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + ]) + with pytest.raises(ValueError, match="Unknown skill set"): + config.get_by_names(["a", "nonexistent"]) +``` + +**Step 3: Run test to verify it fails** + +Run: `uv run pytest tests/models/test_skill_set.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.models.skill_set'" + +**Step 4: Write the implementation** + +Create `ade_bench/models/skill_set.py`: + +```python +"""Pydantic models for skill set configuration.""" + +from pydantic import BaseModel + + +class McpServerConfig(BaseModel): + """Configuration for an MCP server.""" + command: str + args: list[str] = [] + env: dict[str, str] = {} + + +class SkillSet(BaseModel): + """Configuration for a set of skills and tools.""" + name: str + description: str = "" + default: bool = False + agents: list[str] | None = None # None = all agents compatible + skills: list[str] = [] + mcp_servers: dict[str, McpServerConfig] = {} + allowed_tools: list[str] = [] + + def is_compatible_with_agent(self, agent_name: str) -> bool: + """Check if this skill set is compatible with the given agent.""" + if self.agents is None: + return True + return agent_name in self.agents + + +class SkillSetsConfig(BaseModel): + """Root configuration containing all skill sets.""" + sets: list[SkillSet] + + def get_defaults(self) -> list[SkillSet]: + """Get all skill sets marked as default.""" + return [s for s in self.sets if s.default] + + def get_by_name(self, name: str) -> SkillSet | None: + """Get a skill set by name.""" + for s in self.sets: + if s.name == name: + return s + return None + + def get_by_names(self, names: list[str]) -> list[SkillSet]: + """Get multiple skill sets by name. Raises if any not found.""" + result = [] + for name in names: + skill_set = self.get_by_name(name) + if skill_set is None: + available = [s.name for s in self.sets] + raise ValueError( + f"Unknown skill set '{name}'. Available: {', '.join(available)}" + ) + result.append(skill_set) + return result +``` + +Update `ade_bench/models/__init__.py`: + +```python +"""Models for ADE-Bench configuration.""" + +from .skill_set import McpServerConfig, SkillSet, SkillSetsConfig + +__all__ = ["McpServerConfig", "SkillSet", "SkillSetsConfig"] +``` + +**Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/models/test_skill_set.py -v` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add ade_bench/models/ tests/models/ +git commit -m "feat: add Pydantic models for skill set configuration" +``` + +--- + +## Task 2: Create Skill Sets YAML File + +**Files:** +- Create: `experiment_sets/skill-sets.yaml` + +**Step 1: Create the YAML file** + +Create `experiment_sets/skill-sets.yaml`: + +```yaml +# Skill set configurations for ADE-Bench +# Use --plugin-set to select, or run without flag to use all defaults + +sets: + - name: no-plugins + description: Baseline - no skills or MCP + default: true + skills: [] + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] + + - name: dbt-skills + description: dbt skills via Vercel Skills CLI + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] + + - name: dbt-mcp + description: dbt MCP server + default: true + skills: [] + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] + + - name: dbt-full + description: Both skills and MCP + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] +``` + +**Step 2: Commit** + +```bash +git add experiment_sets/skill-sets.yaml +git commit -m "feat: add skill-sets.yaml configuration" +``` + +--- + +## Task 3: Create Skill Set Loader + +**Files:** +- Create: `ade_bench/plugins/__init__.py` +- Create: `ade_bench/plugins/loader.py` +- Test: `tests/plugins/test_loader.py` + +**Step 1: Create plugins directory** + +```bash +mkdir -p ade_bench/plugins tests/plugins +touch ade_bench/plugins/__init__.py tests/plugins/__init__.py +``` + +**Step 2: Write the failing test** + +Create `tests/plugins/test_loader.py`: + +```python +import pytest +from pathlib import Path +from ade_bench.plugins.loader import SkillSetLoader +from ade_bench.models.skill_set import SkillSetsConfig + + +def test_loader_loads_yaml(tmp_path): + yaml_file = tmp_path / "skill-sets.yaml" + yaml_file.write_text(""" +sets: + - name: test + default: true + skills: [] + allowed_tools: [Bash] +""") + loader = SkillSetLoader(yaml_file) + config = loader.load() + assert isinstance(config, SkillSetsConfig) + assert len(config.sets) == 1 + assert config.sets[0].name == "test" + + +def test_loader_file_not_found(): + loader = SkillSetLoader(Path("/nonexistent/skill-sets.yaml")) + with pytest.raises(FileNotFoundError): + loader.load() + + +def test_loader_resolve_skill_sets_explicit(): + """Explicit --plugin-set names are resolved.""" + yaml_content = """ +sets: + - name: a + default: false + allowed_tools: [Bash] + - name: b + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + result = loader.resolve_skill_sets( + plugin_set_names=["a"], + agent_name="claude" + ) + assert len(result) == 1 + assert result[0].name == "a" + + +def test_loader_resolve_skill_sets_defaults(): + """When no --plugin-set, use defaults.""" + yaml_content = """ +sets: + - name: a + default: false + allowed_tools: [Bash] + - name: b + default: true + allowed_tools: [Bash] + - name: c + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + result = loader.resolve_skill_sets( + plugin_set_names=None, + agent_name="claude" + ) + assert len(result) == 2 + assert result[0].name == "b" + assert result[1].name == "c" + + +def test_loader_resolve_skill_sets_filters_incompatible(): + """Skill sets incompatible with agent are filtered out.""" + yaml_content = """ +sets: + - name: claude-only + default: true + agents: [claude] + allowed_tools: [Bash] + - name: all-agents + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + # Claude gets both + result = loader.resolve_skill_sets(None, "claude") + assert len(result) == 2 + + # Gemini only gets all-agents + result = loader.resolve_skill_sets(None, "gemini") + assert len(result) == 1 + assert result[0].name == "all-agents" + + +def test_loader_resolve_skill_sets_error_on_incompatible_explicit(): + """Error when explicitly requested skill set is incompatible.""" + yaml_content = """ +sets: + - name: claude-only + agents: [claude] + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + with pytest.raises(ValueError, match="not compatible with agent 'gemini'"): + loader.resolve_skill_sets(["claude-only"], "gemini") + + +def test_loader_resolve_skill_sets_error_when_none_compatible(): + """Error when no skill sets are compatible with agent.""" + yaml_content = """ +sets: + - name: claude-only + default: true + agents: [claude] + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + with pytest.raises(ValueError, match="No compatible skill sets"): + loader.resolve_skill_sets(None, "gemini") +``` + +**Step 3: Run test to verify it fails** + +Run: `uv run pytest tests/plugins/test_loader.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.loader'" + +**Step 4: Write the implementation** + +Create `ade_bench/plugins/loader.py`: + +```python +"""Loader for skill set configuration.""" + +from pathlib import Path +import yaml + +from ade_bench.models.skill_set import SkillSet, SkillSetsConfig + + +class SkillSetLoader: + """Loads and resolves skill sets from YAML configuration.""" + + def __init__(self, config_path: Path): + self._config_path = config_path + self._config: SkillSetsConfig | None = None + + def load(self) -> SkillSetsConfig: + """Load the skill sets configuration from YAML.""" + if not self._config_path.exists(): + raise FileNotFoundError(f"Skill sets config not found: {self._config_path}") + + with open(self._config_path) as f: + data = yaml.safe_load(f) + + self._config = SkillSetsConfig(**data) + return self._config + + def resolve_skill_sets( + self, + plugin_set_names: list[str] | None, + agent_name: str, + ) -> list[SkillSet]: + """Resolve which skill sets to use for a run. + + Args: + plugin_set_names: Explicit skill set names from --plugin-set, or None for defaults + agent_name: The agent being used (e.g., "claude", "gemini") + + Returns: + List of SkillSet objects to use + + Raises: + ValueError: If requested skill set is not found or incompatible + """ + if self._config is None: + self.load() + + # Get skill sets (explicit or defaults) + if plugin_set_names: + skill_sets = self._config.get_by_names(plugin_set_names) + # Validate all are compatible with agent + for ss in skill_sets: + if not ss.is_compatible_with_agent(agent_name): + raise ValueError( + f"Skill set '{ss.name}' is not compatible with agent '{agent_name}'. " + f"Compatible agents: {ss.agents}" + ) + else: + skill_sets = self._config.get_defaults() + + # Filter to compatible skill sets + compatible = [ss for ss in skill_sets if ss.is_compatible_with_agent(agent_name)] + + if not compatible: + if plugin_set_names: + raise ValueError( + f"No compatible skill sets found for agent '{agent_name}' " + f"from requested: {plugin_set_names}" + ) + else: + raise ValueError( + f"No compatible skill sets found for agent '{agent_name}'. " + f"No default skill sets are compatible with this agent." + ) + + return compatible +``` + +Update `ade_bench/plugins/__init__.py`: + +```python +"""Plugin system for ADE-Bench.""" + +from .loader import SkillSetLoader + +__all__ = ["SkillSetLoader"] +``` + +**Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/plugins/test_loader.py -v` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add ade_bench/plugins/ tests/plugins/ +git commit -m "feat: add SkillSetLoader to load and resolve skill sets" +``` + +--- + +## Task 4: Create SkillsHandler + +**Files:** +- Create: `ade_bench/plugins/skills_handler.py` +- Test: `tests/plugins/test_skills_handler.py` + +**Step 1: Write the failing test** + +Create `tests/plugins/test_skills_handler.py`: + +```python +import pytest +from unittest.mock import MagicMock, call +from ade_bench.plugins.skills_handler import SkillsHandler +from ade_bench.models.skill_set import SkillSet + + +def test_skills_handler_install_no_skills(): + """No-op when skill set has no skills.""" + skill_set = SkillSet(name="test", skills=[], allowed_tools=["Bash"]) + terminal = MagicMock() + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + terminal.container.exec_run.assert_not_called() + + +def test_skills_handler_install_single_skill(): + """Installs a single skill repo.""" + skill_set = SkillSet( + name="test", + skills=["dbt-labs/dbt-agent-skills"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + terminal.container.exec_run.assert_called_once() + call_args = terminal.container.exec_run.call_args + cmd = call_args[0][0] + assert "npx" in cmd[2] + assert "skills add" in cmd[2] + assert "dbt-labs/dbt-agent-skills" in cmd[2] + + +def test_skills_handler_install_multiple_skills(): + """Installs multiple skill repos.""" + skill_set = SkillSet( + name="test", + skills=["repo/a", "repo/b"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + assert terminal.container.exec_run.call_count == 2 + + +def test_skills_handler_install_failure_logs_warning(): + """Logs warning but doesn't raise on install failure.""" + skill_set = SkillSet( + name="test", + skills=["repo/failing"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock( + exit_code=1, + output=b"npm ERR! not found" + ) + + handler = SkillsHandler() + # Should not raise, just log warning + handler.install(skill_set, terminal) +``` + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/plugins/test_skills_handler.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.skills_handler'" + +**Step 3: Write the implementation** + +Create `ade_bench/plugins/skills_handler.py`: + +```python +"""Handler for installing skills via Vercel Skills CLI.""" + +import logging +from ade_bench.models.skill_set import SkillSet +from ade_bench.terminal.docker_compose_manager import DockerComposeManager + +logger = logging.getLogger(__name__) + + +class SkillsHandler: + """Installs skills from skill set configuration.""" + + def install(self, skill_set: SkillSet, terminal: DockerComposeManager) -> None: + """Install skills from the skill set into the container. + + Args: + skill_set: The skill set configuration + terminal: The Docker container manager + """ + if not skill_set.skills: + logger.debug(f"[SkillsHandler] No skills to install for '{skill_set.name}'") + return + + for repo in skill_set.skills: + cmd = f"npx --yes skills add {repo} --all" + logger.info(f"[SkillsHandler] Installing skills from {repo}...") + + result = terminal.container.exec_run( + ["sh", "-c", cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[SkillsHandler] Skills installation failed for {repo}: " + f"{result.output.decode('utf-8')}" + ) + else: + logger.info(f"[SkillsHandler] Skills installed successfully from {repo}") +``` + +**Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/plugins/test_skills_handler.py -v` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add ade_bench/plugins/skills_handler.py tests/plugins/test_skills_handler.py +git commit -m "feat: add SkillsHandler for installing skills" +``` + +--- + +## Task 5: Create McpHandler + +**Files:** +- Create: `ade_bench/plugins/mcp_handler.py` +- Test: `tests/plugins/test_mcp_handler.py` + +**Step 1: Write the failing test** + +Create `tests/plugins/test_mcp_handler.py`: + +```python +import pytest +from unittest.mock import MagicMock, call +from ade_bench.plugins.mcp_handler import McpHandler +from ade_bench.models.skill_set import SkillSet, McpServerConfig + + +def test_mcp_handler_configure_no_servers(): + """No-op when skill set has no MCP servers.""" + skill_set = SkillSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) + terminal = MagicMock() + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + terminal.container.exec_run.assert_not_called() + + +def test_mcp_handler_configure_single_server(): + """Configures a single MCP server.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + # Should have at least one call for mcp add + assert terminal.container.exec_run.call_count >= 1 + calls = terminal.container.exec_run.call_args_list + # Find the mcp add call + mcp_add_call = [c for c in calls if "mcp add" in str(c)] + assert len(mcp_add_call) >= 1 + + +def test_mcp_handler_configure_with_env(): + """Writes env file when env vars are specified.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true", "DISABLE_DISCOVERY": "true"} + ) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + # Check that env file was written + calls = terminal.container.exec_run.call_args_list + env_write_calls = [c for c in calls if "DISABLE_SQL" in str(c)] + assert len(env_write_calls) >= 1 + + +def test_mcp_handler_configure_different_agents(): + """Uses correct agent CLI command.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp"]) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + + # Test claude + handler.configure(skill_set, "claude", terminal) + calls = terminal.container.exec_run.call_args_list + claude_calls = [c for c in calls if "claude mcp add" in str(c)] + assert len(claude_calls) >= 1 + + terminal.reset_mock() + + # Test gemini + handler.configure(skill_set, "gemini", terminal) + calls = terminal.container.exec_run.call_args_list + gemini_calls = [c for c in calls if "gemini mcp add" in str(c)] + assert len(gemini_calls) >= 1 +``` + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/plugins/test_mcp_handler.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.mcp_handler'" + +**Step 3: Write the implementation** + +Create `ade_bench/plugins/mcp_handler.py`: + +```python +"""Handler for configuring MCP servers.""" + +import logging +from ade_bench.models.skill_set import SkillSet +from ade_bench.terminal.docker_compose_manager import DockerComposeManager + +logger = logging.getLogger(__name__) + + +class McpHandler: + """Configures MCP servers from skill set configuration.""" + + def configure(self, skill_set: SkillSet, agent_name: str, terminal: DockerComposeManager) -> None: + """Configure MCP servers for the agent. + + Args: + skill_set: The skill set configuration + agent_name: The agent CLI name (claude, gemini, codex) + terminal: The Docker container manager + """ + if not skill_set.mcp_servers: + logger.debug(f"[McpHandler] No MCP servers to configure for '{skill_set.name}'") + return + + for server_name, config in skill_set.mcp_servers.items(): + logger.info(f"[McpHandler] Configuring MCP server '{server_name}'...") + + # Write env file if env vars specified + env_file_path = None + if config.env: + env_file_path = f"/tmp/{server_name}.env" + env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) + write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" + + result = terminal.container.exec_run( + ["sh", "-c", write_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + if result.exit_code != 0: + logger.warning(f"[McpHandler] Failed to write env file: {result.output.decode('utf-8')}") + + # Build mcp add command + args_str = " ".join(config.args) + if env_file_path: + mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} --env-file {env_file_path} {args_str}" + else: + mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} {args_str}" + + logger.info(f"[McpHandler] Running: {mcp_cmd}") + result = terminal.container.exec_run( + ["sh", "-c", mcp_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[McpHandler] MCP server registration failed for {server_name}: " + f"{result.output.decode('utf-8')}" + ) + else: + logger.info(f"[McpHandler] MCP server '{server_name}' configured successfully") +``` + +**Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/plugins/test_mcp_handler.py -v` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add ade_bench/plugins/mcp_handler.py tests/plugins/test_mcp_handler.py +git commit -m "feat: add McpHandler for configuring MCP servers" +``` + +--- + +## Task 6: Update harness_models.py with Skill Set Metadata + +**Files:** +- Modify: `ade_bench/harness_models.py:76-98` (TrialResults class) + +**Step 1: Update TrialResults model** + +Edit `ade_bench/harness_models.py` to add skill_set field to TrialResults: + +Find this section (around line 76): +```python +class TrialResults(BaseModel): + trial_name: str + task_id: str + ... + used_mcp: bool | None = None +``` + +Replace `used_mcp: bool | None = None` with: + +```python + # Skill set metadata + skill_set_name: str | None = None + skill_set_skills: list[str] | None = None + skill_set_mcp_servers: list[str] | None = None +``` + +**Step 2: Run existing tests** + +Run: `uv run pytest tests/ -v -k "not slow"` +Expected: All tests PASS (model change is additive) + +**Step 3: Commit** + +```bash +git add ade_bench/harness_models.py +git commit -m "feat: add skill set metadata to TrialResults model" +``` + +--- + +## Task 7: Update CLI to Add --plugin-set Flag + +**Files:** +- Modify: `ade_bench/cli/ab/main.py` + +**Step 1: Update CLI** + +Edit `ade_bench/cli/ab/main.py`: + +1. Remove these options from the `run` command: +```python + use_mcp: bool = typer.Option( + False, + "--use-mcp", + help="Enable MCP (Model Context Protocol) for the agent" + ), + use_skills: bool = typer.Option( + False, + "--use-skills", + help="Enable skills for the agent (e.g., dbt-debugging skill)" + ), +``` + +2. Add this option after `log_level`: +```python + plugin_set: list[str] = typer.Option( + None, + "--plugin-set", + help="Space-separated skill set names from skill-sets.yaml (default: use all default sets)" + ), +``` + +3. Update the Harness instantiation to remove `use_mcp` and `use_skills`, add `plugin_set_names`: + +Find: +```python + harness = Harness( + ... + use_mcp=use_mcp, + use_skills=use_skills, + with_profiling=with_profiling + ) +``` + +Replace with: +```python + harness = Harness( + ... + plugin_set_names=plugin_set, + with_profiling=with_profiling + ) +``` + +**Step 2: Verify CLI help** + +Run: `uv run ab run --help` +Expected: Shows `--plugin-set` option, no `--use-mcp` or `--use-skills` + +**Step 3: Commit** + +```bash +git add ade_bench/cli/ab/main.py +git commit -m "feat: replace --use-mcp and --use-skills with --plugin-set" +``` + +--- + +## Task 8: Update Harness to Use Skill Sets + +**Files:** +- Modify: `ade_bench/harness.py` + +**Step 1: Update Harness.__init__** + +Edit `ade_bench/harness.py`: + +1. Add imports at top: +```python +from ade_bench.plugins.loader import SkillSetLoader +from ade_bench.models.skill_set import SkillSet +``` + +2. Update `__init__` signature - remove `use_mcp` and `use_skills`, add `plugin_set_names`: + +Find: +```python + use_mcp: bool = False, + use_skills: bool = False, +``` + +Replace with: +```python + plugin_set_names: list[str] | None = None, +``` + +3. Update instance variables in `__init__`: + +Find: +```python + self._use_mcp = use_mcp + self._use_skills = use_skills +``` + +Replace with: +```python + self._plugin_set_names = plugin_set_names + self._skill_sets: list[SkillSet] = [] +``` + +4. Add skill set loading after `self._init_dataset()`: + +```python + self._init_dataset() + self._init_skill_sets() + self._init_logger() +``` + +5. Add the new method: + +```python + def _init_skill_sets(self) -> None: + """Load and resolve skill sets from configuration.""" + config_path = self._dataset_path.parent / "experiment_sets" / "skill-sets.yaml" + loader = SkillSetLoader(config_path) + self._skill_sets = loader.resolve_skill_sets( + plugin_set_names=self._plugin_set_names, + agent_name=self._agent_name.value + ) + self._logger = logger.getChild(__name__) + self._logger.info( + f"Using skill sets: {[ss.name for ss in self._skill_sets]}" + ) +``` + +**Step 2: Update run() method to loop over skill sets** + +Find the `run()` method and update it to iterate over skill sets, creating separate run IDs: + +```python + def run(self) -> BenchmarkResults: + """Run the benchmark with all configured skill sets.""" + all_results = BenchmarkResults() + + for skill_set in self._skill_sets: + # Create run ID with skill set suffix + skill_set_run_id = f"{self._run_id}__{skill_set.name}" + self._logger.info(f"Starting run for skill set: {skill_set.name}") + + # Run trials for this skill set + results = self._run_with_skill_set(skill_set, skill_set_run_id) + all_results.results.extend(results.results) + + return all_results +``` + +Add the new method: + +```python + def _run_with_skill_set(self, skill_set: SkillSet, run_id: str) -> BenchmarkResults: + """Run benchmark trials with a specific skill set.""" + # Store current run_id and restore after + original_run_id = self._run_id + self._run_id = run_id + self._current_skill_set = skill_set + + # Ensure output directory exists + self._run_path.mkdir(parents=True, exist_ok=True) + + try: + # Call existing run logic (refactored into _execute_trials) + return self._execute_trials() + finally: + self._run_id = original_run_id +``` + +**Step 3: Update _create_agent_for_task to remove use_mcp** + +Find: +```python + # Pass use_mcp flag to installed agents + agent_kwargs["use_mcp"] = self._use_mcp +``` + +Remove those lines. + +**Step 4: Update trial result creation to include skill set metadata** + +In the method that creates TrialResults, add: + +```python + skill_set_name=self._current_skill_set.name if hasattr(self, '_current_skill_set') else None, + skill_set_skills=self._current_skill_set.skills if hasattr(self, '_current_skill_set') else None, + skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if hasattr(self, '_current_skill_set') else None, +``` + +**Step 5: Commit** + +```bash +git add ade_bench/harness.py +git commit -m "feat: update Harness to use skill sets with separate run IDs" +``` + +--- + +## Task 9: Update SetupOrchestrator to Call Handlers + +**Files:** +- Modify: `ade_bench/setup/setup_orchestrator.py` +- Modify: `ade_bench/setup/agent_setup.py` + +**Step 1: Update SetupOrchestrator** + +Edit `ade_bench/setup/setup_orchestrator.py`: + +1. Add imports: +```python +from ade_bench.models.skill_set import SkillSet +from ade_bench.plugins.skills_handler import SkillsHandler +from ade_bench.plugins.mcp_handler import McpHandler +``` + +2. Update `__init__` to accept skill_set instead of use_skills: + +Find: +```python + def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, use_skills=False): + ... + self.use_skills = use_skills +``` + +Replace with: +```python + def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, skill_set: SkillSet | None = None): + ... + self.skill_set = skill_set + self._skills_handler = SkillsHandler() + self._mcp_handler = McpHandler() +``` + +3. Update `setup_agent_config` call in `setup_task`: + +Find: +```python + setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger, self.use_skills) +``` + +Replace with: +```python + setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) + + # Install skills and configure MCP if skill set specified + if self.skill_set: + if self.skill_set.skills: + log_harness_info(self.logger, task_id, "setup", f"Installing skills...") + self._skills_handler.install(self.skill_set, self.terminal) + log_harness_info(self.logger, task_id, "setup", "Skills installed") + + if self.skill_set.mcp_servers: + log_harness_info(self.logger, task_id, "setup", f"Configuring MCP servers...") + agent_name = self.trial_handler.agent_name.value + self._mcp_handler.configure(self.skill_set, agent_name, self.terminal) + log_harness_info(self.logger, task_id, "setup", "MCP servers configured") +``` + +**Step 2: Update agent_setup.py** + +Edit `ade_bench/setup/agent_setup.py`: + +1. Remove `_install_skills_via_cli` function entirely + +2. Update `setup_agent_config` signature to remove `use_skills`: + +Find: +```python +def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills: bool = False) -> None: +``` + +Replace with: +```python +def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: +``` + +3. Remove the skills installation at the end: + +Find and remove: +```python + # Install skills for any agent type when --use-skills is enabled + if use_skills: + _install_skills_via_cli(terminal, trial_handler) +``` + +**Step 3: Commit** + +```bash +git add ade_bench/setup/setup_orchestrator.py ade_bench/setup/agent_setup.py +git commit -m "feat: update SetupOrchestrator to use SkillsHandler and McpHandler" +``` + +--- + +## Task 10: Delete Obsolete Files + +**Files:** +- Delete: `shared/scripts/setup-dbt-mcp.sh` + +**Step 1: Delete the file** + +```bash +git rm shared/scripts/setup-dbt-mcp.sh +``` + +**Step 2: Commit** + +```bash +git commit -m "chore: remove obsolete setup-dbt-mcp.sh (logic moved to McpHandler)" +``` + +--- + +## Task 11: Update Harness to Pass Skill Set to Orchestrator + +**Files:** +- Modify: `ade_bench/harness.py` + +**Step 1: Find where SetupOrchestrator is instantiated** + +Search for `SetupOrchestrator(` in harness.py and update to pass `skill_set`: + +Find patterns like: +```python +SetupOrchestrator( + logger=..., + terminal=..., + session=..., + file_diff_handler=..., + trial_handler=..., + use_skills=self._use_skills +) +``` + +Replace with: +```python +SetupOrchestrator( + logger=..., + terminal=..., + session=..., + file_diff_handler=..., + trial_handler=..., + skill_set=self._current_skill_set if hasattr(self, '_current_skill_set') else None +) +``` + +**Step 2: Run integration test** + +Run: `uv run ab run simple001 --db duckdb --project-type dbt --agent sage --plugin-set no-plugins` +Expected: Run completes without errors + +**Step 3: Commit** + +```bash +git add ade_bench/harness.py +git commit -m "feat: pass skill_set to SetupOrchestrator" +``` + +--- + +## Task 12: Final Integration Test + +**Step 1: Test with defaults (A/B comparison)** + +```bash +uv run ab run simple001 --db duckdb --project-type dbt --agent claude +``` + +Expected: Creates two runs: +- `experiments/__no-plugins/` +- `experiments/__dbt-mcp/` + +**Step 2: Test with explicit plugin set** + +```bash +uv run ab run simple001 --db duckdb --project-type dbt --agent claude --plugin-set dbt-skills +``` + +Expected: Creates one run: +- `experiments/__dbt-skills/` + +**Step 3: Test incompatible agent error** + +```bash +uv run ab run simple001 --db duckdb --project-type dbt --agent gemini --plugin-set dbt-skills +``` + +Expected: Error message about dbt-skills not being compatible with gemini + +**Step 4: Commit final state** + +```bash +git add -A +git commit -m "feat: complete plugin sets implementation" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Pydantic models | `ade_bench/models/skill_set.py` | +| 2 | YAML config | `experiment_sets/skill-sets.yaml` | +| 3 | Loader | `ade_bench/plugins/loader.py` | +| 4 | SkillsHandler | `ade_bench/plugins/skills_handler.py` | +| 5 | McpHandler | `ade_bench/plugins/mcp_handler.py` | +| 6 | Update models | `ade_bench/harness_models.py` | +| 7 | Update CLI | `ade_bench/cli/ab/main.py` | +| 8 | Update Harness | `ade_bench/harness.py` | +| 9 | Update Orchestrator | `ade_bench/setup/setup_orchestrator.py` | +| 10 | Delete obsolete | `shared/scripts/setup-dbt-mcp.sh` | +| 11 | Wire up Harness | `ade_bench/harness.py` | +| 12 | Integration test | Manual verification | From 733883a8bbccc58576a69fe8ee930c5eb3c0218b Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:21:01 +1300 Subject: [PATCH 11/44] feat: add Pydantic models for skill set configuration Co-Authored-By: Claude Opus 4.5 --- ade_bench/models/__init__.py | 5 ++ ade_bench/models/skill_set.py | 56 +++++++++++++++ tests/models/__init__.py | 0 tests/models/test_skill_set.py | 123 +++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 ade_bench/models/__init__.py create mode 100644 ade_bench/models/skill_set.py create mode 100644 tests/models/__init__.py create mode 100644 tests/models/test_skill_set.py diff --git a/ade_bench/models/__init__.py b/ade_bench/models/__init__.py new file mode 100644 index 00000000..33ad926b --- /dev/null +++ b/ade_bench/models/__init__.py @@ -0,0 +1,5 @@ +"""Models for ADE-Bench configuration.""" + +from .skill_set import McpServerConfig, SkillSet, SkillSetsConfig + +__all__ = ["McpServerConfig", "SkillSet", "SkillSetsConfig"] diff --git a/ade_bench/models/skill_set.py b/ade_bench/models/skill_set.py new file mode 100644 index 00000000..da27a457 --- /dev/null +++ b/ade_bench/models/skill_set.py @@ -0,0 +1,56 @@ +"""Pydantic models for skill set configuration.""" + +from pydantic import BaseModel + + +class McpServerConfig(BaseModel): + """Configuration for an MCP server.""" + command: str + args: list[str] = [] + env: dict[str, str] = {} + + +class SkillSet(BaseModel): + """Configuration for a set of skills and tools.""" + name: str + description: str = "" + default: bool = False + agents: list[str] | None = None # None = all agents compatible + skills: list[str] = [] + mcp_servers: dict[str, McpServerConfig] = {} + allowed_tools: list[str] = [] + + def is_compatible_with_agent(self, agent_name: str) -> bool: + """Check if this skill set is compatible with the given agent.""" + if self.agents is None: + return True + return agent_name in self.agents + + +class SkillSetsConfig(BaseModel): + """Root configuration containing all skill sets.""" + sets: list[SkillSet] + + def get_defaults(self) -> list[SkillSet]: + """Get all skill sets marked as default.""" + return [s for s in self.sets if s.default] + + def get_by_name(self, name: str) -> SkillSet | None: + """Get a skill set by name.""" + for s in self.sets: + if s.name == name: + return s + return None + + def get_by_names(self, names: list[str]) -> list[SkillSet]: + """Get multiple skill sets by name. Raises if any not found.""" + result = [] + for name in names: + skill_set = self.get_by_name(name) + if skill_set is None: + available = [s.name for s in self.sets] + raise ValueError( + f"Unknown skill set '{name}'. Available: {', '.join(available)}" + ) + result.append(skill_set) + return result diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/models/test_skill_set.py b/tests/models/test_skill_set.py new file mode 100644 index 00000000..c88ffa6e --- /dev/null +++ b/tests/models/test_skill_set.py @@ -0,0 +1,123 @@ +import pytest +from ade_bench.models.skill_set import SkillSet, McpServerConfig, SkillSetsConfig + + +def test_mcp_server_config_minimal(): + config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + assert config.command == "uvx" + assert config.args == ["dbt-mcp@latest"] + assert config.env == {} + + +def test_mcp_server_config_with_env(): + config = McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true"} + ) + assert config.env == {"DISABLE_SQL": "true"} + + +def test_skill_set_minimal(): + skill_set = SkillSet(name="test", allowed_tools=["Bash"]) + assert skill_set.name == "test" + assert skill_set.description == "" + assert skill_set.default is False + assert skill_set.agents is None + assert skill_set.skills == [] + assert skill_set.mcp_servers == {} + assert skill_set.allowed_tools == ["Bash"] + + +def test_skill_set_full(): + skill_set = SkillSet( + name="dbt-full", + description="Full dbt setup", + default=True, + agents=["claude"], + skills=["dbt-labs/dbt-agent-skills"], + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + }, + allowed_tools=["Bash", "Skill", "mcp__dbt__*"] + ) + assert skill_set.default is True + assert skill_set.agents == ["claude"] + assert len(skill_set.mcp_servers) == 1 + + +def test_skill_set_is_compatible_with_agent_all(): + """When agents is None, compatible with all agents.""" + skill_set = SkillSet(name="test", allowed_tools=["Bash"]) + assert skill_set.is_compatible_with_agent("claude") is True + assert skill_set.is_compatible_with_agent("gemini") is True + + +def test_skill_set_is_compatible_with_agent_restricted(): + """When agents is set, only compatible with listed agents.""" + skill_set = SkillSet(name="test", agents=["claude"], allowed_tools=["Bash"]) + assert skill_set.is_compatible_with_agent("claude") is True + assert skill_set.is_compatible_with_agent("gemini") is False + + +def test_skill_sets_config_from_yaml(): + yaml_content = """ +sets: + - name: no-plugins + default: true + skills: [] + allowed_tools: [Bash, Read] + - name: dbt-skills + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + allowed_tools: [Bash, Skill] +""" + import yaml + data = yaml.safe_load(yaml_content) + config = SkillSetsConfig(**data) + assert len(config.sets) == 2 + assert config.sets[0].name == "no-plugins" + assert config.sets[0].default is True + + +def test_skill_sets_config_get_defaults(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", default=True, allowed_tools=["Bash"]), + SkillSet(name="b", default=False, allowed_tools=["Bash"]), + SkillSet(name="c", default=True, allowed_tools=["Bash"]), + ]) + defaults = config.get_defaults() + assert len(defaults) == 2 + assert defaults[0].name == "a" + assert defaults[1].name == "c" + + +def test_skill_sets_config_get_by_name(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + SkillSet(name="b", allowed_tools=["Bash"]), + ]) + assert config.get_by_name("a").name == "a" + assert config.get_by_name("b").name == "b" + assert config.get_by_name("nonexistent") is None + + +def test_skill_sets_config_get_by_names(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + SkillSet(name="b", allowed_tools=["Bash"]), + SkillSet(name="c", allowed_tools=["Bash"]), + ]) + result = config.get_by_names(["a", "c"]) + assert len(result) == 2 + assert result[0].name == "a" + assert result[1].name == "c" + + +def test_skill_sets_config_get_by_names_unknown_raises(): + config = SkillSetsConfig(sets=[ + SkillSet(name="a", allowed_tools=["Bash"]), + ]) + with pytest.raises(ValueError, match="Unknown skill set"): + config.get_by_names(["a", "nonexistent"]) From 50d016346e54313c11a264ad0eb8aac4ecf8273f Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:21:51 +1300 Subject: [PATCH 12/44] feat: add skill-sets.yaml configuration Defines four skill sets: - no-plugins: baseline without skills or MCP (default) - dbt-skills: Claude-only, installs dbt agent skills - dbt-mcp: configures dbt MCP server (default) - dbt-full: both skills and MCP for Claude Co-Authored-By: Claude Opus 4.5 --- experiment_sets/skill-sets.yaml | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 experiment_sets/skill-sets.yaml diff --git a/experiment_sets/skill-sets.yaml b/experiment_sets/skill-sets.yaml new file mode 100644 index 00000000..1c34f22a --- /dev/null +++ b/experiment_sets/skill-sets.yaml @@ -0,0 +1,51 @@ +# Skill set configurations for ADE-Bench +# Use --plugin-set to select, or run without flag to use all defaults + +sets: + - name: no-plugins + description: Baseline - no skills or MCP + default: true + skills: [] + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] + + - name: dbt-skills + description: dbt skills via Vercel Skills CLI + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] + + - name: dbt-mcp + description: dbt MCP server + default: true + skills: [] + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] + + - name: dbt-full + description: Both skills and MCP + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + mcp_servers: + dbt: + command: uvx + args: [dbt-mcp@latest] + env: + DISABLE_SEMANTIC_LAYER: "true" + DISABLE_DISCOVERY: "true" + DISABLE_ADMIN_API: "true" + DISABLE_SQL: "true" + DISABLE_DBT_CODEGEN: "true" + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] From 4128e2c831b85a465f67a390e98f9c0a110fdb58 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:23:58 +1300 Subject: [PATCH 13/44] feat: add SkillSetLoader to load and resolve skill sets Co-Authored-By: Claude Opus 4.5 --- ade_bench/plugins/__init__.py | 5 ++ ade_bench/plugins/loader.py | 75 ++++++++++++++++++ tests/plugins/__init__.py | 0 tests/plugins/test_loader.py | 143 ++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 ade_bench/plugins/__init__.py create mode 100644 ade_bench/plugins/loader.py create mode 100644 tests/plugins/__init__.py create mode 100644 tests/plugins/test_loader.py diff --git a/ade_bench/plugins/__init__.py b/ade_bench/plugins/__init__.py new file mode 100644 index 00000000..9b09f20d --- /dev/null +++ b/ade_bench/plugins/__init__.py @@ -0,0 +1,5 @@ +"""Plugin system for ADE-Bench.""" + +from .loader import SkillSetLoader + +__all__ = ["SkillSetLoader"] diff --git a/ade_bench/plugins/loader.py b/ade_bench/plugins/loader.py new file mode 100644 index 00000000..d6ddacea --- /dev/null +++ b/ade_bench/plugins/loader.py @@ -0,0 +1,75 @@ +"""Loader for skill set configuration.""" + +from pathlib import Path +import yaml + +from ade_bench.models.skill_set import SkillSet, SkillSetsConfig + + +class SkillSetLoader: + """Loads and resolves skill sets from YAML configuration.""" + + def __init__(self, config_path: Path): + self._config_path = config_path + self._config: SkillSetsConfig | None = None + + def load(self) -> SkillSetsConfig: + """Load the skill sets configuration from YAML.""" + if not self._config_path.exists(): + raise FileNotFoundError(f"Skill sets config not found: {self._config_path}") + + with open(self._config_path) as f: + data = yaml.safe_load(f) + + self._config = SkillSetsConfig(**data) + return self._config + + def resolve_skill_sets( + self, + plugin_set_names: list[str] | None, + agent_name: str, + ) -> list[SkillSet]: + """Resolve which skill sets to use for a run. + + Args: + plugin_set_names: Explicit skill set names from --plugin-set, or None for defaults + agent_name: The agent being used (e.g., "claude", "gemini") + + Returns: + List of SkillSet objects to use + + Raises: + ValueError: If requested skill set is not found or incompatible + """ + if self._config is None: + self.load() + + # Get skill sets (explicit or defaults) + if plugin_set_names: + skill_sets = self._config.get_by_names(plugin_set_names) + # Validate all are compatible with agent + for ss in skill_sets: + if not ss.is_compatible_with_agent(agent_name): + raise ValueError( + f"Skill set '{ss.name}' is not compatible with agent '{agent_name}'. " + f"Compatible agents: {ss.agents}" + ) + else: + skill_sets = self._config.get_defaults() + + # Filter to compatible skill sets + compatible = [ss for ss in skill_sets if ss.is_compatible_with_agent(agent_name)] + + if not compatible: + if plugin_set_names: + raise ValueError( + f"No compatible skill sets found for agent '{agent_name}' " + f"from requested: {plugin_set_names}" + ) + else: + raise ValueError( + f"No compatible skill sets found for agent '{agent_name}'. " + f"No default skill sets are compatible with this agent." + ) + + return compatible diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/test_loader.py b/tests/plugins/test_loader.py new file mode 100644 index 00000000..3b777323 --- /dev/null +++ b/tests/plugins/test_loader.py @@ -0,0 +1,143 @@ +import pytest +from pathlib import Path +from ade_bench.plugins.loader import SkillSetLoader +from ade_bench.models.skill_set import SkillSetsConfig + + +def test_loader_loads_yaml(tmp_path): + yaml_file = tmp_path / "skill-sets.yaml" + yaml_file.write_text(""" +sets: + - name: test + default: true + skills: [] + allowed_tools: [Bash] +""") + loader = SkillSetLoader(yaml_file) + config = loader.load() + assert isinstance(config, SkillSetsConfig) + assert len(config.sets) == 1 + assert config.sets[0].name == "test" + + +def test_loader_file_not_found(): + loader = SkillSetLoader(Path("/nonexistent/skill-sets.yaml")) + with pytest.raises(FileNotFoundError): + loader.load() + + +def test_loader_resolve_skill_sets_explicit(): + """Explicit --plugin-set names are resolved.""" + yaml_content = """ +sets: + - name: a + default: false + allowed_tools: [Bash] + - name: b + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + result = loader.resolve_skill_sets( + plugin_set_names=["a"], + agent_name="claude" + ) + assert len(result) == 1 + assert result[0].name == "a" + + +def test_loader_resolve_skill_sets_defaults(): + """When no --plugin-set, use defaults.""" + yaml_content = """ +sets: + - name: a + default: false + allowed_tools: [Bash] + - name: b + default: true + allowed_tools: [Bash] + - name: c + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + result = loader.resolve_skill_sets( + plugin_set_names=None, + agent_name="claude" + ) + assert len(result) == 2 + assert result[0].name == "b" + assert result[1].name == "c" + + +def test_loader_resolve_skill_sets_filters_incompatible(): + """Skill sets incompatible with agent are filtered out.""" + yaml_content = """ +sets: + - name: claude-only + default: true + agents: [claude] + allowed_tools: [Bash] + - name: all-agents + default: true + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + # Claude gets both + result = loader.resolve_skill_sets(None, "claude") + assert len(result) == 2 + + # Gemini only gets all-agents + result = loader.resolve_skill_sets(None, "gemini") + assert len(result) == 1 + assert result[0].name == "all-agents" + + +def test_loader_resolve_skill_sets_error_on_incompatible_explicit(): + """Error when explicitly requested skill set is incompatible.""" + yaml_content = """ +sets: + - name: claude-only + agents: [claude] + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + with pytest.raises(ValueError, match="not compatible with agent 'gemini'"): + loader.resolve_skill_sets(["claude-only"], "gemini") + + +def test_loader_resolve_skill_sets_error_when_none_compatible(): + """Error when no skill sets are compatible with agent.""" + yaml_content = """ +sets: + - name: claude-only + default: true + agents: [claude] + allowed_tools: [Bash] +""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + f.flush() + loader = SkillSetLoader(Path(f.name)) + + with pytest.raises(ValueError, match="No compatible skill sets"): + loader.resolve_skill_sets(None, "gemini") From c9394a116f4e1a6559442a9836d6fb65163aad7b Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:25:41 +1300 Subject: [PATCH 14/44] feat: add SkillsHandler for installing skills Co-Authored-By: Claude Opus 4.5 --- ade_bench/plugins/skills_handler.py | 39 ++++++++++++++++ tests/plugins/test_skills_handler.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 ade_bench/plugins/skills_handler.py create mode 100644 tests/plugins/test_skills_handler.py diff --git a/ade_bench/plugins/skills_handler.py b/ade_bench/plugins/skills_handler.py new file mode 100644 index 00000000..c1293f06 --- /dev/null +++ b/ade_bench/plugins/skills_handler.py @@ -0,0 +1,39 @@ +"""Handler for installing skills via Vercel Skills CLI.""" + +import logging +from ade_bench.models.skill_set import SkillSet +from ade_bench.terminal.docker_compose_manager import DockerComposeManager + +logger = logging.getLogger(__name__) + + +class SkillsHandler: + """Installs skills from skill set configuration.""" + + def install(self, skill_set: SkillSet, terminal: DockerComposeManager) -> None: + """Install skills from the skill set into the container. + + Args: + skill_set: The skill set configuration + terminal: The Docker container manager + """ + if not skill_set.skills: + logger.debug(f"[SkillsHandler] No skills to install for '{skill_set.name}'") + return + + for repo in skill_set.skills: + cmd = f"npx --yes skills add {repo} --all" + logger.info(f"[SkillsHandler] Installing skills from {repo}...") + + result = terminal.container.exec_run( + ["sh", "-c", cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[SkillsHandler] Skills installation failed for {repo}: " + f"{result.output.decode('utf-8')}" + ) + else: + logger.info(f"[SkillsHandler] Skills installed successfully from {repo}") diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py new file mode 100644 index 00000000..cf8936e3 --- /dev/null +++ b/tests/plugins/test_skills_handler.py @@ -0,0 +1,70 @@ +import pytest +from unittest.mock import MagicMock, call +from ade_bench.plugins.skills_handler import SkillsHandler +from ade_bench.models.skill_set import SkillSet + + +def test_skills_handler_install_no_skills(): + """No-op when skill set has no skills.""" + skill_set = SkillSet(name="test", skills=[], allowed_tools=["Bash"]) + terminal = MagicMock() + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + terminal.container.exec_run.assert_not_called() + + +def test_skills_handler_install_single_skill(): + """Installs a single skill repo.""" + skill_set = SkillSet( + name="test", + skills=["dbt-labs/dbt-agent-skills"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + terminal.container.exec_run.assert_called_once() + call_args = terminal.container.exec_run.call_args + cmd = call_args[0][0] + assert "npx" in cmd[2] + assert "skills add" in cmd[2] + assert "dbt-labs/dbt-agent-skills" in cmd[2] + + +def test_skills_handler_install_multiple_skills(): + """Installs multiple skill repos.""" + skill_set = SkillSet( + name="test", + skills=["repo/a", "repo/b"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = SkillsHandler() + handler.install(skill_set, terminal) + + assert terminal.container.exec_run.call_count == 2 + + +def test_skills_handler_install_failure_logs_warning(): + """Logs warning but doesn't raise on install failure.""" + skill_set = SkillSet( + name="test", + skills=["repo/failing"], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock( + exit_code=1, + output=b"npm ERR! not found" + ) + + handler = SkillsHandler() + # Should not raise, just log warning + handler.install(skill_set, terminal) From a4174b7d30a8138549f720f1229811da323b3d85 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:28:42 +1300 Subject: [PATCH 15/44] feat: add McpHandler for configuring MCP servers Co-Authored-By: Claude Opus 4.5 --- ade_bench/plugins/mcp_handler.py | 61 ++++++++++++++++++++ tests/plugins/test_mcp_handler.py | 92 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 ade_bench/plugins/mcp_handler.py create mode 100644 tests/plugins/test_mcp_handler.py diff --git a/ade_bench/plugins/mcp_handler.py b/ade_bench/plugins/mcp_handler.py new file mode 100644 index 00000000..82afa041 --- /dev/null +++ b/ade_bench/plugins/mcp_handler.py @@ -0,0 +1,61 @@ +"""Handler for configuring MCP servers.""" + +import logging +from ade_bench.models.skill_set import SkillSet +from ade_bench.terminal.docker_compose_manager import DockerComposeManager + +logger = logging.getLogger(__name__) + + +class McpHandler: + """Configures MCP servers from skill set configuration.""" + + def configure(self, skill_set: SkillSet, agent_name: str, terminal: DockerComposeManager) -> None: + """Configure MCP servers for the agent. + + Args: + skill_set: The skill set configuration + agent_name: The agent CLI name (claude, gemini, codex) + terminal: The Docker container manager + """ + if not skill_set.mcp_servers: + logger.debug(f"[McpHandler] No MCP servers to configure for '{skill_set.name}'") + return + + for server_name, config in skill_set.mcp_servers.items(): + logger.info(f"[McpHandler] Configuring MCP server '{server_name}'...") + + # Write env file if env vars specified + env_file_path = None + if config.env: + env_file_path = f"/tmp/{server_name}.env" + env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) + write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" + + result = terminal.container.exec_run( + ["sh", "-c", write_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + if result.exit_code != 0: + logger.warning(f"[McpHandler] Failed to write env file: {result.output.decode('utf-8')}") + + # Build mcp add command + args_str = " ".join(config.args) + if env_file_path: + mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} --env-file {env_file_path} {args_str}" + else: + mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} {args_str}" + + logger.info(f"[McpHandler] Running: {mcp_cmd}") + result = terminal.container.exec_run( + ["sh", "-c", mcp_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[McpHandler] MCP server registration failed for {server_name}: " + f"{result.output.decode('utf-8')}" + ) + else: + logger.info(f"[McpHandler] MCP server '{server_name}' configured successfully") diff --git a/tests/plugins/test_mcp_handler.py b/tests/plugins/test_mcp_handler.py new file mode 100644 index 00000000..9ece9be8 --- /dev/null +++ b/tests/plugins/test_mcp_handler.py @@ -0,0 +1,92 @@ +import pytest +from unittest.mock import MagicMock, call +from ade_bench.plugins.mcp_handler import McpHandler +from ade_bench.models.skill_set import SkillSet, McpServerConfig + + +def test_mcp_handler_configure_no_servers(): + """No-op when skill set has no MCP servers.""" + skill_set = SkillSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) + terminal = MagicMock() + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + terminal.container.exec_run.assert_not_called() + + +def test_mcp_handler_configure_single_server(): + """Configures a single MCP server.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + # Should have at least one call for mcp add + assert terminal.container.exec_run.call_count >= 1 + calls = terminal.container.exec_run.call_args_list + # Find the mcp add call + mcp_add_call = [c for c in calls if "mcp add" in str(c)] + assert len(mcp_add_call) >= 1 + + +def test_mcp_handler_configure_with_env(): + """Writes env file when env vars are specified.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true", "DISABLE_DISCOVERY": "true"} + ) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + handler.configure(skill_set, "claude", terminal) + + # Check that env file was written + calls = terminal.container.exec_run.call_args_list + env_write_calls = [c for c in calls if "DISABLE_SQL" in str(c)] + assert len(env_write_calls) >= 1 + + +def test_mcp_handler_configure_different_agents(): + """Uses correct agent CLI command.""" + skill_set = SkillSet( + name="test", + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp"]) + }, + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = McpHandler() + + # Test claude + handler.configure(skill_set, "claude", terminal) + calls = terminal.container.exec_run.call_args_list + claude_calls = [c for c in calls if "claude mcp add" in str(c)] + assert len(claude_calls) >= 1 + + terminal.reset_mock() + + # Test gemini + handler.configure(skill_set, "gemini", terminal) + calls = terminal.container.exec_run.call_args_list + gemini_calls = [c for c in calls if "gemini mcp add" in str(c)] + assert len(gemini_calls) >= 1 From 900a9f51a12d067d8e8caea0a2155f498f55341e Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:29:38 +1300 Subject: [PATCH 16/44] feat: add skill set metadata to TrialResults model Replace used_mcp field with skill_set_name, skill_set_skills, and skill_set_mcp_servers for richer experiment tracking. Co-Authored-By: Claude Opus 4.5 --- ade_bench/harness_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ade_bench/harness_models.py b/ade_bench/harness_models.py index b94b8b40..74e3d349 100644 --- a/ade_bench/harness_models.py +++ b/ade_bench/harness_models.py @@ -94,7 +94,10 @@ class TrialResults(BaseModel): model_name: str | None = None db_type: str | None = None project_type: str | None = None - used_mcp: bool | None = None + # Skill set metadata + skill_set_name: str | None = None + skill_set_skills: list[str] | None = None + skill_set_mcp_servers: list[str] | None = None class BenchmarkResults(BaseModel): From 1bfadf01c0846b8fbaca673743ae94a11b612467 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:30:29 +1300 Subject: [PATCH 17/44] feat: replace --use-mcp and --use-skills with --plugin-set The new --plugin-set flag accepts skill set names from skill-sets.yaml. When not specified, uses all default skill sets for A/B comparison. Co-Authored-By: Claude Opus 4.5 --- ade_bench/cli/ab/main.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ade_bench/cli/ab/main.py b/ade_bench/cli/ab/main.py index e74be272..485d5117 100644 --- a/ade_bench/cli/ab/main.py +++ b/ade_bench/cli/ab/main.py @@ -152,15 +152,10 @@ def run( "--log-level", help="Set the logging level" ), - use_mcp: bool = typer.Option( - False, - "--use-mcp", - help="Enable MCP (Model Context Protocol) for the agent" - ), - use_skills: bool = typer.Option( - False, - "--use-skills", - help="Enable skills for the agent (e.g., dbt-debugging skill)" + plugin_set: Optional[List[str]] = typer.Option( + None, + "--plugin-set", + help="Skill set names from skill-sets.yaml (default: use all default sets)" ), with_profiling: bool = typer.Option( False, @@ -241,8 +236,7 @@ def run( db_type=db, project_type=project_type, keep_alive=persist, - use_mcp=use_mcp, - use_skills=use_skills, + plugin_set_names=plugin_set, with_profiling=with_profiling ) From 4888a767390c68ed9cd63cb03b067f8b3d88eaa2 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:33:44 +1300 Subject: [PATCH 18/44] feat: update Harness to use skill sets with separate run IDs - Replace use_mcp/use_skills params with plugin_set_names - Add _init_skill_sets() to load skill sets from YAML config - Refactor run() to iterate over skill sets with suffixed run IDs - Move existing run logic to _execute_trials() - Add skill set metadata (name, skills, mcp_servers) to TrialResults - Derive use_skills from current skill set configuration Co-Authored-By: Claude Opus 4.5 --- ade_bench/harness.py | 70 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 8f0ee91d..8559df0f 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -28,6 +28,8 @@ RunMetadata, TrialResults, ) +from ade_bench.models.skill_set import SkillSet +from ade_bench.plugins.loader import SkillSetLoader from ade_bench.setup.setup_orchestrator import SetupOrchestrator from ade_bench.llms.base_llm import ContextLengthExceededError, ParseError from ade_bench.parsers.base_parser import UnitTestStatus, ParserResult @@ -65,8 +67,7 @@ def __init__( db_type: str | None = None, project_type: str | None = None, keep_alive: bool = False, - use_mcp: bool = False, - use_skills: bool = False, + plugin_set_names: list[str] | None = None, with_profiling: bool = False, ): """ @@ -95,8 +96,7 @@ def __init__( db_type: Database type to filter variants (e.g., duckdb, postgres, sqlite, snowflake). project_type: Project type to filter variants (e.g., dbt, other). keep_alive: If True, keep containers alive when tasks fail for debugging. - use_mcp: If True, start a dbt MCP server after setup completes. - use_skills: If True, copy skills directory to container for agent use. + plugin_set_names: List of skill set names to use. If None, uses defaults. with_profiling: If True, will enable the cProfiler. """ self._run_uuid = None @@ -110,8 +110,9 @@ def __init__( self._db_filter = db_type self._project_type_filter = project_type self._keep_alive = keep_alive - self._use_mcp = use_mcp - self._use_skills = use_skills + self._plugin_set_names = plugin_set_names + self._skill_sets: list[SkillSet] = [] + self._current_skill_set: SkillSet | None = None self._with_profiling = with_profiling # Initialize setup orchestrator for variant-specific setup @@ -134,7 +135,7 @@ def __init__( self._run_path.mkdir(parents=True, exist_ok=True) self._init_dataset() - + self._init_skill_sets() self._init_logger() @property @@ -184,9 +185,6 @@ def _create_agent_for_task(self, task_id: str) -> BaseAgent: if self._model_name: agent_kwargs["model_name"] = self._model_name - # Pass use_mcp flag to installed agents - agent_kwargs["use_mcp"] = self._use_mcp - return AgentFactory.get_agent(self._agent_name, **agent_kwargs) def _init_dataset(self) -> None: @@ -197,6 +195,20 @@ def _init_dataset(self) -> None: excluded_task_ids=self._exclude_task_ids, ) + def _init_skill_sets(self) -> None: + """Load and resolve skill sets from configuration.""" + config_path = self._dataset_path.parent / "experiment_sets" / "skill-sets.yaml" + if not config_path.exists(): + # No skill sets config - use empty list (no plugins) + self._skill_sets = [SkillSet(name="no-plugins", allowed_tools=["Bash", "Edit", "Write", "Read", "Glob", "Grep"])] + return + + loader = SkillSetLoader(config_path) + self._skill_sets = loader.resolve_skill_sets( + plugin_set_names=self._plugin_set_names, + agent_name=self._agent_name.value + ) + def _init_logger(self) -> None: file_handler = logging.FileHandler(self._log_output_path) file_handler.setLevel(logging.DEBUG) @@ -557,13 +569,15 @@ def _run_setup( try: # Create setup orchestrator with terminal and session for harness-specific operations + # Determine if skills should be used based on current skill set + use_skills = bool(self._current_skill_set and self._current_skill_set.skills) setup_orchestrator = SetupOrchestrator( logger=self._logger, terminal=terminal, session=session, file_diff_handler=file_diff_handler, trial_handler=trial_handler, - use_skills=self._use_skills + use_skills=use_skills ) # Run setup with timeout using asyncio @@ -614,7 +628,9 @@ def _run_trial( model_name=self._model_name, db_type=config.get("db_type"), project_type=config.get("project_type"), - used_mcp=self._use_mcp, + skill_set_name=self._current_skill_set.name if self._current_skill_set else None, + skill_set_skills=self._current_skill_set.skills if self._current_skill_set else None, + skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if self._current_skill_set else None, ) with spin_up_terminal( @@ -1220,7 +1236,9 @@ def _execute_single_trial( model_name=self._model_name, db_type=config.get("db_type"), project_type=config.get("project_type"), - used_mcp=self._use_mcp, + skill_set_name=self._current_skill_set.name if self._current_skill_set else None, + skill_set_skills=self._current_skill_set.skills if self._current_skill_set else None, + skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if self._current_skill_set else None, ) return trial_results @@ -1318,10 +1336,32 @@ def _execute_tasks(self) -> BenchmarkResults: return results def run(self) -> BenchmarkResults: - """Run the harness. + """Run the benchmark with all configured skill sets.""" + all_results = BenchmarkResults() + original_run_id = self._run_id + + for skill_set in self._skill_sets: + # Create run ID with skill set suffix + self._run_id = f"{original_run_id}__{skill_set.name}" + self._current_skill_set = skill_set + + # Ensure output directory exists for this skill set + self._run_path.mkdir(parents=True, exist_ok=True) + + self._logger.info(f"Starting run for skill set: {skill_set.name}") + + # Run trials for this skill set + results = self._execute_trials() + all_results.results.extend(results.results) + + self._run_id = original_run_id + return all_results + + def _execute_trials(self) -> BenchmarkResults: + """Execute trials for the current skill set. Returns: - BenchmarkResults: The results of the harness run. + BenchmarkResults: The results of the trials. """ log_harness_info(logger, "system", "start", "STARTING HARNESS RUN") log_harness_info(logger, "system", "start", f"Run ID: {self._run_id}") From 13d934771c129e1933df4047abf8177874e7ea64 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:35:11 +1300 Subject: [PATCH 19/44] feat: update SetupOrchestrator to use SkillsHandler and McpHandler - Replace use_skills parameter with skill_set - Add SkillsHandler and McpHandler imports - Call handlers to install skills and configure MCP servers - Remove obsolete _install_skills_via_cli from agent_setup.py - Update harness to pass skill_set to orchestrator Co-Authored-By: Claude Opus 4.5 --- ade_bench/harness.py | 4 +--- ade_bench/setup/agent_setup.py | 29 ++------------------------- ade_bench/setup/setup_orchestrator.py | 28 +++++++++++++++++++++----- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 8559df0f..894fa992 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -569,15 +569,13 @@ def _run_setup( try: # Create setup orchestrator with terminal and session for harness-specific operations - # Determine if skills should be used based on current skill set - use_skills = bool(self._current_skill_set and self._current_skill_set.skills) setup_orchestrator = SetupOrchestrator( logger=self._logger, terminal=terminal, session=session, file_diff_handler=file_diff_handler, trial_handler=trial_handler, - use_skills=use_skills + skill_set=self._current_skill_set ) # Run setup with timeout using asyncio diff --git a/ade_bench/setup/agent_setup.py b/ade_bench/setup/agent_setup.py index ecdfcc13..3ec88ab5 100644 --- a/ade_bench/setup/agent_setup.py +++ b/ade_bench/setup/agent_setup.py @@ -8,6 +8,7 @@ from ..agents.agent_name import AgentName from ..utils.logger import log_harness_info + def _copy_config_file(terminal, trial_handler, config_filename: str, container_filename: str = None) -> None: """Helper to copy a configuration file to the container.""" if container_filename is None: @@ -24,29 +25,7 @@ def _copy_config_file(terminal, trial_handler, config_filename: str, container_f logger.warning(f"Configuration file not found at {config_path}") -def _install_skills_via_cli(terminal, trial_handler) -> None: - """Install dbt skills using the Vercel Skills CLI. - - The CLI automatically detects which agents are available in the container - and installs skills to the appropriate directories (.claude/skills/, - .cursor/skills/, .codex/skills/, etc.). - """ - skills_repo = "dbt-labs/dbt-agent-skills" - install_cmd = f"npx --yes skills add {skills_repo} --all" - - logger.info(f"Installing skills from {skills_repo} (supports all agent types)...") - - result = terminal.container.exec_run( - ["sh", "-c", install_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - - if result.exit_code != 0: - logger.warning(f"Skills installation failed: {result.output.decode('utf-8')}") - else: - logger.info(f"Skills installed successfully from {skills_repo}") - -def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills: bool = False) -> None: +def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: """Setup agent-specific configuration files and resources.""" agent_name = trial_handler.agent_name @@ -62,7 +41,3 @@ def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills _copy_config_file(terminal, trial_handler, "AGENTS.md") elif agent_name == AgentName.MACRO: _copy_config_file(terminal, trial_handler, "MACRO.md") - - # Install skills for any agent type when --use-skills is enabled - if use_skills: - _install_skills_via_cli(terminal, trial_handler) diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index 69a25a43..a1b47bd8 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -1,5 +1,5 @@ """ -Simple setup orchestrator - just calls functions directly. +Setup orchestrator - coordinates task setup and plugin configuration. """ from typing import Dict, Any @@ -10,18 +10,23 @@ from .migration_setup import setup_migration from .agent_setup import setup_agent_config from ..utils.logger import log_harness_info +from ..models.skill_set import SkillSet +from ..plugins.skills_handler import SkillsHandler +from ..plugins.mcp_handler import McpHandler class SetupOrchestrator: - """Simple orchestrator that calls setup functions directly.""" + """Orchestrator that calls setup functions and configures plugins.""" - def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, use_skills=False): + def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, skill_set: SkillSet | None = None): self.logger = logger self.terminal = terminal self.session = session self.file_diff_handler = file_diff_handler self.trial_handler = trial_handler - self.use_skills = use_skills + self.skill_set = skill_set + self._skills_handler = SkillsHandler() + self._mcp_handler = McpHandler() def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: """Setup a task for the given variant.""" @@ -40,7 +45,20 @@ def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: # Setup agent-specific configuration files # Logging is in the setup_agent_config function - setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger, self.use_skills) + setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) + + # Install skills and configure MCP if skill set specified + if self.skill_set: + if self.skill_set.skills: + log_harness_info(self.logger, task_id, "setup", "Installing skills...") + self._skills_handler.install(self.skill_set, self.terminal) + log_harness_info(self.logger, task_id, "setup", "Skills installed") + + if self.skill_set.mcp_servers: + log_harness_info(self.logger, task_id, "setup", "Configuring MCP servers...") + agent_name = self.trial_handler.agent_name.value + self._mcp_handler.configure(self.skill_set, agent_name, self.terminal) + log_harness_info(self.logger, task_id, "setup", "MCP servers configured") # Set up the database From 5389439e15fee11dfe7aea38473ec3b3ca004a82 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:35:39 +1300 Subject: [PATCH 20/44] chore: remove obsolete setup-dbt-mcp.sh Logic has moved to McpHandler in ade_bench/plugins/mcp_handler.py Co-Authored-By: Claude Opus 4.5 --- shared/scripts/setup-dbt-mcp.sh | 77 --------------------------------- 1 file changed, 77 deletions(-) delete mode 100755 shared/scripts/setup-dbt-mcp.sh diff --git a/shared/scripts/setup-dbt-mcp.sh b/shared/scripts/setup-dbt-mcp.sh deleted file mode 100755 index e564a3e1..00000000 --- a/shared/scripts/setup-dbt-mcp.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -# Setup dbt MCP server for installed agents -# Arguments: db_type project_type agent_name - -echo "Setting up dbt MCP server..." - -# Parse arguments -DB_TYPE="${1:-unknown}" -PROJECT_TYPE="${2:-unknown}" -AGENT_NAME="${3:-unknown}" - -echo "Database type: $DB_TYPE" -echo "Project type: $PROJECT_TYPE" -echo "Agent: $AGENT_NAME" - -# Check if project type is dbt -if [[ ! " dbt dbt-fusion " =~ " $PROJECT_TYPE " ]]; then - echo "Skipping dbt MCP setup - '$PROJECT_TYPE' is not supported" - exit 0 -fi - -# Check if database type is supported -if [[ ! " snowflake " =~ " $DB_TYPE " ]]; then - echo "Skipping dbt MCP setup - '$DB_TYPE' is not supported" - exit 0 -fi - -# Get working directory and env file location -project_dir=$(pwd) -env_file="${project_dir}/.env" - -# Find dbt path -dbt_path=$(which dbt) -if [ -z "$dbt_path" ]; then - echo "WARNING: dbt not found in PATH, skipping MCP setup" - exit 0 -fi - -# Create .env file for dbt-mcp -# TODO, because this probably a janky way to do this. -cat > "$env_file" << EOF -DBT_PROJECT_DIR=$project_dir -DBT_PATH=$dbt_path -DISABLE_DBT_CLI=false -DISABLE_SEMANTIC_LAYER=true -DISABLE_DISCOVERY=true -DISABLE_ADMIN_API=true -DISABLE_SQL=true -DISABLE_DBT_CODEGEN=true -EOF - -# Check if dbt-mcp is already installed (pre-installed in Docker image) -if ! command -v dbt-mcp &> /dev/null; then - echo "dbt-mcp not found, installing..." - uv tool install dbt-mcp --force - echo "dbt-mcp installed" -fi - -if [[ "$AGENT_NAME" == "claude" ]]; then - echo "Registering dbt MCP server with Claude..." - claude mcp add dbt -- uvx --env-file "$env_file" dbt-mcp - claude mcp list - -elif [[ "$AGENT_NAME" == "codex" ]]; then - echo "Registering dbt MCP server with Codex..." - codex mcp add dbt -- uvx --env-file "$env_file" dbt-mcp - codex mcp list - -elif [[ "$AGENT_NAME" == "gemini" ]]; then - echo "Registering dbt MCP server with Gemini..." - gemini mcp add dbt uvx -- --env-file "$env_file" dbt-mcp - gemini mcp list - -else - echo "Skipping dbt MCP setup - '$AGENT_NAME' is not supported" - exit 0 -fi \ No newline at end of file From f1308bb467d12304cb725f9ce8370f41a3ba47df Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:38:33 +1300 Subject: [PATCH 21/44] Remove available skills callout from claude.md --- shared/config/CLAUDE.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/shared/config/CLAUDE.md b/shared/config/CLAUDE.md index d7810a93..aa2814ab 100644 --- a/shared/config/CLAUDE.md +++ b/shared/config/CLAUDE.md @@ -2,12 +2,6 @@ You are acting as an expert analyst and data engineer who is taksed with solving analytics and data engineering problems. Follow the requests given to you—do exactly what is asked, nothing more. -## Available Skills - -YOU MUST USE THIS SKILL, DEFINED IN `.claude/skills/dbt-skill` - -- **dbt-skill**: A comprehensive guide to working with dbt projects, including making changes to a project and debugging issues. - ## Available Tools - dbt: You have access to a dbt project, and its configuration files. The project may use dbt Fusion or standard dbt. From 44837afbef9551c9779a033840d9af7254becc13 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:53:04 +1300 Subject: [PATCH 22/44] refactor: rename skill set to plugin set throughout codebase Rename all "skill set" terminology to "plugin set" for clarity: - SkillSet -> PluginSet - SkillSetsConfig -> PluginSetsConfig - SkillSetLoader -> PluginSetLoader - skill-sets.yaml -> plugin-sets.yaml - Related variables, methods, and test names updated Co-Authored-By: Claude Opus 4.5 --- ade_bench/cli/ab/main.py | 2 +- ade_bench/harness.py | 54 ++++---- ade_bench/harness_models.py | 8 +- ade_bench/models/__init__.py | 4 +- .../models/{skill_set.py => plugin_set.py} | 34 ++--- ade_bench/plugins/__init__.py | 4 +- ade_bench/plugins/loader.py | 54 ++++---- ade_bench/plugins/mcp_handler.py | 14 +- ade_bench/plugins/skills_handler.py | 16 +-- ade_bench/setup/setup_orchestrator.py | 18 +-- .../{skill-sets.yaml => plugin-sets.yaml} | 0 tests/models/test_plugin_set.py | 123 ++++++++++++++++++ tests/models/test_skill_set.py | 123 ------------------ tests/plugins/test_loader.py | 52 ++++---- tests/plugins/test_mcp_handler.py | 22 ++-- tests/plugins/test_skills_handler.py | 20 +-- 16 files changed, 274 insertions(+), 274 deletions(-) rename ade_bench/models/{skill_set.py => plugin_set.py} (51%) rename experiment_sets/{skill-sets.yaml => plugin-sets.yaml} (100%) create mode 100644 tests/models/test_plugin_set.py delete mode 100644 tests/models/test_skill_set.py diff --git a/ade_bench/cli/ab/main.py b/ade_bench/cli/ab/main.py index 485d5117..ce46e9d5 100644 --- a/ade_bench/cli/ab/main.py +++ b/ade_bench/cli/ab/main.py @@ -155,7 +155,7 @@ def run( plugin_set: Optional[List[str]] = typer.Option( None, "--plugin-set", - help="Skill set names from skill-sets.yaml (default: use all default sets)" + help="Plugin set names from plugin-sets.yaml (default: use all default sets)" ), with_profiling: bool = typer.Option( False, diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 894fa992..1c69a267 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -28,8 +28,8 @@ RunMetadata, TrialResults, ) -from ade_bench.models.skill_set import SkillSet -from ade_bench.plugins.loader import SkillSetLoader +from ade_bench.models.plugin_set import PluginSet +from ade_bench.plugins.loader import PluginSetLoader from ade_bench.setup.setup_orchestrator import SetupOrchestrator from ade_bench.llms.base_llm import ContextLengthExceededError, ParseError from ade_bench.parsers.base_parser import UnitTestStatus, ParserResult @@ -111,8 +111,8 @@ def __init__( self._project_type_filter = project_type self._keep_alive = keep_alive self._plugin_set_names = plugin_set_names - self._skill_sets: list[SkillSet] = [] - self._current_skill_set: SkillSet | None = None + self._plugin_sets: list[PluginSet] = [] + self._current_plugin_set: PluginSet | None = None self._with_profiling = with_profiling # Initialize setup orchestrator for variant-specific setup @@ -135,7 +135,7 @@ def __init__( self._run_path.mkdir(parents=True, exist_ok=True) self._init_dataset() - self._init_skill_sets() + self._init_plugin_sets() self._init_logger() @property @@ -195,16 +195,16 @@ def _init_dataset(self) -> None: excluded_task_ids=self._exclude_task_ids, ) - def _init_skill_sets(self) -> None: - """Load and resolve skill sets from configuration.""" - config_path = self._dataset_path.parent / "experiment_sets" / "skill-sets.yaml" + def _init_plugin_sets(self) -> None: + """Load and resolve plugin sets from configuration.""" + config_path = self._dataset_path.parent / "experiment_sets" / "plugin-sets.yaml" if not config_path.exists(): - # No skill sets config - use empty list (no plugins) - self._skill_sets = [SkillSet(name="no-plugins", allowed_tools=["Bash", "Edit", "Write", "Read", "Glob", "Grep"])] + # No plugin sets config - use empty list (no plugins) + self._plugin_sets = [PluginSet(name="no-plugins", allowed_tools=["Bash", "Edit", "Write", "Read", "Glob", "Grep"])] return - loader = SkillSetLoader(config_path) - self._skill_sets = loader.resolve_skill_sets( + loader = PluginSetLoader(config_path) + self._plugin_sets = loader.resolve_plugin_sets( plugin_set_names=self._plugin_set_names, agent_name=self._agent_name.value ) @@ -575,7 +575,7 @@ def _run_setup( session=session, file_diff_handler=file_diff_handler, trial_handler=trial_handler, - skill_set=self._current_skill_set + plugin_set=self._current_plugin_set ) # Run setup with timeout using asyncio @@ -626,9 +626,9 @@ def _run_trial( model_name=self._model_name, db_type=config.get("db_type"), project_type=config.get("project_type"), - skill_set_name=self._current_skill_set.name if self._current_skill_set else None, - skill_set_skills=self._current_skill_set.skills if self._current_skill_set else None, - skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if self._current_skill_set else None, + plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, + plugin_set_skills=self._current_plugin_set.skills if self._current_plugin_set else None, + plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, ) with spin_up_terminal( @@ -1234,9 +1234,9 @@ def _execute_single_trial( model_name=self._model_name, db_type=config.get("db_type"), project_type=config.get("project_type"), - skill_set_name=self._current_skill_set.name if self._current_skill_set else None, - skill_set_skills=self._current_skill_set.skills if self._current_skill_set else None, - skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if self._current_skill_set else None, + plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, + plugin_set_skills=self._current_plugin_set.skills if self._current_plugin_set else None, + plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, ) return trial_results @@ -1334,21 +1334,21 @@ def _execute_tasks(self) -> BenchmarkResults: return results def run(self) -> BenchmarkResults: - """Run the benchmark with all configured skill sets.""" + """Run the benchmark with all configured plugin sets.""" all_results = BenchmarkResults() original_run_id = self._run_id - for skill_set in self._skill_sets: - # Create run ID with skill set suffix - self._run_id = f"{original_run_id}__{skill_set.name}" - self._current_skill_set = skill_set + for plugin_set in self._plugin_sets: + # Create run ID with plugin set suffix + self._run_id = f"{original_run_id}__{plugin_set.name}" + self._current_plugin_set = plugin_set - # Ensure output directory exists for this skill set + # Ensure output directory exists for this plugin set self._run_path.mkdir(parents=True, exist_ok=True) - self._logger.info(f"Starting run for skill set: {skill_set.name}") + self._logger.info(f"Starting run for plugin set: {plugin_set.name}") - # Run trials for this skill set + # Run trials for this plugin set results = self._execute_trials() all_results.results.extend(results.results) diff --git a/ade_bench/harness_models.py b/ade_bench/harness_models.py index 74e3d349..5b9282dc 100644 --- a/ade_bench/harness_models.py +++ b/ade_bench/harness_models.py @@ -94,10 +94,10 @@ class TrialResults(BaseModel): model_name: str | None = None db_type: str | None = None project_type: str | None = None - # Skill set metadata - skill_set_name: str | None = None - skill_set_skills: list[str] | None = None - skill_set_mcp_servers: list[str] | None = None + # Plugin set metadata + plugin_set_name: str | None = None + plugin_set_skills: list[str] | None = None + plugin_set_mcp_servers: list[str] | None = None class BenchmarkResults(BaseModel): diff --git a/ade_bench/models/__init__.py b/ade_bench/models/__init__.py index 33ad926b..c6bbcc2f 100644 --- a/ade_bench/models/__init__.py +++ b/ade_bench/models/__init__.py @@ -1,5 +1,5 @@ """Models for ADE-Bench configuration.""" -from .skill_set import McpServerConfig, SkillSet, SkillSetsConfig +from .plugin_set import McpServerConfig, PluginSet, PluginSetsConfig -__all__ = ["McpServerConfig", "SkillSet", "SkillSetsConfig"] +__all__ = ["McpServerConfig", "PluginSet", "PluginSetsConfig"] diff --git a/ade_bench/models/skill_set.py b/ade_bench/models/plugin_set.py similarity index 51% rename from ade_bench/models/skill_set.py rename to ade_bench/models/plugin_set.py index da27a457..d406b395 100644 --- a/ade_bench/models/skill_set.py +++ b/ade_bench/models/plugin_set.py @@ -1,4 +1,4 @@ -"""Pydantic models for skill set configuration.""" +"""Pydantic models for plugin set configuration.""" from pydantic import BaseModel @@ -10,8 +10,8 @@ class McpServerConfig(BaseModel): env: dict[str, str] = {} -class SkillSet(BaseModel): - """Configuration for a set of skills and tools.""" +class PluginSet(BaseModel): + """Configuration for a set of plugins (skills and MCP servers).""" name: str description: str = "" default: bool = False @@ -21,36 +21,36 @@ class SkillSet(BaseModel): allowed_tools: list[str] = [] def is_compatible_with_agent(self, agent_name: str) -> bool: - """Check if this skill set is compatible with the given agent.""" + """Check if this plugin set is compatible with the given agent.""" if self.agents is None: return True return agent_name in self.agents -class SkillSetsConfig(BaseModel): - """Root configuration containing all skill sets.""" - sets: list[SkillSet] +class PluginSetsConfig(BaseModel): + """Root configuration containing all plugin sets.""" + sets: list[PluginSet] - def get_defaults(self) -> list[SkillSet]: - """Get all skill sets marked as default.""" + def get_defaults(self) -> list[PluginSet]: + """Get all plugin sets marked as default.""" return [s for s in self.sets if s.default] - def get_by_name(self, name: str) -> SkillSet | None: - """Get a skill set by name.""" + def get_by_name(self, name: str) -> PluginSet | None: + """Get a plugin set by name.""" for s in self.sets: if s.name == name: return s return None - def get_by_names(self, names: list[str]) -> list[SkillSet]: - """Get multiple skill sets by name. Raises if any not found.""" + def get_by_names(self, names: list[str]) -> list[PluginSet]: + """Get multiple plugin sets by name. Raises if any not found.""" result = [] for name in names: - skill_set = self.get_by_name(name) - if skill_set is None: + plugin_set = self.get_by_name(name) + if plugin_set is None: available = [s.name for s in self.sets] raise ValueError( - f"Unknown skill set '{name}'. Available: {', '.join(available)}" + f"Unknown plugin set '{name}'. Available: {', '.join(available)}" ) - result.append(skill_set) + result.append(plugin_set) return result diff --git a/ade_bench/plugins/__init__.py b/ade_bench/plugins/__init__.py index 9b09f20d..a2a533f6 100644 --- a/ade_bench/plugins/__init__.py +++ b/ade_bench/plugins/__init__.py @@ -1,5 +1,5 @@ """Plugin system for ADE-Bench.""" -from .loader import SkillSetLoader +from .loader import PluginSetLoader -__all__ = ["SkillSetLoader"] +__all__ = ["PluginSetLoader"] diff --git a/ade_bench/plugins/loader.py b/ade_bench/plugins/loader.py index d6ddacea..1f5cafd6 100644 --- a/ade_bench/plugins/loader.py +++ b/ade_bench/plugins/loader.py @@ -1,75 +1,75 @@ -"""Loader for skill set configuration.""" +"""Loader for plugin set configuration.""" from pathlib import Path import yaml -from ade_bench.models.skill_set import SkillSet, SkillSetsConfig +from ade_bench.models.plugin_set import PluginSet, PluginSetsConfig -class SkillSetLoader: - """Loads and resolves skill sets from YAML configuration.""" +class PluginSetLoader: + """Loads and resolves plugin sets from YAML configuration.""" def __init__(self, config_path: Path): self._config_path = config_path - self._config: SkillSetsConfig | None = None + self._config: PluginSetsConfig | None = None - def load(self) -> SkillSetsConfig: - """Load the skill sets configuration from YAML.""" + def load(self) -> PluginSetsConfig: + """Load the plugin sets configuration from YAML.""" if not self._config_path.exists(): - raise FileNotFoundError(f"Skill sets config not found: {self._config_path}") + raise FileNotFoundError(f"Plugin sets config not found: {self._config_path}") with open(self._config_path) as f: data = yaml.safe_load(f) - self._config = SkillSetsConfig(**data) + self._config = PluginSetsConfig(**data) return self._config - def resolve_skill_sets( + def resolve_plugin_sets( self, plugin_set_names: list[str] | None, agent_name: str, - ) -> list[SkillSet]: - """Resolve which skill sets to use for a run. + ) -> list[PluginSet]: + """Resolve which plugin sets to use for a run. Args: - plugin_set_names: Explicit skill set names from --plugin-set, or None for defaults + plugin_set_names: Explicit plugin set names from --plugin-set, or None for defaults agent_name: The agent being used (e.g., "claude", "gemini") Returns: - List of SkillSet objects to use + List of PluginSet objects to use Raises: - ValueError: If requested skill set is not found or incompatible + ValueError: If requested plugin set is not found or incompatible """ if self._config is None: self.load() - # Get skill sets (explicit or defaults) + # Get plugin sets (explicit or defaults) if plugin_set_names: - skill_sets = self._config.get_by_names(plugin_set_names) + plugin_sets = self._config.get_by_names(plugin_set_names) # Validate all are compatible with agent - for ss in skill_sets: - if not ss.is_compatible_with_agent(agent_name): + for ps in plugin_sets: + if not ps.is_compatible_with_agent(agent_name): raise ValueError( - f"Skill set '{ss.name}' is not compatible with agent '{agent_name}'. " - f"Compatible agents: {ss.agents}" + f"Plugin set '{ps.name}' is not compatible with agent '{agent_name}'. " + f"Compatible agents: {ps.agents}" ) else: - skill_sets = self._config.get_defaults() + plugin_sets = self._config.get_defaults() - # Filter to compatible skill sets - compatible = [ss for ss in skill_sets if ss.is_compatible_with_agent(agent_name)] + # Filter to compatible plugin sets + compatible = [ps for ps in plugin_sets if ps.is_compatible_with_agent(agent_name)] if not compatible: if plugin_set_names: raise ValueError( - f"No compatible skill sets found for agent '{agent_name}' " + f"No compatible plugin sets found for agent '{agent_name}' " f"from requested: {plugin_set_names}" ) else: raise ValueError( - f"No compatible skill sets found for agent '{agent_name}'. " - f"No default skill sets are compatible with this agent." + f"No compatible plugin sets found for agent '{agent_name}'. " + f"No default plugin sets are compatible with this agent." ) return compatible diff --git a/ade_bench/plugins/mcp_handler.py b/ade_bench/plugins/mcp_handler.py index 82afa041..4ac3fcf4 100644 --- a/ade_bench/plugins/mcp_handler.py +++ b/ade_bench/plugins/mcp_handler.py @@ -1,28 +1,28 @@ """Handler for configuring MCP servers.""" import logging -from ade_bench.models.skill_set import SkillSet +from ade_bench.models.plugin_set import PluginSet from ade_bench.terminal.docker_compose_manager import DockerComposeManager logger = logging.getLogger(__name__) class McpHandler: - """Configures MCP servers from skill set configuration.""" + """Configures MCP servers from plugin set configuration.""" - def configure(self, skill_set: SkillSet, agent_name: str, terminal: DockerComposeManager) -> None: + def configure(self, plugin_set: PluginSet, agent_name: str, terminal: DockerComposeManager) -> None: """Configure MCP servers for the agent. Args: - skill_set: The skill set configuration + plugin_set: The plugin set configuration agent_name: The agent CLI name (claude, gemini, codex) terminal: The Docker container manager """ - if not skill_set.mcp_servers: - logger.debug(f"[McpHandler] No MCP servers to configure for '{skill_set.name}'") + if not plugin_set.mcp_servers: + logger.debug(f"[McpHandler] No MCP servers to configure for '{plugin_set.name}'") return - for server_name, config in skill_set.mcp_servers.items(): + for server_name, config in plugin_set.mcp_servers.items(): logger.info(f"[McpHandler] Configuring MCP server '{server_name}'...") # Write env file if env vars specified diff --git a/ade_bench/plugins/skills_handler.py b/ade_bench/plugins/skills_handler.py index c1293f06..ce02a5f9 100644 --- a/ade_bench/plugins/skills_handler.py +++ b/ade_bench/plugins/skills_handler.py @@ -1,27 +1,27 @@ """Handler for installing skills via Vercel Skills CLI.""" import logging -from ade_bench.models.skill_set import SkillSet +from ade_bench.models.plugin_set import PluginSet from ade_bench.terminal.docker_compose_manager import DockerComposeManager logger = logging.getLogger(__name__) class SkillsHandler: - """Installs skills from skill set configuration.""" + """Installs skills from plugin set configuration.""" - def install(self, skill_set: SkillSet, terminal: DockerComposeManager) -> None: - """Install skills from the skill set into the container. + def install(self, plugin_set: PluginSet, terminal: DockerComposeManager) -> None: + """Install skills from the plugin set into the container. Args: - skill_set: The skill set configuration + plugin_set: The plugin set configuration terminal: The Docker container manager """ - if not skill_set.skills: - logger.debug(f"[SkillsHandler] No skills to install for '{skill_set.name}'") + if not plugin_set.skills: + logger.debug(f"[SkillsHandler] No skills to install for '{plugin_set.name}'") return - for repo in skill_set.skills: + for repo in plugin_set.skills: cmd = f"npx --yes skills add {repo} --all" logger.info(f"[SkillsHandler] Installing skills from {repo}...") diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index a1b47bd8..52ebfbb4 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -10,7 +10,7 @@ from .migration_setup import setup_migration from .agent_setup import setup_agent_config from ..utils.logger import log_harness_info -from ..models.skill_set import SkillSet +from ..models.plugin_set import PluginSet from ..plugins.skills_handler import SkillsHandler from ..plugins.mcp_handler import McpHandler @@ -18,13 +18,13 @@ class SetupOrchestrator: """Orchestrator that calls setup functions and configures plugins.""" - def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, skill_set: SkillSet | None = None): + def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, plugin_set: PluginSet | None = None): self.logger = logger self.terminal = terminal self.session = session self.file_diff_handler = file_diff_handler self.trial_handler = trial_handler - self.skill_set = skill_set + self.plugin_set = plugin_set self._skills_handler = SkillsHandler() self._mcp_handler = McpHandler() @@ -47,17 +47,17 @@ def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: # Logging is in the setup_agent_config function setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) - # Install skills and configure MCP if skill set specified - if self.skill_set: - if self.skill_set.skills: + # Install skills and configure MCP if plugin set specified + if self.plugin_set: + if self.plugin_set.skills: log_harness_info(self.logger, task_id, "setup", "Installing skills...") - self._skills_handler.install(self.skill_set, self.terminal) + self._skills_handler.install(self.plugin_set, self.terminal) log_harness_info(self.logger, task_id, "setup", "Skills installed") - if self.skill_set.mcp_servers: + if self.plugin_set.mcp_servers: log_harness_info(self.logger, task_id, "setup", "Configuring MCP servers...") agent_name = self.trial_handler.agent_name.value - self._mcp_handler.configure(self.skill_set, agent_name, self.terminal) + self._mcp_handler.configure(self.plugin_set, agent_name, self.terminal) log_harness_info(self.logger, task_id, "setup", "MCP servers configured") diff --git a/experiment_sets/skill-sets.yaml b/experiment_sets/plugin-sets.yaml similarity index 100% rename from experiment_sets/skill-sets.yaml rename to experiment_sets/plugin-sets.yaml diff --git a/tests/models/test_plugin_set.py b/tests/models/test_plugin_set.py new file mode 100644 index 00000000..94dc4192 --- /dev/null +++ b/tests/models/test_plugin_set.py @@ -0,0 +1,123 @@ +import pytest +from ade_bench.models.plugin_set import PluginSet, McpServerConfig, PluginSetsConfig + + +def test_mcp_server_config_minimal(): + config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + assert config.command == "uvx" + assert config.args == ["dbt-mcp@latest"] + assert config.env == {} + + +def test_mcp_server_config_with_env(): + config = McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true"} + ) + assert config.env == {"DISABLE_SQL": "true"} + + +def test_plugin_set_minimal(): + plugin_set = PluginSet(name="test", allowed_tools=["Bash"]) + assert plugin_set.name == "test" + assert plugin_set.description == "" + assert plugin_set.default is False + assert plugin_set.agents is None + assert plugin_set.skills == [] + assert plugin_set.mcp_servers == {} + assert plugin_set.allowed_tools == ["Bash"] + + +def test_plugin_set_full(): + plugin_set = PluginSet( + name="dbt-full", + description="Full dbt setup", + default=True, + agents=["claude"], + skills=["dbt-labs/dbt-agent-skills"], + mcp_servers={ + "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) + }, + allowed_tools=["Bash", "Skill", "mcp__dbt__*"] + ) + assert plugin_set.default is True + assert plugin_set.agents == ["claude"] + assert len(plugin_set.mcp_servers) == 1 + + +def test_plugin_set_is_compatible_with_agent_all(): + """When agents is None, compatible with all agents.""" + plugin_set = PluginSet(name="test", allowed_tools=["Bash"]) + assert plugin_set.is_compatible_with_agent("claude") is True + assert plugin_set.is_compatible_with_agent("gemini") is True + + +def test_plugin_set_is_compatible_with_agent_restricted(): + """When agents is set, only compatible with listed agents.""" + plugin_set = PluginSet(name="test", agents=["claude"], allowed_tools=["Bash"]) + assert plugin_set.is_compatible_with_agent("claude") is True + assert plugin_set.is_compatible_with_agent("gemini") is False + + +def test_plugin_sets_config_from_yaml(): + yaml_content = """ +sets: + - name: no-plugins + default: true + skills: [] + allowed_tools: [Bash, Read] + - name: dbt-skills + agents: [claude] + skills: + - dbt-labs/dbt-agent-skills + allowed_tools: [Bash, Skill] +""" + import yaml + data = yaml.safe_load(yaml_content) + config = PluginSetsConfig(**data) + assert len(config.sets) == 2 + assert config.sets[0].name == "no-plugins" + assert config.sets[0].default is True + + +def test_plugin_sets_config_get_defaults(): + config = PluginSetsConfig(sets=[ + PluginSet(name="a", default=True, allowed_tools=["Bash"]), + PluginSet(name="b", default=False, allowed_tools=["Bash"]), + PluginSet(name="c", default=True, allowed_tools=["Bash"]), + ]) + defaults = config.get_defaults() + assert len(defaults) == 2 + assert defaults[0].name == "a" + assert defaults[1].name == "c" + + +def test_plugin_sets_config_get_by_name(): + config = PluginSetsConfig(sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + PluginSet(name="b", allowed_tools=["Bash"]), + ]) + assert config.get_by_name("a").name == "a" + assert config.get_by_name("b").name == "b" + assert config.get_by_name("nonexistent") is None + + +def test_plugin_sets_config_get_by_names(): + config = PluginSetsConfig(sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + PluginSet(name="b", allowed_tools=["Bash"]), + PluginSet(name="c", allowed_tools=["Bash"]), + ]) + result = config.get_by_names(["a", "c"]) + assert len(result) == 2 + assert result[0].name == "a" + assert result[1].name == "c" + + +def test_plugin_sets_config_get_by_names_unknown_raises(): + config = PluginSetsConfig(sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + ]) + with pytest.raises(ValueError, match="Unknown plugin set"): + config.get_by_names(["a", "nonexistent"]) diff --git a/tests/models/test_skill_set.py b/tests/models/test_skill_set.py deleted file mode 100644 index c88ffa6e..00000000 --- a/tests/models/test_skill_set.py +++ /dev/null @@ -1,123 +0,0 @@ -import pytest -from ade_bench.models.skill_set import SkillSet, McpServerConfig, SkillSetsConfig - - -def test_mcp_server_config_minimal(): - config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - assert config.command == "uvx" - assert config.args == ["dbt-mcp@latest"] - assert config.env == {} - - -def test_mcp_server_config_with_env(): - config = McpServerConfig( - command="uvx", - args=["dbt-mcp@latest"], - env={"DISABLE_SQL": "true"} - ) - assert config.env == {"DISABLE_SQL": "true"} - - -def test_skill_set_minimal(): - skill_set = SkillSet(name="test", allowed_tools=["Bash"]) - assert skill_set.name == "test" - assert skill_set.description == "" - assert skill_set.default is False - assert skill_set.agents is None - assert skill_set.skills == [] - assert skill_set.mcp_servers == {} - assert skill_set.allowed_tools == ["Bash"] - - -def test_skill_set_full(): - skill_set = SkillSet( - name="dbt-full", - description="Full dbt setup", - default=True, - agents=["claude"], - skills=["dbt-labs/dbt-agent-skills"], - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - }, - allowed_tools=["Bash", "Skill", "mcp__dbt__*"] - ) - assert skill_set.default is True - assert skill_set.agents == ["claude"] - assert len(skill_set.mcp_servers) == 1 - - -def test_skill_set_is_compatible_with_agent_all(): - """When agents is None, compatible with all agents.""" - skill_set = SkillSet(name="test", allowed_tools=["Bash"]) - assert skill_set.is_compatible_with_agent("claude") is True - assert skill_set.is_compatible_with_agent("gemini") is True - - -def test_skill_set_is_compatible_with_agent_restricted(): - """When agents is set, only compatible with listed agents.""" - skill_set = SkillSet(name="test", agents=["claude"], allowed_tools=["Bash"]) - assert skill_set.is_compatible_with_agent("claude") is True - assert skill_set.is_compatible_with_agent("gemini") is False - - -def test_skill_sets_config_from_yaml(): - yaml_content = """ -sets: - - name: no-plugins - default: true - skills: [] - allowed_tools: [Bash, Read] - - name: dbt-skills - agents: [claude] - skills: - - dbt-labs/dbt-agent-skills - allowed_tools: [Bash, Skill] -""" - import yaml - data = yaml.safe_load(yaml_content) - config = SkillSetsConfig(**data) - assert len(config.sets) == 2 - assert config.sets[0].name == "no-plugins" - assert config.sets[0].default is True - - -def test_skill_sets_config_get_defaults(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", default=True, allowed_tools=["Bash"]), - SkillSet(name="b", default=False, allowed_tools=["Bash"]), - SkillSet(name="c", default=True, allowed_tools=["Bash"]), - ]) - defaults = config.get_defaults() - assert len(defaults) == 2 - assert defaults[0].name == "a" - assert defaults[1].name == "c" - - -def test_skill_sets_config_get_by_name(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - SkillSet(name="b", allowed_tools=["Bash"]), - ]) - assert config.get_by_name("a").name == "a" - assert config.get_by_name("b").name == "b" - assert config.get_by_name("nonexistent") is None - - -def test_skill_sets_config_get_by_names(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - SkillSet(name="b", allowed_tools=["Bash"]), - SkillSet(name="c", allowed_tools=["Bash"]), - ]) - result = config.get_by_names(["a", "c"]) - assert len(result) == 2 - assert result[0].name == "a" - assert result[1].name == "c" - - -def test_skill_sets_config_get_by_names_unknown_raises(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - ]) - with pytest.raises(ValueError, match="Unknown skill set"): - config.get_by_names(["a", "nonexistent"]) diff --git a/tests/plugins/test_loader.py b/tests/plugins/test_loader.py index 3b777323..3459b384 100644 --- a/tests/plugins/test_loader.py +++ b/tests/plugins/test_loader.py @@ -1,11 +1,11 @@ import pytest from pathlib import Path -from ade_bench.plugins.loader import SkillSetLoader -from ade_bench.models.skill_set import SkillSetsConfig +from ade_bench.plugins.loader import PluginSetLoader +from ade_bench.models.plugin_set import PluginSetsConfig def test_loader_loads_yaml(tmp_path): - yaml_file = tmp_path / "skill-sets.yaml" + yaml_file = tmp_path / "plugin-sets.yaml" yaml_file.write_text(""" sets: - name: test @@ -13,20 +13,20 @@ def test_loader_loads_yaml(tmp_path): skills: [] allowed_tools: [Bash] """) - loader = SkillSetLoader(yaml_file) + loader = PluginSetLoader(yaml_file) config = loader.load() - assert isinstance(config, SkillSetsConfig) + assert isinstance(config, PluginSetsConfig) assert len(config.sets) == 1 assert config.sets[0].name == "test" def test_loader_file_not_found(): - loader = SkillSetLoader(Path("/nonexistent/skill-sets.yaml")) + loader = PluginSetLoader(Path("/nonexistent/plugin-sets.yaml")) with pytest.raises(FileNotFoundError): loader.load() -def test_loader_resolve_skill_sets_explicit(): +def test_loader_resolve_plugin_sets_explicit(): """Explicit --plugin-set names are resolved.""" yaml_content = """ sets: @@ -41,8 +41,8 @@ def test_loader_resolve_skill_sets_explicit(): with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write(yaml_content) f.flush() - loader = SkillSetLoader(Path(f.name)) - result = loader.resolve_skill_sets( + loader = PluginSetLoader(Path(f.name)) + result = loader.resolve_plugin_sets( plugin_set_names=["a"], agent_name="claude" ) @@ -50,7 +50,7 @@ def test_loader_resolve_skill_sets_explicit(): assert result[0].name == "a" -def test_loader_resolve_skill_sets_defaults(): +def test_loader_resolve_plugin_sets_defaults(): """When no --plugin-set, use defaults.""" yaml_content = """ sets: @@ -68,8 +68,8 @@ def test_loader_resolve_skill_sets_defaults(): with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write(yaml_content) f.flush() - loader = SkillSetLoader(Path(f.name)) - result = loader.resolve_skill_sets( + loader = PluginSetLoader(Path(f.name)) + result = loader.resolve_plugin_sets( plugin_set_names=None, agent_name="claude" ) @@ -78,8 +78,8 @@ def test_loader_resolve_skill_sets_defaults(): assert result[1].name == "c" -def test_loader_resolve_skill_sets_filters_incompatible(): - """Skill sets incompatible with agent are filtered out.""" +def test_loader_resolve_plugin_sets_filters_incompatible(): + """Plugin sets incompatible with agent are filtered out.""" yaml_content = """ sets: - name: claude-only @@ -94,20 +94,20 @@ def test_loader_resolve_skill_sets_filters_incompatible(): with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write(yaml_content) f.flush() - loader = SkillSetLoader(Path(f.name)) + loader = PluginSetLoader(Path(f.name)) # Claude gets both - result = loader.resolve_skill_sets(None, "claude") + result = loader.resolve_plugin_sets(None, "claude") assert len(result) == 2 # Gemini only gets all-agents - result = loader.resolve_skill_sets(None, "gemini") + result = loader.resolve_plugin_sets(None, "gemini") assert len(result) == 1 assert result[0].name == "all-agents" -def test_loader_resolve_skill_sets_error_on_incompatible_explicit(): - """Error when explicitly requested skill set is incompatible.""" +def test_loader_resolve_plugin_sets_error_on_incompatible_explicit(): + """Error when explicitly requested plugin set is incompatible.""" yaml_content = """ sets: - name: claude-only @@ -118,14 +118,14 @@ def test_loader_resolve_skill_sets_error_on_incompatible_explicit(): with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write(yaml_content) f.flush() - loader = SkillSetLoader(Path(f.name)) + loader = PluginSetLoader(Path(f.name)) with pytest.raises(ValueError, match="not compatible with agent 'gemini'"): - loader.resolve_skill_sets(["claude-only"], "gemini") + loader.resolve_plugin_sets(["claude-only"], "gemini") -def test_loader_resolve_skill_sets_error_when_none_compatible(): - """Error when no skill sets are compatible with agent.""" +def test_loader_resolve_plugin_sets_error_when_none_compatible(): + """Error when no plugin sets are compatible with agent.""" yaml_content = """ sets: - name: claude-only @@ -137,7 +137,7 @@ def test_loader_resolve_skill_sets_error_when_none_compatible(): with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write(yaml_content) f.flush() - loader = SkillSetLoader(Path(f.name)) + loader = PluginSetLoader(Path(f.name)) - with pytest.raises(ValueError, match="No compatible skill sets"): - loader.resolve_skill_sets(None, "gemini") + with pytest.raises(ValueError, match="No compatible plugin sets"): + loader.resolve_plugin_sets(None, "gemini") diff --git a/tests/plugins/test_mcp_handler.py b/tests/plugins/test_mcp_handler.py index 9ece9be8..4ea32abc 100644 --- a/tests/plugins/test_mcp_handler.py +++ b/tests/plugins/test_mcp_handler.py @@ -1,23 +1,23 @@ import pytest from unittest.mock import MagicMock, call from ade_bench.plugins.mcp_handler import McpHandler -from ade_bench.models.skill_set import SkillSet, McpServerConfig +from ade_bench.models.plugin_set import PluginSet, McpServerConfig def test_mcp_handler_configure_no_servers(): - """No-op when skill set has no MCP servers.""" - skill_set = SkillSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) + """No-op when plugin set has no MCP servers.""" + plugin_set = PluginSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) terminal = MagicMock() handler = McpHandler() - handler.configure(skill_set, "claude", terminal) + handler.configure(plugin_set, "claude", terminal) terminal.container.exec_run.assert_not_called() def test_mcp_handler_configure_single_server(): """Configures a single MCP server.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", mcp_servers={ "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) @@ -28,7 +28,7 @@ def test_mcp_handler_configure_single_server(): terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") handler = McpHandler() - handler.configure(skill_set, "claude", terminal) + handler.configure(plugin_set, "claude", terminal) # Should have at least one call for mcp add assert terminal.container.exec_run.call_count >= 1 @@ -40,7 +40,7 @@ def test_mcp_handler_configure_single_server(): def test_mcp_handler_configure_with_env(): """Writes env file when env vars are specified.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", mcp_servers={ "dbt": McpServerConfig( @@ -55,7 +55,7 @@ def test_mcp_handler_configure_with_env(): terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") handler = McpHandler() - handler.configure(skill_set, "claude", terminal) + handler.configure(plugin_set, "claude", terminal) # Check that env file was written calls = terminal.container.exec_run.call_args_list @@ -65,7 +65,7 @@ def test_mcp_handler_configure_with_env(): def test_mcp_handler_configure_different_agents(): """Uses correct agent CLI command.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", mcp_servers={ "dbt": McpServerConfig(command="uvx", args=["dbt-mcp"]) @@ -78,7 +78,7 @@ def test_mcp_handler_configure_different_agents(): handler = McpHandler() # Test claude - handler.configure(skill_set, "claude", terminal) + handler.configure(plugin_set, "claude", terminal) calls = terminal.container.exec_run.call_args_list claude_calls = [c for c in calls if "claude mcp add" in str(c)] assert len(claude_calls) >= 1 @@ -86,7 +86,7 @@ def test_mcp_handler_configure_different_agents(): terminal.reset_mock() # Test gemini - handler.configure(skill_set, "gemini", terminal) + handler.configure(plugin_set, "gemini", terminal) calls = terminal.container.exec_run.call_args_list gemini_calls = [c for c in calls if "gemini mcp add" in str(c)] assert len(gemini_calls) >= 1 diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py index cf8936e3..484b1d23 100644 --- a/tests/plugins/test_skills_handler.py +++ b/tests/plugins/test_skills_handler.py @@ -1,23 +1,23 @@ import pytest from unittest.mock import MagicMock, call from ade_bench.plugins.skills_handler import SkillsHandler -from ade_bench.models.skill_set import SkillSet +from ade_bench.models.plugin_set import PluginSet def test_skills_handler_install_no_skills(): - """No-op when skill set has no skills.""" - skill_set = SkillSet(name="test", skills=[], allowed_tools=["Bash"]) + """No-op when plugin set has no skills.""" + plugin_set = PluginSet(name="test", skills=[], allowed_tools=["Bash"]) terminal = MagicMock() handler = SkillsHandler() - handler.install(skill_set, terminal) + handler.install(plugin_set, terminal) terminal.container.exec_run.assert_not_called() def test_skills_handler_install_single_skill(): """Installs a single skill repo.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", skills=["dbt-labs/dbt-agent-skills"], allowed_tools=["Bash"] @@ -26,7 +26,7 @@ def test_skills_handler_install_single_skill(): terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") handler = SkillsHandler() - handler.install(skill_set, terminal) + handler.install(plugin_set, terminal) terminal.container.exec_run.assert_called_once() call_args = terminal.container.exec_run.call_args @@ -38,7 +38,7 @@ def test_skills_handler_install_single_skill(): def test_skills_handler_install_multiple_skills(): """Installs multiple skill repos.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", skills=["repo/a", "repo/b"], allowed_tools=["Bash"] @@ -47,14 +47,14 @@ def test_skills_handler_install_multiple_skills(): terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") handler = SkillsHandler() - handler.install(skill_set, terminal) + handler.install(plugin_set, terminal) assert terminal.container.exec_run.call_count == 2 def test_skills_handler_install_failure_logs_warning(): """Logs warning but doesn't raise on install failure.""" - skill_set = SkillSet( + plugin_set = PluginSet( name="test", skills=["repo/failing"], allowed_tools=["Bash"] @@ -67,4 +67,4 @@ def test_skills_handler_install_failure_logs_warning(): handler = SkillsHandler() # Should not raise, just log warning - handler.install(skill_set, terminal) + handler.install(plugin_set, terminal) From 4a1b211187e9661c56202b4ff5668e6042d904a7 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 14:54:35 +1300 Subject: [PATCH 23/44] fix: update results_writer to use plugin_set_name instead of used_mcp The used_mcp field was removed when implementing the plugin set system. Replace with plugin_set_name to fix the AttributeError in CI. Co-Authored-By: Claude Opus 4.5 --- ade_bench/utils/results_writer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ade_bench/utils/results_writer.py b/ade_bench/utils/results_writer.py index baf18a4c..97fceb9d 100644 --- a/ade_bench/utils/results_writer.py +++ b/ade_bench/utils/results_writer.py @@ -131,7 +131,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) "model_name", "db_type", "project_type", - "used_mcp" + "plugin_set" ] with open(output_path, 'w', newline='') as f: @@ -175,7 +175,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) trial_result.model_name or "", trial_result.db_type or "", trial_result.project_type or "", - trial_result.used_mcp if trial_result.used_mcp is not None else "" + trial_result.plugin_set_name or "" ] writer.writerow(row) From 7d034bb35fc0a861d0190a7ba1b1061b435b4b3d Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 15:01:18 +1300 Subject: [PATCH 24/44] feat: use allowed_tools from plugin set instead of hardcoded list - Remove hardcoded ALLOWED_TOOLS from ClaudeCodeAgent - Add allowed_tools parameter to AbstractInstalledAgent - Pass allowed_tools from current plugin set to agent in harness This allows the allowed tools to be configured per plugin set in plugin-sets.yaml rather than being hardcoded in the agent. Co-Authored-By: Claude Opus 4.5 --- ade_bench/agents/installed_agents/abstract_installed_agent.py | 3 ++- .../agents/installed_agents/claude_code/claude_code_agent.py | 4 ++-- ade_bench/harness.py | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index 809f7854..beaf344a 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -26,11 +26,12 @@ class AbstractInstalledAgent(BaseAgent, ABC): NAME = AgentName.ABSTRACT_INSTALLED - def __init__(self, use_mcp: bool = False, model_name: str | None = None, **kwargs): + def __init__(self, use_mcp: bool = False, model_name: str | None = None, allowed_tools: list[str] | None = None, **kwargs): super().__init__(**kwargs) self._variant_config = {} self._use_mcp = use_mcp self._model_name = model_name + self._allowed_tools = allowed_tools or [] @property @abstractmethod diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index 4f8ed7e7..39bc3373 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -15,7 +15,6 @@ class ClaudeCodeAgent(AbstractInstalledAgent): NAME = AgentName.CLAUDE_CODE - ALLOWED_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "mcp__dbt", "Skill"] def __init__(self, **kwargs): super().__init__(**kwargs) @@ -40,7 +39,8 @@ def _run_agent_commands(self, task_prompt: str) -> list[TerminalCommand]: if self._model_name: command += f" --model {self._model_name}" - command += f" --allowedTools {' '.join(self.ALLOWED_TOOLS)}" + if self._allowed_tools: + command += f" --allowedTools {' '.join(self._allowed_tools)}" return [ TerminalCommand( diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 1c69a267..456e2023 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -185,6 +185,10 @@ def _create_agent_for_task(self, task_id: str) -> BaseAgent: if self._model_name: agent_kwargs["model_name"] = self._model_name + # Pass allowed_tools from current plugin set + if self._current_plugin_set and self._current_plugin_set.allowed_tools: + agent_kwargs["allowed_tools"] = self._current_plugin_set.allowed_tools + return AgentFactory.get_agent(self._agent_name, **agent_kwargs) def _init_dataset(self) -> None: From fc9397be117fd45168df9aeb7d42a38717cd2b0e Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 15:01:49 +1300 Subject: [PATCH 25/44] Update plugin-sets.yaml --- experiment_sets/plugin-sets.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index 1c34f22a..c336a663 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -10,8 +10,7 @@ sets: allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] - name: dbt-skills - description: dbt skills via Vercel Skills CLI - agents: [claude] + description: All dbt skills skills: - dbt-labs/dbt-agent-skills mcp_servers: {} @@ -33,9 +32,8 @@ sets: DISABLE_DBT_CODEGEN: "true" allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] - - name: dbt-full - description: Both skills and MCP - agents: [claude] + - name: dbt-skill-mcp + description: dbt skills and MCP server skills: - dbt-labs/dbt-agent-skills mcp_servers: From 187d6176d19efad645b3861d9d999e0bfcaafc5d Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 15:07:48 +1300 Subject: [PATCH 26/44] chore: remove obsolete use_mcp code and update used_mcp references - Remove use_mcp parameter and setup-dbt-mcp.sh logic from AbstractInstalledAgent (MCP is now handled by McpHandler) - Update used_mcp references to plugin_set in: - summarize_results.py - generate_results_html.py - analyze.py - templates/summary.html - Remove unused CLI flags from create_sandbox.py Co-Authored-By: Claude Opus 4.5 --- .../abstract_installed_agent.py | 25 +------------------ scripts_python/analyze.py | 2 +- scripts_python/create_sandbox.py | 15 +---------- scripts_python/generate_results_html.py | 2 +- scripts_python/summarize_results.py | 2 +- templates/summary.html | 2 +- 6 files changed, 6 insertions(+), 42 deletions(-) diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index beaf344a..337149af 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -26,10 +26,9 @@ class AbstractInstalledAgent(BaseAgent, ABC): NAME = AgentName.ABSTRACT_INSTALLED - def __init__(self, use_mcp: bool = False, model_name: str | None = None, allowed_tools: list[str] | None = None, **kwargs): + def __init__(self, model_name: str | None = None, allowed_tools: list[str] | None = None, **kwargs): super().__init__(**kwargs) self._variant_config = {} - self._use_mcp = use_mcp self._model_name = model_name self._allowed_tools = allowed_tools or [] @@ -109,28 +108,6 @@ def perform_task( block=True, max_timeout_sec=config.setup_timeout_sec, # Use setup timeout for installation ) - - # Optionally setup dbt MCP server - if self._use_mcp: - dbt_mcp_script = Path(__file__).parent.parent.parent.parent / "shared" / "scripts" / "setup-dbt-mcp.sh" - session.copy_to_container( - dbt_mcp_script, - container_dir="/scripts", - container_filename="setup-dbt-mcp.sh", - ) - - # Pass db_type, project_type, and agent name - db_type = self._variant_config.get('db_type', 'unknown') - project_type = self._variant_config.get('project_type', 'unknown') - agent_name = self.NAME.value if hasattr(self.NAME, 'value') else str(self.NAME) - session.send_keys( - [ - f"bash /scripts/setup-dbt-mcp.sh {db_type} {project_type} {agent_name}", - "Enter", - ], - block=True, - max_timeout_sec=config.setup_timeout_sec, - ) except TimeoutError: log_harness_info( logger, diff --git a/scripts_python/analyze.py b/scripts_python/analyze.py index 37b2b0fc..cb635c93 100755 --- a/scripts_python/analyze.py +++ b/scripts_python/analyze.py @@ -176,7 +176,7 @@ def get_canonical_column_order() -> List[str]: 'model_name', 'db_type', 'project_type', - 'used_mcp' + 'plugin_set' ] diff --git a/scripts_python/create_sandbox.py b/scripts_python/create_sandbox.py index 1ea04e99..b36ca49f 100644 --- a/scripts_python/create_sandbox.py +++ b/scripts_python/create_sandbox.py @@ -310,12 +310,6 @@ def main(): parser.add_argument("--task", help="Name of the task to create sandbox for") parser.add_argument("--db", required=True, help="Database type (duckdb, sqlite, postgres, snowflake)") parser.add_argument("--project-type", required=True, help="Project type (dbt, other)") - parser.add_argument("--agent", required=False, help="Ignored") - parser.add_argument("--use-mcp", required=False, action="store_true", help="Ignored") - parser.add_argument("--use-skills", required=False, action="store_true", help="Copy skills to sandbox") - parser.add_argument("--persist", required=False, action="store_true", help="Ignored") - parser.add_argument("--no-diffs", required=False, action="store_true", help="Ignored") - parser.add_argument("--seed", required=False, action="store_true", help="Ignored") args = parser.parse_args() @@ -367,14 +361,7 @@ def main(): if not copy_shared_scripts(): sys.exit(1) - # Step 9: Copy skills directory (if enabled) - if args.use_skills: - if not copy_skills(): - sys.exit(1) - else: - print(f"✓ Skipping skills directory (--use-skills not specified)") - - # Step 10: Update dbt configuration files + # Step 9: Update dbt configuration files print(f"✓ Updating dbt configuration files...") if not update_dbt_config(variant, task_name): print(f"❌ Failed to update dbt configuration files") diff --git a/scripts_python/generate_results_html.py b/scripts_python/generate_results_html.py index 9a09ef5c..81418df8 100644 --- a/scripts_python/generate_results_html.py +++ b/scripts_python/generate_results_html.py @@ -165,7 +165,7 @@ def _generate_summary_page(self, experiment_data: Dict[str, Any]): html_content = html_content.replace('{{ model }}', html.escape(model)) html_content = html_content.replace('{{ db_type }}', html.escape(summary['db_type'] or 'Unknown')) html_content = html_content.replace('{{ project_type }}', html.escape(summary['project_type'] or 'Unknown')) - html_content = html_content.replace('{{ used_mcp }}', 'Yes' if summary['used_mcp'] else 'No') + html_content = html_content.replace('{{ plugin_set }}', html.escape(summary['plugin_set'] or 'None')) # Replace the entire table section with the generated HTML table import re diff --git a/scripts_python/summarize_results.py b/scripts_python/summarize_results.py index 35e42e1b..fc13e3e7 100644 --- a/scripts_python/summarize_results.py +++ b/scripts_python/summarize_results.py @@ -134,7 +134,7 @@ def summarize_results(results: BenchmarkResults) -> Dict[str, Any]: 'inferred_model': inferred_model, 'db_type': first_result.db_type if first_result else None, 'project_type': first_result.project_type if first_result else None, - 'used_mcp': first_result.used_mcp if first_result else None, + 'plugin_set': first_result.plugin_set_name if first_result else None, 'agent': first_result.agent if first_result else None, } } diff --git a/templates/summary.html b/templates/summary.html index f33c76a8..75c35176 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -290,7 +290,7 @@

Run Configuration

Model: {{ model }}

DB Type: {{ db_type }}

Project Type: {{ project_type }}

-

MCP Enabled: {{ used_mcp }}

+

Plugin Set: {{ plugin_set }}

Results

From c7eed4bfed7c64851500b0875d93c888bad82c9d Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 15:15:13 +1300 Subject: [PATCH 27/44] Move mcp server installation to after agent is installed --- .../abstract_installed_agent.py | 50 +++++++++- ade_bench/harness.py | 4 + ade_bench/plugins/mcp_handler.py | 61 ------------ ade_bench/setup/setup_orchestrator.py | 19 +--- tests/plugins/test_mcp_handler.py | 92 ------------------- 5 files changed, 58 insertions(+), 168 deletions(-) delete mode 100644 ade_bench/plugins/mcp_handler.py delete mode 100644 tests/plugins/test_mcp_handler.py diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index 337149af..2278fed7 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -18,7 +18,9 @@ from ade_bench.agents.agent_name import AgentName from ade_bench.agents.base_agent import AgentResult, BaseAgent from ade_bench.harness_models import TerminalCommand, FailureMode +from ade_bench.models.plugin_set import McpServerConfig from ade_bench.terminal.tmux_session import TmuxSession +from ade_bench.terminal.docker_compose_manager import DockerComposeManager from ade_bench.utils.logger import log_harness_info, logger from ade_bench.config import config @@ -26,11 +28,12 @@ class AbstractInstalledAgent(BaseAgent, ABC): NAME = AgentName.ABSTRACT_INSTALLED - def __init__(self, model_name: str | None = None, allowed_tools: list[str] | None = None, **kwargs): + def __init__(self, model_name: str | None = None, allowed_tools: list[str] | None = None, mcp_servers: dict[str, McpServerConfig] | None = None, **kwargs): super().__init__(**kwargs) self._variant_config = {} self._model_name = model_name self._allowed_tools = allowed_tools or [] + self._mcp_servers = mcp_servers or {} @property @abstractmethod @@ -63,6 +66,47 @@ def _create_env_setup_file(self) -> str: [f"export {key}='{value}'" for key, value in self._env.items()] ) + def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> None: + """Configure MCP servers after agent installation.""" + agent_cli = self.NAME.value # e.g., "claude", "gemini" + + for server_name, mcp_config in self._mcp_servers.items(): + log_harness_info(logger, task_name, "agent", f"Configuring MCP server '{server_name}'...") + + # Write env file if env vars specified + env_file_path = None + if mcp_config.env: + env_file_path = f"/tmp/{server_name}.env" + env_content = "\n".join(f"{k}={v}" for k, v in mcp_config.env.items()) + write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" + + result = session.container.exec_run( + ["sh", "-c", write_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + if result.exit_code != 0: + logger.warning(f"[MCP] Failed to write env file: {result.output.decode('utf-8')}") + + # Build mcp add command + args_str = " ".join(mcp_config.args) + if env_file_path: + mcp_cmd = f"{agent_cli} mcp add {server_name} -- {mcp_config.command} --env-file {env_file_path} {args_str}" + else: + mcp_cmd = f"{agent_cli} mcp add {server_name} -- {mcp_config.command} {args_str}" + + result = session.container.exec_run( + ["sh", "-c", mcp_cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[MCP] Server registration failed for {server_name}: " + f"{result.output.decode('utf-8')}" + ) + else: + log_harness_info(logger, task_name, "agent", f"MCP server '{server_name}' configured") + def perform_task( self, task_prompt: str, @@ -108,6 +152,10 @@ def perform_task( block=True, max_timeout_sec=config.setup_timeout_sec, # Use setup timeout for installation ) + + # Configure MCP servers after agent is installed + if self._mcp_servers: + self._configure_mcp_servers(session, task_name) except TimeoutError: log_harness_info( logger, diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 456e2023..46e833f8 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -189,6 +189,10 @@ def _create_agent_for_task(self, task_id: str) -> BaseAgent: if self._current_plugin_set and self._current_plugin_set.allowed_tools: agent_kwargs["allowed_tools"] = self._current_plugin_set.allowed_tools + # Pass mcp_servers from current plugin set + if self._current_plugin_set and self._current_plugin_set.mcp_servers: + agent_kwargs["mcp_servers"] = self._current_plugin_set.mcp_servers + return AgentFactory.get_agent(self._agent_name, **agent_kwargs) def _init_dataset(self) -> None: diff --git a/ade_bench/plugins/mcp_handler.py b/ade_bench/plugins/mcp_handler.py deleted file mode 100644 index 4ac3fcf4..00000000 --- a/ade_bench/plugins/mcp_handler.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Handler for configuring MCP servers.""" - -import logging -from ade_bench.models.plugin_set import PluginSet -from ade_bench.terminal.docker_compose_manager import DockerComposeManager - -logger = logging.getLogger(__name__) - - -class McpHandler: - """Configures MCP servers from plugin set configuration.""" - - def configure(self, plugin_set: PluginSet, agent_name: str, terminal: DockerComposeManager) -> None: - """Configure MCP servers for the agent. - - Args: - plugin_set: The plugin set configuration - agent_name: The agent CLI name (claude, gemini, codex) - terminal: The Docker container manager - """ - if not plugin_set.mcp_servers: - logger.debug(f"[McpHandler] No MCP servers to configure for '{plugin_set.name}'") - return - - for server_name, config in plugin_set.mcp_servers.items(): - logger.info(f"[McpHandler] Configuring MCP server '{server_name}'...") - - # Write env file if env vars specified - env_file_path = None - if config.env: - env_file_path = f"/tmp/{server_name}.env" - env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) - write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" - - result = terminal.container.exec_run( - ["sh", "-c", write_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - if result.exit_code != 0: - logger.warning(f"[McpHandler] Failed to write env file: {result.output.decode('utf-8')}") - - # Build mcp add command - args_str = " ".join(config.args) - if env_file_path: - mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} --env-file {env_file_path} {args_str}" - else: - mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} {args_str}" - - logger.info(f"[McpHandler] Running: {mcp_cmd}") - result = terminal.container.exec_run( - ["sh", "-c", mcp_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - - if result.exit_code != 0: - logger.warning( - f"[McpHandler] MCP server registration failed for {server_name}: " - f"{result.output.decode('utf-8')}" - ) - else: - logger.info(f"[McpHandler] MCP server '{server_name}' configured successfully") diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index 52ebfbb4..b5278081 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -12,7 +12,6 @@ from ..utils.logger import log_harness_info from ..models.plugin_set import PluginSet from ..plugins.skills_handler import SkillsHandler -from ..plugins.mcp_handler import McpHandler class SetupOrchestrator: @@ -26,7 +25,6 @@ def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=N self.trial_handler = trial_handler self.plugin_set = plugin_set self._skills_handler = SkillsHandler() - self._mcp_handler = McpHandler() def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: """Setup a task for the given variant.""" @@ -47,18 +45,11 @@ def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: # Logging is in the setup_agent_config function setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) - # Install skills and configure MCP if plugin set specified - if self.plugin_set: - if self.plugin_set.skills: - log_harness_info(self.logger, task_id, "setup", "Installing skills...") - self._skills_handler.install(self.plugin_set, self.terminal) - log_harness_info(self.logger, task_id, "setup", "Skills installed") - - if self.plugin_set.mcp_servers: - log_harness_info(self.logger, task_id, "setup", "Configuring MCP servers...") - agent_name = self.trial_handler.agent_name.value - self._mcp_handler.configure(self.plugin_set, agent_name, self.terminal) - log_harness_info(self.logger, task_id, "setup", "MCP servers configured") + # Install skills if plugin set specified (MCP is configured after agent installation) + if self.plugin_set and self.plugin_set.skills: + log_harness_info(self.logger, task_id, "setup", "Installing skills...") + self._skills_handler.install(self.plugin_set, self.terminal) + log_harness_info(self.logger, task_id, "setup", "Skills installed") # Set up the database diff --git a/tests/plugins/test_mcp_handler.py b/tests/plugins/test_mcp_handler.py deleted file mode 100644 index 4ea32abc..00000000 --- a/tests/plugins/test_mcp_handler.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest -from unittest.mock import MagicMock, call -from ade_bench.plugins.mcp_handler import McpHandler -from ade_bench.models.plugin_set import PluginSet, McpServerConfig - - -def test_mcp_handler_configure_no_servers(): - """No-op when plugin set has no MCP servers.""" - plugin_set = PluginSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) - terminal = MagicMock() - - handler = McpHandler() - handler.configure(plugin_set, "claude", terminal) - - terminal.container.exec_run.assert_not_called() - - -def test_mcp_handler_configure_single_server(): - """Configures a single MCP server.""" - plugin_set = PluginSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - handler.configure(plugin_set, "claude", terminal) - - # Should have at least one call for mcp add - assert terminal.container.exec_run.call_count >= 1 - calls = terminal.container.exec_run.call_args_list - # Find the mcp add call - mcp_add_call = [c for c in calls if "mcp add" in str(c)] - assert len(mcp_add_call) >= 1 - - -def test_mcp_handler_configure_with_env(): - """Writes env file when env vars are specified.""" - plugin_set = PluginSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig( - command="uvx", - args=["dbt-mcp@latest"], - env={"DISABLE_SQL": "true", "DISABLE_DISCOVERY": "true"} - ) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - handler.configure(plugin_set, "claude", terminal) - - # Check that env file was written - calls = terminal.container.exec_run.call_args_list - env_write_calls = [c for c in calls if "DISABLE_SQL" in str(c)] - assert len(env_write_calls) >= 1 - - -def test_mcp_handler_configure_different_agents(): - """Uses correct agent CLI command.""" - plugin_set = PluginSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp"]) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - - # Test claude - handler.configure(plugin_set, "claude", terminal) - calls = terminal.container.exec_run.call_args_list - claude_calls = [c for c in calls if "claude mcp add" in str(c)] - assert len(claude_calls) >= 1 - - terminal.reset_mock() - - # Test gemini - handler.configure(plugin_set, "gemini", terminal) - calls = terminal.container.exec_run.call_args_list - gemini_calls = [c for c in calls if "gemini mcp add" in str(c)] - assert len(gemini_calls) >= 1 From a251814c48e34eaec875e82d668f43b67865d5db Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 16:48:59 +1300 Subject: [PATCH 28/44] Add HTML transcript generation for Claude Code agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add claude-code-transcripts dependency for generating HTML transcripts - Add generate_html_transcript method to LogFormatter base class - Implement Claude Code log formatter with JSON extraction and synthetic prompt injection (Claude's stream-json output doesn't include the initial user prompt, so we inject a placeholder to enable transcript generation) - Update panes page to show sections in chronological order: pre-agent → HTML transcript (or agent.txt fallback) → post-agent - Fix run.log path to use plugin-suffixed directory by initializing file logger per plugin set - Write agent output to sessions/agent.log before calling format_agent_log Co-Authored-By: Claude Opus 4.5 --- .../claude_code/claude_code_agent.py | 12 +- .../claude_code/log_formatter.py | 134 ++++++++++++++- ade_bench/agents/log_formatter.py | 28 +++- ade_bench/harness.py | 42 +++-- pyproject.toml | 1 + scripts_python/generate_results_html.py | 152 +++++++++++++++--- uv.lock | 75 ++++++++- 7 files changed, 395 insertions(+), 49 deletions(-) diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index 39bc3373..316c195d 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -61,11 +61,19 @@ def _parse_agent_output(self, output: str) -> dict[str, Any]: def format_agent_log(self, log_path: Path) -> str | None: """ Format the Claude Code agent's log file into a human-readable string. - + + Also generates an HTML transcript to log_path.parent / "transcript/" + using claude-code-transcripts if available. + Args: log_path: Path to the raw agent.log file (JSON-lines format) - + Returns: Formatted log content as a string, or None if formatting failed """ + # Generate HTML transcript (to sessions/transcript/) + transcript_dir = log_path.parent / "transcript" + self._log_formatter.generate_html_transcript(log_path, transcript_dir) + + # Return text-formatted log return self._log_formatter.format_log(log_path) diff --git a/ade_bench/agents/installed_agents/claude_code/log_formatter.py b/ade_bench/agents/installed_agents/claude_code/log_formatter.py index eba53bd6..f724fa12 100644 --- a/ade_bench/agents/installed_agents/claude_code/log_formatter.py +++ b/ade_bench/agents/installed_agents/claude_code/log_formatter.py @@ -2,17 +2,22 @@ Log formatter for Claude Code agent. This module provides parsing and formatting utilities for Claude Code agent -log files (JSON-lines format). +log files (JSON-lines format), and generates HTML transcripts using +claude-code-transcripts. """ import json +import logging import re +import tempfile from io import StringIO from pathlib import Path from typing import Any, Dict, List from ade_bench.agents.log_formatter import LogFormatter +logger = logging.getLogger(__name__) + class ClaudeCodeLogFormatter(LogFormatter): """Log formatter for Claude Code agent JSON-lines format.""" @@ -23,6 +28,66 @@ def strip_ansi_codes(text: str) -> str: ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') return ansi_escape.sub('', text) + @staticmethod + def extract_jsonl_content(log_path: Path, inject_prompt: str | None = None) -> str: + """ + Extract only the JSON lines from a log file that may contain mixed content. + + The log file may contain terminal output before the JSON lines begin. + This method extracts only valid JSON lines. + + Claude Code's stream-json output doesn't include the initial user prompt, + only the assistant responses and tool results. If no user prompt with text + is found in the log, a synthetic one is injected so that transcript + generation tools can identify conversation boundaries. + + Args: + log_path: Path to the log file + inject_prompt: Optional prompt text to inject if none found + + Returns: + String containing only the JSON lines, newline-separated + """ + json_lines = [] + has_user_text_prompt = False + + with open(log_path, 'r') as f: + for line in f: + stripped = line.strip() + if stripped.startswith('{'): + try: + # Validate it's actually JSON + data = json.loads(stripped) + json_lines.append(stripped) + + # Check if this is a user message with actual text content + if data.get('type') == 'user': + content = data.get('message', {}).get('content', []) + if isinstance(content, str) and content.strip(): + has_user_text_prompt = True + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'text': + has_user_text_prompt = True + break + except json.JSONDecodeError: + continue + + # If no user prompt found, inject a synthetic one at the beginning + if not has_user_text_prompt and json_lines: + prompt_text = inject_prompt or "Claude Code Agent Session" + synthetic_prompt = json.dumps({ + "type": "user", + "timestamp": "", + "message": { + "role": "user", + "content": prompt_text + } + }) + json_lines.insert(0, synthetic_prompt) + + return '\n'.join(json_lines) + @staticmethod def format_tool_input(tool_name: str, tool_input: Dict[str, Any]) -> str: """Format tool input parameters nicely.""" @@ -215,3 +280,70 @@ def format_readable_log(self, turns: List[Dict[str, Any]]) -> str: output.write("=" * 80 + "\n") return output.getvalue() + + def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | None: + """ + Generate an HTML transcript using claude-code-transcripts. + + This method extracts JSON lines from the log file (which may contain + mixed terminal output and JSON) and uses claude-code-transcripts to + generate a clean HTML transcript. + + Args: + log_path: Path to the log file (may contain mixed content) + output_dir: Directory to write HTML transcript files + + Returns: + Path to the generated index.html, or None if generation failed + """ + if not log_path.exists(): + logger.warning(f"Log file not found: {log_path}") + return None + + try: + from claude_code_transcripts import generate_html + + # Extract only JSON lines from the log file + jsonl_content = self.extract_jsonl_content(log_path) + if not jsonl_content: + logger.warning(f"No JSON content found in {log_path}") + return None + + # Write clean JSONL to a temporary file for claude-code-transcripts + output_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.jsonl', delete=False + ) as tmp_file: + tmp_file.write(jsonl_content) + tmp_path = Path(tmp_file.name) + + try: + # Generate HTML transcript + generate_html(tmp_path, output_dir) + + # Check for generated files + index_path = output_dir / "index.html" + if index_path.exists(): + return index_path + + # Check for page-001.html if index.html doesn't exist + page_path = output_dir / "page-001.html" + if page_path.exists(): + return page_path + + logger.warning(f"No HTML output found in {output_dir}") + return None + finally: + # Clean up temporary file + tmp_path.unlink(missing_ok=True) + + except ImportError: + logger.warning( + "claude-code-transcripts not installed. " + "Install with: pip install claude-code-transcripts" + ) + return None + except Exception as e: + logger.warning(f"Transcript generation failed: {e}") + return None diff --git a/ade_bench/agents/log_formatter.py b/ade_bench/agents/log_formatter.py index 2ccb55a4..c877443b 100644 --- a/ade_bench/agents/log_formatter.py +++ b/ade_bench/agents/log_formatter.py @@ -18,10 +18,10 @@ class LogFormatter(ABC): def parse_log_file(self, log_path: Path) -> List[Dict[str, Any]]: """ Parse the agent log file and extract structured information. - + Args: log_path: Path to the log file to parse - + Returns: List of turn dictionaries containing structured log data """ @@ -31,10 +31,10 @@ def parse_log_file(self, log_path: Path) -> List[Dict[str, Any]]: def format_readable_log(self, turns: List[Dict[str, Any]]) -> str: """ Format the parsed turns into a readable text string. - + Args: turns: List of turn dictionaries from parse_log_file - + Returns: Formatted log content as a string """ @@ -43,10 +43,10 @@ def format_readable_log(self, turns: List[Dict[str, Any]]) -> str: def format_log(self, log_path: Path) -> str | None: """ Parse and format a log file in one step. - + Args: log_path: Path to the log file to parse - + Returns: Formatted log content as a string, or None if formatting failed """ @@ -57,3 +57,19 @@ def format_log(self, log_path: Path) -> str | None: return self.format_readable_log(turns) except Exception: return None + + def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | None: + """ + Generate an HTML transcript from the log file. + + Override this method in subclasses to provide HTML transcript generation. + The default implementation returns None (no HTML transcript). + + Args: + log_path: Path to the log file to parse + output_dir: Directory to write HTML transcript files + + Returns: + Path to the generated index.html, or None if not supported/failed + """ + return None diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 46e833f8..27e33103 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -218,15 +218,25 @@ def _init_plugin_sets(self) -> None: ) def _init_logger(self) -> None: - file_handler = logging.FileHandler(self._log_output_path) - file_handler.setLevel(logging.DEBUG) - logger.addHandler(file_handler) - + """Initialize console logging. File logging is initialized per plugin set.""" console_handler = logging.StreamHandler() console_handler.setLevel(self._log_level) logger.addHandler(console_handler) self._logger = logger.getChild(__name__) + self._file_handler: logging.FileHandler | None = None + + def _init_file_logger(self) -> None: + """Initialize or reinitialize file logging for the current run path.""" + # Remove existing file handler if present + if self._file_handler is not None: + logger.removeHandler(self._file_handler) + self._file_handler.close() + + # Create new file handler for the current run path + self._file_handler = logging.FileHandler(self._log_output_path) + self._file_handler.setLevel(logging.DEBUG) + logger.addHandler(self._file_handler) def _is_resolved(self, parser_result: ParserResult | None) -> bool: if parser_result is None: @@ -736,18 +746,19 @@ def _run_trial( parts = full_pane.split('=== ADE_BENCH_PHASE_DELIMITER_AGENT_START ===') post_agent_pane = parts[-1].strip() - # Try to generate a nicely formatted agent.txt from agent.log + # Write raw agent output to sessions/agent.log for potential formatting agent_log_path = trial_handler.sessions_path / "agent.log" - formatted_content = None + agent_log_path.write_text(post_agent_pane) - if agent_log_path.exists(): - try: - # Get formatted content from agent (returns string or None) - formatted_content = task_agent.format_agent_log(agent_log_path) - if formatted_content: - self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") - except Exception as e: - self._logger.warning(f"Failed to format agent.log: {e}. Using raw pane output.") + # Try to generate a nicely formatted agent.txt from agent.log + formatted_content = None + try: + # Get formatted content from agent (returns string or None) + formatted_content = task_agent.format_agent_log(agent_log_path) + if formatted_content: + self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") + except Exception as e: + self._logger.warning(f"Failed to format agent.log: {e}. Using raw pane output.") # Write to file - either formatted content or fallback to raw pane if formatted_content: @@ -1354,6 +1365,9 @@ def run(self) -> BenchmarkResults: # Ensure output directory exists for this plugin set self._run_path.mkdir(parents=True, exist_ok=True) + # Initialize file logger for this plugin set's run path + self._init_file_logger() + self._logger.info(f"Starting run for plugin set: {plugin_set.name}") # Run trials for this plugin set diff --git a/pyproject.toml b/pyproject.toml index 520a4afd..8ad8c15e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "duckdb>=0.9", "snowflake-connector-python>=3.0", "pyarrow>=10.0", + "claude-code-transcripts>=0.6", ] [project.optional-dependencies] diff --git a/scripts_python/generate_results_html.py b/scripts_python/generate_results_html.py index 81418df8..f184ee6a 100644 --- a/scripts_python/generate_results_html.py +++ b/scripts_python/generate_results_html.py @@ -305,38 +305,90 @@ def _generate_results_page(self, task_data: Dict[str, Any], task_dir: Path, task ) def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_html_dir: Path): - """Generate panes detail page.""" - panes_dir = task_dir / "panes" - content = "" + """Generate panes detail page. - if panes_dir.exists(): - pane_files = list(panes_dir.glob("*.txt")) - pane_dict = {p.name: p for p in pane_files} + Displays terminal output in chronological order: + 1. pre-agent.txt (setup before agent runs) + 2. HTML transcript (if available) OR agent.txt (raw output) + 3. post-agent.txt (teardown after agent completes) + """ + import shutil - pane_order = ["pre-agent.txt", "agent.txt", "post-agent.txt"] + panes_dir = task_dir / "panes" + transcript_dir = task_dir / "sessions" / "transcript" - # Main panes in desired order, then any missing panes - main_panes = [pane_dict[p] for p in pane_order if p in pane_dict] - missing_panes = [p for p in pane_files if p.name not in pane_order] + # Check for HTML transcript (always use page-001.html since there's only 1 prompt) + transcript_html = None + if transcript_dir.exists() and (transcript_dir / "page-001.html").exists(): + # Copy transcript directory to HTML output + output_transcript_dir = task_html_dir / "transcript" + if output_transcript_dir.exists(): + shutil.rmtree(output_transcript_dir) + shutil.copytree(transcript_dir, output_transcript_dir) + transcript_html = "transcript/page-001.html" - panes = main_panes + missing_panes + # Build content sections in chronological order + sections = [] - for pane_file in panes: - content += f"\n{'='*80}\n" - content += f"FILE: {pane_file.name}\n" - content += f"{'='*80}\n\n" - with open(pane_file, 'r') as f: - content += f.read() - content += "\n\n" - else: - content = "No panes directory found." + if panes_dir.exists(): + pane_dict = {p.name: p for p in panes_dir.glob("*.txt")} + + # 1. Pre-agent section + if "pre-agent.txt" in pane_dict: + with open(pane_dict["pre-agent.txt"], 'r') as f: + pre_content = f.read().strip() + if pre_content: + sections.append(("Pre-Agent Setup", pre_content, "text")) + + # 2. Agent section - HTML transcript OR raw agent.txt + if transcript_html: + sections.append(("Agent Transcript", transcript_html, "iframe")) + elif "agent.txt" in pane_dict: + with open(pane_dict["agent.txt"], 'r') as f: + agent_content = f.read().strip() + if agent_content: + sections.append(("Agent Output", agent_content, "text")) + + # 3. Post-agent section + if "post-agent.txt" in pane_dict: + with open(pane_dict["post-agent.txt"], 'r') as f: + post_content = f.read().strip() + if post_content: + sections.append(("Post-Agent Output", post_content, "text")) + + # Any other pane files not in the standard order + other_panes = [p for p in pane_dict.keys() + if p not in ["pre-agent.txt", "agent.txt", "post-agent.txt"]] + for pane_name in sorted(other_panes): + with open(pane_dict[pane_name], 'r') as f: + other_content = f.read().strip() + if other_content: + sections.append((f"Other: {pane_name}", other_content, "text")) + + # Build HTML content from sections + content_parts = [] + for title, content, content_type in sections: + if content_type == "iframe": + content_parts.append(f'''
+

{html.escape(title)}

+ +

Open transcript in new tab

+
''') + else: + content_parts.append(f'''
+

{html.escape(title)}

+
{html.escape(content)}
+
''') - self._write_detail_page( + if not content_parts: + content_parts.append("

No panes data found.

") + + # Write directly using template + self._write_panes_page( task_html_dir / "panes.html", "Terminal Panes", task_data['task_id'], - content, - "panes" + "\n".join(content_parts) ) def _generate_diffs_page(self, task_data: Dict[str, Any], task_dir: Path, task_html_dir: Path): @@ -368,8 +420,23 @@ def _generate_diffs_page(self, task_data: Dict[str, Any], task_dir: Path, task_h "diffs" ) - def _write_detail_page(self, output_path: Path, title: str, task_id: str, content: str, content_type: str): - """Write a detail page using the template.""" + def _write_detail_page( + self, + output_path: Path, + title: str, + task_id: str, + content: str, + content_type: str + ): + """Write a detail page using the template. + + Args: + output_path: Path to write the HTML file + title: Page title + task_id: Task identifier + content: Main text content (will be HTML-escaped) + content_type: CSS class for content styling + """ template_path = self.templates_dir / "detail.html" if not template_path.exists(): print(f"Error: Template not found: {template_path}") @@ -387,6 +454,41 @@ def _write_detail_page(self, output_path: Path, title: str, task_id: str, conten with open(output_path, 'w') as f: f.write(html_content) + def _write_panes_page( + self, + output_path: Path, + title: str, + task_id: str, + content_html: str + ): + """Write the panes page with pre-built HTML content. + + Unlike _write_detail_page which escapes content, this method accepts + raw HTML that has already been constructed with proper sections. + + Args: + output_path: Path to write the HTML file + title: Page title + task_id: Task identifier + content_html: Pre-built HTML content (not escaped) + """ + template_path = self.templates_dir / "detail.html" + if not template_path.exists(): + print(f"Error: Template not found: {template_path}") + return + + with open(template_path, 'r') as f: + template_content = f.read() + + # Simple template replacement - content_html is already formatted + html_content = template_content.replace('{{ title }}', html.escape(title)) + html_content = html_content.replace('{{ task_id }}', html.escape(task_id)) + html_content = html_content.replace('{{ content }}', content_html) + html_content = html_content.replace('{{ content_type }}', 'panes') + + with open(output_path, 'w') as f: + f.write(html_content) + def main(): """Main function to generate HTML results.""" diff --git a/uv.lock b/uv.lock index f616ebf7..2aee1376 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -14,6 +14,7 @@ source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "boto3" }, + { name = "claude-code-transcripts" }, { name = "click" }, { name = "docker" }, { name = "duckdb" }, @@ -50,6 +51,7 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.18" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "boto3", specifier = ">=1.26" }, + { name = "claude-code-transcripts", specifier = ">=0.6" }, { name = "click", specifier = ">=8.1" }, { name = "docker", specifier = ">=6.0" }, { name = "duckdb", specifier = ">=0.9" }, @@ -317,6 +319,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "claude-code-transcripts" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "click-default-group" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "questionary" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5e/28056099926dcf7432d9274865fca074736522ff6ad1f1a59312dcd0bb69/claude_code_transcripts-0.6.tar.gz", hash = "sha256:c4cf35cd7f02bf6b71cb577c0a39ad25a184eeaf13ec6a2c8d6cdc2900324390", size = 29011, upload-time = "2026-01-25T05:50:39.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b9/4422e5c8d40988810721a6fa6e48139b242652319c458f1f76408ef3cf54/claude_code_transcripts-0.6-py3-none-any.whl", hash = "sha256:b0d5afbd0355ff7eefcfd64cb9f248d443d6d9899d0997d6fec4256b4dcb9a19", size = 32576, upload-time = "2026-01-25T05:50:38.201Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -329,6 +348,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "click-default-group" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/ce/edb087fb53de63dad3b36408ca30368f438738098e668b78c87f93cd41df/click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e", size = 3505, upload-time = "2023-08-04T07:54:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/1a/aff8bb287a4b1400f69e09a53bd65de96aa5cee5691925b38731c67fc695/click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f", size = 4123, upload-time = "2023-08-04T07:54:56.875Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -622,6 +653,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/2f/9d207039fcfa00d3b30e4d765f062fbcc42c873c7518a8cfebb3eafd00e0/libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793", size = 60873, upload-time = "2025-05-26T19:40:02.284Z" }, ] +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -928,6 +968,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/9e/8c62f05b104d9f00edbb4c298b152deceb393ea67f0288d89d1139d7a859/podman-5.6.0-py3-none-any.whl", hash = "sha256:967ff8ad8c6b851bc5da1a9410973882d80e235a9410b7d1e931ce0c3324fbe3", size = 88713, upload-time = "2025-09-05T09:42:38.405Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "psycopg" version = "3.2.9" @@ -1248,6 +1300,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -1585,3 +1649,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/62/a7c072fbfefb2980a00f99ca994279cb9ecf310cb2e6b2a4d2a28fe192b3/wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091", size = 157587, upload-time = "2026-01-31T03:52:10.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/c1/d73f12f8cdb1891334a2ccf7389eed244d3941e74d80dd220badb937f3fb/wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e", size = 92981, upload-time = "2026-01-31T03:52:09.14Z" }, +] From 5dd58736d0b037dccb82908c797b53fb57074514 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Tue, 3 Feb 2026 20:50:01 +1300 Subject: [PATCH 29/44] dont try to format logs without a formatter, suppress logging from claude transcript gen --- .../claude_code/log_formatter.py | 11 +++++---- ade_bench/harness.py | 23 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/ade_bench/agents/installed_agents/claude_code/log_formatter.py b/ade_bench/agents/installed_agents/claude_code/log_formatter.py index f724fa12..16d9feaf 100644 --- a/ade_bench/agents/installed_agents/claude_code/log_formatter.py +++ b/ade_bench/agents/installed_agents/claude_code/log_formatter.py @@ -6,11 +6,12 @@ claude-code-transcripts. """ +import contextlib +import io import json import logging import re import tempfile -from io import StringIO from pathlib import Path from typing import Any, Dict, List @@ -226,7 +227,7 @@ def parse_log_file(self, log_path: Path) -> List[Dict[str, Any]]: def format_readable_log(self, turns: List[Dict[str, Any]]) -> str: """Format the parsed turns into a readable text string.""" - output = StringIO() + output = io.StringIO() output.write("=" * 80 + "\n") output.write("CLAUDE CODE AGENT INTERACTION LOG\n") @@ -319,8 +320,10 @@ def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | N tmp_path = Path(tmp_file.name) try: - # Generate HTML transcript - generate_html(tmp_path, output_dir) + # Generate HTML transcript (suppress stdout/stderr from library) + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + generate_html(tmp_path, output_dir) # Check for generated files index_path = output_dir / "index.html" diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 27e33103..013c930c 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -746,19 +746,18 @@ def _run_trial( parts = full_pane.split('=== ADE_BENCH_PHASE_DELIMITER_AGENT_START ===') post_agent_pane = parts[-1].strip() - # Write raw agent output to sessions/agent.log for potential formatting - agent_log_path = trial_handler.sessions_path / "agent.log" - agent_log_path.write_text(post_agent_pane) - - # Try to generate a nicely formatted agent.txt from agent.log + # Only write agent.log and format it for agents with log formatters (e.g., Claude Code) formatted_content = None - try: - # Get formatted content from agent (returns string or None) - formatted_content = task_agent.format_agent_log(agent_log_path) - if formatted_content: - self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") - except Exception as e: - self._logger.warning(f"Failed to format agent.log: {e}. Using raw pane output.") + if hasattr(task_agent, '_log_formatter') and task_agent._log_formatter is not None: + agent_log_path = trial_handler.sessions_path / "agent.log" + try: + agent_log_path.write_text(post_agent_pane) + # Get formatted content from agent (returns string or None) + formatted_content = task_agent.format_agent_log(agent_log_path) + if formatted_content: + self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") + except Exception as e: + self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") # Write to file - either formatted content or fallback to raw pane if formatted_content: From 012358068597a60f4c8911f50b2741eeb91944ca Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 06:25:51 +1300 Subject: [PATCH 30/44] add prompt suffix to plugin sets --- ade_bench/harness.py | 15 ++++++++++++++- ade_bench/models/plugin_set.py | 1 + experiment_sets/plugin-sets.yaml | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 013c930c..f4a1a557 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -467,14 +467,21 @@ async def _run_agent_with_timeout( logging_dir: Path, agent: BaseAgent, task_name: str | None = None, + prompt_suffix: str = "", ) -> AgentResult | None: timeouts = TimeoutManager.get_timeouts_for_task(trial_handler.task) + + # Build the full prompt with optional suffix + full_prompt = trial_handler.task_prompt + if prompt_suffix: + full_prompt = f"{full_prompt} {prompt_suffix}" + loop = asyncio.get_event_loop() task = loop.run_in_executor( None, partial( agent.perform_task, - task_prompt=trial_handler.task_prompt, + task_prompt=full_prompt, session=session, logging_dir=logging_dir, task_name=task_name, @@ -492,6 +499,11 @@ def _run_agent( task_name: str | None = None, ) -> tuple[AgentResult | None, FailureMode]: try: + # Get prompt suffix from current plugin set + prompt_suffix = "" + if self._current_plugin_set and self._current_plugin_set.prompt_suffix: + prompt_suffix = self._current_plugin_set.prompt_suffix + result = asyncio.run( self._run_agent_with_timeout( trial_handler=trial_handler, @@ -499,6 +511,7 @@ def _run_agent( logging_dir=trial_handler.agent_logging_dir, agent=agent, task_name=task_name, + prompt_suffix=prompt_suffix, ) ) diff --git a/ade_bench/models/plugin_set.py b/ade_bench/models/plugin_set.py index d406b395..8e5b6b28 100644 --- a/ade_bench/models/plugin_set.py +++ b/ade_bench/models/plugin_set.py @@ -19,6 +19,7 @@ class PluginSet(BaseModel): skills: list[str] = [] mcp_servers: dict[str, McpServerConfig] = {} allowed_tools: list[str] = [] + prompt_suffix: str = "" # Appended to task prompt before execution def is_compatible_with_agent(self, agent_name: str) -> bool: """Check if this plugin set is compatible with the given agent.""" diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index c336a663..92ac442e 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -15,6 +15,7 @@ sets: - dbt-labs/dbt-agent-skills mcp_servers: {} allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] + prompt_suffix: "You can use the dbt skills." - name: dbt-mcp description: dbt MCP server @@ -31,6 +32,7 @@ sets: DISABLE_SQL: "true" DISABLE_DBT_CODEGEN: "true" allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] + prompt_suffix: "You can use the dbt MCP server." - name: dbt-skill-mcp description: dbt skills and MCP server @@ -47,3 +49,4 @@ sets: DISABLE_SQL: "true" DISABLE_DBT_CODEGEN: "true" allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] + prompt_suffix: "You can use the dbt skills and MCP server." \ No newline at end of file From 3d93dec25494517d2f0f3e18338b5034adee7597 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 06:28:12 +1300 Subject: [PATCH 31/44] rename sets and update defaults --- .github/workflows/ci.yml | 2 +- experiment_sets/plugin-sets.yaml | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 267879c7..4f3d3021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: run: docker build -t ade-bench-base -f docker/base/Dockerfile.duckdb-dbt . - name: Run benchmark - run: uv run ade run all --agent ${{ matrix.agent }} --db duckdb --project-type dbt --no-diffs --n-concurrent-trials 6 --no-rebuild + run: uv run ade run all --agent ${{ matrix.agent }} --db duckdb --project-type dbt --no-diffs --n-concurrent-trials 6 --no-rebuild --plugin-set none env: USE_DYNAMIC_LOGGING: "FALSE" DEFAULT_TEST_TIMEOUT_SEC: "120" diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index 92ac442e..84b9ded6 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -2,7 +2,7 @@ # Use --plugin-set to select, or run without flag to use all defaults sets: - - name: no-plugins + - name: none description: Baseline - no skills or MCP default: true skills: [] @@ -11,6 +11,7 @@ sets: - name: dbt-skills description: All dbt skills + default: true skills: - dbt-labs/dbt-agent-skills mcp_servers: {} @@ -34,8 +35,9 @@ sets: allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] prompt_suffix: "You can use the dbt MCP server." - - name: dbt-skill-mcp + - name: dbt-skills-mcp description: dbt skills and MCP server + default: true skills: - dbt-labs/dbt-agent-skills mcp_servers: From f208cb725f1b02023b4aad9a686e92e479dd1794 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:04:31 +1300 Subject: [PATCH 32/44] Capture and display tool usage from agent logs Extract deduplicated tool names from Claude Code agent logs and display them in the HTML results dashboard. Generic tools (Bash, Edit, Glob, etc.) are filtered out, while Skill tool calls are expanded to show the actual skill name (e.g., skill:using-dbt-for-analytics-engineering). - Add extract_tools_used() base method to BaseAgent - Override in ClaudeCodeAgent to parse JSON logs and filter tools - Add tools_used field to TrialResults model - Call extraction in harness after writing agent.log - Add Tools column to HTML results table with styled tags Co-Authored-By: Claude Opus 4.5 --- ade_bench/agents/base_agent.py | 21 ++++++++++-- .../claude_code/claude_code_agent.py | 32 +++++++++++++++++++ ade_bench/harness.py | 9 ++++++ ade_bench/harness_models.py | 1 + scripts_python/summarize_results.py | 15 +++++++-- templates/summary.html | 16 ++++++++++ 6 files changed, 89 insertions(+), 5 deletions(-) diff --git a/ade_bench/agents/base_agent.py b/ade_bench/agents/base_agent.py index c513485d..aa46ca3a 100644 --- a/ade_bench/agents/base_agent.py +++ b/ade_bench/agents/base_agent.py @@ -75,19 +75,34 @@ def _get_network_name(self, container_name: str) -> str: def format_agent_log(self, log_path: Path) -> str | None: """ Format the agent's log file into a human-readable string. - + This method can be overridden by subclasses to provide agent-specific log formatting. The default implementation returns None, indicating that no formatting is available. - + Args: log_path: Path to the raw agent log file - + Returns: Formatted log content as a string, or None if not available """ return None + def extract_tools_used(self, log_path: Path) -> list[str] | None: + """ + Extract deduplicated list of tool names from the agent's log file. + + This method can be overridden by subclasses to provide agent-specific + tool extraction. The default implementation returns None. + + Args: + log_path: Path to the raw agent log file + + Returns: + Sorted list of unique tool names, or None if not available + """ + return None + @abstractmethod def perform_task( self, diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index 316c195d..fc54569e 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -77,3 +77,35 @@ def format_agent_log(self, log_path: Path) -> str | None: # Return text-formatted log return self._log_formatter.format_log(log_path) + + # Generic tools to filter out from tools_used reporting + _GENERIC_TOOLS = frozenset({ + 'Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write', + 'WebFetch', 'WebSearch', 'Task', 'NotebookEdit', + 'TodoRead', 'TodoWrite', + }) + + def extract_tools_used(self, log_path: Path) -> list[str] | None: + """ + Extract deduplicated tool names from Claude Code agent logs. + + Filters out generic tools (Bash, Edit, Glob, etc.) and expands + Skill tool calls to their actual skill names. + """ + try: + turns = self._log_formatter.parse_log_file(log_path) + tool_names = set() + for turn in turns: + for tool in turn.get('tools', []): + name = tool['name'] + # Expand Skill tool to actual skill name + if name == 'Skill': + skill_name = tool.get('input', {}).get('skill') + if skill_name: + tool_names.add(f"skill:{skill_name}") + # Filter out generic tools + elif name not in self._GENERIC_TOOLS: + tool_names.add(name) + return sorted(tool_names) if tool_names else None + except Exception: + return None diff --git a/ade_bench/harness.py b/ade_bench/harness.py index f4a1a557..fdc2a90a 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -761,6 +761,7 @@ def _run_trial( # Only write agent.log and format it for agents with log formatters (e.g., Claude Code) formatted_content = None + agent_log_path = None if hasattr(task_agent, '_log_formatter') and task_agent._log_formatter is not None: agent_log_path = trial_handler.sessions_path / "agent.log" try: @@ -771,6 +772,7 @@ def _run_trial( self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") except Exception as e: self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") + agent_log_path = None # Mark as unavailable on error # Write to file - either formatted content or fallback to raw pane if formatted_content: @@ -778,6 +780,13 @@ def _run_trial( else: trial_handler.agent_pane_path.write_text(post_agent_pane) + # Extract tools used if log file is available + if agent_log_path and agent_log_path.exists(): + try: + results.tools_used = task_agent.extract_tools_used(agent_log_path) + except Exception as e: + self._logger.debug(f"Could not extract tools used: {e}") + # Capture snapshot after agent and create agent diff if file_diff_handler: file_diff_handler.handle_phase_diffing(terminal.container, "agent", trial_handler.task_id, self._logger) diff --git a/ade_bench/harness_models.py b/ade_bench/harness_models.py index 5b9282dc..b65fe389 100644 --- a/ade_bench/harness_models.py +++ b/ade_bench/harness_models.py @@ -98,6 +98,7 @@ class TrialResults(BaseModel): plugin_set_name: str | None = None plugin_set_skills: list[str] | None = None plugin_set_mcp_servers: list[str] | None = None + tools_used: list[str] | None = None class BenchmarkResults(BaseModel): diff --git a/scripts_python/summarize_results.py b/scripts_python/summarize_results.py index fc13e3e7..69132559 100644 --- a/scripts_python/summarize_results.py +++ b/scripts_python/summarize_results.py @@ -81,6 +81,7 @@ def summarize_results(results: BenchmarkResults) -> Dict[str, Any]: 'output_tokens': output_tokens_str, 'cache_tokens': cache_tokens_str, 'turns': turns_str, + 'tools_used': result.tools_used or [], # Store numeric values for totals calculation '_tests_num': calc['_tests'], '_passed_num': calc['_tests_passed'], @@ -190,7 +191,7 @@ def generate_html_table(results: BenchmarkResults) -> str: # Generate table with unique placeholders for action links and task button # Insert 'Task' as second column (after 'Task' id) - headers = [summary['headers'][0], 'Task'] + summary['headers'][1:] + ['Actions'] + headers = [summary['headers'][0], 'Task'] + summary['headers'][1:] + ['Tools', 'Actions'] table_data = [] # Add task rows with unique placeholders @@ -209,6 +210,7 @@ def generate_html_table(results: BenchmarkResults) -> str: task['output_tokens'], task['cache_tokens'], task['turns'], + f"__TOOLS_{i}__", # Placeholder for tools list f"__ACTION_LINKS_{i}__", # Unique placeholder for action links ] table_data.append(row) @@ -229,6 +231,7 @@ def generate_html_table(results: BenchmarkResults) -> str: total_row['output_tokens'], total_row['cache_tokens'], total_row['turns'], + "", # No tools for total row "", # No action links for total row ] table_data.append(total_row_data) @@ -236,7 +239,7 @@ def generate_html_table(results: BenchmarkResults) -> str: # Generate the base table html_table = tabulate(table_data, headers=headers, tablefmt="html") - # Now replace the placeholders with actual action links and task buttons + # Now replace the placeholders with actual action links, task buttons, and tools for i, task in enumerate(summary['tasks']): action_links = f'' html_table = html_table.replace(f"__ACTION_LINKS_{i}__", action_links) @@ -244,6 +247,14 @@ def generate_html_table(results: BenchmarkResults) -> str: task_button = f'' html_table = html_table.replace(f"__TASK_BUTTON_{i}__", task_button) + # Format tools as comma-separated list with styled spans + tools_list = task.get('tools_used', []) + if tools_list: + tools_html = ', '.join(f'{tool}' for tool in tools_list) + else: + tools_html = '-' + html_table = html_table.replace(f"__TOOLS_{i}__", tools_html) + return html_table diff --git a/templates/summary.html b/templates/summary.html index 75c35176..aa77a225 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -194,6 +194,22 @@ font-family: inherit; } + /* Tool tags styling */ + .tool-tag { + display: inline-block; + padding: 2px 6px; + background-color: #264f78; + color: #9cdcfe; + border-radius: 3px; + font-size: 10px; + margin: 1px; + white-space: nowrap; + } + + .no-tools { + color: #6a6a6a; + } + /* Modal/Dialog styles */ .modal-overlay { display: none; From 400ff308ef438008f6a20ca2a49099165fa5b2c2 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:08:41 +1300 Subject: [PATCH 33/44] Fix empty directory created without plugin set suffix Remove early mkdir in __init__ that created the run directory before plugin sets modified the run_id. The directory is already created correctly in run() after the plugin set suffix is added. Co-Authored-By: Claude Opus 4.5 --- ade_bench/harness.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index fdc2a90a..882ba878 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -132,8 +132,6 @@ def __init__( self._n_concurrent_trials = n_concurrent_trials self._n_attempts = n_attempts - self._run_path.mkdir(parents=True, exist_ok=True) - self._init_dataset() self._init_plugin_sets() self._init_logger() From f304e0b024dcf5f73caf307586d578a28902b6a3 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:46:24 +1300 Subject: [PATCH 34/44] Fix --plugin-set to accept space-separated values in quotes Changes the --plugin-set option from List[str] to str to allow passing multiple plugin sets as a quoted string (e.g., --plugin-set "dbt-mcp dbt-skills") instead of requiring multiple flags. Co-Authored-By: Claude Opus 4.5 --- ade_bench/cli/ab/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ade_bench/cli/ab/main.py b/ade_bench/cli/ab/main.py index ce46e9d5..9a856aad 100644 --- a/ade_bench/cli/ab/main.py +++ b/ade_bench/cli/ab/main.py @@ -152,10 +152,10 @@ def run( "--log-level", help="Set the logging level" ), - plugin_set: Optional[List[str]] = typer.Option( + plugin_set: Optional[str] = typer.Option( None, "--plugin-set", - help="Plugin set names from plugin-sets.yaml (default: use all default sets)" + help="Plugin set names from plugin-sets.yaml, space-separated (default: use all default sets)" ), with_profiling: bool = typer.Option( False, @@ -236,7 +236,7 @@ def run( db_type=db, project_type=project_type, keep_alive=persist, - plugin_set_names=plugin_set, + plugin_set_names=plugin_set.split() if plugin_set else None, with_profiling=with_profiling ) From debdf3c05b02b4e16f5fa6a3ff8d7d5bd10a0984 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:47:40 +1300 Subject: [PATCH 35/44] update format of prompt suffix --- ade_bench/harness.py | 2 +- experiment_sets/plugin-sets.yaml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 882ba878..3bee075c 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -472,7 +472,7 @@ async def _run_agent_with_timeout( # Build the full prompt with optional suffix full_prompt = trial_handler.task_prompt if prompt_suffix: - full_prompt = f"{full_prompt} {prompt_suffix}" + full_prompt = f"{full_prompt}\n\n{prompt_suffix}" loop = asyncio.get_event_loop() task = loop.run_in_executor( diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index 84b9ded6..28e02ac2 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -2,13 +2,6 @@ # Use --plugin-set to select, or run without flag to use all defaults sets: - - name: none - description: Baseline - no skills or MCP - default: true - skills: [] - mcp_servers: {} - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] - - name: dbt-skills description: All dbt skills default: true @@ -20,7 +13,7 @@ sets: - name: dbt-mcp description: dbt MCP server - default: true + default: false skills: [] mcp_servers: dbt: @@ -37,7 +30,7 @@ sets: - name: dbt-skills-mcp description: dbt skills and MCP server - default: true + default: false skills: - dbt-labs/dbt-agent-skills mcp_servers: @@ -51,4 +44,11 @@ sets: DISABLE_SQL: "true" DISABLE_DBT_CODEGEN: "true" allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] - prompt_suffix: "You can use the dbt skills and MCP server." \ No newline at end of file + prompt_suffix: "You can use the dbt skills and MCP server." + + - name: none + description: Baseline - no skills or MCP + default: true + skills: [] + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] From 11d18b807951efe9fe7bf0a3f6949953234e6d75 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:56:50 +1300 Subject: [PATCH 36/44] include tools used in result tsv's --- ade_bench/utils/results_writer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ade_bench/utils/results_writer.py b/ade_bench/utils/results_writer.py index 97fceb9d..0a93b335 100644 --- a/ade_bench/utils/results_writer.py +++ b/ade_bench/utils/results_writer.py @@ -127,6 +127,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) "output_tokens", "cache_tokens", "turns", + "tools", "agent", "model_name", "db_type", @@ -156,6 +157,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) # Get failure type failure_type = get_failure_type(trial_result) + row = [ run_id, calc['task_id'], @@ -171,6 +173,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) calc['_output_tokens'], calc['_cache_tokens'], calc['_turns'], + tools_str, trial_result.agent or "", trial_result.model_name or "", trial_result.db_type or "", From 93dfda6a5874b8ea6d512120b4845501f2093f1e Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 07:57:11 +1300 Subject: [PATCH 37/44] include tools used in result tsv's --- ade_bench/utils/results_writer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ade_bench/utils/results_writer.py b/ade_bench/utils/results_writer.py index 0a93b335..2dbb4c65 100644 --- a/ade_bench/utils/results_writer.py +++ b/ade_bench/utils/results_writer.py @@ -157,6 +157,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) # Get failure type failure_type = get_failure_type(trial_result) + tools_str = ",".join(trial_result.tools_used) if trial_result.tools_used else "" row = [ run_id, From f62046da9641c27a4127a22a4bc4f1706b21d9a0 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Wed, 4 Feb 2026 13:12:45 +1300 Subject: [PATCH 38/44] add support for installing a subset of skills --- ade_bench/harness.py | 4 +-- ade_bench/models/plugin_set.py | 17 ++++++++- ade_bench/plugins/skills_handler.py | 54 ++++++++++++++++++++-------- experiment_sets/plugin-sets.yaml | 18 +++++++--- tests/models/test_plugin_set.py | 27 ++++++++++++-- tests/plugins/test_skills_handler.py | 48 ++++++++++++++++++++----- 6 files changed, 135 insertions(+), 33 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 3bee075c..7bdb2102 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -656,7 +656,7 @@ def _run_trial( db_type=config.get("db_type"), project_type=config.get("project_type"), plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, - plugin_set_skills=self._current_plugin_set.skills if self._current_plugin_set else None, + plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, ) @@ -1273,7 +1273,7 @@ def _execute_single_trial( db_type=config.get("db_type"), project_type=config.get("project_type"), plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, - plugin_set_skills=self._current_plugin_set.skills if self._current_plugin_set else None, + plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, ) return trial_results diff --git a/ade_bench/models/plugin_set.py b/ade_bench/models/plugin_set.py index 8e5b6b28..63db2158 100644 --- a/ade_bench/models/plugin_set.py +++ b/ade_bench/models/plugin_set.py @@ -10,13 +10,23 @@ class McpServerConfig(BaseModel): env: dict[str, str] = {} +class SkillOrigin(BaseModel): + """Configuration for a skill origin.""" + location: str # Skill origin (e.g., git URL, local path, GitHub shorthand) + skill_names: list[str] = [] # Empty list means install all skills + + def install_all(self) -> bool: + """Return True if all skills should be installed from this origin.""" + return len(self.skill_names) == 0 + + class PluginSet(BaseModel): """Configuration for a set of plugins (skills and MCP servers).""" name: str description: str = "" default: bool = False agents: list[str] | None = None # None = all agents compatible - skills: list[str] = [] + skills: list[SkillOrigin] = [] mcp_servers: dict[str, McpServerConfig] = {} allowed_tools: list[str] = [] prompt_suffix: str = "" # Appended to task prompt before execution @@ -27,6 +37,11 @@ def is_compatible_with_agent(self, agent_name: str) -> bool: return True return agent_name in self.agents + @property + def skill_locations(self) -> list[str]: + """Get list of skill locations as strings (for result tracking).""" + return [s.location for s in self.skills] + class PluginSetsConfig(BaseModel): """Root configuration containing all plugin sets.""" diff --git a/ade_bench/plugins/skills_handler.py b/ade_bench/plugins/skills_handler.py index ce02a5f9..d9d2aa23 100644 --- a/ade_bench/plugins/skills_handler.py +++ b/ade_bench/plugins/skills_handler.py @@ -1,7 +1,7 @@ """Handler for installing skills via Vercel Skills CLI.""" import logging -from ade_bench.models.plugin_set import PluginSet +from ade_bench.models.plugin_set import PluginSet, SkillOrigin from ade_bench.terminal.docker_compose_manager import DockerComposeManager logger = logging.getLogger(__name__) @@ -21,19 +21,43 @@ def install(self, plugin_set: PluginSet, terminal: DockerComposeManager) -> None logger.debug(f"[SkillsHandler] No skills to install for '{plugin_set.name}'") return - for repo in plugin_set.skills: - cmd = f"npx --yes skills add {repo} --all" - logger.info(f"[SkillsHandler] Installing skills from {repo}...") + for skill_origin in plugin_set.skills: + self._install_skill_origin(skill_origin, terminal) - result = terminal.container.exec_run( - ["sh", "-c", cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) + def _install_skill_origin( + self, skill_origin: SkillOrigin, terminal: DockerComposeManager + ) -> None: + """Install skills from a single skill origin. - if result.exit_code != 0: - logger.warning( - f"[SkillsHandler] Skills installation failed for {repo}: " - f"{result.output.decode('utf-8')}" - ) - else: - logger.info(f"[SkillsHandler] Skills installed successfully from {repo}") + Args: + skill_origin: The skill origin configuration + terminal: The Docker container manager + """ + # Base command with non-interactive flags: + # -y: skip confirmation prompts + # -g: install globally (to ~/.agents/skills) + base_cmd = f"npx --yes skills add {skill_origin.location} -y -g" + + if skill_origin.install_all(): + cmd = f"{base_cmd} --all" + desc = f"all skills from {skill_origin.location}" + else: + # Use --skill flag for each skill name (per npx skills CLI syntax) + skill_flags = " ".join(f"--skill {name}" for name in skill_origin.skill_names) + cmd = f"{base_cmd} {skill_flags}" + desc = f"skills {skill_origin.skill_names} from {skill_origin.location}" + + logger.info(f"[SkillsHandler] Installing {desc}...") + + result = terminal.container.exec_run( + ["sh", "-c", cmd], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + + if result.exit_code != 0: + logger.warning( + f"[SkillsHandler] Skills installation failed for {skill_origin.location}: " + f"{result.output.decode('utf-8')}" + ) + else: + logger.info(f"[SkillsHandler] Successfully installed {desc}") diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index 28e02ac2..67dab206 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -2,11 +2,21 @@ # Use --plugin-set to select, or run without flag to use all defaults sets: - - name: dbt-skills + - name: all-dbt-skills description: All dbt skills default: true skills: - - dbt-labs/dbt-agent-skills + - location: dbt-labs/dbt-agent-skills + mcp_servers: {} + allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] + prompt_suffix: "You can use the dbt skills." + + - name: dbt-for-ae + description: Just the Using dbt for analytics engineering skill + default: false + skills: + - location: dbt-labs/dbt-agent-skills + skill_names: [using-dbt-for-analytics-engineering] mcp_servers: {} allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] prompt_suffix: "You can use the dbt skills." @@ -32,7 +42,7 @@ sets: description: dbt skills and MCP server default: false skills: - - dbt-labs/dbt-agent-skills + - location: dbt-labs/dbt-agent-skills mcp_servers: dbt: command: uvx @@ -45,7 +55,7 @@ sets: DISABLE_DBT_CODEGEN: "true" allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] prompt_suffix: "You can use the dbt skills and MCP server." - + - name: none description: Baseline - no skills or MCP default: true diff --git a/tests/models/test_plugin_set.py b/tests/models/test_plugin_set.py index 94dc4192..57c7f323 100644 --- a/tests/models/test_plugin_set.py +++ b/tests/models/test_plugin_set.py @@ -1,5 +1,5 @@ import pytest -from ade_bench.models.plugin_set import PluginSet, McpServerConfig, PluginSetsConfig +from ade_bench.models.plugin_set import PluginSet, McpServerConfig, PluginSetsConfig, SkillOrigin def test_mcp_server_config_minimal(): @@ -18,6 +18,23 @@ def test_mcp_server_config_with_env(): assert config.env == {"DISABLE_SQL": "true"} +def test_skill_origin_install_all(): + """Empty skill_names means install all skills.""" + origin = SkillOrigin(location="dbt-labs/dbt-agent-skills") + assert origin.install_all() is True + assert origin.skill_names == [] + + +def test_skill_origin_specific_skills(): + """Non-empty skill_names means install only those skills.""" + origin = SkillOrigin( + location="dbt-labs/dbt-agent-skills", + skill_names=["skill1", "skill2"] + ) + assert origin.install_all() is False + assert origin.skill_names == ["skill1", "skill2"] + + def test_plugin_set_minimal(): plugin_set = PluginSet(name="test", allowed_tools=["Bash"]) assert plugin_set.name == "test" @@ -35,7 +52,7 @@ def test_plugin_set_full(): description="Full dbt setup", default=True, agents=["claude"], - skills=["dbt-labs/dbt-agent-skills"], + skills=[SkillOrigin(location="dbt-labs/dbt-agent-skills")], mcp_servers={ "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) }, @@ -44,6 +61,8 @@ def test_plugin_set_full(): assert plugin_set.default is True assert plugin_set.agents == ["claude"] assert len(plugin_set.mcp_servers) == 1 + assert len(plugin_set.skills) == 1 + assert plugin_set.skills[0].location == "dbt-labs/dbt-agent-skills" def test_plugin_set_is_compatible_with_agent_all(): @@ -70,7 +89,8 @@ def test_plugin_sets_config_from_yaml(): - name: dbt-skills agents: [claude] skills: - - dbt-labs/dbt-agent-skills + - location: dbt-labs/dbt-agent-skills + skill_names: [] allowed_tools: [Bash, Skill] """ import yaml @@ -79,6 +99,7 @@ def test_plugin_sets_config_from_yaml(): assert len(config.sets) == 2 assert config.sets[0].name == "no-plugins" assert config.sets[0].default is True + assert config.sets[1].skills[0].location == "dbt-labs/dbt-agent-skills" def test_plugin_sets_config_get_defaults(): diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py index 484b1d23..1c05e62b 100644 --- a/tests/plugins/test_skills_handler.py +++ b/tests/plugins/test_skills_handler.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock, call from ade_bench.plugins.skills_handler import SkillsHandler -from ade_bench.models.plugin_set import PluginSet +from ade_bench.models.plugin_set import PluginSet, SkillOrigin def test_skills_handler_install_no_skills(): @@ -15,11 +15,11 @@ def test_skills_handler_install_no_skills(): terminal.container.exec_run.assert_not_called() -def test_skills_handler_install_single_skill(): - """Installs a single skill repo.""" +def test_skills_handler_install_all_skills(): + """Installs all skills from a repo when skill_names is empty.""" plugin_set = PluginSet( name="test", - skills=["dbt-labs/dbt-agent-skills"], + skills=[SkillOrigin(location="dbt-labs/dbt-agent-skills")], allowed_tools=["Bash"] ) terminal = MagicMock() @@ -34,13 +34,45 @@ def test_skills_handler_install_single_skill(): assert "npx" in cmd[2] assert "skills add" in cmd[2] assert "dbt-labs/dbt-agent-skills" in cmd[2] + assert "--all" in cmd[2] -def test_skills_handler_install_multiple_skills(): - """Installs multiple skill repos.""" +def test_skills_handler_install_specific_skills(): + """Installs only specified skills when skill_names is provided.""" plugin_set = PluginSet( name="test", - skills=["repo/a", "repo/b"], + skills=[SkillOrigin( + location="dbt-labs/dbt-agent-skills", + skill_names=["using-dbt-for-analytics-engineering", "fetching-dbt-docs"] + )], + allowed_tools=["Bash"] + ) + terminal = MagicMock() + terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") + + handler = SkillsHandler() + handler.install(plugin_set, terminal) + + terminal.container.exec_run.assert_called_once() + call_args = terminal.container.exec_run.call_args + cmd = call_args[0][0] + assert "npx" in cmd[2] + assert "skills add" in cmd[2] + assert "dbt-labs/dbt-agent-skills" in cmd[2] + # Verify --skill flag syntax is used for each skill + assert "--skill using-dbt-for-analytics-engineering" in cmd[2] + assert "--skill fetching-dbt-docs" in cmd[2] + assert "--all" not in cmd[2] + + +def test_skills_handler_install_multiple_origins(): + """Installs skills from multiple origins.""" + plugin_set = PluginSet( + name="test", + skills=[ + SkillOrigin(location="repo/a"), + SkillOrigin(location="repo/b"), + ], allowed_tools=["Bash"] ) terminal = MagicMock() @@ -56,7 +88,7 @@ def test_skills_handler_install_failure_logs_warning(): """Logs warning but doesn't raise on install failure.""" plugin_set = PluginSet( name="test", - skills=["repo/failing"], + skills=[SkillOrigin(location="repo/failing")], allowed_tools=["Bash"] ) terminal = MagicMock() From 8b0ff5d4c384ee653ae519cb3e5e7a3cb3ef56da Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Thu, 5 Feb 2026 16:20:11 +1300 Subject: [PATCH 39/44] fix: add dynamic env vars for dbt MCP server configuration The dbt-mcp server requires DBT_PROJECT_DIR and DBT_PATH environment variables to locate the dbt project and executable. The plugin-based MCP configuration was missing these dynamic values that the old setup-dbt-mcp.sh script set. Changes: - Add _get_dbt_dynamic_env() to query container for dbt path - Set DBT_PROJECT_DIR to container app directory (/app) - Set DBT_PATH from `which dbt` in container - Set DISABLE_DBT_CLI=false to enable dbt CLI in dbt-mcp - Detect dbt MCP servers by name or dbt-mcp in args - Merge dynamic vars with static config from plugin-sets.yaml Co-Authored-By: Claude Opus 4.5 --- .../abstract_installed_agent.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index 2278fed7..2eb83c61 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -66,6 +66,31 @@ def _create_env_setup_file(self) -> str: [f"export {key}='{value}'" for key, value in self._env.items()] ) + def _get_dbt_dynamic_env(self, session: TmuxSession, task_name: str | None) -> dict[str, str]: + """Get dynamic environment variables for dbt MCP server.""" + env_vars = {} + + # DBT_PROJECT_DIR is the container app directory + env_vars["DBT_PROJECT_DIR"] = str(DockerComposeManager.CONTAINER_APP_DIR) + + # Get the dbt path from the container + result = session.container.exec_run( + ["sh", "-c", "which dbt"], + workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ) + if result.exit_code == 0: + dbt_path = result.output.decode("utf-8").strip() + if dbt_path: + env_vars["DBT_PATH"] = dbt_path + log_harness_info(logger, task_name, "agent", f"Found dbt at: {dbt_path}") + else: + logger.warning("[MCP] dbt not found in PATH, MCP server may not work correctly") + + # Enable the dbt CLI in dbt-mcp + env_vars["DISABLE_DBT_CLI"] = "false" + + return env_vars + def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> None: """Configure MCP servers after agent installation.""" agent_cli = self.NAME.value # e.g., "claude", "gemini" @@ -73,11 +98,24 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> for server_name, mcp_config in self._mcp_servers.items(): log_harness_info(logger, task_name, "agent", f"Configuring MCP server '{server_name}'...") - # Write env file if env vars specified + # Start with static env vars from config + env_vars = dict(mcp_config.env) + + # For dbt MCP server, add dynamic environment variables + # Check server name or if dbt-mcp appears in any of the args + is_dbt_mcp = server_name == "dbt" or any("dbt-mcp" in arg for arg in mcp_config.args) + if is_dbt_mcp: + dynamic_env = self._get_dbt_dynamic_env(session, task_name) + # Merge dynamic vars (don't override static config) + for key, value in dynamic_env.items(): + if key not in env_vars: + env_vars[key] = value + + # Write env file if we have any env vars env_file_path = None - if mcp_config.env: + if env_vars: env_file_path = f"/tmp/{server_name}.env" - env_content = "\n".join(f"{k}={v}" for k, v in mcp_config.env.items()) + env_content = "\n".join(f"{k}={v}" for k, v in env_vars.items()) write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" result = session.container.exec_run( @@ -86,6 +124,8 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> ) if result.exit_code != 0: logger.warning(f"[MCP] Failed to write env file: {result.output.decode('utf-8')}") + else: + log_harness_info(logger, task_name, "agent", f"Wrote env file with vars: {list(env_vars.keys())}") # Build mcp add command args_str = " ".join(mcp_config.args) From 498c6d96ba2df7564029d0f0cf299fa774b46117 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 9 Feb 2026 15:10:41 +1300 Subject: [PATCH 40/44] remove plan files --- docs/plans/2026-02-03-plugin-sets-design.md | 279 ---- .../2026-02-03-plugin-sets-implementation.md | 1396 ----------------- 2 files changed, 1675 deletions(-) delete mode 100644 docs/plans/2026-02-03-plugin-sets-design.md delete mode 100644 docs/plans/2026-02-03-plugin-sets-implementation.md diff --git a/docs/plans/2026-02-03-plugin-sets-design.md b/docs/plans/2026-02-03-plugin-sets-design.md deleted file mode 100644 index dc2dd63b..00000000 --- a/docs/plans/2026-02-03-plugin-sets-design.md +++ /dev/null @@ -1,279 +0,0 @@ -# Design: Plugin Sets for ADE-Bench - -**Status**: Ready for implementation -**Date**: 2026-02-03 - -## Overview - -Replace the current `--use-mcp` and `--use-skills` flags with a YAML-configured plugin set system. This enables: - -- A/B comparison of agent performance with different skill/MCP configurations -- Declarative configuration instead of CLI flags -- Reusable plugin types (skills, MCP) across multiple vendors - -## Goals - -1. Configure skill sets in YAML, reference by name from CLI -2. Support multiple default skill sets for automatic A/B comparison -3. Generic handlers for skills and MCP servers (not hardcoded per vendor) -4. Capture skill set metadata in results for analysis - -## Non-Goals - -- Transcript generation (separate feature, not in scope) -- Non-Claude agents for skills (skills via Vercel CLI are agent-agnostic, but some may only work with certain agents) - ---- - -## Schema - -**File:** `experiment_sets/skill-sets.yaml` - -```yaml -sets: - - name: no-plugins - description: Baseline - no skills or MCP - default: true - # agents omitted = all agents compatible - skills: [] - mcp_servers: {} - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] - - - name: dbt-skills - description: dbt skills via Vercel Skills CLI - agents: [claude] # Optional - restricts to specified agents - skills: - - dbt-labs/dbt-agent-skills - mcp_servers: {} - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] - - - name: dbt-mcp - description: dbt MCP server - default: true - skills: [] - mcp_servers: - dbt: - command: uvx - args: [dbt-mcp@latest] - env: - DISABLE_SEMANTIC_LAYER: "true" - DISABLE_DISCOVERY: "true" - DISABLE_ADMIN_API: "true" - DISABLE_SQL: "true" - DISABLE_DBT_CODEGEN: "true" - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] - - - name: dbt-full - description: Both skills and MCP - agents: [claude] - skills: - - dbt-labs/dbt-agent-skills - mcp_servers: - dbt: - command: uvx - args: [dbt-mcp@latest] - env: - DISABLE_SEMANTIC_LAYER: "true" - DISABLE_DISCOVERY: "true" - DISABLE_ADMIN_API: "true" - DISABLE_SQL: "true" - DISABLE_DBT_CODEGEN: "true" - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] -``` - -**Pydantic models** (`ade_bench/models/skill_set.py`): - -```python -from pydantic import BaseModel - -class McpServerConfig(BaseModel): - command: str - args: list[str] = [] - env: dict[str, str] = {} - -class SkillSet(BaseModel): - name: str - description: str = "" - default: bool = False - agents: list[str] | None = None # None = all agents compatible - skills: list[str] = [] - mcp_servers: dict[str, McpServerConfig] = {} - allowed_tools: list[str] = [] - -class SkillSetsConfig(BaseModel): - sets: list[SkillSet] -``` - ---- - -## CLI Changes - -**Remove:** -- `--use-mcp` -- `--use-skills` - -**Add:** -- `--plugin-set` (space-separated list of skill set names) - -**Behavior:** - -```bash -# No flag: runs all default skill sets (A/B comparison) -ab run task001 --db duckdb --project-type dbt --agent claude -# Runs: no-plugins, dbt-mcp (both marked default: true) - -# Explicit single set -ab run task001 --db duckdb --project-type dbt --agent claude --plugin-set dbt-skills - -# Explicit multiple sets (space-separated) -ab run task001 --db duckdb --project-type dbt --agent claude --plugin-set no-plugins dbt-mcp -``` - -**Validation at startup:** -1. Load `experiment_sets/skill-sets.yaml` -2. If `--plugin-set` specified, validate names exist; otherwise use defaults -3. Filter to skill sets compatible with `--agent` -4. Error and exit if no compatible skill sets remain -5. Run separate trials for each skill set - ---- - -## Plugin Type Handlers - -Two generic handlers read from skill set config: - -### SkillsHandler - -Installs skills via Vercel Skills CLI. Refactored from existing `_install_skills_via_cli()`. - -```python -class SkillsHandler: - def install(self, skill_set: SkillSet, terminal) -> None: - for repo in skill_set.skills: - cmd = f"npx --yes skills add {repo} --all" - result = terminal.container.exec_run( - ["sh", "-c", cmd], - workdir="/app" - ) - if result.exit_code != 0: - raise RuntimeError(f"Skills installation failed: {result.output}") -``` - -### McpHandler - -Configures MCP servers in agent config. Static env vars from YAML; dynamic vars (`DBT_PROJECT_DIR`, `DBT_PATH`) set during container setup. - -```python -class McpHandler: - def configure(self, skill_set: SkillSet, agent_name: str, terminal) -> None: - for name, config in skill_set.mcp_servers.items(): - # Write env file - env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) - env_path = f"/tmp/{name}.env" - terminal.container.exec_run(["sh", "-c", f"cat > {env_path} << 'EOF'\n{env_content}\nEOF"]) - - # Register with agent - args_str = " ".join(config.args) - cmd = f"{agent_name} mcp add {name} -- {config.command} --env-file {env_path} {args_str}" - terminal.container.exec_run(["sh", "-c", cmd], workdir="/app") -``` - -Both handlers run in the `pre_agent` phase (after setup, before agent starts). - ---- - -## Output Structure - -Each skill set produces a separate run with suffixed run_id: - -``` -experiments/ -├── 2026-02-03__14-30-00__no-plugins/ -│ ├── run_config.yaml -│ ├── results.json -│ └── task_001.duckdb_dbt/ -│ ├── result.json -│ └── agent-logs/ -│ -└── 2026-02-03__14-30-00__dbt-mcp/ - ├── run_config.yaml - ├── results.json - └── task_001.duckdb_dbt/ - ├── result.json - └── agent-logs/ -``` - -### Result Metadata - -**result.json** (per task): -```json -{ - "task_id": "task_001.duckdb_dbt", - "agent": "claude", - "pass": true, - "runtime_ms": 45000, - "skill_set": { - "name": "dbt-mcp", - "skills": [], - "mcp_servers": ["dbt"] - } -} -``` - -**results.json** (aggregated, at run level): -```json -{ - "run_id": "2026-02-03__14-30-00__dbt-mcp", - "skill_set": { - "name": "dbt-mcp", - "skills": [], - "mcp_servers": { - "dbt": { - "command": "uvx", - "args": ["dbt-mcp@latest"], - "env": { - "DISABLE_SEMANTIC_LAYER": "true" - } - } - } - }, - "trials": [...] -} -``` - ---- - -## Implementation Plan - -### New Files - -| File | Purpose | -|------|---------| -| `experiment_sets/skill-sets.yaml` | Skill set definitions | -| `ade_bench/models/skill_set.py` | Pydantic models for schema | -| `ade_bench/plugins/skills_handler.py` | Installs skills via `npx skills add` | -| `ade_bench/plugins/mcp_handler.py` | Configures MCP servers | -| `ade_bench/plugins/skill_set_loader.py` | Loads and validates YAML config | - -### Files to Modify - -| File | Changes | -|------|---------| -| `ade_bench/cli/ab/main.py` | Remove `--use-mcp`, `--use-skills`; add `--plugin-set` | -| `ade_bench/harness.py` | Loop over skill sets, suffix run_id | -| `ade_bench/setup/agent_setup.py` | Remove `_install_skills_via_cli()`, `use_skills` param | -| `ade_bench/setup/setup_orchestrator.py` | Call handlers based on skill set config | -| Container setup scripts | Set `DBT_PROJECT_DIR`, `DBT_PATH` env vars | -| `ade_bench/models/results.py` | Add skill_set field to result models | - -### Files to Delete - -| File | Reason | -|------|--------| -| `shared/scripts/setup-dbt-mcp.sh` | Logic moves to `McpHandler` | - ---- - -## Open Questions - -None - design is ready for implementation. diff --git a/docs/plans/2026-02-03-plugin-sets-implementation.md b/docs/plans/2026-02-03-plugin-sets-implementation.md deleted file mode 100644 index 674c2939..00000000 --- a/docs/plans/2026-02-03-plugin-sets-implementation.md +++ /dev/null @@ -1,1396 +0,0 @@ -# Plugin Sets Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace `--use-mcp` and `--use-skills` flags with YAML-configured plugin sets for A/B comparison. - -**Architecture:** Define skill sets in `experiment_sets/skill-sets.yaml`, load via Pydantic models, apply via generic handlers (SkillsHandler, McpHandler) in pre-agent phase. Multiple default sets run as separate trials with suffixed run IDs. - -**Tech Stack:** Python 3.11+, Pydantic, PyYAML, typer CLI - ---- - -## Task 1: Create Pydantic Models for Skill Sets - -**Files:** -- Create: `ade_bench/models/__init__.py` -- Create: `ade_bench/models/skill_set.py` -- Test: `tests/models/test_skill_set.py` - -**Step 1: Create models directory** - -```bash -mkdir -p ade_bench/models tests/models -touch ade_bench/models/__init__.py tests/models/__init__.py -``` - -**Step 2: Write the failing test** - -Create `tests/models/test_skill_set.py`: - -```python -import pytest -from ade_bench.models.skill_set import SkillSet, McpServerConfig, SkillSetsConfig - - -def test_mcp_server_config_minimal(): - config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - assert config.command == "uvx" - assert config.args == ["dbt-mcp@latest"] - assert config.env == {} - - -def test_mcp_server_config_with_env(): - config = McpServerConfig( - command="uvx", - args=["dbt-mcp@latest"], - env={"DISABLE_SQL": "true"} - ) - assert config.env == {"DISABLE_SQL": "true"} - - -def test_skill_set_minimal(): - skill_set = SkillSet(name="test", allowed_tools=["Bash"]) - assert skill_set.name == "test" - assert skill_set.description == "" - assert skill_set.default is False - assert skill_set.agents is None - assert skill_set.skills == [] - assert skill_set.mcp_servers == {} - assert skill_set.allowed_tools == ["Bash"] - - -def test_skill_set_full(): - skill_set = SkillSet( - name="dbt-full", - description="Full dbt setup", - default=True, - agents=["claude"], - skills=["dbt-labs/dbt-agent-skills"], - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - }, - allowed_tools=["Bash", "Skill", "mcp__dbt__*"] - ) - assert skill_set.default is True - assert skill_set.agents == ["claude"] - assert len(skill_set.mcp_servers) == 1 - - -def test_skill_set_is_compatible_with_agent_all(): - """When agents is None, compatible with all agents.""" - skill_set = SkillSet(name="test", allowed_tools=["Bash"]) - assert skill_set.is_compatible_with_agent("claude") is True - assert skill_set.is_compatible_with_agent("gemini") is True - - -def test_skill_set_is_compatible_with_agent_restricted(): - """When agents is set, only compatible with listed agents.""" - skill_set = SkillSet(name="test", agents=["claude"], allowed_tools=["Bash"]) - assert skill_set.is_compatible_with_agent("claude") is True - assert skill_set.is_compatible_with_agent("gemini") is False - - -def test_skill_sets_config_from_yaml(): - yaml_content = """ -sets: - - name: no-plugins - default: true - skills: [] - allowed_tools: [Bash, Read] - - name: dbt-skills - agents: [claude] - skills: - - dbt-labs/dbt-agent-skills - allowed_tools: [Bash, Skill] -""" - import yaml - data = yaml.safe_load(yaml_content) - config = SkillSetsConfig(**data) - assert len(config.sets) == 2 - assert config.sets[0].name == "no-plugins" - assert config.sets[0].default is True - - -def test_skill_sets_config_get_defaults(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", default=True, allowed_tools=["Bash"]), - SkillSet(name="b", default=False, allowed_tools=["Bash"]), - SkillSet(name="c", default=True, allowed_tools=["Bash"]), - ]) - defaults = config.get_defaults() - assert len(defaults) == 2 - assert defaults[0].name == "a" - assert defaults[1].name == "c" - - -def test_skill_sets_config_get_by_name(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - SkillSet(name="b", allowed_tools=["Bash"]), - ]) - assert config.get_by_name("a").name == "a" - assert config.get_by_name("b").name == "b" - assert config.get_by_name("nonexistent") is None - - -def test_skill_sets_config_get_by_names(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - SkillSet(name="b", allowed_tools=["Bash"]), - SkillSet(name="c", allowed_tools=["Bash"]), - ]) - result = config.get_by_names(["a", "c"]) - assert len(result) == 2 - assert result[0].name == "a" - assert result[1].name == "c" - - -def test_skill_sets_config_get_by_names_unknown_raises(): - config = SkillSetsConfig(sets=[ - SkillSet(name="a", allowed_tools=["Bash"]), - ]) - with pytest.raises(ValueError, match="Unknown skill set"): - config.get_by_names(["a", "nonexistent"]) -``` - -**Step 3: Run test to verify it fails** - -Run: `uv run pytest tests/models/test_skill_set.py -v` -Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.models.skill_set'" - -**Step 4: Write the implementation** - -Create `ade_bench/models/skill_set.py`: - -```python -"""Pydantic models for skill set configuration.""" - -from pydantic import BaseModel - - -class McpServerConfig(BaseModel): - """Configuration for an MCP server.""" - command: str - args: list[str] = [] - env: dict[str, str] = {} - - -class SkillSet(BaseModel): - """Configuration for a set of skills and tools.""" - name: str - description: str = "" - default: bool = False - agents: list[str] | None = None # None = all agents compatible - skills: list[str] = [] - mcp_servers: dict[str, McpServerConfig] = {} - allowed_tools: list[str] = [] - - def is_compatible_with_agent(self, agent_name: str) -> bool: - """Check if this skill set is compatible with the given agent.""" - if self.agents is None: - return True - return agent_name in self.agents - - -class SkillSetsConfig(BaseModel): - """Root configuration containing all skill sets.""" - sets: list[SkillSet] - - def get_defaults(self) -> list[SkillSet]: - """Get all skill sets marked as default.""" - return [s for s in self.sets if s.default] - - def get_by_name(self, name: str) -> SkillSet | None: - """Get a skill set by name.""" - for s in self.sets: - if s.name == name: - return s - return None - - def get_by_names(self, names: list[str]) -> list[SkillSet]: - """Get multiple skill sets by name. Raises if any not found.""" - result = [] - for name in names: - skill_set = self.get_by_name(name) - if skill_set is None: - available = [s.name for s in self.sets] - raise ValueError( - f"Unknown skill set '{name}'. Available: {', '.join(available)}" - ) - result.append(skill_set) - return result -``` - -Update `ade_bench/models/__init__.py`: - -```python -"""Models for ADE-Bench configuration.""" - -from .skill_set import McpServerConfig, SkillSet, SkillSetsConfig - -__all__ = ["McpServerConfig", "SkillSet", "SkillSetsConfig"] -``` - -**Step 5: Run test to verify it passes** - -Run: `uv run pytest tests/models/test_skill_set.py -v` -Expected: All tests PASS - -**Step 6: Commit** - -```bash -git add ade_bench/models/ tests/models/ -git commit -m "feat: add Pydantic models for skill set configuration" -``` - ---- - -## Task 2: Create Skill Sets YAML File - -**Files:** -- Create: `experiment_sets/skill-sets.yaml` - -**Step 1: Create the YAML file** - -Create `experiment_sets/skill-sets.yaml`: - -```yaml -# Skill set configurations for ADE-Bench -# Use --plugin-set to select, or run without flag to use all defaults - -sets: - - name: no-plugins - description: Baseline - no skills or MCP - default: true - skills: [] - mcp_servers: {} - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep] - - - name: dbt-skills - description: dbt skills via Vercel Skills CLI - agents: [claude] - skills: - - dbt-labs/dbt-agent-skills - mcp_servers: {} - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill] - - - name: dbt-mcp - description: dbt MCP server - default: true - skills: [] - mcp_servers: - dbt: - command: uvx - args: [dbt-mcp@latest] - env: - DISABLE_SEMANTIC_LAYER: "true" - DISABLE_DISCOVERY: "true" - DISABLE_ADMIN_API: "true" - DISABLE_SQL: "true" - DISABLE_DBT_CODEGEN: "true" - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, mcp__dbt__*] - - - name: dbt-full - description: Both skills and MCP - agents: [claude] - skills: - - dbt-labs/dbt-agent-skills - mcp_servers: - dbt: - command: uvx - args: [dbt-mcp@latest] - env: - DISABLE_SEMANTIC_LAYER: "true" - DISABLE_DISCOVERY: "true" - DISABLE_ADMIN_API: "true" - DISABLE_SQL: "true" - DISABLE_DBT_CODEGEN: "true" - allowed_tools: [Bash, Edit, Write, Read, Glob, Grep, Skill, mcp__dbt__*] -``` - -**Step 2: Commit** - -```bash -git add experiment_sets/skill-sets.yaml -git commit -m "feat: add skill-sets.yaml configuration" -``` - ---- - -## Task 3: Create Skill Set Loader - -**Files:** -- Create: `ade_bench/plugins/__init__.py` -- Create: `ade_bench/plugins/loader.py` -- Test: `tests/plugins/test_loader.py` - -**Step 1: Create plugins directory** - -```bash -mkdir -p ade_bench/plugins tests/plugins -touch ade_bench/plugins/__init__.py tests/plugins/__init__.py -``` - -**Step 2: Write the failing test** - -Create `tests/plugins/test_loader.py`: - -```python -import pytest -from pathlib import Path -from ade_bench.plugins.loader import SkillSetLoader -from ade_bench.models.skill_set import SkillSetsConfig - - -def test_loader_loads_yaml(tmp_path): - yaml_file = tmp_path / "skill-sets.yaml" - yaml_file.write_text(""" -sets: - - name: test - default: true - skills: [] - allowed_tools: [Bash] -""") - loader = SkillSetLoader(yaml_file) - config = loader.load() - assert isinstance(config, SkillSetsConfig) - assert len(config.sets) == 1 - assert config.sets[0].name == "test" - - -def test_loader_file_not_found(): - loader = SkillSetLoader(Path("/nonexistent/skill-sets.yaml")) - with pytest.raises(FileNotFoundError): - loader.load() - - -def test_loader_resolve_skill_sets_explicit(): - """Explicit --plugin-set names are resolved.""" - yaml_content = """ -sets: - - name: a - default: false - allowed_tools: [Bash] - - name: b - default: true - allowed_tools: [Bash] -""" - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(yaml_content) - f.flush() - loader = SkillSetLoader(Path(f.name)) - result = loader.resolve_skill_sets( - plugin_set_names=["a"], - agent_name="claude" - ) - assert len(result) == 1 - assert result[0].name == "a" - - -def test_loader_resolve_skill_sets_defaults(): - """When no --plugin-set, use defaults.""" - yaml_content = """ -sets: - - name: a - default: false - allowed_tools: [Bash] - - name: b - default: true - allowed_tools: [Bash] - - name: c - default: true - allowed_tools: [Bash] -""" - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(yaml_content) - f.flush() - loader = SkillSetLoader(Path(f.name)) - result = loader.resolve_skill_sets( - plugin_set_names=None, - agent_name="claude" - ) - assert len(result) == 2 - assert result[0].name == "b" - assert result[1].name == "c" - - -def test_loader_resolve_skill_sets_filters_incompatible(): - """Skill sets incompatible with agent are filtered out.""" - yaml_content = """ -sets: - - name: claude-only - default: true - agents: [claude] - allowed_tools: [Bash] - - name: all-agents - default: true - allowed_tools: [Bash] -""" - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(yaml_content) - f.flush() - loader = SkillSetLoader(Path(f.name)) - - # Claude gets both - result = loader.resolve_skill_sets(None, "claude") - assert len(result) == 2 - - # Gemini only gets all-agents - result = loader.resolve_skill_sets(None, "gemini") - assert len(result) == 1 - assert result[0].name == "all-agents" - - -def test_loader_resolve_skill_sets_error_on_incompatible_explicit(): - """Error when explicitly requested skill set is incompatible.""" - yaml_content = """ -sets: - - name: claude-only - agents: [claude] - allowed_tools: [Bash] -""" - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(yaml_content) - f.flush() - loader = SkillSetLoader(Path(f.name)) - - with pytest.raises(ValueError, match="not compatible with agent 'gemini'"): - loader.resolve_skill_sets(["claude-only"], "gemini") - - -def test_loader_resolve_skill_sets_error_when_none_compatible(): - """Error when no skill sets are compatible with agent.""" - yaml_content = """ -sets: - - name: claude-only - default: true - agents: [claude] - allowed_tools: [Bash] -""" - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(yaml_content) - f.flush() - loader = SkillSetLoader(Path(f.name)) - - with pytest.raises(ValueError, match="No compatible skill sets"): - loader.resolve_skill_sets(None, "gemini") -``` - -**Step 3: Run test to verify it fails** - -Run: `uv run pytest tests/plugins/test_loader.py -v` -Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.loader'" - -**Step 4: Write the implementation** - -Create `ade_bench/plugins/loader.py`: - -```python -"""Loader for skill set configuration.""" - -from pathlib import Path -import yaml - -from ade_bench.models.skill_set import SkillSet, SkillSetsConfig - - -class SkillSetLoader: - """Loads and resolves skill sets from YAML configuration.""" - - def __init__(self, config_path: Path): - self._config_path = config_path - self._config: SkillSetsConfig | None = None - - def load(self) -> SkillSetsConfig: - """Load the skill sets configuration from YAML.""" - if not self._config_path.exists(): - raise FileNotFoundError(f"Skill sets config not found: {self._config_path}") - - with open(self._config_path) as f: - data = yaml.safe_load(f) - - self._config = SkillSetsConfig(**data) - return self._config - - def resolve_skill_sets( - self, - plugin_set_names: list[str] | None, - agent_name: str, - ) -> list[SkillSet]: - """Resolve which skill sets to use for a run. - - Args: - plugin_set_names: Explicit skill set names from --plugin-set, or None for defaults - agent_name: The agent being used (e.g., "claude", "gemini") - - Returns: - List of SkillSet objects to use - - Raises: - ValueError: If requested skill set is not found or incompatible - """ - if self._config is None: - self.load() - - # Get skill sets (explicit or defaults) - if plugin_set_names: - skill_sets = self._config.get_by_names(plugin_set_names) - # Validate all are compatible with agent - for ss in skill_sets: - if not ss.is_compatible_with_agent(agent_name): - raise ValueError( - f"Skill set '{ss.name}' is not compatible with agent '{agent_name}'. " - f"Compatible agents: {ss.agents}" - ) - else: - skill_sets = self._config.get_defaults() - - # Filter to compatible skill sets - compatible = [ss for ss in skill_sets if ss.is_compatible_with_agent(agent_name)] - - if not compatible: - if plugin_set_names: - raise ValueError( - f"No compatible skill sets found for agent '{agent_name}' " - f"from requested: {plugin_set_names}" - ) - else: - raise ValueError( - f"No compatible skill sets found for agent '{agent_name}'. " - f"No default skill sets are compatible with this agent." - ) - - return compatible -``` - -Update `ade_bench/plugins/__init__.py`: - -```python -"""Plugin system for ADE-Bench.""" - -from .loader import SkillSetLoader - -__all__ = ["SkillSetLoader"] -``` - -**Step 5: Run test to verify it passes** - -Run: `uv run pytest tests/plugins/test_loader.py -v` -Expected: All tests PASS - -**Step 6: Commit** - -```bash -git add ade_bench/plugins/ tests/plugins/ -git commit -m "feat: add SkillSetLoader to load and resolve skill sets" -``` - ---- - -## Task 4: Create SkillsHandler - -**Files:** -- Create: `ade_bench/plugins/skills_handler.py` -- Test: `tests/plugins/test_skills_handler.py` - -**Step 1: Write the failing test** - -Create `tests/plugins/test_skills_handler.py`: - -```python -import pytest -from unittest.mock import MagicMock, call -from ade_bench.plugins.skills_handler import SkillsHandler -from ade_bench.models.skill_set import SkillSet - - -def test_skills_handler_install_no_skills(): - """No-op when skill set has no skills.""" - skill_set = SkillSet(name="test", skills=[], allowed_tools=["Bash"]) - terminal = MagicMock() - - handler = SkillsHandler() - handler.install(skill_set, terminal) - - terminal.container.exec_run.assert_not_called() - - -def test_skills_handler_install_single_skill(): - """Installs a single skill repo.""" - skill_set = SkillSet( - name="test", - skills=["dbt-labs/dbt-agent-skills"], - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = SkillsHandler() - handler.install(skill_set, terminal) - - terminal.container.exec_run.assert_called_once() - call_args = terminal.container.exec_run.call_args - cmd = call_args[0][0] - assert "npx" in cmd[2] - assert "skills add" in cmd[2] - assert "dbt-labs/dbt-agent-skills" in cmd[2] - - -def test_skills_handler_install_multiple_skills(): - """Installs multiple skill repos.""" - skill_set = SkillSet( - name="test", - skills=["repo/a", "repo/b"], - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = SkillsHandler() - handler.install(skill_set, terminal) - - assert terminal.container.exec_run.call_count == 2 - - -def test_skills_handler_install_failure_logs_warning(): - """Logs warning but doesn't raise on install failure.""" - skill_set = SkillSet( - name="test", - skills=["repo/failing"], - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock( - exit_code=1, - output=b"npm ERR! not found" - ) - - handler = SkillsHandler() - # Should not raise, just log warning - handler.install(skill_set, terminal) -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/plugins/test_skills_handler.py -v` -Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.skills_handler'" - -**Step 3: Write the implementation** - -Create `ade_bench/plugins/skills_handler.py`: - -```python -"""Handler for installing skills via Vercel Skills CLI.""" - -import logging -from ade_bench.models.skill_set import SkillSet -from ade_bench.terminal.docker_compose_manager import DockerComposeManager - -logger = logging.getLogger(__name__) - - -class SkillsHandler: - """Installs skills from skill set configuration.""" - - def install(self, skill_set: SkillSet, terminal: DockerComposeManager) -> None: - """Install skills from the skill set into the container. - - Args: - skill_set: The skill set configuration - terminal: The Docker container manager - """ - if not skill_set.skills: - logger.debug(f"[SkillsHandler] No skills to install for '{skill_set.name}'") - return - - for repo in skill_set.skills: - cmd = f"npx --yes skills add {repo} --all" - logger.info(f"[SkillsHandler] Installing skills from {repo}...") - - result = terminal.container.exec_run( - ["sh", "-c", cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - - if result.exit_code != 0: - logger.warning( - f"[SkillsHandler] Skills installation failed for {repo}: " - f"{result.output.decode('utf-8')}" - ) - else: - logger.info(f"[SkillsHandler] Skills installed successfully from {repo}") -``` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/plugins/test_skills_handler.py -v` -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add ade_bench/plugins/skills_handler.py tests/plugins/test_skills_handler.py -git commit -m "feat: add SkillsHandler for installing skills" -``` - ---- - -## Task 5: Create McpHandler - -**Files:** -- Create: `ade_bench/plugins/mcp_handler.py` -- Test: `tests/plugins/test_mcp_handler.py` - -**Step 1: Write the failing test** - -Create `tests/plugins/test_mcp_handler.py`: - -```python -import pytest -from unittest.mock import MagicMock, call -from ade_bench.plugins.mcp_handler import McpHandler -from ade_bench.models.skill_set import SkillSet, McpServerConfig - - -def test_mcp_handler_configure_no_servers(): - """No-op when skill set has no MCP servers.""" - skill_set = SkillSet(name="test", mcp_servers={}, allowed_tools=["Bash"]) - terminal = MagicMock() - - handler = McpHandler() - handler.configure(skill_set, "claude", terminal) - - terminal.container.exec_run.assert_not_called() - - -def test_mcp_handler_configure_single_server(): - """Configures a single MCP server.""" - skill_set = SkillSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - handler.configure(skill_set, "claude", terminal) - - # Should have at least one call for mcp add - assert terminal.container.exec_run.call_count >= 1 - calls = terminal.container.exec_run.call_args_list - # Find the mcp add call - mcp_add_call = [c for c in calls if "mcp add" in str(c)] - assert len(mcp_add_call) >= 1 - - -def test_mcp_handler_configure_with_env(): - """Writes env file when env vars are specified.""" - skill_set = SkillSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig( - command="uvx", - args=["dbt-mcp@latest"], - env={"DISABLE_SQL": "true", "DISABLE_DISCOVERY": "true"} - ) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - handler.configure(skill_set, "claude", terminal) - - # Check that env file was written - calls = terminal.container.exec_run.call_args_list - env_write_calls = [c for c in calls if "DISABLE_SQL" in str(c)] - assert len(env_write_calls) >= 1 - - -def test_mcp_handler_configure_different_agents(): - """Uses correct agent CLI command.""" - skill_set = SkillSet( - name="test", - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp"]) - }, - allowed_tools=["Bash"] - ) - terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") - - handler = McpHandler() - - # Test claude - handler.configure(skill_set, "claude", terminal) - calls = terminal.container.exec_run.call_args_list - claude_calls = [c for c in calls if "claude mcp add" in str(c)] - assert len(claude_calls) >= 1 - - terminal.reset_mock() - - # Test gemini - handler.configure(skill_set, "gemini", terminal) - calls = terminal.container.exec_run.call_args_list - gemini_calls = [c for c in calls if "gemini mcp add" in str(c)] - assert len(gemini_calls) >= 1 -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/plugins/test_mcp_handler.py -v` -Expected: FAIL with "ModuleNotFoundError: No module named 'ade_bench.plugins.mcp_handler'" - -**Step 3: Write the implementation** - -Create `ade_bench/plugins/mcp_handler.py`: - -```python -"""Handler for configuring MCP servers.""" - -import logging -from ade_bench.models.skill_set import SkillSet -from ade_bench.terminal.docker_compose_manager import DockerComposeManager - -logger = logging.getLogger(__name__) - - -class McpHandler: - """Configures MCP servers from skill set configuration.""" - - def configure(self, skill_set: SkillSet, agent_name: str, terminal: DockerComposeManager) -> None: - """Configure MCP servers for the agent. - - Args: - skill_set: The skill set configuration - agent_name: The agent CLI name (claude, gemini, codex) - terminal: The Docker container manager - """ - if not skill_set.mcp_servers: - logger.debug(f"[McpHandler] No MCP servers to configure for '{skill_set.name}'") - return - - for server_name, config in skill_set.mcp_servers.items(): - logger.info(f"[McpHandler] Configuring MCP server '{server_name}'...") - - # Write env file if env vars specified - env_file_path = None - if config.env: - env_file_path = f"/tmp/{server_name}.env" - env_content = "\n".join(f"{k}={v}" for k, v in config.env.items()) - write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" - - result = terminal.container.exec_run( - ["sh", "-c", write_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - if result.exit_code != 0: - logger.warning(f"[McpHandler] Failed to write env file: {result.output.decode('utf-8')}") - - # Build mcp add command - args_str = " ".join(config.args) - if env_file_path: - mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} --env-file {env_file_path} {args_str}" - else: - mcp_cmd = f"{agent_name} mcp add {server_name} -- {config.command} {args_str}" - - logger.info(f"[McpHandler] Running: {mcp_cmd}") - result = terminal.container.exec_run( - ["sh", "-c", mcp_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) - ) - - if result.exit_code != 0: - logger.warning( - f"[McpHandler] MCP server registration failed for {server_name}: " - f"{result.output.decode('utf-8')}" - ) - else: - logger.info(f"[McpHandler] MCP server '{server_name}' configured successfully") -``` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/plugins/test_mcp_handler.py -v` -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add ade_bench/plugins/mcp_handler.py tests/plugins/test_mcp_handler.py -git commit -m "feat: add McpHandler for configuring MCP servers" -``` - ---- - -## Task 6: Update harness_models.py with Skill Set Metadata - -**Files:** -- Modify: `ade_bench/harness_models.py:76-98` (TrialResults class) - -**Step 1: Update TrialResults model** - -Edit `ade_bench/harness_models.py` to add skill_set field to TrialResults: - -Find this section (around line 76): -```python -class TrialResults(BaseModel): - trial_name: str - task_id: str - ... - used_mcp: bool | None = None -``` - -Replace `used_mcp: bool | None = None` with: - -```python - # Skill set metadata - skill_set_name: str | None = None - skill_set_skills: list[str] | None = None - skill_set_mcp_servers: list[str] | None = None -``` - -**Step 2: Run existing tests** - -Run: `uv run pytest tests/ -v -k "not slow"` -Expected: All tests PASS (model change is additive) - -**Step 3: Commit** - -```bash -git add ade_bench/harness_models.py -git commit -m "feat: add skill set metadata to TrialResults model" -``` - ---- - -## Task 7: Update CLI to Add --plugin-set Flag - -**Files:** -- Modify: `ade_bench/cli/ab/main.py` - -**Step 1: Update CLI** - -Edit `ade_bench/cli/ab/main.py`: - -1. Remove these options from the `run` command: -```python - use_mcp: bool = typer.Option( - False, - "--use-mcp", - help="Enable MCP (Model Context Protocol) for the agent" - ), - use_skills: bool = typer.Option( - False, - "--use-skills", - help="Enable skills for the agent (e.g., dbt-debugging skill)" - ), -``` - -2. Add this option after `log_level`: -```python - plugin_set: list[str] = typer.Option( - None, - "--plugin-set", - help="Space-separated skill set names from skill-sets.yaml (default: use all default sets)" - ), -``` - -3. Update the Harness instantiation to remove `use_mcp` and `use_skills`, add `plugin_set_names`: - -Find: -```python - harness = Harness( - ... - use_mcp=use_mcp, - use_skills=use_skills, - with_profiling=with_profiling - ) -``` - -Replace with: -```python - harness = Harness( - ... - plugin_set_names=plugin_set, - with_profiling=with_profiling - ) -``` - -**Step 2: Verify CLI help** - -Run: `uv run ab run --help` -Expected: Shows `--plugin-set` option, no `--use-mcp` or `--use-skills` - -**Step 3: Commit** - -```bash -git add ade_bench/cli/ab/main.py -git commit -m "feat: replace --use-mcp and --use-skills with --plugin-set" -``` - ---- - -## Task 8: Update Harness to Use Skill Sets - -**Files:** -- Modify: `ade_bench/harness.py` - -**Step 1: Update Harness.__init__** - -Edit `ade_bench/harness.py`: - -1. Add imports at top: -```python -from ade_bench.plugins.loader import SkillSetLoader -from ade_bench.models.skill_set import SkillSet -``` - -2. Update `__init__` signature - remove `use_mcp` and `use_skills`, add `plugin_set_names`: - -Find: -```python - use_mcp: bool = False, - use_skills: bool = False, -``` - -Replace with: -```python - plugin_set_names: list[str] | None = None, -``` - -3. Update instance variables in `__init__`: - -Find: -```python - self._use_mcp = use_mcp - self._use_skills = use_skills -``` - -Replace with: -```python - self._plugin_set_names = plugin_set_names - self._skill_sets: list[SkillSet] = [] -``` - -4. Add skill set loading after `self._init_dataset()`: - -```python - self._init_dataset() - self._init_skill_sets() - self._init_logger() -``` - -5. Add the new method: - -```python - def _init_skill_sets(self) -> None: - """Load and resolve skill sets from configuration.""" - config_path = self._dataset_path.parent / "experiment_sets" / "skill-sets.yaml" - loader = SkillSetLoader(config_path) - self._skill_sets = loader.resolve_skill_sets( - plugin_set_names=self._plugin_set_names, - agent_name=self._agent_name.value - ) - self._logger = logger.getChild(__name__) - self._logger.info( - f"Using skill sets: {[ss.name for ss in self._skill_sets]}" - ) -``` - -**Step 2: Update run() method to loop over skill sets** - -Find the `run()` method and update it to iterate over skill sets, creating separate run IDs: - -```python - def run(self) -> BenchmarkResults: - """Run the benchmark with all configured skill sets.""" - all_results = BenchmarkResults() - - for skill_set in self._skill_sets: - # Create run ID with skill set suffix - skill_set_run_id = f"{self._run_id}__{skill_set.name}" - self._logger.info(f"Starting run for skill set: {skill_set.name}") - - # Run trials for this skill set - results = self._run_with_skill_set(skill_set, skill_set_run_id) - all_results.results.extend(results.results) - - return all_results -``` - -Add the new method: - -```python - def _run_with_skill_set(self, skill_set: SkillSet, run_id: str) -> BenchmarkResults: - """Run benchmark trials with a specific skill set.""" - # Store current run_id and restore after - original_run_id = self._run_id - self._run_id = run_id - self._current_skill_set = skill_set - - # Ensure output directory exists - self._run_path.mkdir(parents=True, exist_ok=True) - - try: - # Call existing run logic (refactored into _execute_trials) - return self._execute_trials() - finally: - self._run_id = original_run_id -``` - -**Step 3: Update _create_agent_for_task to remove use_mcp** - -Find: -```python - # Pass use_mcp flag to installed agents - agent_kwargs["use_mcp"] = self._use_mcp -``` - -Remove those lines. - -**Step 4: Update trial result creation to include skill set metadata** - -In the method that creates TrialResults, add: - -```python - skill_set_name=self._current_skill_set.name if hasattr(self, '_current_skill_set') else None, - skill_set_skills=self._current_skill_set.skills if hasattr(self, '_current_skill_set') else None, - skill_set_mcp_servers=list(self._current_skill_set.mcp_servers.keys()) if hasattr(self, '_current_skill_set') else None, -``` - -**Step 5: Commit** - -```bash -git add ade_bench/harness.py -git commit -m "feat: update Harness to use skill sets with separate run IDs" -``` - ---- - -## Task 9: Update SetupOrchestrator to Call Handlers - -**Files:** -- Modify: `ade_bench/setup/setup_orchestrator.py` -- Modify: `ade_bench/setup/agent_setup.py` - -**Step 1: Update SetupOrchestrator** - -Edit `ade_bench/setup/setup_orchestrator.py`: - -1. Add imports: -```python -from ade_bench.models.skill_set import SkillSet -from ade_bench.plugins.skills_handler import SkillsHandler -from ade_bench.plugins.mcp_handler import McpHandler -``` - -2. Update `__init__` to accept skill_set instead of use_skills: - -Find: -```python - def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, use_skills=False): - ... - self.use_skills = use_skills -``` - -Replace with: -```python - def __init__(self, logger=None, terminal=None, session=None, file_diff_handler=None, trial_handler=None, skill_set: SkillSet | None = None): - ... - self.skill_set = skill_set - self._skills_handler = SkillsHandler() - self._mcp_handler = McpHandler() -``` - -3. Update `setup_agent_config` call in `setup_task`: - -Find: -```python - setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger, self.use_skills) -``` - -Replace with: -```python - setup_agent_config(self.terminal, task_id, self.trial_handler, self.logger) - - # Install skills and configure MCP if skill set specified - if self.skill_set: - if self.skill_set.skills: - log_harness_info(self.logger, task_id, "setup", f"Installing skills...") - self._skills_handler.install(self.skill_set, self.terminal) - log_harness_info(self.logger, task_id, "setup", "Skills installed") - - if self.skill_set.mcp_servers: - log_harness_info(self.logger, task_id, "setup", f"Configuring MCP servers...") - agent_name = self.trial_handler.agent_name.value - self._mcp_handler.configure(self.skill_set, agent_name, self.terminal) - log_harness_info(self.logger, task_id, "setup", "MCP servers configured") -``` - -**Step 2: Update agent_setup.py** - -Edit `ade_bench/setup/agent_setup.py`: - -1. Remove `_install_skills_via_cli` function entirely - -2. Update `setup_agent_config` signature to remove `use_skills`: - -Find: -```python -def setup_agent_config(terminal, task_id: str, trial_handler, logger, use_skills: bool = False) -> None: -``` - -Replace with: -```python -def setup_agent_config(terminal, task_id: str, trial_handler, logger) -> None: -``` - -3. Remove the skills installation at the end: - -Find and remove: -```python - # Install skills for any agent type when --use-skills is enabled - if use_skills: - _install_skills_via_cli(terminal, trial_handler) -``` - -**Step 3: Commit** - -```bash -git add ade_bench/setup/setup_orchestrator.py ade_bench/setup/agent_setup.py -git commit -m "feat: update SetupOrchestrator to use SkillsHandler and McpHandler" -``` - ---- - -## Task 10: Delete Obsolete Files - -**Files:** -- Delete: `shared/scripts/setup-dbt-mcp.sh` - -**Step 1: Delete the file** - -```bash -git rm shared/scripts/setup-dbt-mcp.sh -``` - -**Step 2: Commit** - -```bash -git commit -m "chore: remove obsolete setup-dbt-mcp.sh (logic moved to McpHandler)" -``` - ---- - -## Task 11: Update Harness to Pass Skill Set to Orchestrator - -**Files:** -- Modify: `ade_bench/harness.py` - -**Step 1: Find where SetupOrchestrator is instantiated** - -Search for `SetupOrchestrator(` in harness.py and update to pass `skill_set`: - -Find patterns like: -```python -SetupOrchestrator( - logger=..., - terminal=..., - session=..., - file_diff_handler=..., - trial_handler=..., - use_skills=self._use_skills -) -``` - -Replace with: -```python -SetupOrchestrator( - logger=..., - terminal=..., - session=..., - file_diff_handler=..., - trial_handler=..., - skill_set=self._current_skill_set if hasattr(self, '_current_skill_set') else None -) -``` - -**Step 2: Run integration test** - -Run: `uv run ab run simple001 --db duckdb --project-type dbt --agent sage --plugin-set no-plugins` -Expected: Run completes without errors - -**Step 3: Commit** - -```bash -git add ade_bench/harness.py -git commit -m "feat: pass skill_set to SetupOrchestrator" -``` - ---- - -## Task 12: Final Integration Test - -**Step 1: Test with defaults (A/B comparison)** - -```bash -uv run ab run simple001 --db duckdb --project-type dbt --agent claude -``` - -Expected: Creates two runs: -- `experiments/__no-plugins/` -- `experiments/__dbt-mcp/` - -**Step 2: Test with explicit plugin set** - -```bash -uv run ab run simple001 --db duckdb --project-type dbt --agent claude --plugin-set dbt-skills -``` - -Expected: Creates one run: -- `experiments/__dbt-skills/` - -**Step 3: Test incompatible agent error** - -```bash -uv run ab run simple001 --db duckdb --project-type dbt --agent gemini --plugin-set dbt-skills -``` - -Expected: Error message about dbt-skills not being compatible with gemini - -**Step 4: Commit final state** - -```bash -git add -A -git commit -m "feat: complete plugin sets implementation" -``` - ---- - -## Summary - -| Task | Description | Files | -|------|-------------|-------| -| 1 | Pydantic models | `ade_bench/models/skill_set.py` | -| 2 | YAML config | `experiment_sets/skill-sets.yaml` | -| 3 | Loader | `ade_bench/plugins/loader.py` | -| 4 | SkillsHandler | `ade_bench/plugins/skills_handler.py` | -| 5 | McpHandler | `ade_bench/plugins/mcp_handler.py` | -| 6 | Update models | `ade_bench/harness_models.py` | -| 7 | Update CLI | `ade_bench/cli/ab/main.py` | -| 8 | Update Harness | `ade_bench/harness.py` | -| 9 | Update Orchestrator | `ade_bench/setup/setup_orchestrator.py` | -| 10 | Delete obsolete | `shared/scripts/setup-dbt-mcp.sh` | -| 11 | Wire up Harness | `ade_bench/harness.py` | -| 12 | Integration test | Manual verification | From 7de3b9a77d595e2ba88acc3e374bc09d3031078b Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 9 Feb 2026 16:13:06 +1300 Subject: [PATCH 41/44] refactor: code review follow-ups for plugin set system - Move plugin set models from ade_bench/models/ to harness_models.py (consistent with existing convention) - Change default plugin set to 'none' only (avoid double-run footgun) - Persist prompt_suffix in TrialResults and TSV output for traceability - Replace hasattr(_log_formatter) duck-typing with BaseAgent interface methods (format_agent_log, extract_tools_used already have no-op defaults) - Standardize transcript output to single sessions/transcript.html file instead of agent-specific directory structure - Update CLAUDE.md and README.md: replace --use-mcp with --plugin-set - Add unit tests for _configure_mcp_servers (8 test cases) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 +- README.md | 26 +- .../abstract_installed_agent.py | 2 +- .../claude_code/claude_code_agent.py | 8 +- .../claude_code/log_formatter.py | 69 +++--- ade_bench/agents/log_formatter.py | 6 +- ade_bench/harness.py | 31 ++- ade_bench/harness_models.py | 70 ++++++ ade_bench/models/__init__.py | 5 - ade_bench/models/plugin_set.py | 72 ------ ade_bench/plugins/loader.py | 2 +- ade_bench/plugins/skills_handler.py | 2 +- ade_bench/setup/setup_orchestrator.py | 2 +- ade_bench/utils/results_writer.py | 6 +- experiment_sets/plugin-sets.yaml | 2 +- scripts_python/generate_results_html.py | 15 +- tests/{models => agents}/__init__.py | 0 tests/agents/installed_agents/__init__.py | 0 .../test_abstract_installed_agent.py | 228 ++++++++++++++++++ tests/plugins/test_loader.py | 2 +- tests/plugins/test_skills_handler.py | 2 +- tests/{models => }/test_plugin_set.py | 2 +- 22 files changed, 397 insertions(+), 158 deletions(-) delete mode 100644 ade_bench/models/__init__.py delete mode 100644 ade_bench/models/plugin_set.py rename tests/{models => agents}/__init__.py (100%) create mode 100644 tests/agents/installed_agents/__init__.py create mode 100644 tests/agents/installed_agents/test_abstract_installed_agent.py rename tests/{models => }/test_plugin_set.py (97%) diff --git a/CLAUDE.md b/CLAUDE.md index 51f3d7e3..02779089 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,12 +50,13 @@ uv run scripts_python/run_harness.py --agent sage --task-ids task1 task2 ``` ### Key Parameters: -- `--agent`: Agent type (sage, terminus, etc.) +- `--agent`: Agent type (sage, claude, codex, gemini, etc.) - `--model`: LLM model for AI agents - `--dataset-config`: YAML file defining task collection - `--n-concurrent-trials`: Parallel execution (default: 4) - `--no-rebuild`: Skip Docker rebuilds - `--cleanup`: Remove Docker resources after run +- `--plugin-set`: Plugin set names from `experiment_sets/plugin-sets.yaml` (space-separated). Controls skills, MCP servers, and allowed tools. Defaults to `none` (no plugins). ## Development Workflow diff --git a/README.md b/README.md index 1b920e99..07333812 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ ade run all --db duckdb --project-type dbt --agent claude ### 8. Go beyond - Use the dbt Fusion engine instead of dbt Core with `--project-type dbt-fusion` ([set up Snowflake](#snowflake-setup) first) -- Enable the dbt MCP server with the `--use-mcp` flag (requires Snowflake, see [MCP](#enabling-the-mcp-server) section) +- Enable skills, MCP servers, or both with the `--plugin-set` flag (see [Plugin Sets](#plugin-sets) section) - [Contribute additional tasks or datasets](/docs/CONTRIBUTING.md) --- @@ -159,7 +159,7 @@ ade run \ --seed \ # Optional; flag for creating solution seed CSVs !! DESTRUCTIVE !! RUN WITH CAUTION !! SEE BELOW !! --no-diffs \ # Optional; disables taking snapshots of diffs for faster performance. --persist \ # Optional; keeps the container alive after the trial is over or is aborted. - --use-mcp \ # Optional; creates an dbt MCP server for the agent. Note: Not all agents and databases are supported. + --plugin-set none \ # Optional; plugin set name(s) from experiment_sets/plugin-sets.yaml. Defaults to 'none'. --tasks-dir /absolute/path/to/tasks \ # Optional; path to an external tasks directory. Defaults to 'tasks' in the current directory. ``` @@ -397,14 +397,26 @@ gemini --output-format json --yolo --prompt {task_prompt} --model {model-id} Configuration files for each agent are found in the `/shared/config` directory. You can use `CLAUDE.md` to configure Claude Code, `AGENTS.md` to configure Codex, and `GEMINI.md` to configure Gemini. -### Enabling the MCP server +### Plugin sets -If run with the flag `--use-mcp`, ADE-bench will create a dbt MCP server that the agent is allowed to use. The following databases and agents are supported: +Plugin sets are declarative configurations of skills, MCP servers, and allowed tools that can be applied to benchmark runs. They are defined in `experiment_sets/plugin-sets.yaml`. -- Databases: `snowflake` (duckdb doesn't support multiple simultaneous connections) -- Agents: `claude`, `codex`, `gemini` +Use the `--plugin-set` flag to select one or more plugin sets: -Because the server runs locally, it only has access to the [CLI tools](https://github.com/dbt-labs/dbt-mcp#tools). The others are disabled, because they require access to the dbt platform. +```bash +ade run all --db duckdb --project-type dbt --agent claude --plugin-set all-dbt-skills +ade run all --db snowflake --project-type dbt --agent claude --plugin-set dbt-mcp +ade run all --db duckdb --project-type dbt --agent claude --plugin-set none # baseline (default) +``` + +Available plugin sets include: +- `none` (default): No skills or MCP servers. Baseline configuration. +- `all-dbt-skills`: Installs all skills from `dbt-labs/dbt-agent-skills`. +- `dbt-for-ae`: Installs only the `using-dbt-for-analytics-engineering` skill. +- `dbt-mcp`: Configures the dbt MCP server (Snowflake only — DuckDB doesn't support multiple simultaneous connections). Only [CLI tools](https://github.com/dbt-labs/dbt-mcp#tools) are enabled. +- `dbt-skills-mcp`: Both dbt skills and the dbt MCP server. + +See `experiment_sets/plugin-sets.yaml` for the full configuration of each set. ### The Sage agent diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index 2eb83c61..2c7fbc8d 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -18,7 +18,7 @@ from ade_bench.agents.agent_name import AgentName from ade_bench.agents.base_agent import AgentResult, BaseAgent from ade_bench.harness_models import TerminalCommand, FailureMode -from ade_bench.models.plugin_set import McpServerConfig +from ade_bench.harness_models import McpServerConfig from ade_bench.terminal.tmux_session import TmuxSession from ade_bench.terminal.docker_compose_manager import DockerComposeManager from ade_bench.utils.logger import log_harness_info, logger diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index fc54569e..ca15725d 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -62,7 +62,7 @@ def format_agent_log(self, log_path: Path) -> str | None: """ Format the Claude Code agent's log file into a human-readable string. - Also generates an HTML transcript to log_path.parent / "transcript/" + Also generates an HTML transcript at log_path.parent / "transcript.html" using claude-code-transcripts if available. Args: @@ -71,9 +71,9 @@ def format_agent_log(self, log_path: Path) -> str | None: Returns: Formatted log content as a string, or None if formatting failed """ - # Generate HTML transcript (to sessions/transcript/) - transcript_dir = log_path.parent / "transcript" - self._log_formatter.generate_html_transcript(log_path, transcript_dir) + # Generate HTML transcript as a single well-known file + transcript_path = log_path.parent / "transcript.html" + self._log_formatter.generate_html_transcript(log_path, transcript_path) # Return text-formatted log return self._log_formatter.format_log(log_path) diff --git a/ade_bench/agents/installed_agents/claude_code/log_formatter.py b/ade_bench/agents/installed_agents/claude_code/log_formatter.py index 16d9feaf..1f3d02d3 100644 --- a/ade_bench/agents/installed_agents/claude_code/log_formatter.py +++ b/ade_bench/agents/installed_agents/claude_code/log_formatter.py @@ -282,26 +282,27 @@ def format_readable_log(self, turns: List[Dict[str, Any]]) -> str: return output.getvalue() - def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | None: + def generate_html_transcript(self, log_path: Path, output_path: Path) -> Path | None: """ Generate an HTML transcript using claude-code-transcripts. This method extracts JSON lines from the log file (which may contain mixed terminal output and JSON) and uses claude-code-transcripts to - generate a clean HTML transcript. + generate a clean HTML transcript at the specified output path. Args: log_path: Path to the log file (may contain mixed content) - output_dir: Directory to write HTML transcript files + output_path: Desired output file path (e.g., sessions/transcript.html) Returns: - Path to the generated index.html, or None if generation failed + Path to the generated HTML file, or None if generation failed """ if not log_path.exists(): logger.warning(f"Log file not found: {log_path}") return None try: + import shutil from claude_code_transcripts import generate_html # Extract only JSON lines from the log file @@ -310,36 +311,42 @@ def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | N logger.warning(f"No JSON content found in {log_path}") return None - # Write clean JSONL to a temporary file for claude-code-transcripts - output_dir.mkdir(parents=True, exist_ok=True) + output_path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode='w', suffix='.jsonl', delete=False - ) as tmp_file: - tmp_file.write(jsonl_content) - tmp_path = Path(tmp_file.name) + # Use a temporary directory for claude-code-transcripts output, + # then copy the result to the single well-known output path + with tempfile.TemporaryDirectory() as tmp_output_dir: + tmp_output = Path(tmp_output_dir) - try: - # Generate HTML transcript (suppress stdout/stderr from library) - with contextlib.redirect_stdout(io.StringIO()), \ - contextlib.redirect_stderr(io.StringIO()): - generate_html(tmp_path, output_dir) + with tempfile.NamedTemporaryFile( + mode='w', suffix='.jsonl', delete=False + ) as tmp_file: + tmp_file.write(jsonl_content) + tmp_path = Path(tmp_file.name) - # Check for generated files - index_path = output_dir / "index.html" - if index_path.exists(): - return index_path - - # Check for page-001.html if index.html doesn't exist - page_path = output_dir / "page-001.html" - if page_path.exists(): - return page_path - - logger.warning(f"No HTML output found in {output_dir}") - return None - finally: - # Clean up temporary file - tmp_path.unlink(missing_ok=True) + try: + # Generate HTML transcript (suppress stdout/stderr from library) + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + generate_html(tmp_path, tmp_output) + + # Find the generated file (index.html or page-001.html) + generated = None + for candidate in ["index.html", "page-001.html"]: + candidate_path = tmp_output / candidate + if candidate_path.exists(): + generated = candidate_path + break + + if generated is None: + logger.warning(f"No HTML output found in {tmp_output}") + return None + + # Copy to the well-known output path + shutil.copy2(generated, output_path) + return output_path + finally: + tmp_path.unlink(missing_ok=True) except ImportError: logger.warning( diff --git a/ade_bench/agents/log_formatter.py b/ade_bench/agents/log_formatter.py index c877443b..7390d468 100644 --- a/ade_bench/agents/log_formatter.py +++ b/ade_bench/agents/log_formatter.py @@ -58,7 +58,7 @@ def format_log(self, log_path: Path) -> str | None: except Exception: return None - def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | None: + def generate_html_transcript(self, log_path: Path, output_path: Path) -> Path | None: """ Generate an HTML transcript from the log file. @@ -67,9 +67,9 @@ def generate_html_transcript(self, log_path: Path, output_dir: Path) -> Path | N Args: log_path: Path to the log file to parse - output_dir: Directory to write HTML transcript files + output_path: Desired output file path (e.g., sessions/transcript.html) Returns: - Path to the generated index.html, or None if not supported/failed + Path to the generated HTML file, or None if not supported/failed """ return None diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 7bdb2102..4e4726d8 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -25,10 +25,10 @@ from ade_bench.harness_models import ( BenchmarkResults, FailureMode, + PluginSet, RunMetadata, TrialResults, ) -from ade_bench.models.plugin_set import PluginSet from ade_bench.plugins.loader import PluginSetLoader from ade_bench.setup.setup_orchestrator import SetupOrchestrator from ade_bench.llms.base_llm import ContextLengthExceededError, ParseError @@ -658,6 +658,7 @@ def _run_trial( plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, + prompt_suffix=self._current_plugin_set.prompt_suffix if self._current_plugin_set else None, ) with spin_up_terminal( @@ -757,20 +758,17 @@ def _run_trial( parts = full_pane.split('=== ADE_BENCH_PHASE_DELIMITER_AGENT_START ===') post_agent_pane = parts[-1].strip() - # Only write agent.log and format it for agents with log formatters (e.g., Claude Code) + # Write agent.log and attempt formatting via BaseAgent interface + agent_log_path = trial_handler.sessions_path / "agent.log" formatted_content = None - agent_log_path = None - if hasattr(task_agent, '_log_formatter') and task_agent._log_formatter is not None: - agent_log_path = trial_handler.sessions_path / "agent.log" - try: - agent_log_path.write_text(post_agent_pane) - # Get formatted content from agent (returns string or None) - formatted_content = task_agent.format_agent_log(agent_log_path) - if formatted_content: - self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") - except Exception as e: - self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") - agent_log_path = None # Mark as unavailable on error + try: + agent_log_path.write_text(post_agent_pane) + # format_agent_log() returns None for agents without formatting (BaseAgent default) + formatted_content = task_agent.format_agent_log(agent_log_path) + if formatted_content: + self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") + except Exception as e: + self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") # Write to file - either formatted content or fallback to raw pane if formatted_content: @@ -778,8 +776,8 @@ def _run_trial( else: trial_handler.agent_pane_path.write_text(post_agent_pane) - # Extract tools used if log file is available - if agent_log_path and agent_log_path.exists(): + # Extract tools used (returns None for agents without tool extraction) + if agent_log_path.exists(): try: results.tools_used = task_agent.extract_tools_used(agent_log_path) except Exception as e: @@ -1275,6 +1273,7 @@ def _execute_single_trial( plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, + prompt_suffix=self._current_plugin_set.prompt_suffix if self._current_plugin_set else None, ) return trial_results diff --git a/ade_bench/harness_models.py b/ade_bench/harness_models.py index b65fe389..39b8f4ed 100644 --- a/ade_bench/harness_models.py +++ b/ade_bench/harness_models.py @@ -99,6 +99,7 @@ class TrialResults(BaseModel): plugin_set_skills: list[str] | None = None plugin_set_mcp_servers: list[str] | None = None tools_used: list[str] | None = None + prompt_suffix: str | None = None class BenchmarkResults(BaseModel): @@ -228,3 +229,72 @@ def from_yaml_list(cls, path: Path) -> list["TerminalCommand"]: """Load a list of terminal commands from a YAML file.""" data = yaml.safe_load(path.read_text()) return [cls.model_validate(obj) for obj in data] + + +class McpServerConfig(BaseModel): + """Configuration for an MCP server.""" + command: str + args: list[str] = [] + env: dict[str, str] = {} + + +class SkillOrigin(BaseModel): + """Configuration for a skill origin.""" + location: str # Skill origin (e.g., git URL, local path, GitHub shorthand) + skill_names: list[str] = [] # Empty list means install all skills + + def install_all(self) -> bool: + """Return True if all skills should be installed from this origin.""" + return len(self.skill_names) == 0 + + +class PluginSet(BaseModel): + """Configuration for a set of plugins (skills and MCP servers).""" + name: str + description: str = "" + default: bool = False + agents: list[str] | None = None # None = all agents compatible + skills: list[SkillOrigin] = [] + mcp_servers: dict[str, McpServerConfig] = {} + allowed_tools: list[str] = [] + prompt_suffix: str = "" # Appended to task prompt before execution + + def is_compatible_with_agent(self, agent_name: str) -> bool: + """Check if this plugin set is compatible with the given agent.""" + if self.agents is None: + return True + return agent_name in self.agents + + @property + def skill_locations(self) -> list[str]: + """Get list of skill locations as strings (for result tracking).""" + return [s.location for s in self.skills] + + +class PluginSetsConfig(BaseModel): + """Root configuration containing all plugin sets.""" + sets: list[PluginSet] + + def get_defaults(self) -> list[PluginSet]: + """Get all plugin sets marked as default.""" + return [s for s in self.sets if s.default] + + def get_by_name(self, name: str) -> PluginSet | None: + """Get a plugin set by name.""" + for s in self.sets: + if s.name == name: + return s + return None + + def get_by_names(self, names: list[str]) -> list[PluginSet]: + """Get multiple plugin sets by name. Raises if any not found.""" + result = [] + for name in names: + plugin_set = self.get_by_name(name) + if plugin_set is None: + available = [s.name for s in self.sets] + raise ValueError( + f"Unknown plugin set '{name}'. Available: {', '.join(available)}" + ) + result.append(plugin_set) + return result diff --git a/ade_bench/models/__init__.py b/ade_bench/models/__init__.py deleted file mode 100644 index c6bbcc2f..00000000 --- a/ade_bench/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Models for ADE-Bench configuration.""" - -from .plugin_set import McpServerConfig, PluginSet, PluginSetsConfig - -__all__ = ["McpServerConfig", "PluginSet", "PluginSetsConfig"] diff --git a/ade_bench/models/plugin_set.py b/ade_bench/models/plugin_set.py deleted file mode 100644 index 63db2158..00000000 --- a/ade_bench/models/plugin_set.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Pydantic models for plugin set configuration.""" - -from pydantic import BaseModel - - -class McpServerConfig(BaseModel): - """Configuration for an MCP server.""" - command: str - args: list[str] = [] - env: dict[str, str] = {} - - -class SkillOrigin(BaseModel): - """Configuration for a skill origin.""" - location: str # Skill origin (e.g., git URL, local path, GitHub shorthand) - skill_names: list[str] = [] # Empty list means install all skills - - def install_all(self) -> bool: - """Return True if all skills should be installed from this origin.""" - return len(self.skill_names) == 0 - - -class PluginSet(BaseModel): - """Configuration for a set of plugins (skills and MCP servers).""" - name: str - description: str = "" - default: bool = False - agents: list[str] | None = None # None = all agents compatible - skills: list[SkillOrigin] = [] - mcp_servers: dict[str, McpServerConfig] = {} - allowed_tools: list[str] = [] - prompt_suffix: str = "" # Appended to task prompt before execution - - def is_compatible_with_agent(self, agent_name: str) -> bool: - """Check if this plugin set is compatible with the given agent.""" - if self.agents is None: - return True - return agent_name in self.agents - - @property - def skill_locations(self) -> list[str]: - """Get list of skill locations as strings (for result tracking).""" - return [s.location for s in self.skills] - - -class PluginSetsConfig(BaseModel): - """Root configuration containing all plugin sets.""" - sets: list[PluginSet] - - def get_defaults(self) -> list[PluginSet]: - """Get all plugin sets marked as default.""" - return [s for s in self.sets if s.default] - - def get_by_name(self, name: str) -> PluginSet | None: - """Get a plugin set by name.""" - for s in self.sets: - if s.name == name: - return s - return None - - def get_by_names(self, names: list[str]) -> list[PluginSet]: - """Get multiple plugin sets by name. Raises if any not found.""" - result = [] - for name in names: - plugin_set = self.get_by_name(name) - if plugin_set is None: - available = [s.name for s in self.sets] - raise ValueError( - f"Unknown plugin set '{name}'. Available: {', '.join(available)}" - ) - result.append(plugin_set) - return result diff --git a/ade_bench/plugins/loader.py b/ade_bench/plugins/loader.py index 1f5cafd6..6c0c7a63 100644 --- a/ade_bench/plugins/loader.py +++ b/ade_bench/plugins/loader.py @@ -3,7 +3,7 @@ from pathlib import Path import yaml -from ade_bench.models.plugin_set import PluginSet, PluginSetsConfig +from ade_bench.harness_models import PluginSet, PluginSetsConfig class PluginSetLoader: diff --git a/ade_bench/plugins/skills_handler.py b/ade_bench/plugins/skills_handler.py index d9d2aa23..e532557f 100644 --- a/ade_bench/plugins/skills_handler.py +++ b/ade_bench/plugins/skills_handler.py @@ -1,7 +1,7 @@ """Handler for installing skills via Vercel Skills CLI.""" import logging -from ade_bench.models.plugin_set import PluginSet, SkillOrigin +from ade_bench.harness_models import PluginSet, SkillOrigin from ade_bench.terminal.docker_compose_manager import DockerComposeManager logger = logging.getLogger(__name__) diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index b5278081..8f5e3f9e 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -10,7 +10,7 @@ from .migration_setup import setup_migration from .agent_setup import setup_agent_config from ..utils.logger import log_harness_info -from ..models.plugin_set import PluginSet +from ..harness_models import PluginSet from ..plugins.skills_handler import SkillsHandler diff --git a/ade_bench/utils/results_writer.py b/ade_bench/utils/results_writer.py index 2dbb4c65..c6f5cf26 100644 --- a/ade_bench/utils/results_writer.py +++ b/ade_bench/utils/results_writer.py @@ -132,7 +132,8 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) "model_name", "db_type", "project_type", - "plugin_set" + "plugin_set", + "prompt_suffix" ] with open(output_path, 'w', newline='') as f: @@ -179,7 +180,8 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) trial_result.model_name or "", trial_result.db_type or "", trial_result.project_type or "", - trial_result.plugin_set_name or "" + trial_result.plugin_set_name or "", + trial_result.prompt_suffix or "" ] writer.writerow(row) diff --git a/experiment_sets/plugin-sets.yaml b/experiment_sets/plugin-sets.yaml index 67dab206..fca75693 100644 --- a/experiment_sets/plugin-sets.yaml +++ b/experiment_sets/plugin-sets.yaml @@ -4,7 +4,7 @@ sets: - name: all-dbt-skills description: All dbt skills - default: true + default: false skills: - location: dbt-labs/dbt-agent-skills mcp_servers: {} diff --git a/scripts_python/generate_results_html.py b/scripts_python/generate_results_html.py index f184ee6a..ce268eb6 100644 --- a/scripts_python/generate_results_html.py +++ b/scripts_python/generate_results_html.py @@ -315,17 +315,14 @@ def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_h import shutil panes_dir = task_dir / "panes" - transcript_dir = task_dir / "sessions" / "transcript" + transcript_path = task_dir / "sessions" / "transcript.html" - # Check for HTML transcript (always use page-001.html since there's only 1 prompt) + # Check for HTML transcript (single well-known file) transcript_html = None - if transcript_dir.exists() and (transcript_dir / "page-001.html").exists(): - # Copy transcript directory to HTML output - output_transcript_dir = task_html_dir / "transcript" - if output_transcript_dir.exists(): - shutil.rmtree(output_transcript_dir) - shutil.copytree(transcript_dir, output_transcript_dir) - transcript_html = "transcript/page-001.html" + if transcript_path.exists(): + # Copy transcript file to HTML output + shutil.copy2(transcript_path, task_html_dir / "transcript.html") + transcript_html = "transcript.html" # Build content sections in chronological order sections = [] diff --git a/tests/models/__init__.py b/tests/agents/__init__.py similarity index 100% rename from tests/models/__init__.py rename to tests/agents/__init__.py diff --git a/tests/agents/installed_agents/__init__.py b/tests/agents/installed_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/agents/installed_agents/test_abstract_installed_agent.py b/tests/agents/installed_agents/test_abstract_installed_agent.py new file mode 100644 index 00000000..0de55cb3 --- /dev/null +++ b/tests/agents/installed_agents/test_abstract_installed_agent.py @@ -0,0 +1,228 @@ +import pytest +from unittest.mock import MagicMock, call, patch +from pathlib import Path + +from ade_bench.agents.agent_name import AgentName +from ade_bench.agents.installed_agents.abstract_installed_agent import AbstractInstalledAgent +from ade_bench.harness_models import McpServerConfig, TerminalCommand + + +class ConcreteInstalledAgent(AbstractInstalledAgent): + """Concrete subclass for testing AbstractInstalledAgent.""" + NAME = AgentName.CLAUDE_CODE + + @property + def _env(self) -> dict[str, str]: + return {"TEST_KEY": "test_value"} + + @property + def _install_agent_script(self) -> Path: + return Path("/fake/install.sh") + + def _run_agent_commands(self, task_prompt: str) -> list[TerminalCommand]: + return [] + + +def _make_session(exec_results=None): + """Create a mock TmuxSession with configurable exec_run results.""" + session = MagicMock() + if exec_results is None: + session.container.exec_run.return_value = MagicMock( + exit_code=0, output=b"Success" + ) + else: + session.container.exec_run.side_effect = exec_results + return session + + +class TestConfigureMcpServersNoop: + def test_no_mcp_servers(self): + """No calls when mcp_servers is empty.""" + agent = ConcreteInstalledAgent(mcp_servers={}) + session = _make_session() + agent._configure_mcp_servers(session, "test_task") + session.container.exec_run.assert_not_called() + + +class TestConfigureMcpServersBasic: + def test_single_server_with_env(self): + """Single MCP server writes env file and runs mcp add.""" + mcp_servers = { + "myserver": McpServerConfig( + command="uvx", + args=["some-mcp@latest"], + env={"FOO": "bar", "BAZ": "qux"}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + session = _make_session() + + agent._configure_mcp_servers(session, "test_task") + + # Should be called twice: once to write env file, once for mcp add + assert session.container.exec_run.call_count == 2 + + # First call: write env file + env_write_call = session.container.exec_run.call_args_list[0] + env_cmd = env_write_call[0][0] + assert env_cmd[0] == "sh" + assert env_cmd[1] == "-c" + assert "/tmp/myserver.env" in env_cmd[2] + assert "FOO=bar" in env_cmd[2] + assert "BAZ=qux" in env_cmd[2] + + # Second call: mcp add + mcp_add_call = session.container.exec_run.call_args_list[1] + mcp_cmd = mcp_add_call[0][0] + assert "mcp add myserver" in mcp_cmd[2] + assert "--env-file /tmp/myserver.env" in mcp_cmd[2] + assert "some-mcp@latest" in mcp_cmd[2] + + def test_server_without_env(self): + """MCP server with no env vars skips env file writing.""" + mcp_servers = { + "simple": McpServerConfig( + command="npx", + args=["simple-mcp"], + env={}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + session = _make_session() + + agent._configure_mcp_servers(session, "test_task") + + # Only 1 call (no env file to write, but dbt detection adds nothing) + assert session.container.exec_run.call_count == 1 + + mcp_cmd = session.container.exec_run.call_args[0][0] + assert "mcp add simple" in mcp_cmd[2] + assert "--env-file" not in mcp_cmd[2] + + +class TestConfigureMcpServersDbtDetection: + def test_dbt_server_by_name(self): + """dbt MCP server detected by server name 'dbt'.""" + mcp_servers = { + "dbt": McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_SQL": "true"}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + + # Mock exec_run: first for which dbt, second for env file, third for mcp add + which_dbt_result = MagicMock(exit_code=0, output=b"/usr/local/bin/dbt") + default_result = MagicMock(exit_code=0, output=b"Success") + session = _make_session(exec_results=[ + which_dbt_result, # _get_dbt_dynamic_env: which dbt + default_result, # write env file + default_result, # mcp add + ]) + + agent._configure_mcp_servers(session, "test_task") + + # Verify which dbt was called + first_call = session.container.exec_run.call_args_list[0] + assert first_call[0][0] == ["sh", "-c", "which dbt"] + + # Verify env file includes dynamic vars + env_write_call = session.container.exec_run.call_args_list[1] + env_content = env_write_call[0][0][2] + assert "DISABLE_SQL=true" in env_content # static + assert "DBT_PROJECT_DIR=" in env_content # dynamic + assert "DBT_PATH=/usr/local/bin/dbt" in env_content # dynamic + assert "DISABLE_DBT_CLI=false" in env_content # dynamic + + def test_dbt_server_by_args(self): + """dbt MCP server detected by 'dbt-mcp' in args.""" + mcp_servers = { + "data-tools": McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + + which_dbt_result = MagicMock(exit_code=0, output=b"/usr/bin/dbt") + default_result = MagicMock(exit_code=0, output=b"Success") + session = _make_session(exec_results=[ + which_dbt_result, # which dbt + default_result, # env file + default_result, # mcp add + ]) + + agent._configure_mcp_servers(session, "test_task") + + # Dynamic dbt env vars should be present since dbt-mcp is in args + env_write_call = session.container.exec_run.call_args_list[1] + env_content = env_write_call[0][0][2] + assert "DBT_PATH=/usr/bin/dbt" in env_content + + def test_static_env_takes_precedence(self): + """Static env vars from config are not overridden by dynamic vars.""" + mcp_servers = { + "dbt": McpServerConfig( + command="uvx", + args=["dbt-mcp@latest"], + env={"DISABLE_DBT_CLI": "true"}, # static override + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + + which_dbt_result = MagicMock(exit_code=0, output=b"/usr/bin/dbt") + default_result = MagicMock(exit_code=0, output=b"Success") + session = _make_session(exec_results=[ + which_dbt_result, + default_result, + default_result, + ]) + + agent._configure_mcp_servers(session, "test_task") + + env_write_call = session.container.exec_run.call_args_list[1] + env_content = env_write_call[0][0][2] + # Static value should win over dynamic "false" + assert "DISABLE_DBT_CLI=true" in env_content + + +class TestConfigureMcpServersFailure: + def test_env_file_write_failure_logs_warning(self): + """Non-zero exit on env file write logs warning but continues.""" + mcp_servers = { + "myserver": McpServerConfig( + command="uvx", + args=["mcp@latest"], + env={"KEY": "val"}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + + env_fail = MagicMock(exit_code=1, output=b"Permission denied") + mcp_success = MagicMock(exit_code=0, output=b"OK") + session = _make_session(exec_results=[env_fail, mcp_success]) + + # Should not raise + agent._configure_mcp_servers(session, "test_task") + + # mcp add still called despite env file failure + assert session.container.exec_run.call_count == 2 + + def test_mcp_add_failure_logs_warning(self): + """Non-zero exit on mcp add logs warning but doesn't raise.""" + mcp_servers = { + "myserver": McpServerConfig( + command="uvx", + args=["mcp@latest"], + env={}, + ) + } + agent = ConcreteInstalledAgent(mcp_servers=mcp_servers) + + mcp_fail = MagicMock(exit_code=1, output=b"Command not found") + session = _make_session(exec_results=[mcp_fail]) + + # Should not raise + agent._configure_mcp_servers(session, "test_task") diff --git a/tests/plugins/test_loader.py b/tests/plugins/test_loader.py index 3459b384..7930d14b 100644 --- a/tests/plugins/test_loader.py +++ b/tests/plugins/test_loader.py @@ -1,7 +1,7 @@ import pytest from pathlib import Path from ade_bench.plugins.loader import PluginSetLoader -from ade_bench.models.plugin_set import PluginSetsConfig +from ade_bench.harness_models import PluginSetsConfig def test_loader_loads_yaml(tmp_path): diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py index 1c05e62b..b714b116 100644 --- a/tests/plugins/test_skills_handler.py +++ b/tests/plugins/test_skills_handler.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock, call from ade_bench.plugins.skills_handler import SkillsHandler -from ade_bench.models.plugin_set import PluginSet, SkillOrigin +from ade_bench.harness_models import PluginSet, SkillOrigin def test_skills_handler_install_no_skills(): diff --git a/tests/models/test_plugin_set.py b/tests/test_plugin_set.py similarity index 97% rename from tests/models/test_plugin_set.py rename to tests/test_plugin_set.py index 57c7f323..ccb37c11 100644 --- a/tests/models/test_plugin_set.py +++ b/tests/test_plugin_set.py @@ -1,5 +1,5 @@ import pytest -from ade_bench.models.plugin_set import PluginSet, McpServerConfig, PluginSetsConfig, SkillOrigin +from ade_bench.harness_models import PluginSet, McpServerConfig, PluginSetsConfig, SkillOrigin def test_mcp_server_config_minimal(): From 0a56b840d3273c50303b10b4adf5bce9612755bd Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 9 Feb 2026 16:17:49 +1300 Subject: [PATCH 42/44] fix: escape tool names in HTML report to prevent XSS Co-Authored-By: Claude Opus 4.6 --- scripts_python/summarize_results.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts_python/summarize_results.py b/scripts_python/summarize_results.py index 69132559..e4c70eaf 100644 --- a/scripts_python/summarize_results.py +++ b/scripts_python/summarize_results.py @@ -1,3 +1,5 @@ +import html + from tabulate import tabulate from ade_bench.harness_models import BenchmarkResults from ade_bench.utils.results_writer import format_trial_result, get_failure_type, is_error_result @@ -250,7 +252,7 @@ def generate_html_table(results: BenchmarkResults) -> str: # Format tools as comma-separated list with styled spans tools_list = task.get('tools_used', []) if tools_list: - tools_html = ', '.join(f'{tool}' for tool in tools_list) + tools_html = ', '.join(f'{html.escape(tool)}' for tool in tools_list) else: tools_html = '-' html_table = html_table.replace(f"__TOOLS_{i}__", tools_html) From ebff3ba019e49182dcadcfe05f82ac074766ad14 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 23 Feb 2026 13:37:50 +1300 Subject: [PATCH 43/44] fix: resolve ruff lint errors after merge Co-Authored-By: Claude Opus 4.6 --- ade_bench/harness.py | 2 +- ade_bench/setup/agent_setup.py | 1 - tests/agents/installed_agents/test_abstract_installed_agent.py | 3 +-- tests/plugins/test_skills_handler.py | 3 +-- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 34b7731f..0717821c 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -785,7 +785,7 @@ def _run_trial( # format_agent_log() returns None for agents without formatting (BaseAgent default) formatted_content = task_agent.format_agent_log(agent_log_path) if formatted_content: - self._logger.debug(f"Generated formatted agent.txt from agent.log using agent's formatter") + self._logger.debug("Generated formatted agent.txt from agent.log using agent's formatter") except Exception as e: self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") diff --git a/ade_bench/setup/agent_setup.py b/ade_bench/setup/agent_setup.py index f8e84176..b9a34802 100644 --- a/ade_bench/setup/agent_setup.py +++ b/ade_bench/setup/agent_setup.py @@ -2,7 +2,6 @@ Agent-specific setup functions for copying configuration files and other agent resources. """ -from pathlib import Path from ..utils.logger import logger from ..terminal.docker_compose_manager import DockerComposeManager from ..agents.agent_name import AgentName diff --git a/tests/agents/installed_agents/test_abstract_installed_agent.py b/tests/agents/installed_agents/test_abstract_installed_agent.py index 0de55cb3..f4612203 100644 --- a/tests/agents/installed_agents/test_abstract_installed_agent.py +++ b/tests/agents/installed_agents/test_abstract_installed_agent.py @@ -1,5 +1,4 @@ -import pytest -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock from pathlib import Path from ade_bench.agents.agent_name import AgentName diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py index b714b116..f2bf16d1 100644 --- a/tests/plugins/test_skills_handler.py +++ b/tests/plugins/test_skills_handler.py @@ -1,5 +1,4 @@ -import pytest -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock from ade_bench.plugins.skills_handler import SkillsHandler from ade_bench.harness_models import PluginSet, SkillOrigin From f662b598b263f7de700b535776489931a072df76 Mon Sep 17 00:00:00 2001 From: Joel Labes Date: Mon, 23 Feb 2026 13:40:25 +1300 Subject: [PATCH 44/44] style: apply black formatting to branch files Co-Authored-By: Claude Opus 4.6 --- .../abstract_installed_agent.py | 36 +++++++---- .../claude_code/claude_code_agent.py | 29 ++++++--- .../claude_code/log_formatter.py | 33 +++++----- ade_bench/cli/ab/main.py | 2 +- ade_bench/harness.py | 48 +++++++++++---- ade_bench/harness_models.py | 8 ++- ade_bench/plugins/skills_handler.py | 3 +- ade_bench/setup/setup_orchestrator.py | 1 - ade_bench/utils/results_writer.py | 4 +- scripts_python/analyze.py | 2 +- scripts_python/generate_results_html.py | 49 +++++++-------- scripts_python/summarize_results.py | 6 +- .../test_abstract_installed_agent.py | 41 +++++++------ tests/plugins/test_loader.py | 31 +++++----- tests/plugins/test_skills_handler.py | 25 ++++---- tests/test_plugin_set.py | 60 +++++++++---------- 16 files changed, 217 insertions(+), 161 deletions(-) diff --git a/ade_bench/agents/installed_agents/abstract_installed_agent.py b/ade_bench/agents/installed_agents/abstract_installed_agent.py index 1f62fd8f..0c08e9b8 100644 --- a/ade_bench/agents/installed_agents/abstract_installed_agent.py +++ b/ade_bench/agents/installed_agents/abstract_installed_agent.py @@ -28,7 +28,13 @@ class AbstractInstalledAgent(BaseAgent, ABC): NAME = AgentName.ABSTRACT_INSTALLED - def __init__(self, model_name: str | None = None, allowed_tools: list[str] | None = None, mcp_servers: dict[str, McpServerConfig] | None = None, **kwargs): + def __init__( + self, + model_name: str | None = None, + allowed_tools: list[str] | None = None, + mcp_servers: dict[str, McpServerConfig] | None = None, + **kwargs, + ): super().__init__(**kwargs) self._variant_config = {} self._model_name = model_name @@ -73,8 +79,7 @@ def _get_dbt_dynamic_env(self, session: TmuxSession, task_name: str | None) -> d # Get the dbt path from the container result = session.container.exec_run( - ["sh", "-c", "which dbt"], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ["sh", "-c", "which dbt"], workdir=str(DockerComposeManager.CONTAINER_APP_DIR) ) if result.exit_code == 0: dbt_path = result.output.decode("utf-8").strip() @@ -94,7 +99,9 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> agent_cli = self.NAME.value # e.g., "claude", "gemini" for server_name, mcp_config in self._mcp_servers.items(): - log_harness_info(logger, task_name, "agent", f"Configuring MCP server '{server_name}'...") + log_harness_info( + logger, task_name, "agent", f"Configuring MCP server '{server_name}'..." + ) # Start with static env vars from config env_vars = dict(mcp_config.env) @@ -117,13 +124,19 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> write_cmd = f"cat > {env_file_path} << 'ENVEOF'\n{env_content}\nENVEOF" result = session.container.exec_run( - ["sh", "-c", write_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ["sh", "-c", write_cmd], workdir=str(DockerComposeManager.CONTAINER_APP_DIR) ) if result.exit_code != 0: - logger.warning(f"[MCP] Failed to write env file: {result.output.decode('utf-8')}") + logger.warning( + f"[MCP] Failed to write env file: {result.output.decode('utf-8')}" + ) else: - log_harness_info(logger, task_name, "agent", f"Wrote env file with vars: {list(env_vars.keys())}") + log_harness_info( + logger, + task_name, + "agent", + f"Wrote env file with vars: {list(env_vars.keys())}", + ) # Build mcp add command args_str = " ".join(mcp_config.args) @@ -133,8 +146,7 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> mcp_cmd = f"{agent_cli} mcp add {server_name} -- {mcp_config.command} {args_str}" result = session.container.exec_run( - ["sh", "-c", mcp_cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ["sh", "-c", mcp_cmd], workdir=str(DockerComposeManager.CONTAINER_APP_DIR) ) if result.exit_code != 0: @@ -143,7 +155,9 @@ def _configure_mcp_servers(self, session: TmuxSession, task_name: str | None) -> f"{result.output.decode('utf-8')}" ) else: - log_harness_info(logger, task_name, "agent", f"MCP server '{server_name}' configured") + log_harness_info( + logger, task_name, "agent", f"MCP server '{server_name}' configured" + ) def perform_task( self, diff --git a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py index e410cfb2..72f9bfd9 100644 --- a/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py +++ b/ade_bench/agents/installed_agents/claude_code/claude_code_agent.py @@ -80,11 +80,22 @@ def format_agent_log(self, log_path: Path) -> str | None: return self._log_formatter.format_log(log_path) # Generic tools to filter out from tools_used reporting - _GENERIC_TOOLS = frozenset({ - 'Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write', - 'WebFetch', 'WebSearch', 'Task', 'NotebookEdit', - 'TodoRead', 'TodoWrite', - }) + _GENERIC_TOOLS = frozenset( + { + "Bash", + "Edit", + "Glob", + "Grep", + "Read", + "Write", + "WebFetch", + "WebSearch", + "Task", + "NotebookEdit", + "TodoRead", + "TodoWrite", + } + ) def extract_tools_used(self, log_path: Path) -> list[str] | None: """ @@ -97,11 +108,11 @@ def extract_tools_used(self, log_path: Path) -> list[str] | None: turns = self._log_formatter.parse_log_file(log_path) tool_names = set() for turn in turns: - for tool in turn.get('tools', []): - name = tool['name'] + for tool in turn.get("tools", []): + name = tool["name"] # Expand Skill tool to actual skill name - if name == 'Skill': - skill_name = tool.get('input', {}).get('skill') + if name == "Skill": + skill_name = tool.get("input", {}).get("skill") if skill_name: tool_names.add(f"skill:{skill_name}") # Filter out generic tools diff --git a/ade_bench/agents/installed_agents/claude_code/log_formatter.py b/ade_bench/agents/installed_agents/claude_code/log_formatter.py index bda0f3ff..76e3837c 100644 --- a/ade_bench/agents/installed_agents/claude_code/log_formatter.py +++ b/ade_bench/agents/installed_agents/claude_code/log_formatter.py @@ -52,23 +52,23 @@ def extract_jsonl_content(log_path: Path, inject_prompt: str | None = None) -> s json_lines = [] has_user_text_prompt = False - with open(log_path, 'r') as f: + with open(log_path, "r") as f: for line in f: stripped = line.strip() - if stripped.startswith('{'): + if stripped.startswith("{"): try: # Validate it's actually JSON data = json.loads(stripped) json_lines.append(stripped) # Check if this is a user message with actual text content - if data.get('type') == 'user': - content = data.get('message', {}).get('content', []) + if data.get("type") == "user": + content = data.get("message", {}).get("content", []) if isinstance(content, str) and content.strip(): has_user_text_prompt = True elif isinstance(content, list): for item in content: - if isinstance(item, dict) and item.get('type') == 'text': + if isinstance(item, dict) and item.get("type") == "text": has_user_text_prompt = True break except json.JSONDecodeError: @@ -77,17 +77,16 @@ def extract_jsonl_content(log_path: Path, inject_prompt: str | None = None) -> s # If no user prompt found, inject a synthetic one at the beginning if not has_user_text_prompt and json_lines: prompt_text = inject_prompt or "Claude Code Agent Session" - synthetic_prompt = json.dumps({ - "type": "user", - "timestamp": "", - "message": { - "role": "user", - "content": prompt_text + synthetic_prompt = json.dumps( + { + "type": "user", + "timestamp": "", + "message": {"role": "user", "content": prompt_text}, } - }) + ) json_lines.insert(0, synthetic_prompt) - return '\n'.join(json_lines) + return "\n".join(json_lines) @staticmethod def format_tool_input(tool_name: str, tool_input: Dict[str, Any]) -> str: @@ -325,15 +324,17 @@ def generate_html_transcript(self, log_path: Path, output_path: Path) -> Path | tmp_output = Path(tmp_output_dir) with tempfile.NamedTemporaryFile( - mode='w', suffix='.jsonl', delete=False + mode="w", suffix=".jsonl", delete=False ) as tmp_file: tmp_file.write(jsonl_content) tmp_path = Path(tmp_file.name) try: # Generate HTML transcript (suppress stdout/stderr from library) - with contextlib.redirect_stdout(io.StringIO()), \ - contextlib.redirect_stderr(io.StringIO()): + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): generate_html(tmp_path, tmp_output) # Find the generated file (index.html or page-001.html) diff --git a/ade_bench/cli/ab/main.py b/ade_bench/cli/ab/main.py index 451026fb..8ab2b27e 100644 --- a/ade_bench/cli/ab/main.py +++ b/ade_bench/cli/ab/main.py @@ -125,7 +125,7 @@ def run( plugin_set: Optional[str] = typer.Option( None, "--plugin-set", - help="Plugin set names from plugin-sets.yaml, space-separated (default: use all default sets)" + help="Plugin set names from plugin-sets.yaml, space-separated (default: use all default sets)", ), with_profiling: bool = typer.Option( False, diff --git a/ade_bench/harness.py b/ade_bench/harness.py index 0717821c..ca0f3e41 100644 --- a/ade_bench/harness.py +++ b/ade_bench/harness.py @@ -207,13 +207,17 @@ def _init_plugin_sets(self) -> None: config_path = self._dataset_path.parent / "experiment_sets" / "plugin-sets.yaml" if not config_path.exists(): # No plugin sets config - use empty list (no plugins) - self._plugin_sets = [PluginSet(name="no-plugins", allowed_tools=["Bash", "Edit", "Write", "Read", "Glob", "Grep"])] + self._plugin_sets = [ + PluginSet( + name="no-plugins", + allowed_tools=["Bash", "Edit", "Write", "Read", "Glob", "Grep"], + ) + ] return loader = PluginSetLoader(config_path) self._plugin_sets = loader.resolve_plugin_sets( - plugin_set_names=self._plugin_set_names, - agent_name=self._agent_name.value + plugin_set_names=self._plugin_set_names, agent_name=self._agent_name.value ) def _init_logger(self) -> None: @@ -624,7 +628,7 @@ def _run_setup( session=session, file_diff_handler=file_diff_handler, trial_handler=trial_handler, - plugin_set=self._current_plugin_set + plugin_set=self._current_plugin_set, ) # Run setup with timeout using asyncio @@ -675,9 +679,17 @@ def _run_trial( db_type=config.get("db_type"), project_type=config.get("project_type"), plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, - plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, - plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, - prompt_suffix=self._current_plugin_set.prompt_suffix if self._current_plugin_set else None, + plugin_set_skills=( + self._current_plugin_set.skill_locations if self._current_plugin_set else None + ), + plugin_set_mcp_servers=( + list(self._current_plugin_set.mcp_servers.keys()) + if self._current_plugin_set + else None + ), + prompt_suffix=( + self._current_plugin_set.prompt_suffix if self._current_plugin_set else None + ), ) with spin_up_terminal( @@ -785,9 +797,13 @@ def _run_trial( # format_agent_log() returns None for agents without formatting (BaseAgent default) formatted_content = task_agent.format_agent_log(agent_log_path) if formatted_content: - self._logger.debug("Generated formatted agent.txt from agent.log using agent's formatter") + self._logger.debug( + "Generated formatted agent.txt from agent.log using agent's formatter" + ) except Exception as e: - self._logger.warning(f"Failed to write/format agent.log: {e}. Using raw pane output.") + self._logger.warning( + f"Failed to write/format agent.log: {e}. Using raw pane output." + ) # Write to file - either formatted content or fallback to raw pane if formatted_content: @@ -1323,9 +1339,17 @@ def _execute_single_trial( db_type=config.get("db_type"), project_type=config.get("project_type"), plugin_set_name=self._current_plugin_set.name if self._current_plugin_set else None, - plugin_set_skills=self._current_plugin_set.skill_locations if self._current_plugin_set else None, - plugin_set_mcp_servers=list(self._current_plugin_set.mcp_servers.keys()) if self._current_plugin_set else None, - prompt_suffix=self._current_plugin_set.prompt_suffix if self._current_plugin_set else None, + plugin_set_skills=( + self._current_plugin_set.skill_locations if self._current_plugin_set else None + ), + plugin_set_mcp_servers=( + list(self._current_plugin_set.mcp_servers.keys()) + if self._current_plugin_set + else None + ), + prompt_suffix=( + self._current_plugin_set.prompt_suffix if self._current_plugin_set else None + ), ) return trial_results diff --git a/ade_bench/harness_models.py b/ade_bench/harness_models.py index 4436f5ad..89a3ebb3 100644 --- a/ade_bench/harness_models.py +++ b/ade_bench/harness_models.py @@ -238,6 +238,7 @@ def from_yaml_list(cls, path: Path) -> list["TerminalCommand"]: class McpServerConfig(BaseModel): """Configuration for an MCP server.""" + command: str args: list[str] = [] env: dict[str, str] = {} @@ -245,6 +246,7 @@ class McpServerConfig(BaseModel): class SkillOrigin(BaseModel): """Configuration for a skill origin.""" + location: str # Skill origin (e.g., git URL, local path, GitHub shorthand) skill_names: list[str] = [] # Empty list means install all skills @@ -255,6 +257,7 @@ def install_all(self) -> bool: class PluginSet(BaseModel): """Configuration for a set of plugins (skills and MCP servers).""" + name: str description: str = "" default: bool = False @@ -278,6 +281,7 @@ def skill_locations(self) -> list[str]: class PluginSetsConfig(BaseModel): """Root configuration containing all plugin sets.""" + sets: list[PluginSet] def get_defaults(self) -> list[PluginSet]: @@ -298,8 +302,6 @@ def get_by_names(self, names: list[str]) -> list[PluginSet]: plugin_set = self.get_by_name(name) if plugin_set is None: available = [s.name for s in self.sets] - raise ValueError( - f"Unknown plugin set '{name}'. Available: {', '.join(available)}" - ) + raise ValueError(f"Unknown plugin set '{name}'. Available: {', '.join(available)}") result.append(plugin_set) return result diff --git a/ade_bench/plugins/skills_handler.py b/ade_bench/plugins/skills_handler.py index e532557f..1b91dcfe 100644 --- a/ade_bench/plugins/skills_handler.py +++ b/ade_bench/plugins/skills_handler.py @@ -50,8 +50,7 @@ def _install_skill_origin( logger.info(f"[SkillsHandler] Installing {desc}...") result = terminal.container.exec_run( - ["sh", "-c", cmd], - workdir=str(DockerComposeManager.CONTAINER_APP_DIR) + ["sh", "-c", cmd], workdir=str(DockerComposeManager.CONTAINER_APP_DIR) ) if result.exit_code != 0: diff --git a/ade_bench/setup/setup_orchestrator.py b/ade_bench/setup/setup_orchestrator.py index 77141a2a..4c886a40 100644 --- a/ade_bench/setup/setup_orchestrator.py +++ b/ade_bench/setup/setup_orchestrator.py @@ -59,7 +59,6 @@ def setup_task(self, task_id: str, variant: Dict[str, Any]) -> bool: self._skills_handler.install(self.plugin_set, self.terminal) log_harness_info(self.logger, task_id, "setup", "Skills installed") - # Set up the database db_type = variant.get("db_type") if db_type == "duckdb": diff --git a/ade_bench/utils/results_writer.py b/ade_bench/utils/results_writer.py index 0163880e..48834069 100644 --- a/ade_bench/utils/results_writer.py +++ b/ade_bench/utils/results_writer.py @@ -141,7 +141,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) "db_type", "project_type", "plugin_set", - "prompt_suffix" + "prompt_suffix", ] with open(output_path, "w", newline="") as f: @@ -189,7 +189,7 @@ def write_results_tsv(results: BenchmarkResults, output_path: Path, run_id: str) trial_result.db_type or "", trial_result.project_type or "", trial_result.plugin_set_name or "", - trial_result.prompt_suffix or "" + trial_result.prompt_suffix or "", ] writer.writerow(row) diff --git a/scripts_python/analyze.py b/scripts_python/analyze.py index 7558cfe1..f74c3886 100755 --- a/scripts_python/analyze.py +++ b/scripts_python/analyze.py @@ -186,7 +186,7 @@ def get_canonical_column_order() -> List[str]: "model_name", "db_type", "project_type", - "plugin_set" + "plugin_set", ] diff --git a/scripts_python/generate_results_html.py b/scripts_python/generate_results_html.py index bc3d363f..d32c0f3c 100644 --- a/scripts_python/generate_results_html.py +++ b/scripts_python/generate_results_html.py @@ -359,7 +359,7 @@ def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_h # 1. Pre-agent section if "pre-agent.txt" in pane_dict: - with open(pane_dict["pre-agent.txt"], 'r') as f: + with open(pane_dict["pre-agent.txt"], "r") as f: pre_content = f.read().strip() if pre_content: sections.append(("Pre-Agent Setup", pre_content, "text")) @@ -368,23 +368,26 @@ def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_h if transcript_html: sections.append(("Agent Transcript", transcript_html, "iframe")) elif "agent.txt" in pane_dict: - with open(pane_dict["agent.txt"], 'r') as f: + with open(pane_dict["agent.txt"], "r") as f: agent_content = f.read().strip() if agent_content: sections.append(("Agent Output", agent_content, "text")) # 3. Post-agent section if "post-agent.txt" in pane_dict: - with open(pane_dict["post-agent.txt"], 'r') as f: + with open(pane_dict["post-agent.txt"], "r") as f: post_content = f.read().strip() if post_content: sections.append(("Post-Agent Output", post_content, "text")) # Any other pane files not in the standard order - other_panes = [p for p in pane_dict.keys() - if p not in ["pre-agent.txt", "agent.txt", "post-agent.txt"]] + other_panes = [ + p + for p in pane_dict.keys() + if p not in ["pre-agent.txt", "agent.txt", "post-agent.txt"] + ] for pane_name in sorted(other_panes): - with open(pane_dict[pane_name], 'r') as f: + with open(pane_dict[pane_name], "r") as f: other_content = f.read().strip() if other_content: sections.append((f"Other: {pane_name}", other_content, "text")) @@ -393,16 +396,20 @@ def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_h content_parts = [] for title, content, content_type in sections: if content_type == "iframe": - content_parts.append(f'''
+ content_parts.append( + f"""

{html.escape(title)}

Open transcript in new tab

-
''') +
""" + ) else: - content_parts.append(f'''
+ content_parts.append( + f"""

{html.escape(title)}

{html.escape(content)}
-
''') +
""" + ) if not content_parts: content_parts.append("

No panes data found.

") @@ -412,7 +419,7 @@ def _generate_panes_page(self, task_data: Dict[str, Any], task_dir: Path, task_h task_html_dir / "panes.html", "Terminal Panes", task_data["task_id"], - "\n".join(content_parts) + "\n".join(content_parts), ) def _generate_diffs_page(self, task_data: Dict[str, Any], task_dir: Path, task_html_dir: Path): @@ -466,13 +473,7 @@ def _write_detail_page( with open(output_path, "w") as f: f.write(html_content) - def _write_panes_page( - self, - output_path: Path, - title: str, - task_id: str, - content_html: str - ): + def _write_panes_page(self, output_path: Path, title: str, task_id: str, content_html: str): """Write the panes page with pre-built HTML content. Unlike _write_detail_page which escapes content, this method accepts @@ -489,16 +490,16 @@ def _write_panes_page( print(f"Error: Template not found: {template_path}") return - with open(template_path, 'r') as f: + with open(template_path, "r") as f: template_content = f.read() # Simple template replacement - content_html is already formatted - html_content = template_content.replace('{{ title }}', html.escape(title)) - html_content = html_content.replace('{{ task_id }}', html.escape(task_id)) - html_content = html_content.replace('{{ content }}', content_html) - html_content = html_content.replace('{{ content_type }}', 'panes') + html_content = template_content.replace("{{ title }}", html.escape(title)) + html_content = html_content.replace("{{ task_id }}", html.escape(task_id)) + html_content = html_content.replace("{{ content }}", content_html) + html_content = html_content.replace("{{ content_type }}", "panes") - with open(output_path, 'w') as f: + with open(output_path, "w") as f: f.write(html_content) diff --git a/scripts_python/summarize_results.py b/scripts_python/summarize_results.py index 077b28a7..25e83c26 100644 --- a/scripts_python/summarize_results.py +++ b/scripts_python/summarize_results.py @@ -271,9 +271,11 @@ def generate_html_table(results: BenchmarkResults) -> str: html_table = html_table.replace(f"__TASK_BUTTON_{i}__", task_button) # Format tools as comma-separated list with styled spans - tools_list = task.get('tools_used', []) + tools_list = task.get("tools_used", []) if tools_list: - tools_html = ', '.join(f'{html.escape(tool)}' for tool in tools_list) + tools_html = ", ".join( + f'{html.escape(tool)}' for tool in tools_list + ) else: tools_html = '-' html_table = html_table.replace(f"__TOOLS_{i}__", tools_html) diff --git a/tests/agents/installed_agents/test_abstract_installed_agent.py b/tests/agents/installed_agents/test_abstract_installed_agent.py index f4612203..aaff4aaf 100644 --- a/tests/agents/installed_agents/test_abstract_installed_agent.py +++ b/tests/agents/installed_agents/test_abstract_installed_agent.py @@ -8,6 +8,7 @@ class ConcreteInstalledAgent(AbstractInstalledAgent): """Concrete subclass for testing AbstractInstalledAgent.""" + NAME = AgentName.CLAUDE_CODE @property @@ -26,9 +27,7 @@ def _make_session(exec_results=None): """Create a mock TmuxSession with configurable exec_run results.""" session = MagicMock() if exec_results is None: - session.container.exec_run.return_value = MagicMock( - exit_code=0, output=b"Success" - ) + session.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") else: session.container.exec_run.side_effect = exec_results return session @@ -114,11 +113,13 @@ def test_dbt_server_by_name(self): # Mock exec_run: first for which dbt, second for env file, third for mcp add which_dbt_result = MagicMock(exit_code=0, output=b"/usr/local/bin/dbt") default_result = MagicMock(exit_code=0, output=b"Success") - session = _make_session(exec_results=[ - which_dbt_result, # _get_dbt_dynamic_env: which dbt - default_result, # write env file - default_result, # mcp add - ]) + session = _make_session( + exec_results=[ + which_dbt_result, # _get_dbt_dynamic_env: which dbt + default_result, # write env file + default_result, # mcp add + ] + ) agent._configure_mcp_servers(session, "test_task") @@ -147,11 +148,13 @@ def test_dbt_server_by_args(self): which_dbt_result = MagicMock(exit_code=0, output=b"/usr/bin/dbt") default_result = MagicMock(exit_code=0, output=b"Success") - session = _make_session(exec_results=[ - which_dbt_result, # which dbt - default_result, # env file - default_result, # mcp add - ]) + session = _make_session( + exec_results=[ + which_dbt_result, # which dbt + default_result, # env file + default_result, # mcp add + ] + ) agent._configure_mcp_servers(session, "test_task") @@ -173,11 +176,13 @@ def test_static_env_takes_precedence(self): which_dbt_result = MagicMock(exit_code=0, output=b"/usr/bin/dbt") default_result = MagicMock(exit_code=0, output=b"Success") - session = _make_session(exec_results=[ - which_dbt_result, - default_result, - default_result, - ]) + session = _make_session( + exec_results=[ + which_dbt_result, + default_result, + default_result, + ] + ) agent._configure_mcp_servers(session, "test_task") diff --git a/tests/plugins/test_loader.py b/tests/plugins/test_loader.py index 7930d14b..9ad30dfd 100644 --- a/tests/plugins/test_loader.py +++ b/tests/plugins/test_loader.py @@ -6,13 +6,15 @@ def test_loader_loads_yaml(tmp_path): yaml_file = tmp_path / "plugin-sets.yaml" - yaml_file.write_text(""" + yaml_file.write_text( + """ sets: - name: test default: true skills: [] allowed_tools: [Bash] -""") +""" + ) loader = PluginSetLoader(yaml_file) config = loader.load() assert isinstance(config, PluginSetsConfig) @@ -38,14 +40,12 @@ def test_loader_resolve_plugin_sets_explicit(): allowed_tools: [Bash] """ import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(yaml_content) f.flush() loader = PluginSetLoader(Path(f.name)) - result = loader.resolve_plugin_sets( - plugin_set_names=["a"], - agent_name="claude" - ) + result = loader.resolve_plugin_sets(plugin_set_names=["a"], agent_name="claude") assert len(result) == 1 assert result[0].name == "a" @@ -65,14 +65,12 @@ def test_loader_resolve_plugin_sets_defaults(): allowed_tools: [Bash] """ import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(yaml_content) f.flush() loader = PluginSetLoader(Path(f.name)) - result = loader.resolve_plugin_sets( - plugin_set_names=None, - agent_name="claude" - ) + result = loader.resolve_plugin_sets(plugin_set_names=None, agent_name="claude") assert len(result) == 2 assert result[0].name == "b" assert result[1].name == "c" @@ -91,7 +89,8 @@ def test_loader_resolve_plugin_sets_filters_incompatible(): allowed_tools: [Bash] """ import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(yaml_content) f.flush() loader = PluginSetLoader(Path(f.name)) @@ -115,7 +114,8 @@ def test_loader_resolve_plugin_sets_error_on_incompatible_explicit(): allowed_tools: [Bash] """ import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(yaml_content) f.flush() loader = PluginSetLoader(Path(f.name)) @@ -134,7 +134,8 @@ def test_loader_resolve_plugin_sets_error_when_none_compatible(): allowed_tools: [Bash] """ import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(yaml_content) f.flush() loader = PluginSetLoader(Path(f.name)) diff --git a/tests/plugins/test_skills_handler.py b/tests/plugins/test_skills_handler.py index f2bf16d1..5e768131 100644 --- a/tests/plugins/test_skills_handler.py +++ b/tests/plugins/test_skills_handler.py @@ -19,7 +19,7 @@ def test_skills_handler_install_all_skills(): plugin_set = PluginSet( name="test", skills=[SkillOrigin(location="dbt-labs/dbt-agent-skills")], - allowed_tools=["Bash"] + allowed_tools=["Bash"], ) terminal = MagicMock() terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") @@ -40,11 +40,13 @@ def test_skills_handler_install_specific_skills(): """Installs only specified skills when skill_names is provided.""" plugin_set = PluginSet( name="test", - skills=[SkillOrigin( - location="dbt-labs/dbt-agent-skills", - skill_names=["using-dbt-for-analytics-engineering", "fetching-dbt-docs"] - )], - allowed_tools=["Bash"] + skills=[ + SkillOrigin( + location="dbt-labs/dbt-agent-skills", + skill_names=["using-dbt-for-analytics-engineering", "fetching-dbt-docs"], + ) + ], + allowed_tools=["Bash"], ) terminal = MagicMock() terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") @@ -72,7 +74,7 @@ def test_skills_handler_install_multiple_origins(): SkillOrigin(location="repo/a"), SkillOrigin(location="repo/b"), ], - allowed_tools=["Bash"] + allowed_tools=["Bash"], ) terminal = MagicMock() terminal.container.exec_run.return_value = MagicMock(exit_code=0, output=b"Success") @@ -86,15 +88,10 @@ def test_skills_handler_install_multiple_origins(): def test_skills_handler_install_failure_logs_warning(): """Logs warning but doesn't raise on install failure.""" plugin_set = PluginSet( - name="test", - skills=[SkillOrigin(location="repo/failing")], - allowed_tools=["Bash"] + name="test", skills=[SkillOrigin(location="repo/failing")], allowed_tools=["Bash"] ) terminal = MagicMock() - terminal.container.exec_run.return_value = MagicMock( - exit_code=1, - output=b"npm ERR! not found" - ) + terminal.container.exec_run.return_value = MagicMock(exit_code=1, output=b"npm ERR! not found") handler = SkillsHandler() # Should not raise, just log warning diff --git a/tests/test_plugin_set.py b/tests/test_plugin_set.py index ccb37c11..579021cf 100644 --- a/tests/test_plugin_set.py +++ b/tests/test_plugin_set.py @@ -10,11 +10,7 @@ def test_mcp_server_config_minimal(): def test_mcp_server_config_with_env(): - config = McpServerConfig( - command="uvx", - args=["dbt-mcp@latest"], - env={"DISABLE_SQL": "true"} - ) + config = McpServerConfig(command="uvx", args=["dbt-mcp@latest"], env={"DISABLE_SQL": "true"}) assert config.env == {"DISABLE_SQL": "true"} @@ -27,10 +23,7 @@ def test_skill_origin_install_all(): def test_skill_origin_specific_skills(): """Non-empty skill_names means install only those skills.""" - origin = SkillOrigin( - location="dbt-labs/dbt-agent-skills", - skill_names=["skill1", "skill2"] - ) + origin = SkillOrigin(location="dbt-labs/dbt-agent-skills", skill_names=["skill1", "skill2"]) assert origin.install_all() is False assert origin.skill_names == ["skill1", "skill2"] @@ -53,10 +46,8 @@ def test_plugin_set_full(): default=True, agents=["claude"], skills=[SkillOrigin(location="dbt-labs/dbt-agent-skills")], - mcp_servers={ - "dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"]) - }, - allowed_tools=["Bash", "Skill", "mcp__dbt__*"] + mcp_servers={"dbt": McpServerConfig(command="uvx", args=["dbt-mcp@latest"])}, + allowed_tools=["Bash", "Skill", "mcp__dbt__*"], ) assert plugin_set.default is True assert plugin_set.agents == ["claude"] @@ -94,6 +85,7 @@ def test_plugin_sets_config_from_yaml(): allowed_tools: [Bash, Skill] """ import yaml + data = yaml.safe_load(yaml_content) config = PluginSetsConfig(**data) assert len(config.sets) == 2 @@ -103,11 +95,13 @@ def test_plugin_sets_config_from_yaml(): def test_plugin_sets_config_get_defaults(): - config = PluginSetsConfig(sets=[ - PluginSet(name="a", default=True, allowed_tools=["Bash"]), - PluginSet(name="b", default=False, allowed_tools=["Bash"]), - PluginSet(name="c", default=True, allowed_tools=["Bash"]), - ]) + config = PluginSetsConfig( + sets=[ + PluginSet(name="a", default=True, allowed_tools=["Bash"]), + PluginSet(name="b", default=False, allowed_tools=["Bash"]), + PluginSet(name="c", default=True, allowed_tools=["Bash"]), + ] + ) defaults = config.get_defaults() assert len(defaults) == 2 assert defaults[0].name == "a" @@ -115,21 +109,25 @@ def test_plugin_sets_config_get_defaults(): def test_plugin_sets_config_get_by_name(): - config = PluginSetsConfig(sets=[ - PluginSet(name="a", allowed_tools=["Bash"]), - PluginSet(name="b", allowed_tools=["Bash"]), - ]) + config = PluginSetsConfig( + sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + PluginSet(name="b", allowed_tools=["Bash"]), + ] + ) assert config.get_by_name("a").name == "a" assert config.get_by_name("b").name == "b" assert config.get_by_name("nonexistent") is None def test_plugin_sets_config_get_by_names(): - config = PluginSetsConfig(sets=[ - PluginSet(name="a", allowed_tools=["Bash"]), - PluginSet(name="b", allowed_tools=["Bash"]), - PluginSet(name="c", allowed_tools=["Bash"]), - ]) + config = PluginSetsConfig( + sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + PluginSet(name="b", allowed_tools=["Bash"]), + PluginSet(name="c", allowed_tools=["Bash"]), + ] + ) result = config.get_by_names(["a", "c"]) assert len(result) == 2 assert result[0].name == "a" @@ -137,8 +135,10 @@ def test_plugin_sets_config_get_by_names(): def test_plugin_sets_config_get_by_names_unknown_raises(): - config = PluginSetsConfig(sets=[ - PluginSet(name="a", allowed_tools=["Bash"]), - ]) + config = PluginSetsConfig( + sets=[ + PluginSet(name="a", allowed_tools=["Bash"]), + ] + ) with pytest.raises(ValueError, match="Unknown plugin set"): config.get_by_names(["a", "nonexistent"])