Skip to content

Commit f0db848

Browse files
tmeschterCopilot
andauthored
feat: review per-call tool usage in the integration-tests dashboard (#2659)
* feat: capture per-run tool-call sequence for review Each integration-test agent run now writes a per-run tool-usage-<token>.json alongside its agent-metadata-<token>.md report (1:1 correlation by filename), so the ordered list of tools called in a specific run can be reconstructed even when the same stimulus runs multiple times in one directory. The capture records each tool call's name, arguments (secret-redacted, full), toolCallId, success, and order, including the 'skill' pseudo-tool. The dashboard blob enumerators exclude tool-usage-*.json from API enumeration for now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: upload per-call tool usage to Azure Table for dashboard review Phase 2a: add the pipeline that makes each integration-test run's tool calls queryable from the dashboard. - tests/scripts/upload-tool-usage.ts: new uploader writing one Azure Table row per tool call (name, order, success, duration, output size); full arguments stay in the per-run blob and are fetched on demand. - dashboard/api getToolUsage.ts: GET /api/tool-usage read endpoint with skill/test/branch/runId/runToken filters. - dashboard/infra: provision the integrationtoolusage table and wire the TOOL_USAGE_TABLE_NAME app setting through main/storage/function-app bicep. - CI: add an 'Upload tool usage to table' step to the integration and azure-deploy workflows. - agent-runner.ts: capture per-call wall-clock durationMs and UTF-8 outputBytes alongside the existing tool sequence. - tests: unit coverage for the uploader transforms and the new capture fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: show per-call tool usage in the integration-tests dashboard Phase 2b: surface each run's tool calls for review in the dashboard. - integration-tests App.tsx: add a collapsible 'Tools' toggle under every test item (passed and failed) in the details panel. On expand it lazy-loads GET /api/tool-usage filtered by skill + test + selected date, groups rows by runToken (one block per run), and lists each call as order, success indicator, tool name, duration, and output size. Clicking 'args' fetches the call's full arguments on demand from the per-run tool-usage blob. - getToolUsage.ts: add a runDate filter and include durationMs/outputBytes in the projected rows. - integration-tests.css: styles for the tools toggle, run blocks, call rows, metrics, and the args panel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR #2659 review comments - Include tool-usage-*.json blobs in the dashboard data tree so the on-demand args fetch can locate them (blobEnumerator). - Require at least one filter on GET /api/tool-usage, returning 400 otherwise to avoid unfiltered full-table scans. - Batch Azure Table writes via submitTransaction in chunks of <=100 per partition instead of sequential upserts; add unit tests for the grouping helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d78321c commit f0db848

15 files changed

Lines changed: 1528 additions & 4 deletions

File tree

.github/workflows/test-all-integration.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,16 @@ jobs:
398398
BRANCH: ${{ github.ref_name }}
399399
RUN_ID: ${{ github.run_id }}
400400
run: npm run upload:token-usage
401+
402+
# Upload per-run tool usage (one row per tool call) to the Azure Table that
403+
# powers the dashboard's per-run tool review. Runs for both scheduled and manual runs.
404+
# Note: The managed identity must have Storage Table Data Contributor on the storage account.
405+
- name: Upload tool usage to table
406+
if: always() && vars.REPORT_STORAGE_ACCOUNT != ''
407+
env:
408+
TOOL_USAGE_STORAGE_ACCOUNT: ${{ vars.REPORT_STORAGE_ACCOUNT }}
409+
TOOL_USAGE_TABLE_NAME: ${{ vars.TOOL_USAGE_TABLE || 'integrationtoolusage' }}
410+
SKILL: ${{ matrix.skill }}
411+
BRANCH: ${{ github.ref_name }}
412+
RUN_ID: ${{ github.run_id }}
413+
run: npm run upload:tool-usage

.github/workflows/test-azure-deploy.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,16 @@ jobs:
271271
BRANCH: ${{ github.ref_name }}
272272
RUN_ID: ${{ github.run_id }}
273273
run: npm run upload:token-usage
274+
275+
# Upload per-run tool usage (one row per tool call) to the Azure Table that
276+
# powers the dashboard's per-run tool review. Runs for both scheduled and manual runs.
277+
# Note: The managed identity must have Storage Table Data Contributor on the storage account.
278+
- name: Upload tool usage to table
279+
if: always() && vars.REPORT_STORAGE_ACCOUNT != ''
280+
env:
281+
TOOL_USAGE_STORAGE_ACCOUNT: ${{ vars.REPORT_STORAGE_ACCOUNT }}
282+
TOOL_USAGE_TABLE_NAME: ${{ vars.TOOL_USAGE_TABLE || 'integrationtoolusage' }}
283+
SKILL: azure-deploy
284+
BRANCH: ${{ github.ref_name }}
285+
RUN_ID: ${{ github.run_id }}
286+
run: npm run upload:tool-usage

dashboard/api/src/functions/getData.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import { logRequestIdentity } from "../requestIdentity";
1515
* 5. ${DATE}/${RUN_ID}/{skill-name}/{arbitrary-test-case-name}/agent-metadata-{datetime}{optional-dedupe-suffix}.md
1616
* 6. ${DATE}/${RUN_ID}/{skill-name}/{arbitrary-test-case-name}/agent-metadata.json
1717
* 7. ${DATE}/${RUN_ID}/{skill-name}/{arbitrary-test-case-name}/token-usage.json
18+
* 8. ${DATE}/${RUN_ID}/{skill-name}/{arbitrary-test-case-name}/tool-usage-{datetime}{optional-dedupe-suffix}.json
1819
*
1920
* The test-run-{datetime}-{skill-name}-SKILL-REPORT.md is unique per skill. It is a summarized version of the result of all test runs in its job.
2021
* The test-consolidated-report.md is unique per test case. It is a summarized version of the result of all agent runs for its test case.
2122
* The agent-metadata-{datetime}{optional-dedupe-suffix}.md captures the details of each agent run for its test case.
22-
* token-usage.json and agent-metadata.json should not be exposed for now.
23+
* The tool-usage-{datetime}{optional-dedupe-suffix}.json captures the ordered tool calls of each agent run, named to match its agent-metadata-*.md report.
24+
* token-usage.json, agent-metadata.json, and tool-usage-*.json should not be exposed for now.
2325
*
2426
* For azure-deploy skill:
2527
* 1. ${DATE}/${RUN_ID}/{skill-name}/{test-group}/test-run-{datetime}-{skill-name}-SKILL-REPORT.md
@@ -29,6 +31,7 @@ import { logRequestIdentity } from "../requestIdentity";
2931
* 5. ${DATE}/${RUN_ID}/{skill-name}/{test-group}/{arbitrary-test-case-name}/agent-metadata-{datetime}{optional-dedupe-suffix}.md
3032
* 6. ${DATE}/${RUN_ID}/{skill-name}/{test-group}/{arbitrary-test-case-name}/agent-metadata.json
3133
* 7. ${DATE}/${RUN_ID}/{skill-name}/{test-group}/{arbitrary-test-case-name}/token-usage.json
34+
* 8. ${DATE}/${RUN_ID}/{skill-name}/{test-group}/{arbitrary-test-case-name}/tool-usage-{datetime}{optional-dedupe-suffix}.json
3235
*
3336
* All ${DATE} are in the format of yyyy-mm-dd.
3437
*/
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
2+
import { TableClient } from "@azure/data-tables";
3+
import { AzureCliCredential, ManagedIdentityCredential } from "@azure/identity";
4+
import { logRequestIdentity } from "../requestIdentity";
5+
6+
const STORAGE_ACCOUNT_NAME = process.env.STORAGE_ACCOUNT_NAME;
7+
const TOOL_USAGE_TABLE_NAME = process.env.TOOL_USAGE_TABLE_NAME;
8+
9+
function getToolUsageTableClient(): TableClient {
10+
if (!STORAGE_ACCOUNT_NAME) {
11+
throw new Error("STORAGE_ACCOUNT_NAME environment variable is not set");
12+
}
13+
if (!TOOL_USAGE_TABLE_NAME) {
14+
throw new Error("TOOL_USAGE_TABLE_NAME environment variable is not set");
15+
}
16+
const clientId = process.env.AZURE_CLIENT_ID;
17+
const isDevEnvironment = process.env.AZURE_FUNCTIONS_ENVIRONMENT === "Development";
18+
const credential = isDevEnvironment ? new AzureCliCredential() : new ManagedIdentityCredential(clientId!);
19+
return new TableClient(
20+
`https://${STORAGE_ACCOUNT_NAME}.table.core.windows.net`,
21+
TOOL_USAGE_TABLE_NAME,
22+
credential
23+
);
24+
}
25+
26+
/** Escape a value for use inside an OData string literal (single quotes are doubled). */
27+
function odataLiteral(value: string): string {
28+
return value.replace(/'/g, "''");
29+
}
30+
31+
/**
32+
* Build the OData filter for tool-usage queries from optional equality filters.
33+
* Returns undefined when no filters are provided.
34+
*/
35+
export function buildToolUsageFilter(filters: {
36+
skill?: string;
37+
test?: string;
38+
branch?: string;
39+
runId?: string;
40+
runToken?: string;
41+
runDate?: string;
42+
}): string | undefined {
43+
const clauses: string[] = [];
44+
if (filters.skill) clauses.push(`skill eq '${odataLiteral(filters.skill)}'`);
45+
if (filters.test) clauses.push(`testName eq '${odataLiteral(filters.test)}'`);
46+
if (filters.branch) clauses.push(`branch eq '${odataLiteral(filters.branch)}'`);
47+
if (filters.runId) clauses.push(`runId eq '${odataLiteral(filters.runId)}'`);
48+
if (filters.runToken) clauses.push(`runToken eq '${odataLiteral(filters.runToken)}'`);
49+
if (filters.runDate) clauses.push(`runDate eq '${odataLiteral(filters.runDate)}'`);
50+
return clauses.length > 0 ? clauses.join(" and ") : undefined;
51+
}
52+
53+
/**
54+
* Returns integration-test tool usage rows from the table.
55+
* GET /api/tool-usage
56+
* Query params: skill (optional), test (optional), branch (optional),
57+
* runId (optional), runToken (optional), runDate (optional)
58+
*
59+
* Each row represents a single tool call in one run. Full tool arguments are not
60+
* stored here — they live in the per-run blob and are fetched on demand.
61+
*/
62+
async function getToolUsage(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
63+
logRequestIdentity(request, context, "getToolUsage");
64+
65+
const filter = buildToolUsageFilter({
66+
skill: request.query.get("skill") || undefined,
67+
test: request.query.get("test") || undefined,
68+
branch: request.query.get("branch") || undefined,
69+
runId: request.query.get("runId") || undefined,
70+
runToken: request.query.get("runToken") || undefined,
71+
runDate: request.query.get("runDate") || undefined,
72+
});
73+
74+
// Require at least one filter. An unfiltered scan of the one-row-per-tool-call
75+
// table can be very large and risks timeouts / excessive storage reads.
76+
if (!filter) {
77+
return {
78+
status: 400,
79+
headers: { "Content-Type": "application/json" },
80+
body: JSON.stringify({
81+
error: "At least one filter is required: skill, test, branch, runId, runToken, or runDate.",
82+
}),
83+
};
84+
}
85+
86+
try {
87+
const tableClient = getToolUsageTableClient();
88+
const listOptions = { queryOptions: { filter } };
89+
const entities: Record<string, unknown>[] = [];
90+
91+
for await (const entity of tableClient.listEntities(listOptions)) {
92+
entities.push({
93+
skill: entity.skill,
94+
testName: entity.testName,
95+
branch: entity.branch,
96+
runId: entity.runId,
97+
runDate: entity.runDate,
98+
runTimestamp: entity.runTimestamp,
99+
runToken: entity.runToken,
100+
reportFile: entity.reportFile,
101+
sessionId: entity.sessionId,
102+
model: entity.model,
103+
order: entity.order,
104+
toolName: entity.toolName,
105+
toolCallId: entity.toolCallId,
106+
successState: entity.successState,
107+
durationMs: entity.durationMs,
108+
outputBytes: entity.outputBytes,
109+
});
110+
}
111+
112+
return {
113+
status: 200,
114+
headers: { "Content-Type": "application/json" },
115+
body: JSON.stringify(entities),
116+
};
117+
} catch (err: any) {
118+
context.error("Error querying tool usage:", err?.message ?? err);
119+
return {
120+
status: 500,
121+
headers: { "Content-Type": "application/json" },
122+
body: JSON.stringify({ error: "Failed to query tool usage" }),
123+
};
124+
}
125+
}
126+
127+
app.http("getToolUsage", {
128+
methods: ["GET"],
129+
authLevel: "anonymous",
130+
route: "tool-usage",
131+
handler: getToolUsage,
132+
});

dashboard/infra/main.bicep

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ param msbenchReportsContainerName string = 'msbench-reports'
2121
@description('Name of the Azure Table that stores integration-test token usage history.')
2222
param tokenUsageTableName string = 'integrationtokenusage'
2323

24+
@description('Name of the Azure Table that stores integration-test per-run tool usage history.')
25+
param toolUsageTableName string = 'integrationtoolusage'
26+
2427
@description('Principal (object) ID of the user-assigned managed identity used by the integration test pipeline to write token usage rows (skillcitestidentity).')
2528
param ciTestIdentityPrincipalId string = '531282f7-49cb-4149-af74-6c84a5270e87'
2629

@@ -76,6 +79,7 @@ module storage './modules/storage.bicep' = {
7679
environmentName: environmentName
7780
principalId: identity.outputs.identityPrincipalId
7881
tokenUsageTableName: tokenUsageTableName
82+
toolUsageTableName: toolUsageTableName
7983
ciTestIdentityPrincipalId: ciTestIdentityPrincipalId
8084
}
8185
}
@@ -99,6 +103,7 @@ module functionApp './modules/function-app.bicep' = {
99103
userAssignedIdentityClientId: identity.outputs.identityClientId
100104
storageAccountName: storage.outputs.storageAccountName
101105
tokenUsageTableName: storage.outputs.tokenUsageTableName
106+
toolUsageTableName: storage.outputs.toolUsageTableName
102107
msbenchStorageAccountName: msbenchStorageAccountName
103108
msbenchEvalTableName: msbenchEvalTableName
104109
msbenchReportsContainerName: msbenchReportsContainerName

dashboard/infra/modules/function-app.bicep

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ param storageAccountName string
2121
@description('Name of the Azure Table that stores integration-test token usage history.')
2222
param tokenUsageTableName string
2323

24+
@description('Name of the Azure Table that stores integration-test per-run tool usage history.')
25+
param toolUsageTableName string
26+
2427
@description('Application Insights connection string for monitoring.')
2528
param appInsightsConnectionString string
2629

@@ -118,6 +121,7 @@ resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
118121
{ name: 'AZURE_CLIENT_ID', value: userAssignedIdentityClientId }
119122
{ name: 'STORAGE_ACCOUNT_NAME', value: storageAccountName }
120123
{ name: 'TOKEN_USAGE_TABLE_NAME', value: tokenUsageTableName }
124+
{ name: 'TOOL_USAGE_TABLE_NAME', value: toolUsageTableName }
121125
{ name: 'MSBENCH_STORAGE_ACCOUNT', value: msbenchStorageAccountName }
122126
{ name: 'MSBENCH_REPORTS_CONTAINER', value: msbenchReportsContainerName }
123127
{ name: 'MSBENCH_EVAL_TABLE_NAME', value: msbenchEvalTableName }

dashboard/infra/modules/storage.bicep

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ param principalId string
1515
@description('Name of the Azure Table that stores integration-test token usage history.')
1616
param tokenUsageTableName string = 'integrationtokenusage'
1717

18+
@description('Name of the Azure Table that stores integration-test per-run tool usage history.')
19+
param toolUsageTableName string = 'integrationtoolusage'
20+
1821
@description('Principal (object) ID of the user-assigned managed identity used by the integration test pipeline to write token usage rows (skillcitestidentity in the skillcitest resource group, GithubCopilotForAzure-Testing subscription).')
1922
param ciTestIdentityPrincipalId string = '531282f7-49cb-4149-af74-6c84a5270e87'
2023

@@ -97,6 +100,11 @@ resource tokenUsageTable 'Microsoft.Storage/storageAccounts/tableServices/tables
97100
name: tokenUsageTableName
98101
}
99102

103+
resource toolUsageTable 'Microsoft.Storage/storageAccounts/tableServices/tables@2023-05-01' = {
104+
parent: tableServices
105+
name: toolUsageTableName
106+
}
107+
100108
resource storageBlobDataReaderRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
101109
name: guid(storageAccount.id, principalId, storageBlobDataReaderRoleId)
102110
scope: storageAccount
@@ -107,7 +115,7 @@ resource storageBlobDataReaderRole 'Microsoft.Authorization/roleAssignments@2022
107115
}
108116
}
109117

110-
// Allows the dashboard Function App identity to read token-usage entities from the table.
118+
// Allows the dashboard Function App identity to read token-usage and tool-usage entities from the tables.
111119
resource storageTableDataReaderRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
112120
name: guid(storageAccount.id, principalId, storageTableDataReaderRoleId)
113121
scope: storageAccount
@@ -118,7 +126,7 @@ resource storageTableDataReaderRole 'Microsoft.Authorization/roleAssignments@202
118126
}
119127
}
120128

121-
// Allows the integration test pipeline identity to write token-usage entities to the table.
129+
// Allows the integration test pipeline identity to write token-usage and tool-usage entities to the tables.
122130
resource storageTableDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
123131
name: guid(storageAccount.id, ciTestIdentityPrincipalId, storageTableDataContributorRoleId)
124132
scope: storageAccount
@@ -131,3 +139,4 @@ resource storageTableDataContributorRole 'Microsoft.Authorization/roleAssignment
131139

132140
output storageAccountName string = storageAccount.name
133141
output tokenUsageTableName string = tokenUsageTable.name
142+
output toolUsageTableName string = toolUsageTable.name

0 commit comments

Comments
 (0)