Skip to content

Commit 7a70045

Browse files
committed
feat(dashboard-agent): validate chart queries when render_view runs
A chart block's TRQL query used to run only in the panel, after the turn, so a bad query left a broken chart the model never learned about. render_view now runs each chart query through the query API first and fails by name with the query error, so the model fixes it in the same turn. The rows are discarded — the panel stays the runner. Skipped when the turn has no delegated token or the validation request itself fails.
1 parent 52cb895 commit 7a70045

3 files changed

Lines changed: 212 additions & 37 deletions

File tree

internal-packages/dashboard-agent/src/dashboard-agent.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -731,9 +731,10 @@ describe("watch wake narration", () => {
731731
});
732732

733733
expect(calls.appendMessage).toHaveLength(2);
734-
expect(
735-
calls.appendMessage.map((call) => (call as { message: UIMessage }).message.id)
736-
).toEqual(["wake:watch:watch_1:fired", "wake:watch:watch_2:expired"]);
734+
expect(calls.appendMessage.map((call) => (call as { message: UIMessage }).message.id)).toEqual([
735+
"wake:watch:watch_1:fired",
736+
"wake:watch:watch_2:expired",
737+
]);
737738
});
738739
});
739740

@@ -1780,6 +1781,114 @@ describe("buildDashboardAgentTools", () => {
17801781
}
17811782
});
17821783

