chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,3 @@
node_modules
.trigger
.env
@@ -0,0 +1,42 @@
# @internal/dashboard-agent
The in-dashboard agent, built on `chat.agent` and deployed as its own Trigger
project. This is the launch-week dogfood: we run our own product on the
primitive we ship.
## Why a separate package (not inside apps/webapp)
The agent has **no access to the main database, ClickHouse, or webapp
internals** — it reads everything via the API. Living in a standalone package
that doesn't depend on the webapp makes that firewall **structural**: the
package physically cannot import webapp server code. It also keeps the webapp a
pure Remix app instead of a dual Remix-app-and-Trigger-project, and gives the
agent a small, fast, independently deployable + testable build context.
It writes conversation state to its own datastore via `@internal/dashboard-agent-db`
(the same package the webapp reads from for the History tab). It never touches
Prisma.
## Deploy / dev
This is a Trigger project with its own `trigger.config.ts`. The project ref is
read from `TRIGGER_DASHBOARD_AGENT_PROJECT_REF` (never hardcoded — public repo).
```bash
cd internal-packages/dashboard-agent
TRIGGER_DASHBOARD_AGENT_PROJECT_REF=<your-project> pnpm run dev # trigger dev
TRIGGER_DASHBOARD_AGENT_PROJECT_REF=<your-project> pnpm run deploy # trigger deploy
```
Runtime env the deployed task needs: `DASHBOARD_AGENT_DATABASE_URL` (the agent
datastore) and `OBJECT_STORE_*` (chat.agent's built-in conversation snapshot).
## Consumed by the webapp
The webapp imports only the task **type** for transport type-safety:
```ts
import type { dashboardAgent } from "@internal/dashboard-agent";
```
Never a value import (see `src/index.ts`).
@@ -0,0 +1,33 @@
// Load the monorepo root .env (the package's .env is a symlink to it) so the
// real-model evals pick up ANTHROPIC_API_KEY without the caller exporting it.
// Runs as a vitest setupFile before the eval modules are imported, so the
// `ANTHROPIC_API_KEY` gate in dashboard-agent.eval.ts sees the loaded value.
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
resolve(here, ".env"), // package symlink to the root .env
resolve(here, "../../.env"), // monorepo root
resolve(process.cwd(), ".env"),
];
for (const path of candidates) {
if (!existsSync(path)) continue;
for (const line of readFileSync(path, "utf8").split("\n")) {
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
if (!match) continue;
const key = match[1]!;
if (process.env[key] !== undefined) continue;
let value = match[2]!.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
break;
}
@@ -0,0 +1,33 @@
{
"name": "@internal/dashboard-agent",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"exports": {
".": "./src/index.ts",
"./tool-schemas": "./src/tool-schemas.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@internal/dashboard-agent-db": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"ai": "^6.0.116",
"zod": "3.25.76"
},
"devDependencies": {
"@ai-sdk/provider": "3.0.8",
"@trigger.dev/build": "workspace:*",
"trigger.dev": "workspace:*",
"vitest": "4.1.7"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:evals": "vitest run --config vitest.eval.config.ts",
"dev": "trigger dev",
"deploy": "trigger deploy"
}
}
@@ -0,0 +1,293 @@
// Evals for the dashboard agent: they run the REAL model through the agent and
// score behavior, which unit tests (mock model) can't. Two layers, following
// common practice (DeepEval "tool correctness", Vercel AI SDK + vitest evals,
// LLM-as-judge with an analytic rubric):
//
// 1. Tool selection — does the model pick the right read tool for a question?
// Scored as an aggregate pass rate with a threshold, since a real model is
// nondeterministic; a single miss shouldn't red the suite, a trend should.
// 2. Answer quality — an LLM judge scores the final answer against the tool
// data it was given (reason-before-score, structured output, grounded on
// facts to blunt verbosity/self-enhancement bias).
//
// These hit the real Anthropic API (cost + nondeterminism), so they live in
// `*.eval.ts` (not run by `pnpm test`) and skip unless ANTHROPIC_API_KEY is set.
// Run with `pnpm --filter @internal/dashboard-agent run test:evals`.
//
// `@trigger.dev/sdk/ai/test` first so the resource catalog installs before the
// agent module registers.
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
import { anthropic } from "@ai-sdk/anthropic";
import { generateObject, tool, type ToolSet, type UIMessage, type UIMessageChunk } from "ai";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
dashboardAgent,
dashboardAgentModelKey,
dashboardAgentStoreKey,
dashboardAgentToolsKey,
type DashboardAgentStore,
} from "./dashboard-agent";
import { dashboardAgentToolSchemas } from "./tool-schemas";
const HAS_KEY = Boolean(process.env.ANTHROPIC_API_KEY);
// The agent's real model; a capable judge (a stronger/different judge would
// further reduce self-enhancement bias).
const AGENT_MODEL = "claude-sonnet-4-6";
const JUDGE_MODEL = "claude-sonnet-4-6";
const CLIENT_DATA = { userId: "user_eval", organizationId: "org_eval" };
const NOOP_STORE: DashboardAgentStore = {
ensureChat: async () => {},
persistMessages: async () => {},
persistTurn: async () => {},
setChatTitleIfDefault: async () => {},
};
// Realistic, fixed tool results so the model has something concrete to act on
// and the judge has a ground truth to check the answer against.
const FIXTURES: Record<string, unknown> = {
list_projects: {
projects: [{ ref: "proj_eval1", name: "Checkout", slug: "checkout", organization: "Acme" }],
},
list_environments: {
environments: [
{ slug: "dev", type: "DEVELOPMENT", paused: false },
{ slug: "prod", type: "PRODUCTION", paused: false },
],
},
list_tasks: {
tasks: [
{ slug: "send-receipt", filePath: "src/trigger/receipt.ts", triggerSource: "STANDARD" },
{ slug: "nightly-rollup", filePath: "src/trigger/rollup.ts", triggerSource: "SCHEDULED" },
],
},
list_runs: {
runs: [
{ id: "run_a1", status: "FAILED", taskIdentifier: "send-receipt", durationMs: 0 },
{ id: "run_a2", status: "COMPLETED", taskIdentifier: "send-receipt", durationMs: 1200 },
],
nextCursor: undefined,
},
get_run: {
id: "run_a1",
status: "FAILED",
taskIdentifier: "send-receipt",
durationMs: 0,
error: { name: "TimeoutError", message: "Stripe API timed out after 30s" },
},
get_run_trace: {
traceId: "trace_a1",
spans: [
{ depth: 0, task: "send-receipt", durationMs: 30010, isError: true, message: "run" },
{ depth: 1, durationMs: 30000, isError: true, message: "POST api.stripe.com/charges" },
],
truncated: false,
},
list_errors: {
errors: [
{
id: "error_stripe",
taskIdentifier: "send-receipt",
errorType: "TimeoutError",
errorMessage: "Stripe API timed out after 30s",
status: "unresolved",
count: 37,
},
{
id: "error_oom",
taskIdentifier: "nightly-rollup",
errorType: "OutOfMemoryError",
errorMessage: "JS heap out of memory",
status: "ignored",
count: 4,
},
],
nextCursor: undefined,
},
get_error: {
id: "error_stripe",
taskIdentifier: "send-receipt",
errorType: "TimeoutError",
errorMessage: "Stripe API timed out after 30s",
status: "unresolved",
count: 37,
affectedVersions: ["20260101.1", "20260102.1"],
resolvedAt: null,
},
};
// Real schemas (so the model sees the real tool descriptions) + stubbed executes
// that record each call (a spy) and return the fixture. This is the seam that
// lets us observe tool selection and judge answers with no live API.
function makeFixtureTools(calls: Array<{ tool: string; input: unknown }>): ToolSet {
const entries = Object.entries(dashboardAgentToolSchemas).map(([name, schema]) => {
const s = schema as { description?: string; inputSchema: z.ZodTypeAny };
const withExecute = tool({
description: s.description,
inputSchema: s.inputSchema,
execute: async (input: unknown) => {
calls.push({ tool: name, input });
// render_view is a presentation tool: it echoes the spec, like the real one.
if (name === "render_view") return input;
return FIXTURES[name] ?? {};
},
});
return [name, withExecute] as const;
});
return Object.fromEntries(entries) as ToolSet;
}
function userMessage(text: string, id = "u1"): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function collectText(chunks: UIMessageChunk[]): string {
return chunks
.filter((c): c is Extract<UIMessageChunk, { type: "text-delta" }> => c.type === "text-delta")
.map((c) => c.delta)
.join("");
}
let caseCounter = 0;
async function runCase(question: string): Promise<{
calls: Array<{ tool: string; input: unknown }>;
answer: string;
}> {
const calls: Array<{ tool: string; input: unknown }> = [];
const harness = mockChatAgent(dashboardAgent, {
chatId: `eval_${caseCounter++}`,
clientData: CLIENT_DATA,
setupLocals: ({ set }) => {
set(dashboardAgentStoreKey, NOOP_STORE);
set(dashboardAgentModelKey, anthropic(AGENT_MODEL));
set(dashboardAgentToolsKey, makeFixtureTools(calls));
},
});
try {
const turn = await harness.sendMessage(userMessage(question));
return { calls, answer: collectText(turn.chunks) };
} finally {
await harness.close();
}
}
// ---------------------------------------------------------------------------
// LLM-as-judge: analytic rubric, reason-before-score, structured output.
// ---------------------------------------------------------------------------
const Verdict = z.object({
reasoning: z.string().describe("One or two sentences of reasoning, written BEFORE the scores."),
grounded: z
.number()
.int()
.min(1)
.max(5)
.describe(
"Is every fact in the answer present in the tool data? Penalize any run id, error name, count, status, version, or metric not in the data. 5 = fully grounded, 1 = fabricated."
),
answersQuestion: z
.number()
.int()
.min(1)
.max(5)
.describe("Does the answer directly address the user's question? 5 = fully, 1 = not at all."),
concise: z
.number()
.int()
.min(1)
.max(5)
.describe("Direct and free of padding. Do not reward length."),
});
const JUDGE_SYSTEM = [
"You are a strict evaluator of a Trigger.dev dashboard assistant.",
"You are given the user's question, the data the assistant retrieved through its tools (treat this as the only ground truth), and the assistant's answer.",
"Reason briefly first, then score each criterion from 1 to 5.",
"Judge only on factual grounding and whether the question is answered. Do NOT reward verbosity, confidence, or style. Penalize any value (run id, error name, count, status, version, metric) that does not appear in the tool data.",
].join(" ");
async function judge(args: {
question: string;
toolData: unknown;
answer: string;
}): Promise<z.infer<typeof Verdict>> {
const { object } = await generateObject({
model: anthropic(JUDGE_MODEL),
schema: Verdict,
system: JUDGE_SYSTEM,
prompt: [
`User question:\n${args.question}`,
`Tool data (ground truth):\n${JSON.stringify(args.toolData, null, 2)}`,
`Assistant answer:\n${args.answer}`,
"Score the answer.",
].join("\n\n"),
});
return object;
}
// ---------------------------------------------------------------------------
// Tool-selection cases
// ---------------------------------------------------------------------------
const TOOL_CASES: Array<{ question: string; expect: string }> = [
{ question: "What errors are happening in this environment?", expect: "list_errors" },
{ question: "What's broken right now?", expect: "list_errors" },
{ question: "Are there any unresolved errors?", expect: "list_errors" },
{ question: "Give me the full detail for error_stripe.", expect: "get_error" },
{ question: "Show me the runs behind the error error_stripe.", expect: "list_runs" },
{ question: "Show me the failed runs in this environment.", expect: "list_runs" },
{ question: "List the most recent runs of the send-receipt task.", expect: "list_runs" },
{ question: "What's the status of run run_a1?", expect: "get_run" },
{ question: "Why did run run_a1 fail? Walk me through what happened.", expect: "get_run_trace" },
{ question: "What tasks are deployed in this environment?", expect: "list_tasks" },
{ question: "Which projects can I access?", expect: "list_projects" },
{ question: "What environments does this project have?", expect: "list_environments" },
];
const TOOL_SELECTION_THRESHOLD = 0.83; // tolerate ~2/12 misses; a trend reds the suite
describe.skipIf(!HAS_KEY)("dashboardAgent evals (real model)", () => {
it("tool selection: picks the right tool for the question", async () => {
const results: Array<{ question: string; expected: string; got: string; ok: boolean }> = [];
for (const c of TOOL_CASES) {
const { calls } = await runCase(c.question);
const got = calls[0]?.tool ?? "(none)";
results.push({ question: c.question, expected: c.expect, got, ok: got === c.expect });
}
const passed = results.filter((r) => r.ok).length;
const rate = passed / results.length;
// Surface the full table so a failing case is diagnosable, not just a number.
// process.stdout.write (not console.log) so it survives vitest's console intercept.
process.stdout.write(
`\ntool selection: ${passed}/${results.length} (${(rate * 100).toFixed(0)}%)\n` +
results
.map(
(r) =>
` ${r.ok ? "PASS" : "FAIL"} ${r.got.padEnd(18)} (want ${r.expected}) ${r.question}`
)
.join("\n") +
"\n"
);
expect(rate).toBeGreaterThanOrEqual(TOOL_SELECTION_THRESHOLD);
}, 180_000);
it("answer quality: grounded and on-question (LLM judge)", async () => {
const question = "What errors are happening in this environment? Summarize the top ones.";
const { calls, answer } = await runCase(question);
expect(calls[0]?.tool).toBe("list_errors");
expect(answer.length).toBeGreaterThan(0);
const verdict = await judge({ question, toolData: FIXTURES.list_errors, answer });
process.stdout.write(`\nanswer:\n${answer}\n\njudge: ${JSON.stringify(verdict)}\n`);
expect(verdict.grounded).toBeGreaterThanOrEqual(4);
expect(verdict.answersQuestion).toBeGreaterThanOrEqual(4);
}, 120_000);
});
@@ -0,0 +1,315 @@
// `@trigger.dev/sdk/ai/test` MUST be imported before the agent module so the
// resource catalog is installed before `chat.agent({ id })` / `prompts.define`
// register at module load.
import { mockChatAgent, type MockChatAgentHarness } from "@trigger.dev/sdk/ai/test";
import type {
LanguageModelV3FinishReason,
LanguageModelV3StreamPart,
LanguageModelV3Usage,
} from "@ai-sdk/provider";
import { simulateReadableStream, type UIMessage, type UIMessageChunk } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { afterEach, describe, expect, it } from "vitest";
import {
dashboardAgent,
dashboardAgentModelKey,
dashboardAgentStoreKey,
type DashboardAgentStore,
} from "./dashboard-agent";
import { buildDashboardAgentTools } from "./tools";
// ---------------------------------------------------------------------------
// Mock model helpers
// ---------------------------------------------------------------------------
const USAGE: LanguageModelV3Usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 5, text: 5, reasoning: undefined },
};
function finish(unified: LanguageModelV3FinishReason["unified"]): LanguageModelV3StreamPart {
return { type: "finish", finishReason: { unified, raw: unified }, usage: USAGE };
}
function textStep(text: string, id = "t1"): LanguageModelV3StreamPart[] {
return [
{ type: "text-start", id },
{ type: "text-delta", id, delta: text },
{ type: "text-end", id },
finish("stop"),
];
}
function toolCallStep(
toolName: string,
input: Record<string, unknown> = {},
toolCallId = "tc1"
): LanguageModelV3StreamPart[] {
return [
{ type: "tool-call", toolCallId, toolName, input: JSON.stringify(input) },
finish("tool-calls"),
];
}
/**
* A MockLanguageModelV3 that plays one stream per `streamText` step (call), plus
* a `doGenerate` for the background title generation (`generateText`). Each
* `doStream` call returns a fresh stream for the next entry in `steps` (the last
* entry repeats if the model is called more times than there are steps).
*/
function mockModel(steps: LanguageModelV3StreamPart[][], titleText = "Test Chat Title") {
let call = 0;
return new MockLanguageModelV3({
doStream: async () => {
const chunks = steps[Math.min(call, steps.length - 1)] ?? [];
call++;
return { stream: simulateReadableStream({ chunks }) };
},
doGenerate: async () => ({
content: [{ type: "text", text: titleText }],
finishReason: { unified: "stop", raw: "stop" },
usage: USAGE,
warnings: [],
}),
});
}
// ---------------------------------------------------------------------------
// Fake store — records the persistence the agent performs
// ---------------------------------------------------------------------------
type StoreCalls = {
ensureChat: unknown[];
persistMessages: unknown[];
persistTurn: unknown[];
setChatTitleIfDefault: unknown[];
};
function fakeStore(): { store: DashboardAgentStore; calls: StoreCalls } {
const calls: StoreCalls = {
ensureChat: [],
persistMessages: [],
persistTurn: [],
setChatTitleIfDefault: [],
};
const store: DashboardAgentStore = {
ensureChat: async (args) => void calls.ensureChat.push(args),
persistMessages: async (args) => void calls.persistMessages.push(args),
persistTurn: async (args) => void calls.persistTurn.push(args),
setChatTitleIfDefault: async (args) => void calls.setChatTitleIfDefault.push(args),
};
return { store, calls };
}
const CLIENT_DATA = { userId: "user_1", organizationId: "org_1" };
function userMessage(text: string, id = "u1"): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function collectText(chunks: UIMessageChunk[]): string {
return chunks
.filter((c): c is Extract<UIMessageChunk, { type: "text-delta" }> => c.type === "text-delta")
.map((c) => c.delta)
.join("");
}
// A tool executed when the agent emits a `tool-output-available` chunk (carries
// the result, keyed by toolCallId). On a head-start handover the tool-call is
// supplied by the handover partial rather than streamed by the model, so the
// output chunk is the only reliable signal that the call actually ran.
function executedTool(chunks: UIMessageChunk[]): boolean {
return chunks.some((c) => (c as { type?: string }).type === "tool-output-available");
}
// ---------------------------------------------------------------------------
// Harness tests
// ---------------------------------------------------------------------------
describe("dashboardAgent (mock harness)", () => {
let harness: MockChatAgentHarness | undefined;
afterEach(async () => {
await harness?.close();
harness = undefined;
});
it("streams the model's response and persists the turn", async () => {
const { store, calls } = fakeStore();
harness = mockChatAgent(dashboardAgent, {
chatId: "chat_text",
clientData: CLIENT_DATA,
setupLocals: ({ set }) => {
set(dashboardAgentStoreKey, store);
set(dashboardAgentModelKey, mockModel([textStep("hello from the agent")]));
},
});
const turn = await harness.sendMessage(userMessage("hi"));
expect(collectText(turn.chunks)).toBe("hello from the agent");
// Persistence ran through the injected store, not a real database.
expect(calls.ensureChat).toHaveLength(1);
expect(calls.persistMessages).toHaveLength(1);
// onTurnComplete persists after the turn-complete chunk; give it a tick.
await new Promise((r) => setTimeout(r, 30));
expect(calls.persistTurn).toHaveLength(1);
});
it("executes a read tool the model calls, then answers from the result", async () => {
const { store } = fakeStore();
harness = mockChatAgent(dashboardAgent, {
chatId: "chat_tool",
clientData: CLIENT_DATA,
setupLocals: ({ set }) => {
set(dashboardAgentStoreKey, store);
// Step 1: the model calls list_errors. Step 2: it answers.
set(
dashboardAgentModelKey,
mockModel([toolCallStep("list_errors"), textStep("you have no errors")])
);
},
});
const turn = await harness.sendMessage(userMessage("any errors?"));
// The tool executed inside the agent (no delegated token in clientData, so it
// returns its graceful no-auth result — no network), and the model answered.
expect(executedTool(turn.chunks)).toBe(true);
expect(collectText(turn.chunks)).toBe("you have no errors");
});
it("rolls an Anthropic cache breakpoint onto the last message", async () => {
const { store } = fakeStore();
const model = mockModel([textStep("cached")]);
harness = mockChatAgent(dashboardAgent, {
chatId: "chat_cache",
clientData: CLIENT_DATA,
setupLocals: ({ set }) => {
set(dashboardAgentStoreKey, store);
set(dashboardAgentModelKey, model);
},
});
await harness.sendMessage(userMessage("hi"));
// The prepareMessages hook should have placed a cacheControl breakpoint on
// the last message of the prompt the model received.
const prompt = model.doStreamCalls[0]?.prompt ?? [];
const last = prompt[prompt.length - 1] as { providerOptions?: Record<string, unknown> };
expect(last?.providerOptions?.anthropic).toMatchObject({
cacheControl: { type: "ephemeral" },
});
});
it("Head Start handover: executes the handed-over tool call despite the cache hook (regression)", async () => {
const { store } = fakeStore();
// Only step 2 runs in the agent — the warm route already did step 1 and hands
// over the pending tool call.
const model = mockModel([textStep("resolved from the tool")]);
harness = mockChatAgent(dashboardAgent, {
chatId: "chat_headstart",
clientData: CLIENT_DATA,
mode: "handover-prepare",
headStartMessages: [userMessage("what errors are happening?")],
setupLocals: ({ set }) => {
set(dashboardAgentStoreKey, store);
set(dashboardAgentModelKey, model);
},
});
// The reshaped partial the SDK's chat.headStart sends on a tool-calls finish:
// a tool-approval round whose trailing tool message must survive prepareMessages
// for collectToolApprovals to execute the pending call.
const toolCallId = "tc_hs";
const approvalId = "ap_hs";
const turn = await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId, toolName: "list_errors", input: {} },
{ type: "tool-approval-request", approvalId, toolCallId },
],
},
{
role: "tool",
content: [{ type: "tool-approval-response", approvalId, approved: true }],
},
],
isFinal: false,
});
// With the SDK guard (preserveToolApprovalTail) the handed-over tool executes
// and the model answers from its result. Without it, the bare tool_use would
// never execute (no tool output) — this is the regression guard.
expect(executedTool(turn.chunks)).toBe(true);
expect(collectText(turn.chunks)).toBe("resolved from the tool");
});
});
// ---------------------------------------------------------------------------
// Tool unit tests (no harness) — the data lane fails closed without a token
// ---------------------------------------------------------------------------
describe("buildDashboardAgentTools", () => {
it("exposes the read tools plus render_view, and the data tools fail closed with no token", async () => {
const tools = buildDashboardAgentTools({});
expect(Object.keys(tools).sort()).toEqual(
[
"ask_support",
"get_error",
"get_query_schema",
"get_run",
"get_run_trace",
"list_environments",
"list_errors",
"list_projects",
"list_runs",
"list_tasks",
"run_query",
"render_view",
].sort()
);
// No userActorToken / apiOrigin => every data tool returns a graceful
// error, never throws and never hits the network. render_view is a
// presentation tool and ask_support is gated on its own env config
// (not the token), so both are exempt.
for (const name of Object.keys(tools)) {
if (name === "render_view" || name === "ask_support") continue;
const tool = tools[name] as { execute?: (input: unknown, opts: unknown) => Promise<unknown> };
const result = (await tool.execute?.({}, {})) as { error?: string };
expect(result).toHaveProperty("error");
expect(typeof result.error).toBe("string");
}
});
it("render_view echoes a validated view spec back as its output", async () => {
const tools = buildDashboardAgentTools({});
const renderView = tools.render_view as {
execute: (input: unknown, opts: unknown) => Promise<unknown>;
};
const spec = {
blocks: [
{
type: "diagnosis",
runId: "run_abc123",
summary: "The task threw because the order had no line items.",
category: "user_code_error",
likelyCause: "processOrder throws when items is empty.",
confidence: "high",
evidence: [
{ type: "error", detail: "Error: order has no items", reference: "run_abc123" },
],
nextSteps: ["Validate the payload before triggering."],
},
],
};
const output = await renderView.execute(spec, {});
expect(output).toEqual(spec);
});
});
@@ -0,0 +1,387 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
createDashboardAgentDb,
ensureChat,
persistMessages,
persistTurn,
setChatTitleIfDefault,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { chat } from "@trigger.dev/sdk/ai";
import { locals, logger, tasks } from "@trigger.dev/sdk";
import {
createProviderRegistry,
generateText,
stepCountIs,
streamText,
type LanguageModel,
type ModelMessage,
type ToolSet,
type UIMessage,
} from "ai";
import { z } from "zod";
import type { EvalTurnPayload, evalTurn } from "./eval-turn";
import { codeSystemPrompt, systemPrompt, titlePrompt } from "./prompts";
import { buildDashboardAgentTools } from "./tools";
/**
* The in-dashboard agent, built on chat.agent and deployed as an internal task
* by the webapp. This is the launch-week dogfood: we run our own product on the
* primitive we ship.
*
* No tools yet: it answers from a dashboard-managed system prompt (Anthropic,
* resolved via the provider registry) with prompt caching, persists the
* conversation to the agent's own datastore (NOT the main DB — the agent has no
* access to that), and generates the chat title in the background. Runtime
* history is owned by chat.agent's built-in object-store snapshot; the rows we
* write here are the display read-model the dashboard's History tab and panel
* render from.
*/
// One connection pool per worker process. onBoot fires on every fresh worker
// (initial, preloaded, and continuation runs), so the pool is established there
// and reused across turns within the run.
let dbClient: DashboardAgentDbClient | undefined;
function getDb(): DashboardAgentDbClient {
if (!dbClient) {
const connectionString = process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DASHBOARD_AGENT_DATABASE_URL (or DATABASE_URL) must be set for the dashboard agent"
);
}
// Small client pool — the agent runs in many short-lived containers and the
// PlanetScale pooler does the real pooling.
dbClient = createDashboardAgentDb(connectionString, { max: 2 });
}
return dbClient;
}
// Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK
// models. Anthropic only for now; add another @ai-sdk/* provider here to let
// the dashboard pick its models on a prompt.
const registry = createProviderRegistry({ anthropic });
// The persistence the agent does against its own datastore, behind an interface
// so it can be injected. Production lazily builds one over the env-configured
// Drizzle client (below); unit tests inject a fake via `locals` (the DI pattern
// from the chat.agent testing guide) so the agent never needs a real database.
export interface DashboardAgentStore {
ensureChat(args: Parameters<typeof ensureChat>[1]): Promise<unknown>;
persistMessages(args: Parameters<typeof persistMessages>[1]): Promise<unknown>;
persistTurn(args: Parameters<typeof persistTurn>[1]): Promise<unknown>;
setChatTitleIfDefault(args: Parameters<typeof setChatTitleIfDefault>[1]): Promise<unknown>;
}
export const dashboardAgentStoreKey = locals.create<DashboardAgentStore>("dashboard-agent.store");
// Returns the injected store if a test seeded one, otherwise lazily builds the
// production store over the env-configured Drizzle client and caches it.
function getStore(): DashboardAgentStore {
const injected = locals.get(dashboardAgentStoreKey);
if (injected) return injected;
const { db } = getDb();
return locals.set(dashboardAgentStoreKey, {
ensureChat: (args) => ensureChat(db, args),
persistMessages: (args) => persistMessages(db, args),
persistTurn: (args) => persistTurn(db, args),
setChatTitleIfDefault: (args) => setChatTitleIfDefault(db, args),
});
}
// Optional language-model override. Production leaves this unset and resolves the
// model from the managed prompt through the provider registry; unit tests inject
// a mock model here so `run()` and title generation never reach a provider.
export const dashboardAgentModelKey = locals.create<LanguageModel>("dashboard-agent.model");
// Optional tool-set override. Production leaves this unset and builds the real
// tools per turn; tests and evals inject a fixture tool set (real schemas,
// stubbed executes) so the model's tool choice can be observed and its answers
// judged without a live API.
export const dashboardAgentToolsKey = locals.create<ToolSet>("dashboard-agent.tools");
// The system prompt is dashboard-managed (text + model + config). Resolving it
// is an API call, so cache it per worker process — workers are short-lived
// (idleTimeoutInSeconds), so a dashboard edit lands within a recycle.
type DashboardAgentMode = "assistant" | "code";
// A turn is in `code` mode when the `in` proxy injected a repo snapshot (i.e. the
// current project has a connected repo). Drives both the tool set and the prompt.
function modeFor(clientData: { repoSnapshot?: unknown } | undefined): DashboardAgentMode {
return clientData?.repoSnapshot ? "code" : "assistant";
}
let cachedSystemPrompt: Awaited<ReturnType<typeof systemPrompt.resolve>> | undefined;
let cachedCodePrompt: Awaited<ReturnType<typeof codeSystemPrompt.resolve>> | undefined;
async function getSystemPrompt(mode: DashboardAgentMode = "assistant") {
if (mode === "code") {
cachedCodePrompt ??= await codeSystemPrompt.resolve({});
return cachedCodePrompt;
}
cachedSystemPrompt ??= await systemPrompt.resolve({});
return cachedSystemPrompt;
}
function extractText(message: UIMessage): string {
return (message.parts ?? [])
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join(" ")
.trim();
}
// Pair this turn's tool-calls with their results (the ground truth the eval
// judge checks the answer against). Works off the model-format messages.
function extractToolActivity(
messages: ModelMessage[]
): Array<{ toolName: string; input?: unknown; output?: unknown }> {
const byId = new Map<string, { toolName: string; input?: unknown; output?: unknown }>();
for (const message of messages) {
if (!Array.isArray(message.content)) continue;
for (const part of message.content as Array<{
type: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
output?: unknown;
}>) {
if (part.type === "tool-call" && part.toolCallId) {
byId.set(part.toolCallId, { toolName: String(part.toolName ?? ""), input: part.input });
} else if (part.type === "tool-result" && part.toolCallId) {
const existing = byId.get(part.toolCallId);
if (existing) existing.output = part.output;
}
}
}
return [...byId.values()];
}
function cleanTitle(raw: string): string {
return raw
.trim()
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
}
// Generate a short title from the first user message using the cheaper title
// model, then write it only if the chat still has the default title. Runs in
// the background (chat.defer) so it never blocks the response.
async function generateAndSaveTitle(
store: DashboardAgentStore,
chatId: string,
uiMessages: UIMessage[]
): Promise<void> {
const firstUserMessage = uiMessages.find((message) => message.role === "user");
const userText = firstUserMessage ? extractText(firstUserMessage) : "";
if (!userText) return;
const resolved = await titlePrompt.resolve({});
const { text } = await generateText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}`
),
system: resolved.text,
prompt: userText,
...resolved.toAISDKTelemetry(),
});
const title = cleanTitle(text);
if (title) {
await store.setChatTitleIfDefault({ chatId, title });
}
}
// A chat belongs to an org + user. The current project/env (and the page) are
// per-turn context for the agent, not chat identity — one conversation can span
// several projects/envs.
const clientDataSchema = z.object({
userId: z.string(),
organizationId: z.string(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
currentPage: z.string().optional(),
// Injected server-side by the `in` proxy on each turn (never sent from the
// browser): a short-lived read-only delegated token for the user, the API
// origin to call back to, and the current project ref + env its tools read.
userActorToken: z.string().optional(),
apiOrigin: z.string().optional(),
projectRef: z.string().optional(),
environmentName: z.string().optional(),
// Injected only when the current project has a connected GitHub repo: a signed,
// short-lived archive pointer the code-mode source tools read from.
repoSnapshot: z
.object({
tarballUrl: z.string(),
owner: z.string(),
repo: z.string(),
sha: z.string(),
defaultBranch: z.string().optional(),
})
.optional(),
});
export const dashboardAgent = chat.agent({
id: "dashboard-agent",
clientDataSchema,
// Latency levers come next (Head Start, prompt caching, AI Prompts). Scaffold
// keeps a short idle window so suspended runs release their DB pool.
idleTimeoutInSeconds: 60,
// Read-only tools, rebuilt per turn from the delegated token the `in` proxy
// injects. Declaring them here (not just inside run) lets the SDK re-apply
// each tool's output conversion when it replays prior-turn history.
tools: async ({ clientData }) =>
locals.get(dashboardAgentToolsKey) ?? buildDashboardAgentTools(clientData ?? {}),
onBoot: async () => {
// Establish the store (and, in production, its connection pool) once.
getStore();
},
onChatStart: async ({ chatId, clientData }) => {
await getStore().ensureChat({
id: chatId,
organizationId: clientData.organizationId,
userId: clientData.userId,
metadata: {
context: {
projectId: clientData.projectId,
environmentId: clientData.environmentId,
currentPage: clientData.currentPage,
},
},
});
},
onTurnStart: async ({ chatId, uiMessages, clientData }) => {
// Make the user's message durable in the display copy before the model
// starts streaming. Awaited, never chat.defer — a mid-stream refresh must
// not read an empty transcript.
await getStore().persistMessages({ chatId, messages: uiMessages });
// Load the dashboard-managed system prompt for this turn. The code-mode
// variant is used when the project has a connected repo. Set every turn so
// continuation runs (which skip onChatStart) still get it; the resolve is
// cached per process. The Anthropic cache breakpoint on the system block
// carries through toStreamTextOptions() and survives suspend/resume.
chat.prompt.set(await getSystemPrompt(modeFor(clientData)), {
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
},
onTurnComplete: async ({
chatId,
turn,
uiMessages,
newMessages,
responseMessage,
clientData,
chatAccessToken,
lastEventId,
runId,
}) => {
// Persist the finalized transcript + refreshed session state in one
// transaction so a refresh on the next page load reads both consistently.
const store = getStore();
await store.persistTurn({
chatId,
messages: uiMessages,
session: {
publicAccessToken: chatAccessToken,
lastEventId,
runId,
},
});
// First exchange: generate a title with the cheaper title model in the
// background. Deferred from onTurnComplete, so it runs during the idle wait
// and never blocks the response; the write is conditional (default title).
if (uiMessages.length <= 2) {
chat.defer(generateAndSaveTitle(store, chatId, uiMessages));
}
// Runtime eval: score this turn in a SEPARATE, idempotency-keyed task so it
// never blocks or bills the agent run. Best-effort — enqueue failures must
// not break the turn. Every turn for now (internal, low volume); add a
// sample rate here when this scales.
if (clientData?.organizationId && clientData?.userId && responseMessage) {
try {
const resolved = await getSystemPrompt(modeFor(clientData));
// The current turn's question. On a Head Start turn it arrives in the
// boot payload (not in newUIMessages), so take the latest user message
// from the full transcript, which holds for normal turns too.
const userMessage = [...uiMessages].reverse().find((m) => m.role === "user");
await tasks.trigger<typeof evalTurn>(
"dashboard-agent-eval-turn",
{
chatId,
turn,
agentRunId: runId,
organizationId: clientData.organizationId,
userId: clientData.userId,
projectRef: clientData.projectRef,
environment: clientData.environmentName,
currentPage: clientData.currentPage,
model: resolved.model,
promptSlug: resolved.promptId,
promptVersion: resolved.version,
userText: userMessage ? extractText(userMessage) : "",
assistantText: extractText(responseMessage),
toolActivity: extractToolActivity(newMessages),
} satisfies EvalTurnPayload,
{ idempotencyKey: `eval:${chatId}:${turn}` }
);
} catch (error) {
logger.error("Failed to enqueue dashboard-agent turn eval", { error });
}
}
},
// Roll an Anthropic cache breakpoint onto the last message every turn so the
// growing conversation prefix is cached and read back cheaply. Composes with
// the system-block breakpoint above. This is the canonical prompt-caching
// pattern; chat.agent keeps the Head Start handover's tool-approval tail
// intact across this hook, so it's safe on a resume turn.
prepareMessages: ({ messages }) => {
if (messages.length === 0) return messages;
const last = messages[messages.length - 1];
return [
...messages.slice(0, -1),
{
...last,
providerOptions: {
...last.providerOptions,
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
];
},
// System prompt + model come from the managed prompt (set in onTurnStart),
// so they're dashboard-editable. toStreamTextOptions() supplies the system
// text (with its cache breakpoint), config, telemetry, and prepareStep
// wiring; the model string is resolved through the registry here so
// streamText keeps a typed model.
run: async ({ messages, signal, tools }) => {
const resolved = chat.prompt();
return streamText({
...chat.toStreamTextOptions({ tools }),
// Tests inject a mock model via locals; production resolves the managed
// prompt's model through the provider registry.
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
),
messages,
abortSignal: signal,
// toStreamTextOptions() defaults to a single step; override so the model
// can call a tool and then answer from its result in the same turn.
stopWhen: stepCountIs(10),
});
},
});
@@ -0,0 +1,200 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
createDashboardAgentDb,
insertTurnEval,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { logger, task } from "@trigger.dev/sdk";
import { generateObject } from "ai";
import { z } from "zod";
/**
* Runtime eval. The dashboard agent triggers this from `onTurnComplete` after
* every turn (decoupled task, idempotency-keyed, so it never blocks or bills the
* agent run). One LLM-judge call produces both a quality verdict (did the agent
* answer well, grounded in its tool results) and an insight classification
* (intent, outcome, sentiment, and whether the turn exposes a product / docs /
* support gap), then writes one `chat_turn_evals` row. Higher-level views ("top
* capability gaps", "what users struggle with") are aggregations over those rows.
*/
const JUDGE_MODEL = "claude-sonnet-4-6";
// One connection pool per worker process for the eval task (separate from the
// agent's; eval runs are their own runs and may land on other workers).
let dbClient: DashboardAgentDbClient | undefined;
function getEvalDb(): DashboardAgentDbClient {
if (!dbClient) {
const connectionString = process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DASHBOARD_AGENT_DATABASE_URL (or DATABASE_URL) must be set for the eval task"
);
}
dbClient = createDashboardAgentDb(connectionString, { max: 2 });
}
return dbClient;
}
/** What `onTurnComplete` hands the eval task. */
export type EvalTurnPayload = {
chatId: string;
turn: number;
agentRunId?: string;
organizationId: string;
userId: string;
projectRef?: string;
environment?: string;
currentPage?: string;
model?: string;
promptSlug?: string;
promptVersion?: number;
/** The user's question this turn. */
userText: string;
/** The agent's answer this turn. */
assistantText: string;
/** Tools the agent called this turn, with inputs and outputs (the judge's ground truth). */
toolActivity: Array<{ toolName: string; input?: unknown; output?: unknown }>;
};
const SIGNAL_TYPES = [
"missing_tool",
"missing_data",
"permission_blocked",
"docs_gap",
"feature_request",
"confusing_ux",
"hallucination",
"repeated_question",
] as const;
// Combined quality + insight verdict. Reasoning first (so the judge thinks
// before it scores), then the scores and classification.
const TurnEval = z.object({
reasoning: z.string().describe("One or two sentences of reasoning, BEFORE the scores."),
// Quality, 1-5.
grounded: z
.number()
.int()
.min(1)
.max(5)
.describe(
"Does the answer use only facts from the tool results? Penalize invented ids/counts/status. 5 = fully grounded."
),
answered: z
.number()
.int()
.min(1)
.max(5)
.describe("Does it directly answer the question? 5 = fully."),
concise: z.number().int().min(1).max(5).describe("Direct, no padding. Do not reward length."),
// Insight classification.
intentCategory: z
.enum(["debug_run", "find_data", "how_to", "config", "capability_request", "billing", "other"])
.describe("What the user was trying to do."),
outcome: z
.enum(["resolved", "partial", "unresolved", "deflected"])
.describe(
"Did the agent actually help? deflected = sent the user elsewhere without answering."
),
sentiment: z.enum(["positive", "neutral", "negative", "frustrated"]),
capabilityGap: z
.boolean()
.describe("The agent lacked a tool, data, or permission needed to fully help."),
docsGap: z
.boolean()
.describe("A how-to the agent answered weakly or that better docs would solve."),
supportOpportunity: z
.boolean()
.describe(
"The user seems stuck, blocked, or frustrated and would benefit from a human follow-up."
),
featureRequest: z.boolean().describe("The user wants something the product does not do."),
topics: z
.array(z.string())
.describe("1-3 short topic tags, e.g. 'concurrency', 'failed deploys'."),
signals: z
.array(
z.object({
type: z.enum(SIGNAL_TYPES),
severity: z.enum(["low", "med", "high"]),
detail: z.string(),
evidence: z.string().optional().describe("A short quote from the user or answer."),
suggestedAction: z.string().optional(),
})
)
.describe("Typed, actionable signals. Empty when the turn was clean."),
summary: z.string().describe("One line: what the user asked and how it went."),
});
const JUDGE_SYSTEM = [
"You evaluate one turn of the Trigger.dev dashboard assistant, a read-only agent that answers questions about a user's runs, tasks, errors, deployments, and environments by calling read tools.",
"You are given the user's question, the data the agent retrieved through its tools (treat this as the only ground truth), and the agent's answer.",
"Reason briefly first, then fill in the scores and classification.",
"Score quality only on factual grounding and whether the question was answered; do not reward verbosity or confidence. Penalize any run id, error name, count, status, version, or metric not present in the tool data.",
"Then classify the turn for product insight. Flag capabilityGap when the agent could not fully help because it lacked a tool, data, or permission (it is read-only, so any request to change something is a capability gap). Flag docsGap for how-to questions a doc would answer better. Flag supportOpportunity when the user seems stuck or frustrated. Flag featureRequest when they want something the product does not do. Capture concrete, actionable signals.",
].join(" ");
export const evalTurn = task({
id: "dashboard-agent-eval-turn",
run: async (payload: EvalTurnPayload, { ctx }) => {
const { object } = await generateObject({
model: anthropic(JUDGE_MODEL),
schema: TurnEval,
system: JUDGE_SYSTEM,
prompt: [
`User question:\n${payload.userText || "(none)"}`,
`Tools the agent called (ground truth):\n${JSON.stringify(payload.toolActivity, null, 2)}`,
`Agent answer:\n${payload.assistantText || "(empty)"}`,
"Evaluate this turn.",
].join("\n\n"),
});
const toolError = payload.toolActivity.some(
(t) => t.output != null && typeof t.output === "object" && "error" in (t.output as object)
);
await insertTurnEval(getEvalDb().db, {
chatId: payload.chatId,
turn: payload.turn,
organizationId: payload.organizationId,
userId: payload.userId,
agentRunId: payload.agentRunId,
evalRunId: ctx.run.id,
projectRef: payload.projectRef,
environment: payload.environment,
currentPage: payload.currentPage,
model: payload.model,
promptSlug: payload.promptSlug,
promptVersion: payload.promptVersion,
toolsUsed: payload.toolActivity.map((t) => t.toolName),
toolError,
judgeModel: JUDGE_MODEL,
scoreGrounded: object.grounded,
scoreAnswered: object.answered,
scoreConcise: object.concise,
passed: object.grounded >= 4 && object.answered >= 4,
intentCategory: object.intentCategory,
outcome: object.outcome,
sentiment: object.sentiment,
capabilityGap: object.capabilityGap,
docsGap: object.docsGap,
supportOpportunity: object.supportOpportunity,
featureRequest: object.featureRequest,
topics: object.topics,
signals: object.signals,
summary: object.summary,
userText: payload.userText,
judge: object,
});
logger.info("dashboard-agent turn evaluated", {
chatId: payload.chatId,
turn: payload.turn,
outcome: object.outcome,
passed: object.grounded >= 4 && object.answered >= 4,
});
return { summary: object.summary, outcome: object.outcome };
},
});
@@ -0,0 +1,11 @@
// The webapp imports the task TYPE from here for end-to-end transport typing:
// import type { dashboardAgent } from "@internal/dashboard-agent";
// useTriggerChatTransport<typeof dashboardAgent>({ task: "dashboard-agent", ... })
// Always import it `type`-only — a value import would pull the task's runtime
// dependencies (postgres, drizzle, ai) into the webapp bundle and try to
// register the task in the webapp's context.
export * from "./dashboard-agent.js";
// The view-catalog block types, for the webapp's render registry. Type-only —
// these come from the light schema module and pull no runtime into the bundle.
export type { ChartBlock, DiagnosisBlock, ViewBlock } from "./tool-schemas.js";
@@ -0,0 +1,50 @@
import { prompts } from "@trigger.dev/sdk";
import {
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
DASHBOARD_AGENT_MODEL,
DASHBOARD_AGENT_SYSTEM_PROMPT,
} from "./tool-schemas";
/**
* Managed prompts for the dashboard agent. Defining them here registers them
* with the resource catalog, so the CLI syncs them to the dashboard's Prompts
* page on deploy — where the text, model, and config become versionable and
* overridable without a redeploy. The `model` is a `"provider:model-id"` string
* resolved at runtime through the provider registry in `dashboard-agent.ts`.
*
* The system prompt's default text lives in `tool-schemas.ts` (a light module)
* so the head-start route can use the same default without importing the SDK
* runtime. A dashboard override only affects the agent run.
*/
export const systemPrompt = prompts.define({
id: "dashboard-agent-system",
description: "System prompt for the in-dashboard Trigger.dev agent.",
model: `anthropic:${DASHBOARD_AGENT_MODEL}`,
content: DASHBOARD_AGENT_SYSTEM_PROMPT,
});
// Code mode: used for turns where the current project has a connected GitHub
// repo, so the agent has the source-reading tools too.
export const codeSystemPrompt = prompts.define({
id: "dashboard-agent-system-code",
description:
"System prompt for the in-dashboard agent when the project's GitHub repo is connected.",
model: `anthropic:${DASHBOARD_AGENT_MODEL}`,
content: DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
});
export const titlePrompt = prompts.define({
id: "dashboard-agent-title",
description: "Generates a short title for a dashboard agent conversation.",
model: "anthropic:claude-haiku-4-5",
content: `You write a short, descriptive title for a conversation between a user and the Trigger.dev dashboard agent.
Rules:
- 3 to 6 words.
- No surrounding quotes and no trailing punctuation.
- Capture the user's intent, not the assistant's answer.
- Plain text only.
Reply with only the title.`,
});
@@ -0,0 +1,145 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildRepoTools, disposeRepoWorkspaces, workdirFor, type RepoSnapshot } from "./repo-tools";
// The code tools normally download + extract a tarball. Here we pre-seed the
// deterministic workspace path with a `.ready` marker, so `ensureWorkspace`
// serves it without any network fetch and the tools run fully offline.
const snapshot: RepoSnapshot = {
tarballUrl: "http://unused.invalid/never-fetched",
owner: "acme",
repo: "demo",
sha: "deadbeefdeadbeef",
defaultBranch: "main",
};
// A second snapshot at a different commit, returned by the run-SHA resolver for
// a known run id. Its order.ts has a different LIMIT so we can tell them apart.
const pinnedSnapshot: RepoSnapshot = {
tarballUrl: "http://unused.invalid/never-fetched",
owner: "acme",
repo: "demo",
sha: "cafebabecafebabecafebabecafebabecafebabe",
defaultBranch: "main",
};
const resolveRunSnapshot = async (runId: string) =>
runId === "run_pinned" ? pinnedSnapshot : null;
const tools = buildRepoTools(snapshot, resolveRunSnapshot);
// Tool.execute takes (input, options); options is unused by these tools.
const call = (tool: any, input: any) => tool.execute(input, {} as any);
// rg may not be installed in CI; detect at collection time so the search/list
// tests skip cleanly there (they're covered end-to-end against a real repo).
let hasRg = false;
try {
execFileSync("rg", ["--version"], { stdio: "ignore" });
hasRg = true;
} catch {
hasRg = false;
}
beforeAll(async () => {
const dir = workdirFor(snapshot);
await mkdir(join(dir, "src/trigger"), { recursive: true });
await writeFile(
join(dir, "src/trigger/order.ts"),
'import { task } from "@trigger.dev/sdk";\nconst LIMIT = 10000;\nexport const order = task({ id: "order" });\n'
);
await writeFile(join(dir, "README.md"), "# demo\n");
await writeFile(join(dir, ".ready"), snapshot.sha);
// The pinned commit's workspace, with a different LIMIT.
const pinnedDir = workdirFor(pinnedSnapshot);
await mkdir(join(pinnedDir, "src/trigger"), { recursive: true });
await writeFile(join(pinnedDir, "src/trigger/order.ts"), "const LIMIT = 5000;\n");
await writeFile(join(pinnedDir, ".ready"), pinnedSnapshot.sha);
});
afterAll(async () => {
await disposeRepoWorkspaces();
await rm(workdirFor(snapshot), { recursive: true, force: true });
await rm(workdirFor(pinnedSnapshot), { recursive: true, force: true });
});
describe("repo-tools", () => {
it("get_repo_info returns the connected repo and pinned commit", async () => {
const res = await call(tools.get_repo_info, {});
expect(res).toEqual({
owner: "acme",
repo: "demo",
sha: "deadbeefdeadbeef",
defaultBranch: "main",
});
});
it("read_file reads a file from the workspace", async () => {
const res: any = await call(tools.read_file, { path: "src/trigger/order.ts" });
expect(res.error).toBeUndefined();
expect(res.path).toBe("src/trigger/order.ts");
expect(res.content).toContain("const LIMIT = 10000;");
});
it("read_file honors a line range", async () => {
const res: any = await call(tools.read_file, {
path: "src/trigger/order.ts",
startLine: 2,
endLine: 2,
});
expect(res.content).toBe("const LIMIT = 10000;");
expect(res.startLine).toBe(2);
expect(res.endLine).toBe(2);
});
it("read_file refuses to escape the repository root", async () => {
for (const path of ["../../../etc/passwd", "src/../../escape", "../outside.txt"]) {
const res: any = await call(tools.read_file, { path });
expect(res.error).toMatch(/escapes the repository root/);
}
});
it("read_file errors on a missing file", async () => {
const res: any = await call(tools.read_file, { path: "does/not/exist.ts" });
expect(res.error).toBeDefined();
});
it("read_file with runId reads the run's pinned commit", async () => {
const def: any = await call(tools.read_file, { path: "src/trigger/order.ts" });
expect(def.content).toContain("const LIMIT = 10000;");
const pinned: any = await call(tools.read_file, {
path: "src/trigger/order.ts",
runId: "run_pinned",
});
expect(pinned.error).toBeUndefined();
expect(pinned.content).toContain("const LIMIT = 5000;");
});
it("get_repo_info with runId reports the pinned commit", async () => {
const res: any = await call(tools.get_repo_info, { runId: "run_pinned" });
expect(res.sha).toBe(pinnedSnapshot.sha);
});
it("read_file with an unresolvable runId errors instead of falling back", async () => {
const res: any = await call(tools.read_file, {
path: "src/trigger/order.ts",
runId: "run_unknown",
});
expect(res.error).toMatch(/Couldn't resolve the source/);
});
it.runIf(hasRg)("search_code finds a match (and does not hang on stdin)", async () => {
const res: any = await call(tools.search_code, { query: "const LIMIT" });
expect(res.error).toBeUndefined();
expect(
res.matches.some((m: any) => String(m.file).includes("order.ts") && /LIMIT/.test(m.text))
).toBe(true);
});
it.runIf(hasRg)("list_files lists workspace files", async () => {
const res: any = await call(tools.list_files, {});
expect(res.error).toBeUndefined();
expect(res.files).toContain("src/trigger/order.ts");
});
});
@@ -0,0 +1,294 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import { tool, type ToolSet } from "ai";
import {
getRepoInfoSchema,
listFilesSchema,
readFileSchema,
searchCodeSchema,
} from "./tool-schemas";
const execFileAsync = promisify(execFile);
/**
* Code-mode tools: read the user's connected repo from the agent task's own
* filesystem. The webapp resolves a short-lived signed tarball URL for the repo
* at a specific commit (the GitHub token never reaches here) and injects it as
* `repoSnapshot` in the turn metadata. The first file tool of a turn downloads +
* extracts that tarball into a scratch workdir keyed by commit, then `ripgrep`
* and plain fs reads serve the tools. The workspace is re-derivable: a cold
* resume just re-fetches.
*
* Like the API tools, these return `{ error }` instead of throwing so the model
* can recover and explain.
*/
export type RepoSnapshot = {
/** Signed, time-limited archive URL (GitHub codeload). No auth needed to GET. */
tarballUrl: string;
owner: string;
repo: string;
/** The commit the archive is pinned to. */
sha: string;
defaultBranch?: string;
};
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; // 100MB ceiling on the download
const MAX_READ_BYTES = 256 * 1024; // per read_file
const MAX_LIST_FILES = 500;
const MAX_MATCHES = 80;
const FETCH_TIMEOUT_MS = 30_000;
// In-flight + completed extractions, keyed by workdir, so concurrent tool calls
// in a turn extract once. Module scope: shared across turns of a warm run.
const workspaces = new Map<string, Promise<string>>();
export function workdirFor(snapshot: RepoSnapshot): string {
// Hash the identity so different (owner, repo, sha) tuples can't collide onto
// the same workspace dir (e.g. via hyphen placement), which would let one
// repo reuse another's extracted source.
const key = createHash("sha256")
.update(`${snapshot.owner}\0${snapshot.repo}\0${snapshot.sha}`)
.digest("hex");
return join(tmpdir(), "dashboard-agent-repo", key);
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
// Download the signed tarball and extract it (strip the GitHub top-level dir).
// Memoized per workdir; a present `.ready` marker means a prior extraction
// finished, so a warm run reuses it.
async function ensureWorkspace(snapshot: RepoSnapshot): Promise<string> {
const workdir = workdirFor(snapshot);
const existing = workspaces.get(workdir);
if (existing) return existing;
const job = (async () => {
if (await exists(join(workdir, ".ready"))) return workdir;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let tarPath: string | undefined;
try {
const res = await fetch(snapshot.tarballUrl, { signal: controller.signal });
if (!res.ok) throw new Error(`archive download failed (status ${res.status})`);
const length = Number(res.headers.get("content-length") ?? 0);
if (length > MAX_ARCHIVE_BYTES) throw new Error(`archive too large (${length} bytes)`);
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_ARCHIVE_BYTES)
throw new Error(`archive too large (${bytes.length} bytes)`);
const scratch = await mkdtemp(join(tmpdir(), "dashboard-agent-tar-"));
tarPath = join(scratch, "repo.tar.gz");
await writeFile(tarPath, bytes);
await mkdir(workdir, { recursive: true });
await execFileAsync("tar", ["-xzf", tarPath, "-C", workdir, "--strip-components=1"]);
await writeFile(join(workdir, ".ready"), snapshot.sha);
await rm(scratch, { recursive: true, force: true });
return workdir;
} finally {
clearTimeout(timer);
if (tarPath) await rm(tarPath, { force: true }).catch(() => {});
}
})();
workspaces.set(workdir, job);
try {
return await job;
} catch (error) {
// Don't cache a failed extraction; let the next call retry.
workspaces.delete(workdir);
throw error;
}
}
// Resolve a tool-supplied path inside the workspace, rejecting any `..` escape.
// Lexical only — pair with a realpath check before touching the path so a
// symlink inside the repo can't point readFile/rg at something outside.
function safeResolve(workdir: string, input: string): string | null {
const cleaned = input.replace(/^\/+/, "");
if (isAbsolute(cleaned)) return null;
const target = resolve(workdir, cleaned);
if (target !== workdir && !target.startsWith(workdir + sep)) return null;
return target;
}
function isInside(root: string, target: string): boolean {
return target === root || target.startsWith(root + sep);
}
/**
* Dispose extracted workspaces. Production run containers are ephemeral (the fs
* is torn down at run end), so this is mainly for dev hygiene and tests.
*/
export async function disposeRepoWorkspaces(): Promise<void> {
const dirs = [...workspaces.keys()];
workspaces.clear();
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => {})));
}
/** Resolve a run-pinned snapshot for a runId, or null. See the webapp's repo/snapshot route. */
export type RunSnapshotResolver = (runId: string) => Promise<RepoSnapshot | null>;
export function buildRepoTools(
defaultSnapshot: RepoSnapshot,
resolveRunSnapshot?: RunSnapshotResolver
): ToolSet {
// Pick the snapshot for a call: a runId pins to that run's deployed commit
// (resolved server-side), otherwise the default tracked-branch snapshot.
async function snapshotFor(runId?: string): Promise<RepoSnapshot | { error: string }> {
if (!runId) return defaultSnapshot;
if (!resolveRunSnapshot)
return { error: "Reading a specific run's source isn't available here." };
const snap = await resolveRunSnapshot(runId);
return (
snap ?? {
error: `Couldn't resolve the source for ${runId} (it may be a dev run, or the project has no connected repo).`,
}
);
}
// snapshotFor + ensureWorkspace, returning the workdir or an error result.
async function loadWorkdir(runId?: string): Promise<{ workdir: string } | { error: string }> {
const snap = await snapshotFor(runId);
if ("error" in snap) return snap;
try {
// Canonicalize the root so the per-tool realpath checks below compare
// against the real workspace path (tmpdir is itself a symlink on macOS).
return { workdir: await realpath(await ensureWorkspace(snap)) };
} catch (error) {
return { error: `Couldn't load the repository: ${(error as Error).message}` };
}
}
return {
get_repo_info: tool({
...getRepoInfoSchema,
execute: async ({ runId }) => {
const snap = await snapshotFor(runId);
if ("error" in snap) return snap;
return {
owner: snap.owner,
repo: snap.repo,
sha: snap.sha,
defaultBranch: snap.defaultBranch,
};
},
}),
list_files: tool({
...listFilesSchema,
execute: async ({ glob, path, runId }) => {
const loaded = await loadWorkdir(runId);
if ("error" in loaded) return loaded;
const { workdir } = loaded;
const args = ["--files"];
if (glob) args.push("-g", glob);
const sub = path ? safeResolve(workdir, path) : workdir;
if (sub === null) return { error: "Path escapes the repository root." };
// Resolve symlinks: reject only when the path exists and points outside.
const realSub = await realpath(sub).catch(() => null);
if (realSub && !isInside(workdir, realSub)) {
return { error: "Path escapes the repository root." };
}
const cwd = realSub ?? sub;
try {
const { stdout } = await execFileAsync("rg", args, { cwd, maxBuffer: 16 * 1024 * 1024 });
const files = stdout
.split("\n")
.filter(Boolean)
.map((f) => relative(workdir, resolve(cwd, f)));
return {
files: files.slice(0, MAX_LIST_FILES),
truncated: files.length > MAX_LIST_FILES,
};
} catch (error) {
// rg exits 1 when there are no matches; treat as empty, not an error.
if ((error as { code?: number }).code === 1) return { files: [], truncated: false };
return { error: `Couldn't list files: ${(error as Error).message}` };
}
},
}),
read_file: tool({
...readFileSchema,
execute: async ({ path, startLine, endLine, runId }) => {
const loaded = await loadWorkdir(runId);
if ("error" in loaded) return loaded;
const { workdir } = loaded;
const target = safeResolve(workdir, path);
if (target === null) return { error: "Path escapes the repository root." };
// Resolve symlinks: reject only when the file exists and points outside
// (a missing file falls through to the not-found error below).
const realTarget = await realpath(target).catch(() => null);
if (realTarget && !isInside(workdir, realTarget)) {
return { error: "Path escapes the repository root." };
}
let content: string;
let truncated = false;
try {
const buf = await readFile(realTarget ?? target);
content = buf.subarray(0, MAX_READ_BYTES).toString("utf8");
truncated = buf.length > MAX_READ_BYTES;
} catch {
return { error: `Couldn't read ${path} (not found or not a file).` };
}
if (startLine != null || endLine != null) {
const lines = content.split("\n");
const from = Math.max(1, startLine ?? 1);
const to = Math.min(lines.length, endLine ?? lines.length);
content = lines.slice(from - 1, to).join("\n");
return { path, content, startLine: from, endLine: to };
}
return { path, content, truncated };
},
}),
search_code: tool({
...searchCodeSchema,
execute: async ({ query, glob, maxResults, runId }) => {
const loaded = await loadWorkdir(runId);
if ("error" in loaded) return loaded;
const { workdir } = loaded;
const cap = Math.min(maxResults ?? 40, MAX_MATCHES);
const args = ["--line-number", "--no-heading", "--color", "never", "--max-count", "5"];
if (glob) args.push("-g", glob);
// The trailing "." is required: with no path, rg reads stdin (an open pipe
// in a spawned process) and blocks forever. The "." makes it search files.
args.push("-e", query, ".");
try {
const { stdout } = await execFileAsync("rg", args, {
cwd: workdir,
maxBuffer: 16 * 1024 * 1024,
});
const matches = stdout
.split("\n")
.filter(Boolean)
.slice(0, cap)
.map((line) => {
const m = line.match(/^([^:]+):(\d+):(.*)$/);
return m
? { file: m[1], line: Number(m[2]), text: m[3].slice(0, 300) }
: { text: line };
});
return { matches, truncated: matches.length >= cap };
} catch (error) {
if ((error as { code?: number }).code === 1) return { matches: [], truncated: false };
return { error: `Couldn't search: ${(error as Error).message}` };
}
},
}),
};
}
@@ -0,0 +1,473 @@
/**
* Schema-only tool definitions + the default system prompt text, shared between
* the chat.agent task and the webapp's `chat.headStart` route handler.
*
* HARD CONSTRAINT — bundle isolation. The head-start route imports this file
* and runs it in the webapp process, so anything imported here lands in that
* bundle. Allowed imports: `ai` (for `tool()`), `zod`, type-only AI SDK. Nothing
* else — no `@internal/dashboard-agent-db`, no `@trigger.dev/sdk` runtime, no
* `postgres`/`drizzle`. The `execute` fns (the data lane that calls the API as
* the user) live in `tools.ts`, which imports these schemas and adds executes
* on top; the route handler never sees them.
*/
import { tool } from "ai";
import { z } from "zod";
export const listProjectsSchema = tool({
description:
"List the Trigger.dev projects the user can access, with each project's ref, name, slug, and organization.",
inputSchema: z.object({}),
});
export const listEnvironmentsSchema = tool({
description:
"List the environments (dev, staging, production, preview branches) for a project. Defaults to the current project when projectRef is omitted.",
inputSchema: z.object({
projectRef: z
.string()
.optional()
.describe("Project ref like proj_... . Defaults to the current project."),
}),
});
export const listTasksSchema = tool({
description:
"List the tasks deployed in the current environment's latest deployment, with each task's slug, file path, and trigger source.",
inputSchema: z.object({}),
});
export const listRunsSchema = tool({
description:
"List recent runs in the current environment, newest first. Optionally filter by status, task, time period, or the error group they belong to. Use this for 'what's been running', 'recent failures', or 'show me the runs behind this error'.",
inputSchema: z.object({
status: z
.string()
.optional()
.describe("Run status filter, e.g. COMPLETED, FAILED, EXECUTING, QUEUED, CANCELED."),
taskIdentifier: z.string().optional().describe("Only runs of this task id."),
errorId: z
.string()
.optional()
.describe(
"Only runs that hit this error group (an error_... id from list_errors/get_error)."
),
period: z
.string()
.optional()
.describe("Relative window, e.g. 1h, 24h, 7d. Max 30d; larger values are capped at 30d."),
limit: z
.number()
.int()
.positive()
.max(50)
.optional()
.describe("Max runs to return (default 10)."),
}),
});
export const getRunSchema = tool({
description:
"Get the status, timing, cost, and error details for a single run in the current environment, by its run id (run_...).",
inputSchema: z.object({
runId: z.string().describe("The run id, e.g. run_abc123."),
}),
});
export const getRunTraceSchema = tool({
description:
"Get a run's execution trace: the timeline of spans (tasks, waits, attempts) with durations and error flags. Use this to explain why a run failed, retried, or was slow.",
inputSchema: z.object({
runId: z.string().describe("The run id, e.g. run_abc123."),
}),
});
export const listErrorsSchema = tool({
description:
"List error groups in the current environment: distinct errors grouped by fingerprint, with occurrence count, first/last seen, and lifecycle status (unresolved/resolved/ignored). Use this for 'what's broken', 'recent errors', 'top errors', etc.",
inputSchema: z.object({
status: z
.string()
.optional()
.describe(
"Filter by lifecycle status: unresolved, resolved, or ignored. Comma-separate for multiple. Defaults to all."
),
taskIdentifier: z
.string()
.optional()
.describe("Only errors from this task id. Comma-separate for multiple."),
search: z.string().optional().describe("Free-text match against the error type and message."),
period: z
.string()
.optional()
.describe("Relative window for the occurrence count, e.g. 1h, 24h, 7d. Defaults to 1d."),
limit: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe("Max error groups to return (default 20)."),
}),
});
export const getErrorSchema = tool({
description:
"Get the full detail for a single error group by its id (error_...): type, message, occurrence count, first/last seen, affected task versions, and lifecycle state (who resolved/ignored it and when). Pair with list_runs(errorId) to see the runs behind it.",
inputSchema: z.object({
errorId: z.string().describe("The error group id, e.g. error_abc123, from list_errors."),
}),
});
// Analytics query tools (TRQL over the user's ClickHouse-backed data). Read-only.
export const getQuerySchemaSchema = tool({
description:
"Discover the analytics tables and columns you can query with TRQL. Call with no table to list the available tables (runs, metrics, llm_metrics, llm_models) and what each holds; call with a table name to get that table's columns, types, descriptions, and time column. Use this before writing a run_query.",
inputSchema: z.object({
table: z
.string()
.optional()
.describe(
"A table name (e.g. 'runs') to get its columns. Omit to list the available tables."
),
}),
});
export const runQuerySchema = tool({
description:
"Run a read-only TRQL query against the current environment's analytics data and return the result rows. TRQL is a SQL-style language over ClickHouse: bucket time with toStartOfHour/toStartOfDay on the table's time column for time series, and use countIf/sumIf to produce one numeric column per series. Always call get_query_schema first. Results are capped, so keep queries aggregated. To chart the result, follow with a render_view chart block.",
inputSchema: z.object({
query: z
.string()
.describe(
"The TRQL query. A read-only SELECT over runs / metrics / llm_metrics / llm_models."
),
period: z
.string()
.optional()
.describe(
"Time window shorthand like '24h', '7d', '30d' (max 30d), applied to the table's time column."
),
}),
});
export const askSupportSchema = tool({
description:
"Ask the Trigger.dev support assistant a question about how Trigger.dev works: docs, concepts, features, configuration, best practices, and troubleshooting how-tos (e.g. 'how do retries work?', 'how do I set a concurrency limit?', 'does Trigger.dev support cron schedules?'). Use this for product/knowledge questions, NOT for the user's own runs, errors, or data (use the read and query tools for those). Returns a composed answer.",
inputSchema: z.object({
question: z
.string()
.describe("The user's question about how Trigger.dev works, in natural language."),
}),
});
// ---------------------------------------------------------------------------
// View catalog — our own small "generative UI" layer.
//
// The agent renders rich, on-brand UI by emitting a *spec* (a stack of blocks
// drawn from a fixed catalog) via the `render_view` tool, instead of inventing
// arbitrary markup. The webapp has a render registry mapping each block `type`
// to a React component (see components/dashboard-agent/view-catalog.tsx). This
// gives us json-render's safety (only catalog blocks, validated, no arbitrary
// HTML) without its zod 4 / React 19 dependency — we stay on the pinned zod 3.
//
// `render_view`'s `execute` (in tools.ts) just validates + echoes the spec back;
// there's no API call. Add a new block by adding a member to `viewBlockSchema`
// here and a renderer entry in the webapp registry.
// ---------------------------------------------------------------------------
// The "why did this run fail?" failure card — the first (and for now only)
// catalog block. The agent gathers evidence with the read tools, then fills
// these fields. `type` is the discriminant the render registry keys off.
export const diagnosisBlockSchema = z.object({
type: z.literal("diagnosis"),
runId: z.string().describe("The run this diagnoses, e.g. run_abc123."),
summary: z.string().describe("One or two plain-language sentences: what happened and why."),
category: z
.enum([
"user_code_error",
"configuration",
"dependency",
"timeout",
"out_of_memory",
"rate_limit",
"external_service",
"infrastructure",
"cancellation",
"unknown",
])
.describe("Your classification of the root cause."),
likelyCause: z
.string()
.describe(
"The most probable root cause, in specific terms — name the code, config, or dependency."
),
confidence: z
.enum(["high", "medium", "low"])
.describe("How confident you are in this diagnosis given the evidence. Be honest."),
evidence: z
.array(
z.object({
type: z.enum([
"error",
"failed_span",
"child_run",
"logs",
"deploy",
"source",
"historical_match",
]),
detail: z.string().describe("What this piece of evidence shows."),
reference: z
.string()
.optional()
.describe(
"Optional pointer to the evidence: a run id (run_...), error id (error_...), file:line, version, or URL."
),
})
)
.describe(
"The concrete signals behind the diagnosis. Cite real ids, spans, versions, or file:line."
),
impact: z
.string()
.optional()
.describe("Optional: how widespread this is, e.g. how many runs hit the same error recently."),
nextSteps: z.array(z.string()).describe("Actionable recommendations, most important first."),
actions: z
.array(
z.object({
label: z.string().describe("Button text, e.g. 'View run' or 'Read the retries docs'."),
kind: z
.enum(["view_run", "docs"])
.describe(
"view_run links to a run page in this environment; docs opens an external URL."
),
target: z.string().describe("For view_run: a run id (run_...). For docs: an https URL."),
})
)
.optional()
.describe("Optional call-to-action buttons rendered under the card."),
});
// The chart block carries the TRQL query (not the rows): the panel runs it
// through the dashboard's own query execution + QueryResultsChart, so the chart
// is live and matches the Query page exactly. The agent describes the chart with
// the SAME config the dashboard's chart builder uses (chartType + axis columns +
// group/aggregation) and writes a query whose result columns map onto it.
export const chartBlockSchema = z.object({
type: z.literal("chart"),
title: z.string().optional().describe("Optional chart title."),
query: z
.string()
.describe(
"A read-only TRQL SELECT whose result columns map onto the axes below. The panel runs this query and renders the result, so write it the same way you would for run_query (toStartOfHour/toStartOfDay buckets, countIf/sumIf per series)."
),
period: z
.string()
.optional()
.describe(
"Time window shorthand like '24h', '7d', '30d' (max 30d), applied to the table's time column."
),
chartType: z
.enum(["line", "bar"])
.describe(
"line for trends over time, bar for comparing categories. Stack with `stacked` for composition."
),
xAxisColumn: z
.string()
.describe(
"The result column for the x-axis: a time bucket (for line) or a category (for bar)."
),
yAxisColumns: z
.array(z.string())
.min(1)
.describe("The numeric result column(s) to plot. One per series, unless groupByColumn is set."),
groupByColumn: z
.string()
.nullish()
.describe(
"Optional result column to split a single yAxisColumn into one series per distinct value."
),
stacked: z
.boolean()
.optional()
.describe("Stack the series (cumulative/composition). Default false."),
aggregation: z
.enum(["sum", "avg", "count", "min", "max"])
.optional()
.describe("How to combine values that share an x point. Default sum."),
});
export const viewBlockSchema = z.discriminatedUnion("type", [
diagnosisBlockSchema,
chartBlockSchema,
]);
export type DiagnosisBlock = z.infer<typeof diagnosisBlockSchema>;
export type ChartBlock = z.infer<typeof chartBlockSchema>;
export type ViewBlock = z.infer<typeof viewBlockSchema>;
export const renderViewSchema = tool({
description:
"Render a structured view in the dashboard panel: a stack of catalog blocks, instead of plain prose. The catalog has two blocks: `diagnosis` (the 'why did this run fail?' failure card, after gathering evidence with the read/source tools) and `chart` (a line/bar chart of run_query results). Keep any accompanying message to a one-line lead-in.",
inputSchema: z.object({
blocks: z.array(viewBlockSchema).min(1).describe("The blocks to render, top to bottom."),
}),
});
// Code-mode tools (only present when the project has a connected GitHub repo).
// They read the repo's source at a pinned commit from the agent's filesystem.
// Optional run-SHA pinning: pass a run id to read the exact source that run's
// deployed version came from, instead of the latest tracked-branch commit.
const runIdField = z
.string()
.optional()
.describe(
"Optional run id (run_...) to read the exact source that run's deployed version came from, instead of the latest. Use this when investigating a specific run."
);
export const getRepoInfoSchema = tool({
description:
"Get the connected GitHub repository the agent can read: owner, repo name, the commit SHA the source is pinned to, and the default branch.",
inputSchema: z.object({ runId: runIdField }),
});
export const listFilesSchema = tool({
description:
"List source files in the connected repository (respecting .gitignore). Optionally filter by a glob like '**/*.ts' or scope to a subdirectory. Use this to find where something lives before reading it.",
inputSchema: z.object({
glob: z.string().optional().describe("Glob filter, e.g. 'src/**/*.ts' or '*.json'."),
path: z
.string()
.optional()
.describe("Subdirectory (relative to repo root) to scope the listing to."),
runId: runIdField,
}),
});
export const readFileSchema = tool({
description:
"Read a file from the connected repository by its path relative to the repo root. Optionally restrict to a line range. Use this to read the actual task source behind a run or error.",
inputSchema: z.object({
path: z
.string()
.describe("File path relative to the repo root, e.g. src/trigger/processOrder.ts."),
startLine: z.number().int().positive().optional().describe("First line to include (1-based)."),
endLine: z.number().int().positive().optional().describe("Last line to include (1-based)."),
runId: runIdField,
}),
});
export const searchCodeSchema = tool({
description:
"Search the connected repository's source with a ripgrep query (regex or literal). Returns file:line matches. Use this to locate a task definition, an error string, a symbol, or config across the repo.",
inputSchema: z.object({
query: z.string().describe("The ripgrep pattern to search for."),
glob: z.string().optional().describe("Restrict the search to files matching this glob."),
maxResults: z
.number()
.int()
.positive()
.max(80)
.optional()
.describe("Max matches to return (default 40)."),
runId: runIdField,
}),
});
/**
* The schema-only tool set, in the same key order the agent attaches executes
* to in `tools.ts`. Passed to `chat.headStart`'s `streamText` so step 1 can
* emit tool calls (the agent run executes them on step 2+).
*/
export const dashboardAgentToolSchemas = {
list_projects: listProjectsSchema,
list_environments: listEnvironmentsSchema,
list_tasks: listTasksSchema,
list_runs: listRunsSchema,
get_run: getRunSchema,
get_run_trace: getRunTraceSchema,
list_errors: listErrorsSchema,
get_error: getErrorSchema,
get_query_schema: getQuerySchemaSchema,
run_query: runQuerySchema,
ask_support: askSupportSchema,
render_view: renderViewSchema,
};
// Code mode adds the source tools. Same key order `buildDashboardAgentTools`
// attaches executes in (api tools, then repo tools), so head-start's warm step
// matches the agent run.
export const dashboardAgentCodeToolSchemas = {
...dashboardAgentToolSchemas,
get_repo_info: getRepoInfoSchema,
list_files: listFilesSchema,
read_file: readFileSchema,
search_code: searchCodeSchema,
};
/**
* Default model + system prompt, single-sourced here (a light module) so both
* the managed prompt in `prompts.ts` and the head-start route use the same
* values without the route importing the SDK runtime. A dashboard override only
* affects the agent run; the warm step-1 uses these defaults.
*/
// Anthropic model id used by both the warm step-1 route (via `anthropic(id)`)
// and the managed prompt default (as `anthropic:${id}`). Same model both sides
// so step 1 and step 2+ don't shift tone.
export const DASHBOARD_AGENT_MODEL = "claude-sonnet-4-6";
export const DASHBOARD_AGENT_SYSTEM_PROMPT = `You are the Trigger.dev dashboard agent, an assistant embedded in the Trigger.dev web dashboard.
Trigger.dev is a platform for writing and running reliable background tasks and AI agents in TypeScript. Users reach you from inside their dashboard while looking at runs, tasks, schedules, queues, deployments, and logs.
You have read-only tools that act as the user against their own account:
- list_projects: the projects the user can access.
- list_environments: the environments for a project (defaults to the current one).
- list_tasks: the tasks deployed in the current environment.
- list_runs: recent runs in the current environment, filterable by status, task, time period, or error group.
- get_run: status, timing, cost, and error details for a run by its run id.
- get_run_trace: a run's execution timeline (spans, durations, errors) for explaining why it failed, retried, or was slow.
- list_errors: distinct errors in the current environment grouped by fingerprint, with occurrence counts and status (unresolved/resolved/ignored).
- get_error: full detail for one error group by its error id, including affected versions and who resolved or ignored it.
- get_query_schema: discover the analytics tables and columns you can query with TRQL (runs, metrics, llm_metrics, llm_models).
- run_query: run a read-only TRQL query (SQL-style over ClickHouse) against the current environment's analytics data.
- ask_support: ask the Trigger.dev support assistant about how Trigger.dev works (docs, concepts, features, configuration, how-tos).
- render_view: render a structured view in the panel from the block catalog. The catalog has the "diagnosis" block (a failure card for a single run) and the "chart" block (a line/bar chart of run_query results).
Guidelines:
- Be concise and direct. A short, correct answer beats a long one.
- Prefer reading live data with your tools over guessing. When a run id, task, project, or environment is in question, look it up.
- For "what's broken" or "why is X failing" questions, start with list_errors to find the error groups, get_error for the detail, then list_runs with that error id to drill into the actual failing runs (and get_run_trace for one of them).
- Your tools are read-only and scoped to the current environment for run and task lookups. You can't change anything; for actions, point the user to where in the dashboard they can do it.
- Never invent run IDs, task identifiers, metrics, or features. If a tool returns an error or nothing, say so plainly.
- Use Trigger.dev's own terminology: tasks, runs, attempts, queues, deployments, environments, schedules, waitpoints.
- For questions about how Trigger.dev itself works (concepts, features, configuration, best practices, how-tos, "how do I..."), use ask_support rather than guessing. For the user's own runs, errors, tasks, and metrics, use the read and query tools. A question can need both: ask_support for the how-to, the read tools for their specific data.
Diagnosing why a run failed:
- When the user asks why a specific run failed (or to investigate a run or error), gather evidence before answering: get_run for the status and error, get_run_trace for the failing span and timeline, and get_error / list_errors to see whether it's a recurring pattern and how widespread it is.
- Then call render_view with a single "diagnosis" block holding your findings: a short summary, the failure category, the likely root cause in specific terms, your confidence, the concrete evidence (cite real run ids, error ids, span messages, and versions), the impact, the next steps, and any action buttons. This renders the failure card, so keep any accompanying message to a one-line lead-in rather than repeating the card.
- Be honest about confidence. If the evidence is thin or ambiguous, mark it low and say what's missing rather than overstating a guess.
Answering with data and charts:
- 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.
- 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.
- 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.`;
// Used when the current project has a connected GitHub repo: the base prompt
// plus the source-reading tools and how to use them.
export const DASHBOARD_AGENT_CODE_SYSTEM_PROMPT = `${DASHBOARD_AGENT_SYSTEM_PROMPT}
This project has its GitHub repository connected, so you can also read its source code:
- get_repo_info: the connected repo and the commit your source is pinned to.
- list_files: list source files (respects .gitignore), filterable by glob or subdirectory.
- read_file: read a file by its repo-relative path, optionally a line range.
- search_code: ripgrep the source for a task definition, error string, symbol, or config.
Source guidelines:
- When explaining why a run or error happened, read the actual task source rather than guessing. Find the task with search_code or list_files, then read_file the relevant code.
- When investigating a specific run, pass its run id as the runId argument to read_file/search_code/list_files. That reads the exact source the run's deployed version came from (the code that actually ran). Without runId you read the latest tracked-branch commit. Cite file paths (and line numbers when useful).
- When you render a diagnosis block for a run, read its deployed source (with the runId argument) and add a "source" evidence item whose reference is the relevant file:line, so the card points at the exact code that ran.
- Stay read-only: you can explain and point at code, but you can't edit it or open PRs.`;
@@ -0,0 +1,527 @@
import { tool, type ToolSet } from "ai";
import {
askSupportSchema,
getErrorSchema,
getQuerySchemaSchema,
getRunSchema,
getRunTraceSchema,
listEnvironmentsSchema,
listErrorsSchema,
listProjectsSchema,
listRunsSchema,
listTasksSchema,
renderViewSchema,
runQuerySchema,
} from "./tool-schemas";
import { buildRepoTools, type RepoSnapshot } from "./repo-tools";
/**
* Read-only tools for the dashboard agent. The agent is firewalled from the
* main database, so every tool reaches the user's data the sanctioned way: the
* public Trigger.dev API, authenticated as the user with the short-lived
* delegated token the `in` proxy injects into the turn's metadata.
*
* - User-level reads (projects, environments) use the delegated token directly.
* - Environment-scoped reads (runs, tasks, errors) first exchange the token for
* an env JWT for the current project + environment, then call the API with that.
*
* Tools return `{ error }` on failure rather than throwing, so the model can
* recover and explain instead of the turn dying.
*/
// The per-turn context the `in` proxy injects server-side. All optional: on a
// turn that didn't carry a token (e.g. an older session) we expose no tools.
export type DashboardAgentToolContext = {
userActorToken?: string;
apiOrigin?: string;
projectRef?: string;
// Canonical API env name (dev/staging/prod/preview), resolved by the proxy.
environmentName?: string;
// The dashboard path the user is on, passed as context to ask_support.
currentPage?: string;
// Present only when the current project has a connected GitHub repo: a signed
// archive pointer the code-mode file tools read from. Adds the source tools.
repoSnapshot?: RepoSnapshot;
};
type FetchResult = { ok: true; data: unknown } | { ok: false; status: number };
async function apiGet(origin: string, path: string, token: string): Promise<FetchResult> {
const res = await fetch(`${origin}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!res.ok) return { ok: false, status: res.status };
return { ok: true, data: await res.json() };
}
// Swap the delegated token for an env JWT scoped to the current project + env.
// The exchange ceilings these scopes to the token's read-only cap, so the JWT
// can never widen the grant. Returns null when there's no current env or the
// exchange is denied.
async function exchangeEnvJwt(
origin: string,
userActorToken: string,
projectRef: string,
environmentName: string
): Promise<string | null> {
const res = await fetch(`${origin}/api/v1/projects/${projectRef}/${environmentName}/jwt`, {
method: "POST",
headers: { Authorization: `Bearer ${userActorToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
claims: { scopes: ["read:runs", "read:deployments", "read:errors", "read:query"] },
}),
});
if (!res.ok) return null;
const data = (await res.json()) as { token?: string };
return data.token ?? null;
}
function curateProjects(data: unknown) {
const projects = Array.isArray(data) ? data : [];
return {
projects: projects.map((p: any) => ({
ref: p.externalRef,
name: p.name,
slug: p.slug,
organization: p.organization?.title,
})),
};
}
function curateEnvironments(data: unknown) {
const envs = Array.isArray(data) ? data : [];
return {
environments: envs.map((e: any) => ({
slug: e.slug,
type: e.type,
paused: e.paused,
branchName: e.branchName ?? undefined,
})),
};
}
function curateRun(run: any) {
return {
id: run.id,
status: run.status,
taskIdentifier: run.taskIdentifier,
version: run.version,
isQueued: run.isQueued,
isExecuting: run.isExecuting,
isCompleted: run.isCompleted,
isFailed: run.isFailed,
isCancelled: run.isCancelled,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
durationMs: run.durationMs,
costInCents: run.costInCents,
attemptCount: run.attemptCount,
tags: run.tags,
error: run.error ? { name: run.error.name, message: run.error.message } : undefined,
};
}
function curateTasks(data: unknown) {
const tasks = (data as any)?.worker?.tasks ?? [];
return {
tasks: (Array.isArray(tasks) ? tasks : []).map((t: any) => ({
slug: t.slug,
filePath: t.filePath,
triggerSource: t.triggerSource,
})),
};
}
function curateRuns(data: unknown) {
const runs = (data as any)?.data ?? [];
return {
runs: (Array.isArray(runs) ? runs : []).map((r: any) => ({
id: r.id,
status: r.status,
taskIdentifier: r.taskIdentifier,
version: r.version,
isTest: r.isTest,
createdAt: r.createdAt,
startedAt: r.startedAt,
finishedAt: r.finishedAt,
durationMs: r.durationMs,
tags: r.tags,
})),
nextCursor: (data as any)?.pagination?.next,
};
}
// Flatten the nested trace tree into a compact, depth-tagged list so the model
// can reason over the timeline without the full span payloads (output,
// properties, raw events are dropped). Capped so a deep trace stays small.
const MAX_TRACE_SPANS = 60;
function curateTrace(data: unknown) {
const root = (data as any)?.trace?.rootSpan;
const spans: Array<Record<string, unknown>> = [];
const walk = (span: any, depth: number) => {
if (!span || spans.length >= MAX_TRACE_SPANS) return;
const d = span.data ?? {};
spans.push({
depth,
message: d.message,
task: d.taskSlug,
durationMs: d.duration,
level: d.level,
isError: d.isError,
isPartial: d.isPartial,
});
for (const child of span.children ?? []) walk(child, depth + 1);
};
walk(root, 0);
return {
traceId: (data as any)?.trace?.traceId,
spans,
truncated: spans.length >= MAX_TRACE_SPANS,
};
}
function curateErrors(data: unknown) {
const groups = (data as any)?.data ?? [];
return {
errors: (Array.isArray(groups) ? groups : []).map((g: any) => ({
id: g.id,
taskIdentifier: g.taskIdentifier,
errorType: g.errorType,
errorMessage: g.errorMessage,
status: g.status,
count: g.count,
firstSeen: g.firstSeen,
lastSeen: g.lastSeen,
})),
nextCursor: (data as any)?.pagination?.next,
};
}
function curateError(group: any) {
return {
id: group.id,
taskIdentifier: group.taskIdentifier,
errorType: group.errorType,
errorMessage: group.errorMessage,
status: group.status,
count: group.count,
firstSeen: group.firstSeen,
lastSeen: group.lastSeen,
affectedVersions: group.affectedVersions,
resolvedAt: group.resolvedAt,
resolvedInVersion: group.resolvedInVersion,
resolvedBy: group.resolvedBy,
ignoredAt: group.ignoredAt,
ignoredUntil: group.ignoredUntil,
ignoredReason: group.ignoredReason,
ignoredByUserId: group.ignoredByUserId,
};
}
// Cap the run-list lookback at 30 days. Parse the `<number><unit>` window and
// clamp anything larger (or unparseable) down to 30d, so the agent can't scan
// huge time ranges. Returns the effective period so the model reports the real
// window it queried.
const MAX_PERIOD_SECONDS = 30 * 24 * 60 * 60;
const PERIOD_UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
function clampPeriod(period: string): string {
const match = /^(\d+)\s*([smhdw])$/.exec(period.trim());
if (!match) return "30d";
const seconds = Number(match[1]) * PERIOD_UNIT_SECONDS[match[2]];
return seconds > MAX_PERIOD_SECONDS ? "30d" : period.trim();
}
const NO_AUTH = { error: "No delegated access is available for this turn." } as const;
// Always returns the same tool set so it stays stable across turns (the SDK
// replays it over prior history). When a turn carried no delegated token, each
// tool reports that rather than silently disappearing.
export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSet {
const { userActorToken, apiOrigin, projectRef, environmentName } = ctx;
const origin = apiOrigin ? apiOrigin.replace(/\/$/, "") : "";
const hasAuth = Boolean(userActorToken && origin);
// Exchange lazily and once per turn — turns that never touch an env tool
// never pay for the exchange.
let envJwtPromise: Promise<string | null> | undefined;
function getEnvJwt(): Promise<string | null> {
if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(null);
envJwtPromise ??= exchangeEnvJwt(origin, userActorToken!, projectRef, environmentName);
return envJwtPromise;
}
// Run-SHA pinning: ask the webapp for a snapshot pinned to a specific run's
// deployed commit (it mints the scoped token + signed URL server-side). null
// means the file tools fall back to the default tracked-branch snapshot.
const resolveRunSnapshot = async (runId: string): Promise<RepoSnapshot | null> => {
if (!hasAuth || !projectRef || !environmentName) return null;
const result = await apiGet(
origin,
`/api/v1/projects/${projectRef}/${environmentName}/repo/snapshot?runId=${encodeURIComponent(runId)}`,
userActorToken!
);
if (!result.ok) return null;
const d = result.data as Partial<RepoSnapshot> | undefined;
if (!d?.tarballUrl || !d.owner || !d.repo || !d.sha) return null;
return {
tarballUrl: d.tarballUrl,
owner: d.owner,
repo: d.repo,
sha: d.sha,
defaultBranch: d.defaultBranch,
};
};
const apiTools: ToolSet = {
list_projects: tool({
...listProjectsSchema,
execute: async () => {
if (!hasAuth) return NO_AUTH;
const result = await apiGet(origin, "/api/v1/projects", userActorToken!);
if (!result.ok) return { error: `Couldn't list projects (status ${result.status}).` };
return curateProjects(result.data);
},
}),
list_environments: tool({
...listEnvironmentsSchema,
execute: async ({ projectRef: inputRef }) => {
if (!hasAuth) return NO_AUTH;
const ref = inputRef ?? projectRef;
if (!ref) return { error: "No project ref available. Ask the user which project." };
const result = await apiGet(
origin,
`/api/v1/projects/${ref}/environments`,
userActorToken!
);
if (!result.ok) return { error: `Couldn't list environments (status ${result.status}).` };
return curateEnvironments(result.data);
},
}),
get_run: tool({
...getRunSchema,
execute: async ({ runId }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to read runs from." };
const result = await apiGet(origin, `/api/v3/runs/${runId}`, envJwt);
if (!result.ok) return { error: `Couldn't get run ${runId} (status ${result.status}).` };
return curateRun(result.data);
},
}),
list_tasks: tool({
...listTasksSchema,
execute: async () => {
if (!hasAuth) return NO_AUTH;
if (!projectRef || !environmentName) {
return { error: "No current environment is available to read tasks from." };
}
// The worker-by-tag route is user-level (PAT/UAT), so this uses the
// delegated token directly — no env-JWT exchange.
const result = await apiGet(
origin,
`/api/v1/projects/${projectRef}/${environmentName}/workers/current`,
userActorToken!
);
if (!result.ok) return { error: `Couldn't list tasks (status ${result.status}).` };
return curateTasks(result.data);
},
}),
list_runs: tool({
...listRunsSchema,
execute: async ({ status, taskIdentifier, errorId, period, limit }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to read runs from." };
const effectivePeriod = period ? clampPeriod(period) : undefined;
const sp = new URLSearchParams();
if (status) sp.append("filter[status]", status);
if (taskIdentifier) sp.append("filter[taskIdentifier]", taskIdentifier);
if (errorId) sp.append("filter[error]", errorId);
if (effectivePeriod) sp.append("filter[createdAt][period]", effectivePeriod);
sp.append("page[size]", String(Math.min(limit ?? 10, 50)));
const result = await apiGet(origin, `/api/v1/runs?${sp.toString()}`, envJwt);
if (!result.ok) return { error: `Couldn't list runs (status ${result.status}).` };
return { ...curateRuns(result.data), period: effectivePeriod };
},
}),
get_run_trace: tool({
...getRunTraceSchema,
execute: async ({ runId }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to read runs from." };
const result = await apiGet(origin, `/api/v1/runs/${runId}/trace`, envJwt);
if (!result.ok)
return { error: `Couldn't get the trace for ${runId} (status ${result.status}).` };
return curateTrace(result.data);
},
}),
list_errors: tool({
...listErrorsSchema,
execute: async ({ status, taskIdentifier, search, period, limit }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to read errors from." };
const sp = new URLSearchParams();
if (status) sp.append("filter[status]", status);
if (taskIdentifier) sp.append("filter[taskIdentifier]", taskIdentifier);
if (search) sp.append("filter[search]", search);
if (period) sp.append("filter[period]", period);
sp.append("page[size]", String(Math.min(limit ?? 20, 100)));
const result = await apiGet(origin, `/api/v1/errors?${sp.toString()}`, envJwt);
if (!result.ok) return { error: `Couldn't list errors (status ${result.status}).` };
return curateErrors(result.data);
},
}),
get_error: tool({
...getErrorSchema,
execute: async ({ errorId }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to read errors from." };
const result = await apiGet(origin, `/api/v1/errors/${errorId}`, envJwt);
if (!result.ok)
return { error: `Couldn't get error ${errorId} (status ${result.status}).` };
return curateError(result.data);
},
}),
get_query_schema: tool({
...getQuerySchemaSchema,
execute: async ({ table }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to query." };
const result = await apiGet(origin, "/api/v1/query/schema", envJwt);
if (!result.ok)
return { error: `Couldn't load the query schema (status ${result.status}).` };
const tables = ((result.data as { tables?: any[] })?.tables ?? []) as any[];
// No table → list what's queryable; a table → its columns.
if (!table) {
return {
tables: tables.map((t) => ({
name: t.name,
description: t.description,
timeColumn: t.timeColumn,
})),
};
}
const match = tables.find((t) => t.name === table);
if (!match) {
return {
error: `Unknown table "${table}". Available: ${tables.map((t) => t.name).join(", ")}.`,
};
}
return {
name: match.name,
description: match.description,
timeColumn: match.timeColumn,
columns: (match.columns ?? []).map((c: any) => ({
name: c.name,
type: c.type,
description: c.description,
allowedValues: c.allowedValues,
coreColumn: c.coreColumn,
})),
};
},
}),
run_query: tool({
...runQuerySchema,
execute: async ({ query, period }) => {
const envJwt = await getEnvJwt();
if (!envJwt) return { error: "No current environment is available to query." };
let res: Response;
try {
res = await fetch(`${origin}/api/v1/query`, {
method: "POST",
headers: {
Authorization: `Bearer ${envJwt}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ query, scope: "environment", period, format: "json" }),
});
} catch (error) {
return { error: `Query request failed: ${(error as Error).message}` };
}
// The route returns 400 with { error } for invalid TRQL; surface it so
// the model can fix the query rather than the turn dying.
const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string };
if (!res.ok) return { error: data.error ?? `Query failed (status ${res.status}).` };
const rows = Array.isArray(data.results)
? (data.results as Array<Record<string, unknown>>)
: [];
const cap = 200;
return { rows: rows.slice(0, cap), rowCount: rows.length, truncated: rows.length > cap };
},
}),
// Knowledge lane: forward the question to the support assistant via the
// service-to-service /api/ask proxy (the support-chat agent composes the
// answer). No user data and no UAT — knowledge is public, so this uses a
// shared secret, runs server-side in the task, and never reaches the browser.
ask_support: tool({
...askSupportSchema,
execute: async ({ question }) => {
const url = process.env.SUPPORT_ASK_URL ?? "http://localhost:3939/api/ask";
const secret = process.env.SUPPORT_ASK_SECRET;
if (!secret)
return { error: "The support assistant isn't configured in this environment." };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60_000);
try {
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/json" },
body: JSON.stringify({
question,
context: ctx.currentPage ? { currentPage: ctx.currentPage } : undefined,
}),
signal: controller.signal,
});
if (!res.ok)
return { error: `The support assistant request failed (status ${res.status}).` };
// The endpoint streams a UI-message SSE; accumulate the text-delta
// chunks into the final answer (tool-output-error chunks are noise).
const body = await res.text();
let answer = "";
for (const line of body.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const chunk = JSON.parse(payload) as { type?: string; delta?: string };
if (chunk.type === "text-delta" && typeof chunk.delta === "string")
answer += chunk.delta;
} catch {
// Skip keepalives / non-JSON lines.
}
}
answer = answer.trim();
return answer ? { answer } : { error: "The support assistant returned no answer." };
} catch (error) {
return { error: `Couldn't reach the support assistant: ${(error as Error).message}` };
} finally {
clearTimeout(timer);
}
},
}),
// Presentation tool, not a data tool: it renders a view spec the agent
// composed from already-gathered data. zod validates the spec before this
// runs, so execute just echoes it back as the tool output for the dashboard
// render registry to pick up. No auth, no API call — always available.
render_view: tool({
...renderViewSchema,
execute: async (view) => view,
}),
};
// Code mode: when the project has a connected repo, add the source tools.
if (!ctx.repoSnapshot) return apiTools;
return { ...apiTools, ...buildRepoTools(ctx.repoSnapshot, resolveRunSnapshot) };
}
@@ -0,0 +1,30 @@
import { defineConfig } from "@trigger.dev/sdk";
import { aptGet } from "@trigger.dev/build/extensions/core";
/**
* The dashboard agent is its own Trigger project, deployed independently of the
* webapp. It deliberately does NOT live inside apps/webapp: the agent has no
* access to the main database, ClickHouse, or webapp internals (it reads
* everything via the API), and keeping it in a separate package makes that
* firewall structural rather than a convention.
*
* The project ref is read from the environment so no cloud project ref is
* committed to this public repo. For local dev, set
* TRIGGER_DASHBOARD_AGENT_PROJECT_REF to a project you own and run the CLI from
* this directory.
*/
export default defineConfig({
project: process.env.TRIGGER_DASHBOARD_AGENT_PROJECT_REF ?? "",
dirs: ["./src"],
// Keep test + eval files out of the task index. They import vitest, which
// throws at registration. Setting this replaces the built-in defaults, so the
// test/spec patterns are repeated alongside the eval one.
ignorePatterns: ["**/*.test.ts", "**/*.spec.ts", "**/*.eval.ts"],
compatibilityFlags: ["run_engine_v2"],
maxDuration: 3600,
// Code mode shells out to ripgrep to search the user's cloned repo. git + tar
// are already in the base image; ripgrep is not.
build: {
extensions: [aptGet({ packages: ["ripgrep"] })],
},
});
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "trigger.config.ts"],
"exclude": ["node_modules", ".trigger"]
}
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
testTimeout: 20000,
hookTimeout: 20000,
},
esbuild: {
target: "node18",
},
});
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
// Evals are separate from unit tests: they hit the real model (cost +
// nondeterminism), so they only run via `pnpm run test:evals`, never `pnpm test`.
export default defineConfig({
test: {
include: ["src/**/*.eval.ts"],
environment: "node",
setupFiles: ["./eval-setup.ts"],
// Real-model turns run sequentially (the harness is single-agent-per-process).
testTimeout: 240000,
hookTimeout: 60000,
},
esbuild: {
target: "node18",
},
});