Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e3dc7fa
Add data-query category: AL query-generation benchmark
Jul 12, 2026
0d46002
Merge remote-tracking branch 'origin/main' into onbuyuka/data-query-c…
Jul 13, 2026
8d08fbe
Fix pre-commit: ruff-format + ty (Sequence[Mapping] for result_sets_m…
Jul 13, 2026
f62ace6
Register data-query in Get-BCBenchDatasetPath (container setup)
Jul 13, 2026
a06dec4
data-query: clear workspace contents instead of rmtree (dir is mounte…
Jul 13, 2026
ec5dfee
data-query: drop invalid 'Extensible' property from wrapped API query…
Jul 13, 2026
614dea7
data-query: don't crash the job on a gold-query compile failure
Jul 13, 2026
5761e0e
data-query: normalize query object name and use per-object API entity…
Jul 13, 2026
237c930
data-query: use proven Invoke-AppBuildAndPublish + in-container OData…
Jul 13, 2026
d7ad1dd
data-query: build Basic auth header by hand for the in-container ODat…
Jul 13, 2026
8268f07
data-query: calibrate prompts + fix the intersection gold query
Jul 13, 2026
2a41a6c
data-query: add 5 more deterministically-scorable tasks (6 -> 11)
Jul 28, 2026
129c5ef
data-query: address PR review feedback (scoring integrity + robustness)
Jul 28, 2026
afde215
data-query: fix invalid Count columns in gold queries and skill
Jul 28, 2026
56f2335
data-query: exclude unscorable results from the bceval export too
Jul 28, 2026
faf22ba
data-query: validate the gold query before evaluating the agent's
Jul 28, 2026
45715a3
Withhold gold queries from the agent's filesystem during generation
Jul 28, 2026
adfca7c
Merge remote-tracking branch 'origin/main' into onbuyuka/data-query-c…
Jul 28, 2026
1c9aa72
Harden gold withholding: also hide the .git object database from the …
Jul 28, 2026
d24eb18
Add --skills dispatch flag to toggle agent skills per run
Jul 28, 2026
cad29ec
Normalize only numeric result values, preserve Code strings verbatim
Jul 28, 2026
979a388
Merge branch 'main' into onbuyuka/data-query-category
haoranpb Jul 29, 2026
9820ae7
Simplify data-query harness per owner review
Jul 29, 2026
3ccb00d
Merge origin/main through PR #761
haoranpb Jul 30, 2026
f0921f5
uptake PR#761 with better extensibility
haoranpb Jul 30, 2026
96fd0b4
uptake the file sysmte operation
haoranpb Jul 30, 2026
ee06e26
the latest release is 28.3 I believe
haoranpb Jul 30, 2026
231a27e
Merge branch 'main' of https://github.com/microsoft/BC-Bench into onb…
haoranpb Aug 11, 2026
c218e13
uptake `bootstrap_app_json` util function
haoranpb Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/claude-evaluation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
- "bug-fix"
- "test-generation"
- "code-review"
- "data-query"
test-run:
description: "Indicate this is a test run (with few entries)"
required: false
Expand All @@ -39,6 +40,11 @@ on:
required: false
default: false
type: boolean
skills:
description: "Enable agent skills"
required: false
default: false
type: boolean
repeat:
description: "Number of times to run sequentially (ignored for test runs)"
required: false
Expand Down Expand Up @@ -140,7 +146,8 @@ jobs:
--repo-path "${{ steps.setup-env.outputs.repo_path }}" `
--output-dir "${{ env.EVALUATION_RESULTS_DIR }}" `
${{ inputs.al-mcp && '--al-mcp' || '' }} `
${{ inputs.al-lsp && '--al-lsp' || '' }}
${{ inputs.al-lsp && '--al-lsp' || '' }} `
${{ inputs.skills && '--skills' || '' }}

- name: Upload evaluation results
uses: actions/upload-artifact@v6
Expand Down Expand Up @@ -176,4 +183,4 @@ jobs:
workflow-file: claude-evaluation.yml
repeat: ${{ inputs.repeat }}
workflow-inputs: |
{"model": "${{ inputs.model }}", "category": "${{ inputs.category }}", "test-run": "${{ inputs.test-run }}", "al-mcp": "${{ inputs.al-mcp }}", "al-lsp": "${{ inputs.al-lsp }}", "git-ref": "${{ inputs.git-ref || github.ref_name }}"}
{"model": "${{ inputs.model }}", "category": "${{ inputs.category }}", "test-run": "${{ inputs.test-run }}", "al-mcp": "${{ inputs.al-mcp }}", "al-lsp": "${{ inputs.al-lsp }}", "skills": "${{ inputs.skills }}", "git-ref": "${{ inputs.git-ref || github.ref_name }}"}
11 changes: 9 additions & 2 deletions .github/workflows/copilot-evaluation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ on:
- "bug-fix"
- "test-generation"
- "code-review"
- "data-query"
test-run:
description: "Indicate this is a test run (with few entries)"
required: false
Expand All @@ -46,6 +47,11 @@ on:
required: false
default: false
type: boolean
skills:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A toggle here will indeed simplify things when testing, e.g. an experiment comparing with and without skills.

We probably care more about updating the skills instead, where a private branch will be created. So the skills can be easily toggled in config.yaml.

Also remember skills=true will copy all the skills, so it's bit hard to control the variables from a single toggle

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair points. The --skills flag was mainly to make the with/without-skills A/B easy from a dispatch. Since #761 refactors setup_agent_skills (and skills are really toggled via config.yaml on a private branch, as you note), I will reconcile the flag - keep, drop, or scope it to a single named skill - when I rebase this PR onto #761. Leaving this open until then.

description: "Enable agent skills"
required: false
default: false
type: boolean
repeat:
description: "Number of times to run sequentially (ignored for test runs)"
required: false
Expand Down Expand Up @@ -145,7 +151,8 @@ jobs:
--repo-path "${{ steps.setup-env.outputs.repo_path }}" `
--output-dir "${{ env.EVALUATION_RESULTS_DIR }}" `
${{ inputs.al-mcp && '--al-mcp' || '' }} `
${{ inputs.al-lsp && '--al-lsp' || '' }}
${{ inputs.al-lsp && '--al-lsp' || '' }} `
${{ inputs.skills && '--skills' || '' }}

- name: Upload evaluation results
uses: actions/upload-artifact@v6
Expand Down Expand Up @@ -181,4 +188,4 @@ jobs:
workflow-file: copilot-evaluation.yml
repeat: ${{ inputs.repeat }}
workflow-inputs: |
{"model": "${{ inputs.model }}", "category": "${{ inputs.category }}", "test-run": "${{ inputs.test-run }}", "al-mcp": "${{ inputs.al-mcp }}", "al-lsp": "${{ inputs.al-lsp }}", "git-ref": "${{ inputs.git-ref || github.ref_name }}"}
{"model": "${{ inputs.model }}", "category": "${{ inputs.category }}", "test-run": "${{ inputs.test-run }}", "al-mcp": "${{ inputs.al-mcp }}", "al-lsp": "${{ inputs.al-lsp }}", "skills": "${{ inputs.skills }}", "git-ref": "${{ inputs.git-ref || github.ref_name }}"}
11 changes: 11 additions & 0 deletions dataset/dataquery.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{"instance_id": "dataquery__outstanding-sales-value-by-customer-1", "environment_setup_version": "28.3", "created_at": "2026-07-10", "metadata": {"area": "sales"}, "nl_prompt": "For each customer that has at least one open sales order line, return the customer's number, the customer's name, and their total outstanding amount. Use open sales order lines (Sales Line records whose document type is Order) and sum the line 'Outstanding Amount' field (which is net of VAT). Customers with no open sales order lines must not appear.", "ordered": false, "gold_query": "query 50100 OutstandingSalesByCustomer\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Customer; Customer)\n {\n column(No; \"No.\") { }\n column(Name; Name) { }\n dataitem(SalesLine; \"Sales Line\")\n {\n DataItemLink = \"Sell-to Customer No.\" = Customer.\"No.\";\n DataItemTableFilter = \"Document Type\" = const(Order);\n column(TotalOutstanding; \"Outstanding Amount\") { Method = Sum; }\n }\n }\n }\n}"}
{"instance_id": "dataquery__total-sold-quantity-by-item-1", "environment_setup_version": "28.3", "created_at": "2026-07-10", "metadata": {"area": "inventory"}, "nl_prompt": "Across all posted sales invoice lines whose type is Item, return each item's number together with the total quantity sold (the sum of the line Quantity). Produce one row per item number.", "ordered": false, "gold_query": "query 50100 SoldQuantityByItem\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(SalesInvoiceLine; \"Sales Invoice Line\")\n {\n DataItemTableFilter = Type = const(Item);\n column(ItemNo; \"No.\") { }\n column(TotalQuantity; Quantity) { Method = Sum; }\n }\n }\n}"}
{"instance_id": "dataquery__avg-invoice-amount-by-country-1", "environment_setup_version": "28.3", "created_at": "2026-07-10", "metadata": {"area": "finance"}, "nl_prompt": "For each country/region, return the country/region code and the average posted sales invoice line amount. Average the 'Amount' field (net of VAT) over all posted sales invoice lines, grouping the lines by their bill-to customer's Country/Region Code.", "ordered": false, "gold_query": "query 50100 AvgInvoiceAmountByCountry\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Customer; Customer)\n {\n column(CountryRegionCode; \"Country/Region Code\") { }\n dataitem(SalesInvoiceHeader; \"Sales Invoice Header\")\n {\n DataItemLink = \"Bill-to Customer No.\" = Customer.\"No.\";\n dataitem(SalesInvoiceLine; \"Sales Invoice Line\")\n {\n DataItemLink = \"Document No.\" = SalesInvoiceHeader.\"No.\";\n column(AvgAmount; Amount) { Method = Average; }\n }\n }\n }\n }\n}"}
{"instance_id": "dataquery__total-purchase-amount-by-vendor-1", "environment_setup_version": "28.3", "created_at": "2026-07-10", "metadata": {"area": "purchasing"}, "nl_prompt": "For each vendor that has at least one posted purchase invoice line, return the vendor's number, the vendor's name, and their total posted purchase amount. Sum the 'Amount' field (net of VAT) from posted purchase invoice lines. Vendors with no posted purchase invoice lines must not appear.", "ordered": false, "gold_query": "query 50100 PurchaseAmountByVendor\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Vendor; Vendor)\n {\n column(No; \"No.\") { }\n column(Name; Name) { }\n dataitem(PurchInvLine; \"Purch. Inv. Line\")\n {\n DataItemLink = \"Buy-from Vendor No.\" = Vendor.\"No.\";\n column(TotalAmount; Amount) { Method = Sum; }\n }\n }\n }\n}"}
{"instance_id": "dataquery__open-sales-order-count-by-customer-1", "environment_setup_version": "28.3", "created_at": "2026-07-13", "metadata": {"area": "sales"}, "nl_prompt": "For each customer that has at least one open sales order, return the customer's number, the customer's name, and the number of open sales orders they have. An open sales order is a sales document whose document type is Order; count the order documents (headers), not the order lines. Customers with no open sales orders must not appear.", "ordered": false, "gold_query": "query 50100 OpenSalesOrdersByCustomer\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Customer; Customer)\n {\n column(No; \"No.\") { }\n column(Name; Name) { }\n dataitem(SalesHeader; \"Sales Header\")\n {\n DataItemLink = \"Sell-to Customer No.\" = Customer.\"No.\";\n DataItemTableFilter = \"Document Type\" = const(Order);\n column(OrderCount) { Method = Count; }\n }\n }\n }\n}"}
{"instance_id": "dataquery__opportunity-count-by-status-1", "environment_setup_version": "28.3", "created_at": "2026-07-10", "metadata": {"area": "crm"}, "nl_prompt": "Return the number of CRM opportunities in each status. Group the opportunities by their Status field and, for each status value that occurs, output the status and the count of opportunities with that status.", "ordered": false, "gold_query": "query 50100 OpportunityCountByStatus\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Opportunity; Opportunity)\n {\n column(Status; Status) { }\n column(OpportunityCount) { Method = Count; }\n }\n }\n}"}
{"instance_id": "dataquery__customer-count-by-country-1", "environment_setup_version": "28.3", "created_at": "2026-07-28", "metadata": {"area": "sales"}, "nl_prompt": "Group all customers by their Country/Region Code and return, for each distinct code, the Country/Region Code and the number of customers that have it. Include customers whose Country/Region Code is blank as their own group. Count the Customer records (one row per distinct Country/Region Code).", "ordered": false, "gold_query": "query 50100 CustomerCountByCountry\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Customer; Customer)\n {\n column(CountryRegionCode; \"Country/Region Code\") { }\n column(CustomerCount) { Method = Count; }\n }\n }\n}"}
{"instance_id": "dataquery__outstanding-purchase-value-by-vendor-1", "environment_setup_version": "28.3", "created_at": "2026-07-28", "metadata": {"area": "purchase"}, "nl_prompt": "For each vendor that has at least one open purchase order line, return the vendor's number, the vendor's name, and their total outstanding amount. Use open purchase order lines (Purchase Line records whose Document Type is Order) and sum the line 'Outstanding Amount' field (net of VAT). Vendors with no open purchase order lines must not appear.", "ordered": false, "gold_query": "query 50100 OutstandingPurchaseByVendor\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Vendor; Vendor)\n {\n column(No; \"No.\") { }\n column(Name; Name) { }\n dataitem(PurchaseLine; \"Purchase Line\")\n {\n DataItemLink = \"Buy-from Vendor No.\" = Vendor.\"No.\";\n DataItemTableFilter = \"Document Type\" = const(Order);\n column(TotalOutstanding; \"Outstanding Amount\") { Method = Sum; }\n }\n }\n }\n}"}
{"instance_id": "dataquery__total-posted-sales-amount-by-customer-1", "environment_setup_version": "28.3", "created_at": "2026-07-28", "metadata": {"area": "sales"}, "nl_prompt": "For each customer that has at least one posted sales invoice line, return the customer's number, the customer's name, and their total posted sales amount. Join posted sales invoice headers to their lines via Document No., group by the header's Bill-to Customer No., and sum the line 'Amount' field (net of VAT). Customers with no posted sales invoice lines must not appear.", "ordered": false, "gold_query": "query 50100 PostedSalesAmountByCustomer\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(Customer; Customer)\n {\n column(No; \"No.\") { }\n column(Name; Name) { }\n dataitem(SalesInvoiceHeader; \"Sales Invoice Header\")\n {\n DataItemLink = \"Bill-to Customer No.\" = Customer.\"No.\";\n dataitem(SalesInvoiceLine; \"Sales Invoice Line\")\n {\n DataItemLink = \"Document No.\" = SalesInvoiceHeader.\"No.\";\n column(TotalAmount; Amount) { Method = Sum; }\n }\n }\n }\n }\n}"}
{"instance_id": "dataquery__line-count-per-open-sales-order-1", "environment_setup_version": "28.3", "created_at": "2026-07-28", "metadata": {"area": "sales"}, "nl_prompt": "For each open sales order, return the order's document number and the number of lines it has. Use Sales Line records whose Document Type is Order, group them by Document No., and count the lines per order (one row per order document number).", "ordered": false, "gold_query": "query 50100 LineCountPerOpenSalesOrder\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(SalesLine; \"Sales Line\")\n {\n DataItemTableFilter = \"Document Type\" = const(Order);\n column(DocumentNo; \"Document No.\") { }\n column(LineCount) { Method = Count; }\n }\n }\n}"}
{"instance_id": "dataquery__total-purchased-quantity-by-item-1", "environment_setup_version": "28.3", "created_at": "2026-07-28", "metadata": {"area": "purchase"}, "nl_prompt": "Across all posted purchase invoice lines whose Type is Item, return each item's number together with the total purchased quantity (the sum of the line Quantity). Produce one row per item number.", "ordered": false, "gold_query": "query 50100 PurchasedQuantityByItem\n{\n QueryType = Normal;\n\n elements\n {\n dataitem(PurchInvLine; \"Purch. Inv. Line\")\n {\n DataItemTableFilter = Type = const(Item);\n column(ItemNo; \"No.\") { }\n column(TotalQuantity; Quantity) { Method = Sum; }\n }\n }\n}"}
46 changes: 46 additions & 0 deletions docs/data-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
layout: default
title: Data Query - BC-Bench
---

# Data Query

This category benchmarks an agent's ability to **generate Business Central AL queries** from a natural-language data question — an offline query-generation benchmark. There is **no MCP server and no live server in the loop**: the agent writes an AL query, and the query is evaluated deterministically.

Given a question, the agent authors a single AL `query` object and writes it to `query.al`. The harness then **compiles and runs both the generated query and a gold reference query** against a fixed dataset (the BC container's Contoso demo data) and compares the result sets.

## How it is scored

Data Query is **execution-based** (like bug-fix), with no LLM judge:

- **build** — the generated query compiled and ran.
- **resolved** (the headline `ResolutionRate`) — the generated query's result set **matches the gold query's**. Rows are compared by value (numbers normalized, column names/order ignored); row order is ignored unless the entry marks the question as `ordered`.

To execute a query, the harness wraps it as an API query (injecting `APIPublisher`/`APIGroup`/`APIVersion`/`EntitySetName`), publishes a throwaway app to the container, and reads the query's OData endpoint. This runs on the `GitHub-BCBench` self-hosted runner (`requires_container = True`).

> This complements the AI Test Toolkit evals in the BC platform repo: those test the **MCP server** end-to-end, while BC-Bench benchmarks **models/agents** on query generation.

## Dataset

Each entry has an `nl_prompt` (the question) and a `gold_query` (the reference AL query whose result set defines "correct"), plus `environment_setup_version` (the BC artifact) and `ordered`. See `dataset/dataquery.jsonl`.

## Running it (no local containers)

Trigger it from the GitHub **Actions** tab — the self-hosted `GitHub-BCBench` runner provisions the BC container for you (a stock **sandbox artifact with Cronus/Contoso demo data** — no special build is needed, since the query is just compiled and run):

1. Actions → **Evaluation with GitHub Copilot** (or **Evaluation with Claude Code**) → **Run workflow**.
2. Set **category** = `data-query`, pick a **model**, leave **test-run** = `true` for a quick 2-entry run.
3. The run: provisions the container → the agent writes `query.al` → the harness compiles + runs the generated and gold queries → compares result sets → `summarize-results` reports `ResolutionRate` / `BuildRate`.

`data-query` sets `requires_container = True`; its container setup **skips the repo clone** (there is no repo — the agent generates from scratch) and just stands up the sandbox container.

### Local (optional)

```bash
uv run bcbench evaluate copilot dataquery__outstanding-sales-value-by-customer-1 \
--category data-query --container-name <bc-sandbox> --username admin --password <pw>
```

The optional `al-mcp` / `al-lsp` levers give the agent AL compiler/language-server feedback while it authors the query.

[← Back to Home](index.md)
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A benchmark for evaluating AI coding agents on real-world **Business Central (AL
| [Bug Fixing](bug-fix.md) | Follows [SWE-Bench](https://www.swebench.com/) methodology to evaluate bug fixing in AL code |
| [Test Generation](test-generation.md) | "Reverses" SWE-Bench: Generates reproduction tests (TDD) instead of fixes |
| [Code Review](code-review.md) | Reviews AL pull requests; scored with Precision / Recall / F1 against gold findings |
| [Data Query](data-query.md) | Generates AL queries from natural-language data questions; scored deterministically by running the query and comparing its result set to a gold query |

## Diagnostics

Expand Down
3 changes: 2 additions & 1 deletion scripts/BCBenchUtils.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ function Get-BCBenchDatasetPath {
param(
[Parameter(Mandatory = $true)]
# Category validation lives only here: every caller resolves the dataset path through this function, so there's no need to duplicate ValidateSet on each caller.
[ValidateSet("bug-fix", "test-generation", "code-review", "nl2al")]
[ValidateSet("bug-fix", "test-generation", "code-review", "nl2al", "data-query")]
[string] $Category
)

Expand All @@ -499,6 +499,7 @@ function Get-BCBenchDatasetPath {
"test-generation" { $DatasetName = "bcbench.jsonl" }
"code-review" { $DatasetName = "codereview.jsonl" }
"nl2al" { $DatasetName = "nl2al.jsonl" }
"data-query" { $DatasetName = "dataquery.jsonl" }
}

[string] $projectRoot = Split-Path $PSScriptRoot -Parent
Expand Down
5 changes: 4 additions & 1 deletion scripts/Setup-ContainerAndRepository.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ if (-not $SkipRepo) {
Invoke-GitCloneWithRetry -RepoUrl $cloneInfo.Url -Token $cloneInfo.Token -ClonePath $RepoPath -CommitSha $commitSha -SparseCheckoutPaths $cloneInfo.SparseCheckoutPaths
}
else {
Write-Log "Skipping repository clone (SkipRepo flag set)" -Level Info
# Categories that scaffold their own workspace still need the folder to exist: it is shared into
# the container below, and Compile-AppInBcContainer throws for any path not shared with it.
Write-Log "Skipping repository clone (SkipRepo flag set); creating empty workspace at $RepoPath" -Level Info
New-Item -ItemType Directory -Path $RepoPath -Force | Out-Null
}

if (-not $SkipContainer) {
Expand Down
3 changes: 2 additions & 1 deletion src/bcbench/agent/claude/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def run_claude_code(
output_dir: Path,
al_mcp: bool = False,
al_lsp: bool = False,
skills: bool = False,
container_name: str = "bcbench",
) -> tuple[AgentMetrics | None, ExperimentConfiguration]:
"""Run Claude Code on a single dataset entry.
Expand All @@ -46,7 +47,7 @@ def run_claude_code(
mcp_config_json, mcp_server_names = build_mcp_config(claude_config, entry, repo_path, al_mcp=al_mcp, container_name=container_name)
lsp_plugin_dir: Path | None = build_al_lsp_plugin(entry, category, repo_path, AgentType.CLAUDE, al_lsp=al_lsp, container_name=container_name)
instructions_enabled: bool = setup_instructions_from_config(claude_config, entry, repo_path, agent_type=AgentType.CLAUDE)
skills_enabled: bool = setup_agent_skills(claude_config, entry, repo_path, agent_type=AgentType.CLAUDE)
skills_enabled: bool = setup_agent_skills(claude_config, entry, repo_path, agent_type=AgentType.CLAUDE, skills_enabled_override=skills)
custom_agent: str | None = setup_custom_agent(claude_config, entry, repo_path, agent_type=AgentType.CLAUDE)
tool_log_path: Path = setup_hooks(repo_path, AgentType.CLAUDE, output_dir)
plugins: dict[str, Path] = resolve_config_plugins(claude_config)
Expand Down
Loading
Loading