1784+
// -------------------------------------------------------------------------
1785+
// render_view: a chart block's query is validated inside the turn
1786+
// -------------------------------------------------------------------------
1787+
1788+
const CHART_SPEC = {
1789+
blocks: [
1790+
{
1791+
type: "chart",
1792+
title: "Runs per hour",
1793+
query: "SELECT toStartOfHour(triggered_at) AS bucket, count() AS runs FROM runs",
1794+
period: "24h",
1795+
chartType: "line" as const,
1796+
xAxisColumn: "bucket",
1797+
yAxisColumns: ["runs"],
1798+
},
1799+
],
1800+
};
1801+
1802+
const renderView = (ctx: Record<string, unknown>, spec: unknown) =>
1803+
(
1804+
buildDashboardAgentTools(ctx).render_view as {
1805+
execute: (i: unknown, o: unknown) => Promise<any>;
1806+
}
1807+
).execute(spec, {});
1808+
1809+
const queryRequests = (requests: Array<{ url: string }>) =>
1810+
requests.filter((r) => r.url.endsWith("/api/v1/query"));
1811+
1812+
it("render_view fails with the chart query's own error, committing no blocks", async () => {
1813+
const fetchStub = stubFetch((url) => {
1814+
if (url.endsWith("/jwt")) return { body: { token: "jwt_1" } };
1815+
return { status: 400, body: { error: "Unknown column createdAt" } };
1816+
});
1817+
try {
1818+
const result = await renderView(ENV_CTX, {
1819+
blocks: [{ ...CHART_SPEC.blocks[0], query: "SELECT createdAt FROM runs" }],
1820+
});
1821+
expect(result.error).toContain("Unknown column createdAt");
1822+
expect(result.blocks).toBeUndefined();
1823+
expect(queryRequests(fetchStub.requests)).toHaveLength(1);
1824+
} finally {
1825+
fetchStub.restore();
1826+
}
1827+
});
1828+
1829+
it("render_view commits the chart when its query runs, validating it once", async () => {
1830+
const fetchStub = stubFetch((url, init) => {
1831+
if (url.endsWith("/jwt")) return { body: { token: "jwt_1" } };
1832+
// The validation runs the same window the panel will render.
1833+
expect(JSON.parse(String(init?.body))).toMatchObject({
1834+
scope: "environment",
1835+
period: "24h",
1836+
});
1837+
return { body: { results: [{ bucket: "2026-01-01T00:00:00Z", runs: 1 }] } };
1838+
});
1839+
try {
1840+
// The rows aren't embedded in the block — the panel stays the runner.
1841+
await expect(renderView(ENV_CTX, CHART_SPEC)).resolves.toEqual({ blocks: CHART_SPEC.blocks });
1842+
expect(queryRequests(fetchStub.requests)).toHaveLength(1);
1843+
} finally {
1844+
fetchStub.restore();
1845+
}
1846+
});
1847+
1848+
it("render_view commits the chart when the validation request itself fails", async () => {
1849+
const fetchStub = stubFetch((url) => {
1850+
if (url.endsWith("/jwt")) return { body: { token: "jwt_1" } };
1851+
throw new Error("ECONNREFUSED");
1852+
});
1853+
try {
1854+
await expect(renderView(ENV_CTX, CHART_SPEC)).resolves.toEqual({ blocks: CHART_SPEC.blocks });
1855+
} finally {
1856+
fetchStub.restore();
1857+
}
1858+
});
1859+
1860+
it("render_view skips chart validation when the turn carries no delegated token", async () => {
1861+
const fetchStub = stubFetch(() => ({ status: 400, body: { error: "never asked" } }));
1862+
try {
1863+
await expect(renderView({}, CHART_SPEC)).resolves.toEqual({ blocks: CHART_SPEC.blocks });
1864+
expect(fetchStub.requests).toEqual([]);
1865+
} finally {
1866+
fetchStub.restore();
1867+
}
1868+
});
1869+
1870+
it("render_view never runs a query for non-chart blocks", async () => {
1871+
const fetchStub = stubFetch(() => ({ status: 400, body: { error: "never asked" } }));
1872+
try {
1873+
const blocks = [
1874+
{
1875+
type: "diagnosis",
1876+
runId: "run_abc123",
1877+
summary: "The task threw because the order had no line items.",
1878+
category: "user_code_error",
1879+
likelyCause: "processOrder throws when items is empty.",
1880+
confidence: "high",
1881+
evidence: [{ type: "error", detail: "Error: order has no items" }],
1882+
nextSteps: ["Validate the payload before triggering."],
1883+
},
1884+
];
1885+
await expect(renderView(ENV_CTX, { blocks })).resolves.toEqual({ blocks });
1886+
expect(fetchStub.requests).toEqual([]);
1887+
} finally {
1888+
fetchStub.restore();
1889+
}
1890+
});
1891+
17831892
it("get_current_page returns the turn's structured page context", async () => {
17841893
const pageContext = {
17851894
page: { kind: "run" as const, runId: "run_1", status: "FAILED", taskId: "send-receipt" },

internal-packages/dashboard-agent/src/tool-schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ Investigations:
637637
638638
Answering with data and charts:
639639
- For questions about metrics, trends, counts, rates, costs, or "over time" / "by task" style aggregations, query the analytics data. First call get_query_schema (no table to list the tables, then a table name for its columns), then write a TRQL query. TRQL is SQL-style over ClickHouse: bucket time with toStartOfHour/toStartOfDay on the table's time column, produce one numeric column per series with countIf/sumIf, always include a time filter, and keep the result aggregated to a few dozen points.
640-
- To chart the answer, call render_view with a "chart" block containing the TRQL query itself plus chartType (line for trends over time, bar for categories), xAxisColumn, yAxisColumns, and groupByColumn when you split a single value column into series. The panel runs the query and renders it, so you don't have to run_query first just to chart — but the panel runs it AFTER your turn, so a broken query becomes a broken chart you never see. Column names are snake_case and the runs time column is triggered_at (not created_at); when unsure of a column, check get_query_schema before charting.
640+
- To chart the answer, call render_view with a "chart" block containing the TRQL query itself plus chartType (line for trends over time, bar for categories), xAxisColumn, yAxisColumns, and groupByColumn when you split a single value column into series. The panel runs the query and renders it, so you don't have to run_query first just to chart — render_view runs the query to check it and fails with the error if it's broken, so read that message and render again. Column names are snake_case and the runs time column is triggered_at (not created_at); when unsure of a column, check get_query_schema before charting.
641641
- Use run_query when you want to state specific numbers in prose, or to sanity-check a query before charting. If it returns an error, read the message and fix the query.
642642
- A chart never answers alone. A superlative or ranking question — "which tasks fail most", "what's slowest", "which queue is busiest" — is answered IN PROSE, naming the winner and its number ("send-order-receipt — 3 of the 4 failures"); the chart illustrates that answer, it is not the answer. Run the query with run_query when you need the number to say it.
643643
- On a ranking or failures chart, give the top item buttons through the chart block's "actions": an ask action phrasing the user's own follow-up ("Investigate the send-order-receipt failures — why are they failing?"), plus a navigate action to the page that shows it (its filtered runs list, its error, its queue) when you hold a canonical trigger:// target for it. Two or three, never more.

internal-packages/dashboard-agent/src/tools.ts

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type ParsedTriggerUri,
1818
type ViewBlockInput,
1919
} from "@internal/dashboard-agent-contracts";
20+
import { logger } from "@trigger.dev/sdk";
2021
import { tool, type ToolSet } from "ai";
2122
import {
2223
askSupportSchema,
@@ -116,6 +117,13 @@ export type InvestigationsCapability = {
116117

117118
type FetchResult = { ok: true; data: unknown } | { ok: false; status: number };
118119

120+
// A query POST outcome, split by blame: "query" is the server rejecting the TRQL
121+
// itself (a 4xx carrying a message the model can act on), "transport" is the
122+
// request or the server breaking. Chart validation only fails a render on "query".
123+
type QueryPostResult =
124+
| { ok: true; rows: Array<Record<string, unknown>> }
125+
| { ok: false; kind: "query" | "transport"; error: string };
126+
119127
async function apiGet(origin: string, path: string, token: string): Promise<FetchResult> {
120128
const res = await fetch(`${origin}${path}`, {
121129
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
@@ -548,6 +556,74 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
548556
return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet);
549557
}
550558

559+
/**
560+
* Run a TRQL query against the query API. A POST, so it can't use envApiGet —
561+
* same JWT cache and same one-shot re-exchange on a 401, spelled out here.
562+
* `null` means there is no current environment. Shared by the run_query tool
563+
* and chart-block validation, so both see the same window and the same errors.
564+
*/
565+
async function postQuery(
566+
query: string,
567+
period: string | undefined
568+
): Promise<QueryPostResult | null> {
569+
const attempt = await withEnvJwt<{ res: Response } | { error: string }>(
570+
async (jwt) => {
571+
try {
572+
return {
573+
res: await fetch(`${origin}/api/v1/query`, {
574+
method: "POST",
575+
headers: {
576+
Authorization: `Bearer ${jwt}`,
577+
"Content-Type": "application/json",
578+
Accept: "application/json",
579+
},
580+
body: JSON.stringify({ query, scope: "environment", period, format: "json" }),
581+
}),
582+
};
583+
} catch (error) {
584+
return { error: `Query request failed: ${(error as Error).message}` };
585+
}
586+
},
587+
(result) => "res" in result && result.res.status === 401
588+
);
589+
if (!attempt) return null;
590+
if ("error" in attempt) return { ok: false, kind: "transport", error: attempt.error };
591+
const res = attempt.res;
592+
// The route returns 400 with { error } for invalid TRQL; surface it so the
593+
// model can fix the query rather than the turn dying.
594+
const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string };
595+
if (!res.ok) {
596+
return {
597+
ok: false,
598+
kind: res.status >= 500 ? "transport" : "query",
599+
error: data.error ?? `Query failed (status ${res.status}).`,
600+
};
601+
}
602+
return {
603+
ok: true,
604+
rows: Array.isArray(data.results) ? (data.results as Array<Record<string, unknown>>) : [],
605+
};
606+
}
607+
608+
/**
609+
* The query error a chart block's query produced, or null when it's fine — or
610+
* when we can't tell. Validation is a tripwire, not a gate: with no delegated
611+
* token (e.g. a wake or action turn) or on a broken request we skip it rather
612+
* than block the render.
613+
*/
614+
async function validateChartQuery(
615+
query: string,
616+
period: string | undefined
617+
): Promise<string | null> {
618+
const result = await postQuery(query, period);
619+
if (!result || result.ok) return null;
620+
if (result.kind === "transport") {
621+
logger.warn("Skipped chart query validation", { error: result.error });
622+
return null;
623+
}
624+
return result.error;
625+
}
626+
551627
/**
552628
* One request to the watch-alerts routes, as the user (delegated token).
553629
*
@@ -1253,39 +1329,11 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
12531329
run_query: tool({
12541330
...runQuerySchema,
12551331
execute: async ({ query, period }) => {
1256-
// POST, so it can't use envApiGet — same JWT cache and same one-shot
1257-
// re-exchange on a 401, spelled out around the query request.
1258-
const attempt = await withEnvJwt<{ res: Response } | { error: string }>(
1259-
async (jwt) => {
1260-
try {
1261-
return {
1262-
res: await fetch(`${origin}/api/v1/query`, {
1263-
method: "POST",
1264-
headers: {
1265-
Authorization: `Bearer ${jwt}`,
1266-
"Content-Type": "application/json",
1267-
Accept: "application/json",
1268-
},
1269-
body: JSON.stringify({ query, scope: "environment", period, format: "json" }),
1270-
}),
1271-
};
1272-
} catch (error) {
1273-
return { error: `Query request failed: ${(error as Error).message}` };
1274-
}
1275-
},
1276-
(result) => "res" in result && result.res.status === 401
1277-
);
1278-
if (!attempt) return { error: "No current environment is available to query." };
1279-
if ("error" in attempt) return attempt;
1280-
const res = attempt.res;
1281-
// The route returns 400 with { error } for invalid TRQL; surface it so
1282-
// the model can fix the query rather than the turn dying.
1283-
const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string };
1284-
if (!res.ok) return { error: data.error ?? `Query failed (status ${res.status}).` };
1285-
const rows = Array.isArray(data.results)
1286-
? (data.results as Array<Record<string, unknown>>)
1287-
: [];
1332+
const result = await postQuery(query, period);
1333+
if (!result) return { error: "No current environment is available to query." };
1334+
if (!result.ok) return { error: result.error };
12881335
const cap = 200;
1336+
const rows = result.rows;
12891337
return { rows: rows.slice(0, cap), rowCount: rows.length, truncated: rows.length > cap };
12901338
},
12911339
}),
@@ -1349,9 +1397,27 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
13491397
// An `investigation` block is the exception — see `renderInvestigations`: it
13501398
// is the one progressive block, so the executor (not the model) owns its
13511399
// identity and commits each revision before the block reaches the transcript.
1400+
//
1401+
// A `chart` block gets its query run here first. The panel runs that query
1402+
// after the turn, so a broken one would leave a chart the model never learns
1403+
// failed; validating now turns it into a named failure the model can fix in
1404+
// this turn. The rows are thrown away — the panel stays the runner and the
1405+
// renderer — and the double execution is near-free thanks to ClickHouse's
1406+
// 30s query cache.
13521407
render_view: tool({
13531408
...renderViewSchema,
1354-
execute: async (view) => renderInvestigations(view.blocks, view.investigationId),
1409+
execute: async (view) => {
1410+
for (const block of view.blocks) {
1411+
if (block.type !== "chart") continue;
1412+
const queryError = await validateChartQuery(block.query, block.period);
1413+
if (queryError) {
1414+
return {
1415+
error: `The chart query failed: ${queryError}. Fix the query — column names are snake_case — and render the chart again.`,
1416+
};
1417+
}
1418+
}
1419+
return renderInvestigations(view.blocks, view.investigationId);
1420+
},
13551421
}),
13561422

13571423
get_report: tool({

0 commit comments

Comments
 (0)