feat(webapp,sdk): in-dashboard AI agent (#4018)

## Summary

Adds an in-dashboard AI agent: a chat panel, reachable from any
environment
page, that answers questions about your runs, errors, tasks, and
analytics,
diagnoses why a run failed, charts your data, reads your connected
repo's
source, and answers product and how-to questions. It is gated behind the
`hasDashboardAgentAccess` feature flag (global or per-org, default off),
so
this PR ships disabled: the launcher is hidden unless the flag is
enabled.

## Design

The agent runs as a standalone `chat.agent` Trigger task in its own
internal
package, with no access to the webapp database, Prisma, or ClickHouse.
It reads
the user's data over the public API, acting as the user via a
short-lived
delegated user-actor token minted server-side each turn (never in the
browser),
building on
[#3997](https://github.com/triggerdotdev/trigger.dev/pull/3997). The
error and analytics tools use
[#4005](https://github.com/triggerdotdev/trigger.dev/pull/4005)
and the TRQL query API.

The first turn of a new chat streams from a warm webapp route (Head
Start) while
the durable agent boots in parallel. Structured answers (a run-failure
diagnosis
card, a live chart) render through a small typed view catalog rather
than
arbitrary markup. A knowledge lane forwards product and how-to questions
to the
support assistant.

Conversation history lives in a separate Drizzle-backed store on its own
Postgres schema, kept as a display read-model so it can never corrupt
the
agent's model context.

The SDK changes add an `apiClient` option to
`chat.createStartSessionAction` and
`chat.headStart`, and keep the Head Start tool-approval tail intact
across a
custom `prepareMessages` hook so prompt caching and Head Start compose.
This commit is contained in:
Eric Allam
2026-06-24 19:04:28 +01:00
committed by GitHub
parent 2c82d4c4d1
commit c06005b353
78 changed files with 9685 additions and 1814 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Fix `chat.agent` / `AgentChat` when the agent is deployed to a Trigger.dev preview branch. The realtime message-append and stream-subscribe calls now send the `x-trigger-branch` header (sourced from the same resolver `sessions.start` uses), so messaging a preview-branch chat agent no longer fails with `x-trigger-branch header required for preview env`.
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Fix Head Start handovers breaking when a `chat.agent` also defines a `prepareMessages` hook. A handover hands the first turn's pending tool call to the agent as a tool-approval round whose trailing tool message must reach the model untouched. A `prepareMessages` hook that rewrites the last message (for example the recommended prompt-caching breakpoint) could disturb it, so the turn failed with "tool_use ids were found without tool_result". The agent now preserves that approval tail across `prepareMessages`, so caching and Head Start compose cleanly.
+14
View File
@@ -0,0 +1,14 @@
---
"@trigger.dev/sdk": patch
---
`chat.headStart` now accepts an `apiClient` option (base URL + access token), so the head-start route can create the session and trigger the agent run against a different project/environment than the warm server's ambient Trigger config. Useful when your `chat.agent` lives in a separate project from the app serving the route. Mirrors the `apiClient` option on `chat.createStartSessionAction`; your LLM provider keys stay in the `run` callback and are unaffected.
```ts
export const POST = chat.headStart({
agentId: "my-agent",
apiClient: { baseURL, accessToken },
run: async ({ chat }) =>
streamText({ ...chat.toStreamTextOptions({ tools }), model: anthropic("claude-sonnet-4-6") }),
});
```
@@ -0,0 +1,13 @@
---
"@trigger.dev/sdk": patch
---
`chat.createStartSessionAction` now accepts an `apiClient` option, so you can scope a chat session start to a specific environment's API config (`baseURL` / `accessToken`) without setting a global `TRIGGER_SECRET_KEY`. Useful when one server starts chats across more than one environment.
```ts
const startSession = chat.createStartSessionAction("my-chat", {
apiClient: { baseURL, accessToken },
});
await startSession({ chatId, clientData });
```
+191
View File
@@ -0,0 +1,191 @@
---
name: drizzle
description: Use this skill when writing or modifying Drizzle ORM schemas, queries, or migrations in this repo — specifically the `@internal/dashboard-agent-db` package (the dashboard agent's conversation datastore). Covers pg-core schema definition, the postgres-js driver, drizzle-kit migrations, and this repo's conventions: a dedicated Postgres schema, foreign-key-free cross-database design, pooler-safe connections, and the access-pattern query layer. Drizzle is NOT the main database — that's Prisma.
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Drizzle ORM (this repo)
Drizzle is used in exactly one place: **`internal-packages/dashboard-agent-db`** (`@internal/dashboard-agent-db`), the in-dashboard agent's conversation store. Everything else in the monorepo is **Prisma** (`@trigger.dev/database`). Keep them separate.
Pinned versions: **`drizzle-orm` ^0.45**, **`drizzle-kit` ^0.31** (dev), **`postgres` ^3.4** (postgres.js driver). drizzle-orm and drizzle-kit are intentionally on different version lines — 0.31.x is the correct companion for 0.45.x, there is no peer dependency between them.
## Critical rules
1. **Drizzle is only the agent's own datastore.** The agent (and its task bundle) must have **no access to the main Prisma database or ClickHouse**. Never import the Prisma client into the agent task or into `@internal/dashboard-agent-db`. Main data is reached via the API, not Drizzle.
2. **Foreign-key-free.** In cloud this DB is a *separate* PlanetScale database, so it can't FK into the main DB. Reference main entities (`organizationId`, `userId`, …) **by id only — never `.references()`**. Joins happen in app code; tenant scoping is enforced in the query layer.
3. **One dedicated Postgres schema.** All tables live under `pgSchema("trigger_dashboard_agent")` so they're schema-qualified and isolated from Prisma's `public` schema (this is what makes the OSS single-database fallback safe).
4. **Pooler-safe connections.** Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style), so postgres.js must run with **`prepare: false`** — prepared statements don't survive a connection being handed to another client between checkouts.
5. **Node16 module resolution.** Relative imports need explicit **`.js`** extensions (`import { chats } from "./schema.js"`), even though the source is `.ts`.
6. **Scope every user query.** All queries that touch user data go through `src/queries.ts` and are scoped by `organizationId` / `userId`, so callers can't forget the `where`. Don't write ad-hoc cross-tenant queries elsewhere.
## Package layout
```text
internal-packages/dashboard-agent-db/
drizzle.config.ts # drizzle-kit config (schema path, out dir, schemaFilter)
drizzle/ # generated migrations (committed)
src/
schema.ts # pgSchema + table definitions
client.ts # createDashboardAgentDb() — postgres.js + drizzle
queries.ts # the access-pattern layer (org/user-scoped)
index.ts # barrel: re-exports schema, client, queries
```
`package.json` points `main`/`types` at `./src/index.ts` (consumed as source, no build step) — same as other simple internal packages.
## Schema (pg-core)
Use `pgSchema(...).table(...)`, not the bare `pgTable`, so tables land in the dedicated schema. ([schemas](https://orm.drizzle.team/docs/schemas), [pg column types](https://orm.drizzle.team/docs/column-types/pg), [indexes](https://orm.drizzle.team/docs/indexes-constraints))
```ts
import { sql } from "drizzle-orm";
import { index, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent");
export const chats = dashboardAgentSchema.table(
"chats",
{
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(), // FK-free: id only, no .references()
userId: text("user_id").notNull(),
title: text("title").notNull().default("New chat"),
// JSONB with a typed view; .default([]) / .default({}) emit '[]'::jsonb / '{}'::jsonb
messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
deletedAt: timestamp("deleted_at", { withTimezone: true }), // soft delete
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
// Extra config returns an ARRAY in drizzle-orm 0.36+ (not an object).
(t) => [
// Partial + ordered composite index. `.desc()` on the column, `.where(sql`...`)` for partial.
index("chats_org_user_last_msg_idx")
.on(t.organizationId, t.userId, t.lastMessageAt.desc())
.where(sql`${t.deletedAt} is null`),
]
);
// Inferred row types for the query layer + consumers.
export type Chat = typeof chats.$inferSelect;
export type NewChat = typeof chats.$inferInsert;
```
Notes:
- `timestamp(..., { withTimezone: true })``timestamp with time zone`. Use `.defaultNow()` for `DEFAULT now()`.
- For a "newest first, nulls last" sort the partial index uses `.desc()`; the *query* uses raw `sql` for `NULLS LAST` (see below).
- Don't add `.references()` — see critical rule 2.
## Client (postgres.js + drizzle)
([connect overview](https://orm.drizzle.team/docs/connect-overview)) One small pool, `prepare: false`. In the agent task create it once in `onBoot` (per-process); in the webapp wrap it in the `singleton(...)` helper.
```ts
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres, { type Sql } from "postgres";
import * as schema from "./schema.js";
export type DashboardAgentDb = PostgresJsDatabase<typeof schema>;
export function createDashboardAgentDb(connectionString: string, opts: { max?: number } = {}) {
const sql: Sql = postgres(connectionString, {
max: opts.max ?? 5, // small — the pooler does the real pooling
idle_timeout: 20, // release conns when an agent run suspends
prepare: false, // REQUIRED for transaction-mode poolers
});
return { db: drizzle(sql, { schema }), sql, close: () => sql.end() };
}
```
## Queries (the access-pattern layer)
([select](https://orm.drizzle.team/docs/select), [insert](https://orm.drizzle.team/docs/insert), [operators](https://orm.drizzle.team/docs/operators), [transactions](https://orm.drizzle.team/docs/transactions), [joins](https://orm.drizzle.team/docs/joins))
```ts
import { and, desc, eq, isNull, sql } from "drizzle-orm";
// Select EXPLICIT columns for list views — never select a large blob (messages)
// or a secret (tokens) you don't need. `NULLS LAST` needs raw sql in orderBy.
await db
.select({ id: chats.id, title: chats.title, lastMessageAt: chats.lastMessageAt })
.from(chats)
.where(and(eq(chats.organizationId, orgId), eq(chats.userId, userId), isNull(chats.deletedAt)))
.orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt))
.limit(50);
// Idempotent create (avoids a duplicate-key race between two writers).
await db.insert(chats).values({ id, organizationId: orgId, userId }).onConflictDoNothing();
// Upsert.
await db
.insert(chatSessions)
.values({ chatId, publicAccessToken })
.onConflictDoUpdate({ target: chatSessions.chatId, set: { publicAccessToken, updatedAt: sql`now()` } });
// Owner-scope a join (this DB is FK-free, so enforce ownership in the query).
await db
.select({ /* session cols */ })
.from(chatSessions)
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
.where(and(eq(chatSessions.chatId, chatId), eq(chats.userId, userId)));
// Multi-write that must be consistent on the next read → one transaction.
await db.transaction(async (tx) => {
await tx.update(chats).set({ messages, updatedAt: sql`now()` }).where(eq(chats.id, chatId));
await tx.insert(chatSessions).values({ /* ... */ }).onConflictDoUpdate({ /* ... */ });
});
```
Use `sql\`now()\`` for DB-side timestamps in updates.
## Migrations (drizzle-kit)
([kit overview](https://orm.drizzle.team/docs/kit-overview), [generate](https://orm.drizzle.team/docs/drizzle-kit-generate), [migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate))
`drizzle.config.ts` must set **`schemaFilter`** so drizzle-kit only ever manages our schema — never Prisma's `public` (critical in the OSS single-DB fallback):
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
schemaFilter: ["trigger_dashboard_agent"],
dbCredentials: { url: process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://placeholder" },
});
```
Workflow:
```bash
cd internal-packages/dashboard-agent-db
pnpm run db:generate # diff schema.ts → emit SQL into drizzle/. OFFLINE (no DB needed).
# review the generated drizzle/000N_*.sql before committing
pnpm run db:migrate # apply pending migrations. Needs a real DATABASE URL.
```
- `db:generate` is **offline** — it only reads `schema.ts`, so you can verify a schema change compiles to valid DDL with no database. Use it as a fast check.
- drizzle-kit names migration files with a **random suffix** (`0000_magenta_lilandra.sql`). Don't regenerate a committed migration just to "refresh" it — that churns the filename. After the first migration is committed, schema changes produce a **new** `000N_*.sql`; commit that.
- Generated DDL for a new schema is one `CREATE SCHEMA` + schema-qualified `CREATE TABLE`s + indexes, **no foreign keys** (by design here).
## Common gotchas
- **`prepare: false`** is not optional with a pooler — without it you'll get prepared-statement errors under load.
- **Missing `.js` extension** on a relative import → TS2835 under Node16 resolution.
- **Extra-config callback returns an array** `(t) => [ ... ]` in drizzle-orm 0.36+. The old object form `(t) => ({ ... })` is deprecated.
- **`NULLS LAST` / `NULLS FIRST`** aren't on the `desc()` helper — use raw `sql\`col desc nulls last\`` in `orderBy`.
- **Don't `SELECT *` into list views** — explicitly pick columns so you never ship a megabyte `messages` blob or a session token to a list query.
- **Adding a dependency**: edit `package.json`, then `pnpm i` from the repo root (never `pnpm add`). Mind the repo's `minimumReleaseAge` (3 days) — pin with a caret range and let pnpm resolve an old-enough version.
## Reference (official docs)
- Schema declaration — https://orm.drizzle.team/docs/sql-schema-declaration
- PostgreSQL column types — https://orm.drizzle.team/docs/column-types/pg
- Schemas (`pgSchema`) — https://orm.drizzle.team/docs/schemas
- Indexes & constraints — https://orm.drizzle.team/docs/indexes-constraints
- Connect (postgres-js) — https://orm.drizzle.team/docs/connect-overview
- Select / Insert / Update / Delete — https://orm.drizzle.team/docs/select · /insert · /update · /delete
- Joins / Operators — https://orm.drizzle.team/docs/joins · /operators
- Transactions — https://orm.drizzle.team/docs/transactions
- drizzle-kit (generate / migrate / push) — https://orm.drizzle.team/docs/kit-overview
+1
View File
@@ -67,6 +67,7 @@ apps/**/public/build
**/.claude/settings.local.json
.claude/architecture/
.claude/docs-plans/
.claude/plans/
.claude/review-guides/
.claude/scheduled_tasks.lock
.mcp.log
@@ -0,0 +1,133 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ChartBlock } from "@internal/dashboard-agent";
import { useEffect, useState } from "react";
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
import { Spinner } from "~/components/primitives/Spinner";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
// Render an agent "chart" block by running its TRQL query through the dashboard's
// own /resources/metric endpoint (session-authed, returns rows + real column
// metadata) and feeding the result into QueryResultsChart. So the chart is live
// and matches the Query page exactly: the agent only emits the query + chart
// config, never the rows. Runs against the project/env the panel is open in.
type MetricResponse =
| { success: false; error: string }
| {
success: true;
data: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
timeRange: { from: string; to: string };
};
};
type ChartState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "ready";
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
timeRange?: { from: string; to: string };
};
export function AgentChart({ block }: { block: ChartBlock }) {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
const [state, setState] = useState<ChartState>({ status: "loading" });
const organizationId = organization?.id;
const projectId = project?.id;
const environmentId = environment?.id;
useEffect(() => {
// The block can render before its `query` has finished streaming in; wait
// for it rather than POST an empty query (which 400s).
if (!block.query) return;
if (!organizationId || !projectId || !environmentId) {
setState({ status: "error", error: "No environment context to run the query." });
return;
}
const controller = new AbortController();
setState({ status: "loading" });
fetch("/resources/metric", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: block.query,
organizationId,
projectId,
environmentId,
scope: "environment",
period: block.period ?? null,
from: null,
to: null,
}),
signal: controller.signal,
})
.then(async (res) => (await res.json()) as MetricResponse)
.then((data) => {
if (controller.signal.aborted) return;
if (!data.success) {
setState({ status: "error", error: data.error });
} else {
setState({
status: "ready",
rows: data.data.rows,
columns: data.data.columns,
timeRange: data.data.timeRange,
});
}
})
.catch((err) => {
if (controller.signal.aborted) return;
setState({ status: "error", error: err?.message ?? "The query failed to run." });
});
return () => controller.abort();
}, [block.query, block.period, organizationId, projectId, environmentId]);
const config: ChartConfiguration = {
chartType: block.chartType,
xAxisColumn: block.xAxisColumn,
yAxisColumns: block.yAxisColumns ?? [],
groupByColumn: block.groupByColumn ?? null,
stacked: block.stacked ?? false,
sortByColumn: null,
sortDirection: "desc",
aggregation: block.aggregation ?? "sum",
};
return (
<div className="overflow-hidden rounded-lg border border-charcoal-600 bg-charcoal-850">
{block.title ? (
<div className="border-b border-charcoal-700 bg-charcoal-800 px-3 py-2 text-xs font-medium text-text-dimmed">
{block.title}
</div>
) : null}
<div className="h-64 w-full p-2">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-dimmed">
<Spinner className="size-3" />
Running query
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-error">
{state.error}
</div>
) : (
<QueryResultsChart
rows={state.rows}
columns={state.columns}
config={config}
timeRange={state.timeRange}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { SparklesIcon } from "@heroicons/react/20/solid";
import { useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
/**
* Mounts the dashboard agent in the env layout. Renders the page content
* (`children` = the route Outlet); when the agent is open it splits the layout
* into a resizable content + agent panel using the shared Resizable primitive,
* with `autosaveId` persisting the width. When closed it's a floating launcher.
*
* `hasAccess` is resolved server-side in the env layout loader (via
* `canAccessDashboardAgent`: global env, admins/impersonators, then the
* global/per-org feature flag, default off), so the launcher is hidden unless
* the agent is enabled. The resource routes enforce the same check server-side.
*/
export function DashboardAgent({
children,
hasAccess = false,
}: {
children: React.ReactNode;
hasAccess?: boolean;
}) {
const [open, setOpen] = useState(false);
if (!hasAccess) {
return <div className="h-full min-h-0">{children}</div>;
}
if (!open) {
return (
<div className="relative h-full min-h-0">
<div className="h-full overflow-hidden">{children}</div>
<button
type="button"
aria-label="Open the dashboard agent"
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 z-40 flex items-center gap-1.5 rounded-full border border-charcoal-650 bg-background-bright px-3.5 py-2 text-sm text-text-bright shadow-lg transition hover:border-charcoal-550"
>
<SparklesIcon className="size-4 text-indigo-500" />
Ask the agent
</button>
</div>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
autosaveId="dashboard-agent-split"
className="h-full min-h-0"
>
<ResizablePanel id="dashboard-content" min="320px">
<div className="h-full overflow-hidden">{children}</div>
</ResizablePanel>
<ResizableHandle id="dashboard-agent-handle" />
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
<DashboardAgentPanel onClose={() => setOpen(false)} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
@@ -0,0 +1,194 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentMessages } from "./DashboardAgentMessages";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
// The persisted session for a chat: the session-scoped token plus the stream
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
// from replaying the previous turn.
export type DashboardAgentSession = {
publicAccessToken: string;
lastEventId?: string;
};
// Per-turn context for the agent. Matches the agent's clientDataSchema input.
export type DashboardAgentClientData = {
userId: string;
organizationId: string;
projectId?: string;
environmentId?: string;
currentPage?: string;
};
/**
* A single conversation. The panel mounts this with `key={chatId}`, so each
* chat gets its own transport constructed with its persisted session — the
* resume cursor flows in declaratively via the `sessions` option rather than
* an imperative setSession after the fact. A fresh chat passes no session and
* starts a new run on first send.
*/
export function DashboardAgentChat({
chatId,
initialMessages,
session,
clientData,
apiOrigin,
actionPath,
projectSlug,
environmentSlug,
currentPage,
pendingFirstMessage,
streaming,
onTurnSettled,
}: {
chatId: string;
initialMessages: UIMessage[];
session: DashboardAgentSession | null;
clientData: DashboardAgentClientData;
apiOrigin: string;
actionPath: string;
projectSlug: string;
environmentSlug: string;
currentPage: string;
// Cold start: send this first message through the transport once on mount to
// trigger the turn. Undefined for head-started and resumed chats.
pendingFirstMessage?: string;
// Head start: the turn is already in flight, so hydrate the session as
// streaming so the transport resumes `session.out` instead of treating it as
// a settled session with nothing to reconnect to.
streaming?: boolean;
onTurnSettled: () => void;
}) {
const [input, setInput] = useState("");
const transport = useTriggerChatTransport<typeof dashboardAgent>({
task: "dashboard-agent",
baseURL: apiOrigin,
// New chats are created server-side (the `create` action owns the id and
// runs head start), so there's no client-driven head-start route here.
// Redirect only the `in`/append to the same-origin proxy, which mints +
// injects the delegated user token server-side. `baseURL` stays a string so
// `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we
// never override it. The proxy forwards the same path on to the API.
fetch: (url, init, ctx) => {
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
const { pathname, search } = new URL(url);
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
},
clientData,
sessions: session
? {
[chatId]: {
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
// Head-started chats are mid-turn, so mark the session streaming to
// make the transport resume `session.out`. A settled session
// (history) stays false — its transcript loads from the store.
isStreaming: streaming ?? false,
},
}
: undefined,
startSession: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "start");
body.set("chatId", chatId);
body.set("clientData", JSON.stringify(clientData));
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
if (!res.ok || !data.publicAccessToken) {
throw new Error(data.error ?? "The dashboard agent couldn't start.");
}
return { publicAccessToken: data.publicAccessToken };
},
accessToken: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "token");
body.set("chatId", chatId);
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok || !data.token) {
throw new Error(data.error ?? "Couldn't refresh the dashboard agent token.");
}
return data.token;
},
});
const {
messages,
sendMessage,
status,
stop: aiStop,
error,
} = useChat({
id: chatId,
messages: initialMessages,
transport,
// Resume an existing/head-started session's stream. A cold-start chat has a
// session but nothing to resume yet — it sends its first message instead.
resume: !!session && !pendingFirstMessage,
});
const isStreaming = status === "streaming";
const isThinking = status === "submitted";
// Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
useEffect(() => {
if (pendingFirstMessage && !sentFirst.current) {
sentFirst.current = true;
void sendMessage({ text: pendingFirstMessage });
}
}, [pendingFirstMessage, sendMessage]);
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
setInput("");
void sendMessage({ text: trimmed });
},
[isStreaming, sendMessage]
);
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop();
}, [transport, chatId, aiStop]);
// Tell the panel to refresh its history list once a turn settles, so the new
// chat appears and titles/timestamps stay current.
const prevStatus = useRef(status);
useEffect(() => {
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
const nowSettled = status === "ready" || status === "error";
if (wasInFlight && nowSettled) onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
{messages.length === 0 ? (
<DashboardAgentSuggestedPrompts onSelect={submit} />
) : (
<DashboardAgentMessages messages={messages} isThinking={isThinking} error={error} />
)}
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
isStreaming={isStreaming}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
export function DashboardAgentComposer({
value,
onChange,
onSubmit,
onStop,
isStreaming,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isStreaming: boolean;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
return (
<div className="border-t border-grid-bright p-3">
<div className="rounded-2xl border border-charcoal-650 bg-background-bright p-2 transition focus-within:border-charcoal-550">
<div className="flex items-end gap-2">
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
}}
placeholder="Type a message…"
className={cn(
"max-h-[40vh] min-h-[40px] flex-1 resize-none border-0 bg-transparent px-2 py-1.5 text-sm text-text-bright placeholder-text-dimmed outline-none ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 [field-sizing:content] focus:outline-none focus:ring-0"
)}
/>
{isStreaming ? (
<Button variant="danger/small" LeadingIcon={StopIcon} onClick={onStop}>
Stop
</Button>
) : (
<Button
variant="primary/small"
LeadingIcon={PaperAirplaneIcon}
onClick={onSubmit}
disabled={!value.trim()}
>
Send
</Button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
export function DashboardAgentContextBanner({
projectSlug,
environmentSlug,
currentPage,
}: {
projectSlug: string;
environmentSlug: string;
currentPage: string;
}) {
return (
<div className="flex items-center gap-1.5 border-b border-grid-bright bg-charcoal-800/30 px-3 py-1.5 text-xs text-text-dimmed">
<span className="shrink-0">Context:</span>
<span className="truncate font-medium text-text-bright">{projectSlug}</span>
<span>/</span>
<span className="truncate">{environmentSlug}</span>
<span>/</span>
<span className="truncate capitalize">{currentPage}</span>
</div>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
/**
* The new-chat "draft" state: suggested prompts + composer with no transport
* mounted and no chat id yet. The chat id is server-owned, so the first send
* goes to the panel's `create` call, which generates the id and returns it;
* only then does the real `DashboardAgentChat` mount. The client never invents
* a chat id.
*/
export function DashboardAgentDraft({
onSubmit,
projectSlug,
environmentSlug,
currentPage,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
environmentSlug: string;
currentPage: string;
}) {
const [input, setInput] = useState("");
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setInput("");
onSubmit(trimmed);
},
[onSubmit]
);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
<DashboardAgentSuggestedPrompts onSelect={submit} />
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
/>
</>
);
}
@@ -0,0 +1,57 @@
import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
export function DashboardAgentHeader({
view,
onNewChat,
onToggleHistory,
onClose,
}: {
view: "chat" | "history";
onNewChat: () => void;
onToggleHistory: () => void;
onClose: () => void;
}) {
return (
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-sm font-medium text-text-bright">Dashboard agent</span>
<div className="flex items-center gap-0.5">
<IconButton label="New chat" icon={PencilSquareIcon} onClick={onNewChat} />
<IconButton
label="History"
icon={ClockIcon}
onClick={onToggleHistory}
active={view === "history"}
/>
<IconButton label="Close" icon={XMarkIcon} onClick={onClose} />
</div>
</div>
);
}
function IconButton({
label,
icon: Icon,
onClick,
active,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
active?: boolean;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
className={cn(
"rounded p-1.5 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright",
active && "bg-charcoal-700 text-text-bright"
)}
>
<Icon className="size-4" />
</button>
);
}
@@ -0,0 +1,81 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DateTime } from "~/components/primitives/DateTime";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
// Date fields arrive as strings over the loader's JSON.
export type DashboardAgentChat = {
id: string;
title: string;
lastMessageAt: string | null;
updatedAt: string;
};
export function DashboardAgentHistory({
chats,
currentChatId,
onSelect,
onNewChat,
onDelete,
}: {
chats: DashboardAgentChat[];
currentChatId: string;
onSelect: (chatId: string) => void;
onNewChat: () => void;
onDelete: (chatId: string) => void;
}) {
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-2">
<button
type="button"
onClick={onNewChat}
className="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-text-bright transition hover:bg-charcoal-800"
>
<PlusIcon className="size-4 text-green-500" />
New chat
</button>
{chats.length === 0 ? (
<Paragraph variant="small" className="p-2 text-text-dimmed">
No previous chats yet.
</Paragraph>
) : (
<ol className="space-y-0.5">
{chats.map((chat) => (
<li key={chat.id}>
<div
className={cn(
"group flex items-center gap-2 rounded-sm px-2 py-1.5 transition-colors hover:bg-charcoal-800",
chat.id === currentChatId && "bg-charcoal-750 hover:bg-charcoal-750"
)}
>
<button
type="button"
onClick={() => onSelect(chat.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left outline-none focus-custom"
>
<span className="line-clamp-1 text-sm text-text-bright">{chat.title}</span>
{chat.lastMessageAt && (
<span className="text-xs text-text-dimmed">
<DateTime date={chat.lastMessageAt} showTooltip={false} />
</span>
)}
</button>
<button
type="button"
onClick={() => onDelete(chat.id)}
aria-label="Delete chat"
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
>
<TrashIcon className="size-3.5" />
</button>
</div>
</li>
))}
</ol>
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import type { UIMessage } from "@ai-sdk/react";
import { memo } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
import { ViewBlocks } from "./view-catalog";
// The shared MessageBubble renders `step-start` parts as a dashed "step"
// separator — useful in the run inspector / playground, just noise in this
// simple chat. Drop them before rendering (reference preserved when there are
// none, so memoization still holds for those messages).
function stripStepParts(message: UIMessage): UIMessage {
if (!message.parts?.some((p) => p.type === "step-start")) return message;
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
}
// A completed render_view tool part carries a `{ blocks }` view spec the agent
// composed (see the dashboard-agent view catalog). We render those blocks as
// rich cards instead of the generic tool row.
function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | null {
const p = part as { type: string; output?: { blocks?: unknown[] } };
if (p.type !== "tool-render_view") return null;
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
}
// Renders one message. Assistant messages that include a completed render_view
// part get the catalog cards (plus the gather tool rows / lead-in text for
// transparency); everything else uses the shared MessageBubble unchanged, so
// its streaming memoization is preserved for the common case.
const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
message,
}: {
message: UIMessage;
}) {
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
return <MessageBubble message={message} />;
}
return (
<div className="space-y-2">
{message.parts.map((part, i) => {
const spec = viewSpecFor(part);
if (spec) return <ViewBlocks key={i} blocks={spec.blocks as never} />;
return renderPart(part, i);
})}
</div>
);
});
// Renders the conversation with the shared agent message renderer — the same
// MessageBubble the run inspector and playground use, so agent output looks
// identical everywhere — except where the agent emits a view-catalog block,
// which renders as a rich card.
export function DashboardAgentMessages({
messages,
isThinking,
error,
}: {
messages: UIMessage[];
isThinking: boolean;
error?: Error;
}) {
const rootRef = useAutoScrollToBottom([messages, isThinking]);
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div ref={rootRef} className="space-y-4 p-4">
{messages.map((message) => (
<DashboardAgentMessageBubble key={message.id} message={stripStepParts(message)} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-sm text-text-dimmed">
<Spinner className="size-3" />
Thinking
</div>
)}
{error && (
<div className="rounded border border-error/30 bg-error/10 px-3 py-2">
<span className="text-xs text-error">{error.message}</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,282 @@
import type { UIMessage } from "@ai-sdk/react";
import { useLocation } from "@remix-run/react";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { useApiOrigin } from "~/hooks/useApiOrigin";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import {
DashboardAgentChat,
type DashboardAgentClientData,
type DashboardAgentSession,
} from "./DashboardAgentChat";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { DashboardAgentHeader } from "./DashboardAgentHeader";
import {
DashboardAgentHistory,
type DashboardAgentChat as DashboardAgentChatListItem,
} from "./DashboardAgentHistory";
// Restore the last open chat across panel re-opens and page reloads. Scoped by
// org because chats are org-scoped. localStorage (not a cookie) since the panel
// only mounts client-side — the server never needs this.
const lastChatStorageKey = (organizationId: string) =>
`tdev:dashboard-agent:last-chat:${organizationId}`;
type ActiveChat = {
chatId: string;
messages: UIMessage[];
session: DashboardAgentSession | null;
// Cold start only: the agent run has no warm step-1, so the mounted chat sends
// this first message through the transport to trigger the turn. Undefined for
// head-started and resumed chats — their stream is resumed, not re-sent.
pendingFirstMessage?: string;
// True for a head-started chat: the turn is already in flight server-side, so
// the transport must hydrate the session as streaming to resume `session.out`.
streaming?: boolean;
};
/**
* The dashboard agent side panel. Owns history, the active chat, and last-chat
* persistence. New chats start in a draft state with no id; the server
* generates the chat id on the first send (`create`) and owns the chat record,
* so the client never invents an id. Existing chats resolve their stored
* transcript + session before mounting `DashboardAgentChat` (keyed by chatId).
*/
export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const user = useUser();
const apiOrigin = useApiOrigin();
const location = useLocation();
const [view, setView] = useState<"chat" | "history">("chat");
const [chats, setChats] = useState<DashboardAgentChatListItem[]>([]);
const [active, setActive] = useState<ActiveChat | null>(null);
const [loading, setLoading] = useState(false);
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
const storageKey = lastChatStorageKey(organization.id);
const currentPage = location.pathname.split("/").filter(Boolean).pop() ?? "overview";
const clientData = useMemo<DashboardAgentClientData>(
() => ({
userId: user.id,
organizationId: organization.id,
projectId: project.id,
environmentId: environment.id,
currentPage: location.pathname,
}),
[user.id, organization.id, project.id, environment.id, location.pathname]
);
const loadHistory = useCallback(async () => {
const res = await fetch(actionPath);
if (res.ok) {
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
setChats(data.chats ?? []);
}
}, [actionPath]);
// Bumped on each open so a slower earlier open can't overwrite a newer one
// when chats are switched rapidly.
const openChatRequestSeq = useRef(0);
// Open an existing chat: fetch its stored transcript + session so resume flows
// in through the transport at mount. A stored id that's gone (deleted / never
// sent) drops back to the draft state.
const openChat = useCallback(
async (id: string) => {
setView("chat");
const seq = ++openChatRequestSeq.current;
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
})
: { messages: [], session: null };
if (seq !== openChatRequestSeq.current) return;
if (data.messages && data.messages.length > 0) {
setActive({
chatId: id,
messages: data.messages,
session: data.session?.publicAccessToken
? {
publicAccessToken: data.session.publicAccessToken,
lastEventId: data.session.lastEventId ?? undefined,
}
: null,
});
} else {
// Nothing stored under this id — drop to a fresh draft.
setActive(null);
}
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
[actionPath]
);
// Start a new chat by sending its first message. The server generates the id,
// creates the chat record, and kicks off the first turn (head start when
// configured, else a cold session). We then mount the real chat on the server
// id and either resume its stream (head start) or send the message through
// the transport (cold start).
const createChat = useCallback(
async (text: string) => {
setView("chat");
const seq = ++openChatRequestSeq.current;
setLoading(true);
try {
const userMessage: UIMessage = {
id: generateFriendlyId("msg"),
role: "user",
parts: [{ type: "text", text }],
};
const body = new FormData();
body.set("intent", "create");
body.set("message", JSON.stringify(userMessage));
body.set("clientData", JSON.stringify(clientData));
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as {
chatId?: string;
publicAccessToken?: string;
headStarted?: boolean;
error?: string;
};
// A newer open/create (or New chat) superseded this one — drop the result.
if (seq !== openChatRequestSeq.current) return;
if (!res.ok || !data.chatId || !data.publicAccessToken) {
setActive(null);
return;
}
setActive({
chatId: data.chatId,
messages: data.headStarted ? [userMessage] : [],
session: { publicAccessToken: data.publicAccessToken },
pendingFirstMessage: data.headStarted ? undefined : text,
streaming: data.headStarted,
});
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
[actionPath, clientData]
);
// On open, restore the last chat if there is one; otherwise stay in the draft
// state (active = null). Runs once per mount.
const restored = useRef(false);
useEffect(() => {
if (restored.current) return;
restored.current = true;
let stored: string | null = null;
try {
stored = window.localStorage.getItem(storageKey);
} catch {
/* localStorage unavailable — start fresh */
}
if (stored) void openChat(stored);
}, [openChat, storageKey]);
// Persist the active chat as the one to restore next time.
useEffect(() => {
if (!active?.chatId) return;
try {
window.localStorage.setItem(storageKey, active.chatId);
} catch {
/* ignore */
}
}, [active?.chatId, storageKey]);
const newChat = useCallback(() => {
// Invalidate any in-flight open/create so its result can't replace the draft.
openChatRequestSeq.current += 1;
setLoading(false);
setView("chat");
setActive(null);
}, []);
const switchChat = useCallback(
(id: string) => {
void openChat(id);
},
[openChat]
);
const deleteChat = useCallback(
async (id: string) => {
const body = new FormData();
body.set("intent", "delete");
body.set("chatId", id);
await fetch(actionPath, { method: "POST", body });
if (id === active?.chatId) newChat();
void loadHistory();
},
[actionPath, active?.chatId, newChat, loadHistory]
);
const toggleHistory = useCallback(() => {
setView((v) => {
if (v === "chat") void loadHistory();
return v === "chat" ? "history" : "chat";
});
}, [loadHistory]);
return (
<div className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150">
<DashboardAgentHeader
view={view}
onNewChat={newChat}
onToggleHistory={toggleHistory}
onClose={onClose}
/>
{view === "history" ? (
<DashboardAgentHistory
chats={chats}
currentChatId={active?.chatId ?? ""}
onSelect={switchChat}
onNewChat={newChat}
onDelete={deleteChat}
/>
) : loading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="size-5" />
</div>
) : active ? (
<DashboardAgentChat
key={active.chatId}
chatId={active.chatId}
initialMessages={active.messages}
session={active.session}
pendingFirstMessage={active.pendingFirstMessage}
streaming={active.streaming}
clientData={clientData}
apiOrigin={apiOrigin}
actionPath={actionPath}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
onTurnSettled={loadHistory}
/>
) : (
<DashboardAgentDraft
onSubmit={createChat}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
/>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { SparklesIcon } from "@heroicons/react/20/solid";
import { Paragraph } from "~/components/primitives/Paragraph";
// Static for now; later these can be page-aware (per currentPage) or server-driven.
const SUGGESTED_PROMPTS = [
"What can you help me with?",
"How do retries work in Trigger.dev?",
"Where do I set environment variables?",
"Explain what this page shows.",
];
export function DashboardAgentSuggestedPrompts({
onSelect,
}: {
onSelect: (prompt: string) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-4">
<div className="flex flex-col items-center gap-1.5 text-center">
<SparklesIcon className="size-6 text-indigo-500" />
<Paragraph variant="small" className="text-text-dimmed">
Ask about your runs, errors, or how Trigger.dev works.
</Paragraph>
</div>
<div className="flex w-full flex-col gap-1.5">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSelect(prompt)}
className="rounded-md border border-charcoal-700 bg-charcoal-800/40 px-3 py-2 text-left text-sm text-text-dimmed transition hover:border-charcoal-600 hover:text-text-bright"
>
{prompt}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,216 @@
import { Link } from "@remix-run/react";
import type { DiagnosisBlock } from "@internal/dashboard-agent";
import { Badge } from "~/components/primitives/Badge";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3RunPath } from "~/utils/pathBuilder";
// The "why did this run fail?" failure card — the first block in the dashboard
// agent's view catalog. Rendered from a `diagnosis` block the agent emits via
// the render_view tool (see internal-packages/dashboard-agent tool-schemas).
// Everything here is plain presentation of validated fields; no markup comes
// from the model, so there's nothing to sanitize beyond outbound URLs.
const CATEGORY_LABELS: Record<DiagnosisBlock["category"], string> = {
user_code_error: "Code error",
configuration: "Configuration",
dependency: "Dependency",
timeout: "Timeout",
out_of_memory: "Out of memory",
rate_limit: "Rate limit",
external_service: "External service",
infrastructure: "Infrastructure",
cancellation: "Cancelled",
unknown: "Unknown",
};
const CONFIDENCE_STYLES: Record<DiagnosisBlock["confidence"], string> = {
high: "border-emerald-500/40 text-emerald-400",
medium: "border-amber-500/40 text-amber-400",
low: "border-charcoal-600 text-text-dimmed",
};
const EVIDENCE_LABELS: Record<DiagnosisBlock["evidence"][number]["type"], string> = {
error: "Error",
failed_span: "Failed span",
child_run: "Child run",
logs: "Logs",
deploy: "Deploy",
source: "Source",
historical_match: "History",
};
// Build a run-page path in the current org/project/env, or null when that route
// context is absent (e.g. the storybook page) so the card degrades to plain
// text rather than throwing.
function useRunPath(runId: string): string | null {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
if (!organization || !project || !environment) return null;
return v3RunPath(organization, project, environment, { friendlyId: runId });
}
// Internal link to a run page, built from the canonical path builder so it stays
// correct if the route shape changes. Falls back to plain text off-context.
function RunLink({ runId, className }: { runId: string; className?: string }) {
const to = useRunPath(runId);
if (!to) return <span className={cn("font-mono text-text-dimmed", className)}>{runId}</span>;
return (
<Link to={to} className={cn("text-indigo-400 underline hover:text-indigo-300", className)}>
{runId}
</Link>
);
}
// Render an evidence `reference`: a run id links to its run page, an https URL
// becomes an external link, everything else (error id, file:line, version) is
// shown as monospace text.
function EvidenceReference({ reference }: { reference: string }) {
if (/^run_[a-z0-9]+$/i.test(reference)) {
return <RunLink runId={reference} className="font-mono text-xs" />;
}
const safeUrl = toSafeUrl(reference);
if (safeUrl) {
return (
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300"
>
{reference}
</a>
);
}
return <span className="font-mono text-xs text-text-dimmed">{reference}</span>;
}
function DiagnosisActions({ actions }: { actions: NonNullable<DiagnosisBlock["actions"]> }) {
const buttonClass =
"inline-flex items-center rounded border border-charcoal-600 bg-charcoal-800 px-2.5 py-1 text-xs text-text-bright transition-colors hover:border-charcoal-500 hover:bg-charcoal-750";
return (
<div className="flex flex-wrap gap-2 pt-1">
{actions.map((action, i) => {
if (action.kind === "view_run" && /^run_[a-z0-9]+$/i.test(action.target)) {
return <RunActionButton key={i} runId={action.target} label={action.label} className={buttonClass} />;
}
if (action.kind === "docs") {
const safeUrl = toSafeUrl(action.target);
if (!safeUrl) return null;
return (
<a
key={i}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className={buttonClass}
>
{action.label}
</a>
);
}
return null;
})}
</div>
);
}
function RunActionButton({
runId,
label,
className,
}: {
runId: string;
label: string;
className: string;
}) {
const to = useRunPath(runId);
if (!to) return <span className={className}>{label}</span>;
return (
<Link to={to} className={className}>
{label}
</Link>
);
}
export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
const evidence = block.evidence ?? [];
const nextSteps = block.nextSteps ?? [];
const actions = block.actions ?? [];
return (
<div className="overflow-hidden rounded-lg border border-charcoal-600 bg-charcoal-850">
<div className="flex flex-wrap items-center gap-2 border-b border-charcoal-700 bg-charcoal-800 px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">Run diagnosis</span>
<Badge variant="small" className="border-rose-500/40 text-rose-400">
{CATEGORY_LABELS[block.category] ?? block.category}
</Badge>
<Badge variant="small" className={cn("uppercase", CONFIDENCE_STYLES[block.confidence])}>
{block.confidence} confidence
</Badge>
{block.runId ? <RunLink runId={block.runId} className="ml-auto font-mono text-xs" /> : null}
</div>
<div className="space-y-3 px-3 py-3">
<p className="text-sm text-text-bright">{block.summary}</p>
<Section title="Likely cause">
<p className="text-sm text-text-dimmed">{block.likelyCause}</p>
</Section>
{evidence.length > 0 ? (
<Section title="Evidence">
<ul className="space-y-1.5">
{evidence.map((item, i) => (
<li key={i} className="text-xs text-text-dimmed">
<span className="mr-1.5 rounded-sm bg-charcoal-700 px-1 py-0.5 text-[10px] uppercase tracking-wide text-text-dimmed">
{EVIDENCE_LABELS[item.type] ?? item.type}
</span>
<span className="text-text-bright">{item.detail}</span>
{item.reference ? (
<span className="ml-1.5">
<EvidenceReference reference={item.reference} />
</span>
) : null}
</li>
))}
</ul>
</Section>
) : null}
{block.impact ? (
<Section title="Impact">
<p className="text-sm text-text-dimmed">{block.impact}</p>
</Section>
) : null}
{nextSteps.length > 0 ? (
<Section title="Next steps">
<ol className="list-decimal space-y-1 pl-4">
{nextSteps.map((step, i) => (
<li key={i} className="text-sm text-text-dimmed">
{step}
</li>
))}
</ol>
</Section>
) : null}
{actions.length > 0 ? <DiagnosisActions actions={actions} /> : null}
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
{children}
</div>
);
}
@@ -0,0 +1,29 @@
import type { ViewBlock } from "@internal/dashboard-agent";
import { AgentChart } from "./AgentChart";
import { RunDiagnosisCard } from "./RunDiagnosisCard";
// The render registry for the dashboard agent's view catalog — our small
// "generative UI" layer. The agent emits a `render_view` tool call whose output
// is `{ blocks: ViewBlock[] }` (a spec drawn from the catalog defined in
// internal-packages/dashboard-agent). Here we map each block `type` to its
// component. Unknown types are skipped, so an older/newer agent can never
// render arbitrary content — same guarantee a generative-UI framework gives,
// without the dependency. Add a block by adding a `case` here and a union
// member in the package's `viewBlockSchema`.
export function ViewBlocks({ blocks }: { blocks: ViewBlock[] }) {
if (!Array.isArray(blocks)) return null;
return (
<div className="space-y-2">
{blocks.map((block, i) => {
switch (block.type) {
case "diagnosis":
return <RunDiagnosisCard key={i} block={block} />;
case "chart":
return <AgentChart key={i} block={block} />;
default:
return null;
}
})}
</div>
);
}
+18
View File
@@ -97,6 +97,24 @@ const EnvironmentSchema = z
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20),
// Dashboard-agent conversation store. Cloud points this at the dedicated
// PlanetScale database; when unset it falls back to DATABASE_URL (OSS), where
// the tables live in the isolated `trigger_dashboard_agent` schema.
DASHBOARD_AGENT_DATABASE_URL: z.string().optional(),
// The secret key (tr_*) for the runtime environment the dashboard-agent task
// is deployed to. The chat session is created in that environment via the
// standard chat.agent SDK flow. When unset, the live agent is disabled — the
// conversation store / History still work, no chat can start.
DASHBOARD_AGENT_SECRET_KEY: z.string().optional(),
// Global default for the `hasDashboardAgentAccess` flag. "0" (off) ships the
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
// (org featureFlags) and admins/impersonators win regardless.
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
// Anthropic key for the dashboard agent's Head Start route only (the warm
// first-turn step-1 LLM call runs in this process). The agent run itself
// uses its own key on the Trigger side. When unset, Head Start is disabled
// and the first turn falls back to the normal cold-start path.
ANTHROPIC_API_KEY: z.string().optional(),
DIRECT_URL: z
.string()
.refine(
+8
View File
@@ -0,0 +1,8 @@
import { useTypedRouteLoaderData } from "remix-typedjson";
import { loader } from "../root";
export function useApiOrigin() {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch!.apiOrigin;
}
@@ -160,6 +160,7 @@ export class OrganizationsPresenter {
const globalFlags = await flags({
defaultValues: {
hasAiAccess: env.AI_FEATURES_ENABLED === "1",
hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1",
hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1",
},
});
+1
View File
@@ -71,6 +71,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
features,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
apiOrigin: env.API_ORIGIN ?? env.APP_ORIGIN,
triggerCliTag: env.TRIGGER_CLI_TAG,
kapa,
timezone,
@@ -1,7 +1,10 @@
import { Outlet } from "@remix-run/react";
import { Outlet, useLoaderData } from "@remix-run/react";
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
@@ -29,7 +32,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
select: {
id: true,
externalRef: true,
organization: { select: { id: true } },
organization: { select: { id: true, featureFlags: true } },
environments: {
select: {
id: true,
@@ -85,11 +88,32 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
return project;
// Resolve dashboard-agent access here (single source of truth: global env,
// admins/impersonators, then the global/per-org feature flag, default off) so
// the launcher button is hidden when it's not enabled. The org's featureFlags
// came from the membership-checked project query above, so we pass them in to
// avoid a second org lookup.
const hasDashboardAgentAccess = await canAccessDashboardAgent({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
});
return {
...project,
hasDashboardAgentAccess,
};
};
export default function Page() {
return <Outlet />;
const { hasDashboardAgentAccess } = useLoaderData<typeof loader>();
return (
<DashboardAgent hasAccess={hasDashboardAgentAccess}>
<Outlet />
</DashboardAgent>
);
}
// Caught here (inside the project SideMenu's Outlet) rather than at the project
@@ -0,0 +1,88 @@
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
import { z } from "zod";
import { env as $env } from "~/env.server";
import {
type AuthenticationResult,
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
import {
resolveDashboardAgentRepoSnapshot,
resolveRunCommit,
} from "~/services/dashboardAgent.server";
import { logger } from "~/services/logger.server";
// Resolve a signed source-archive pointer for the project's connected repo, used
// by the dashboard agent's code tools. With `?runId=run_...` it pins to the
// commit that run's deployed version came from (run-SHA pinning); without it,
// the tracked branch head. The GitHub token never leaves the server, only the
// short-lived signed URL is returned. Auth mirrors the worker-by-tag route: a
// delegated user-actor token authenticates as its user (identity-only).
const ParamsSchema = z.object({
projectRef: z.string(),
env: z.enum(["dev", "staging", "prod", "preview"]),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
let authenticationResult: AuthenticationResult | undefined;
if (bearer && isUserActorToken(bearer)) {
const claims = await verifyUserActorToken($env.SESSION_SECRET, bearer);
if (!claims) return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
authenticationResult = { type: "personalAccessToken", result: { userId: claims.userId } };
} else {
authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
}
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) return json({ error: "Invalid Params" }, { status: 400 });
const { projectRef, env } = parsed.data;
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
triggerBranch
);
const runId = new URL(request.url).searchParams.get("runId") ?? undefined;
let ref: string | undefined;
let version: string | undefined;
let dirty = false;
if (runId) {
const commit = await resolveRunCommit(runtimeEnv.id, runId);
if (!commit) {
return json(
{ error: "That run has no deployed commit (it may be a dev run)." },
{ status: 404 }
);
}
ref = commit.sha;
version = commit.version;
dirty = commit.dirty;
}
const snapshot = await resolveDashboardAgentRepoSnapshot(runtimeEnv.projectId, { ref });
if (!snapshot) {
return json({ error: "No connected repository for this project." }, { status: 404 });
}
return json({ ...snapshot, version, dirty });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to resolve dashboard agent repo snapshot", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -1,4 +1,5 @@
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
@@ -6,6 +7,7 @@ import { type GetWorkerByTagResponse } from "@trigger.dev/core/v3/schemas";
import { env as $env } from "~/env.server";
import { v3RunsPath } from "~/utils/pathBuilder";
import {
type AuthenticationResult,
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
@@ -25,11 +27,27 @@ type ParamsSchema = z.infer<typeof ParamsSchema>;
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
// A delegated user-actor token authenticates as its user, like a PAT.
// Resolve it here (the shared `authenticateRequest` deliberately doesn't
// accept UATs) so the dashboard agent can list a project's deployed tasks
// on the user's behalf. Identity-only, same as the PAT path below — there's
// no ability check on this route, so the cap isn't enforced here (matches
// PAT behavior).
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
let authenticationResult: AuthenticationResult | undefined;
if (bearer && isUserActorToken(bearer)) {
const claims = await verifyUserActorToken($env.SESSION_SECRET, bearer);
if (!claims) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
authenticationResult = { type: "personalAccessToken", result: { userId: claims.userId } };
} else {
authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
}
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -0,0 +1,125 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { $replica } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import {
dashboardAgentApiOrigin,
mintDashboardAgentUserActorToken,
resolveDashboardAgentRepoSnapshot,
} from "~/services/dashboardAgent.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
// Same-origin proxy for the chat "in"/append request. The transport routes the
// `in` endpoint here (and the `out` SSE stream direct to the Trigger API), so
// every turn passes through the dashboard's own session before reaching the
// agent. We use that hop to mint a fresh read-only delegated token for the
// signed-in user and inject it into the turn's metadata server-side. The token
// reaches the agent without ever touching the browser, and minting stays tied
// to the user's own session (no shared-secret backdoor).
//
// The append body is `{ kind, payload: { metadata, ... } }`; we add the token
// (plus the API origin and the server-vouched project ref + env) to
// `payload.metadata`. Only `kind === "message"` turns carry metadata — stop
// chunks pass through untouched. We forward only the headers the API needs and
// deliberately drop the dashboard session cookie.
const FORWARDED_HEADERS = [
"authorization",
"content-type",
"x-part-id",
"x-trigger-source",
"x-trigger-branch",
];
// The API's env routes key on the canonical env name (dev/staging/prod/preview),
// not the dashboard URL slug (e.g. staging's slug is "stg"). Map from the env
// type so the agent's tools address the right environment. Preview branches
// aren't threaded yet (they'd need the branch on every tool call) — a follow-up.
const ENV_NAME_BY_TYPE: Record<string, string> = {
DEVELOPMENT: "dev",
STAGING: "staging",
PRODUCTION: "prod",
PREVIEW: "preview",
};
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (
!(await canAccessDashboardAgent({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
}))
) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) return json({ error: "Project not found" }, { status: 404 });
// The SDK builds the upstream path (`realtime/v1/sessions/{chatId}/in/append`);
// it arrives here as the splat. Forward it verbatim to the Trigger API.
const upstreamPath = params["*"];
if (!upstreamPath) return json({ error: "Not found" }, { status: 404 });
const apiOrigin = dashboardAgentApiOrigin();
const url = new URL(request.url);
const upstreamUrl = `${apiOrigin.replace(/\/$/, "")}/${upstreamPath}${url.search}`;
// Resolve the dashboard env slug to the canonical API env name its tools use.
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
where: { projectId: project.id, slug: envParam },
select: { type: true },
});
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
// When the project has a connected GitHub repo, resolve a signed source-archive
// pointer (code mode). Null otherwise -> the agent stays in assistant mode.
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
// Inject the delegated token + context into the turn's metadata.
const raw = await request.text();
let body = raw;
try {
const parsed = JSON.parse(raw) as {
kind?: string;
payload?: { metadata?: Record<string, unknown> };
};
if (parsed.kind === "message" && parsed.payload) {
parsed.payload.metadata = {
...(parsed.payload.metadata ?? {}),
userActorToken: await mintDashboardAgentUserActorToken(user.id),
apiOrigin,
projectRef: project.externalRef,
environmentName,
...(repoSnapshot ? { repoSnapshot } : {}),
};
body = JSON.stringify(parsed);
}
} catch {
// Non-JSON or unexpected shape — forward unchanged rather than break the turn.
}
const headers = new Headers();
for (const name of FORWARDED_HEADERS) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
try {
const upstream = await fetch(upstreamUrl, { method: "POST", headers, body });
const text = await upstream.text();
return new Response(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
} catch (error) {
logger.error("Dashboard agent in-proxy failed", { error, upstreamPath });
return json({ error: "The dashboard agent couldn't reach the run." }, { status: 502 });
}
}
@@ -0,0 +1,244 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
chatExists,
createChat,
getChatMessages,
getSession,
listChats,
renameChat,
setChatPinned,
softDeleteChat,
} from "@internal/dashboard-agent-db";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import type { UIMessage } from "ai";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import {
dashboardAgentApiOrigin,
isDashboardAgentConfigured,
mintDashboardAgentToken,
mintDashboardAgentUserActorToken,
resolveDashboardAgentRepoSnapshot,
startDashboardAgentSession,
} from "~/services/dashboardAgent.server";
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
// The agent's tools address the canonical env name, not the dashboard URL slug.
const ENV_NAME_BY_TYPE: Record<string, string> = {
DEVELOPMENT: "dev",
STAGING: "staging",
PRODUCTION: "prod",
PREVIEW: "preview",
};
const ActionBody = z.object({
intent: z.enum(["start", "create", "token", "rename", "pin", "delete"]),
// Omitted for `create` (the server generates it); required for the rest.
chatId: z.string().min(1).optional(),
// The first user message (JSON UIMessage), for `create`.
message: z.string().optional(),
clientData: z.string().optional(),
title: z.string().optional(),
pinned: z.enum(["true", "false"]).optional(),
});
// History list, or — with ?chatId= — the stored transcript + session for resume.
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params);
if (!(await canAccessDashboardAgent({ userId, isAdmin: user.admin, isImpersonating: user.isImpersonating, organizationSlug }))) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) return json({ error: "Project not found" }, { status: 404 });
const chatId = new URL(request.url).searchParams.get("chatId");
if (chatId) {
const [messages, session] = await Promise.all([
getChatMessages(dashboardAgentDb, { chatId, userId }),
getSession(dashboardAgentDb, { chatId, userId }),
]);
return json({ messages: messages ?? [], session });
}
const chats = await listChats(dashboardAgentDb, {
organizationId: project.organizationId,
userId,
});
return json({ chats });
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (!(await canAccessDashboardAgent({ userId, isAdmin: user.admin, isImpersonating: user.isImpersonating, organizationSlug }))) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) return json({ error: "Project not found" }, { status: 404 });
const parsed = ActionBody.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return json({ error: "Invalid request" }, { status: 400 });
// Create a new chat: the SERVER generates the id and owns the chat record, so
// a client can never name another user's chat. Kicks off the first turn (head
// start when configured, else a cold session) and returns the id + token. The
// client mounts with that id and resumes the stream.
if (parsed.data.intent === "create") {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
let firstMessage: UIMessage | undefined;
try {
firstMessage = parsed.data.message
? (JSON.parse(parsed.data.message) as UIMessage)
: undefined;
} catch {
return json({ error: "Invalid message" }, { status: 400 });
}
if (!firstMessage) return json({ error: "message is required" }, { status: 400 });
let clientData: Record<string, unknown> | undefined;
try {
clientData = parsed.data.clientData
? (JSON.parse(parsed.data.clientData) as Record<string, unknown>)
: undefined;
} catch {
/* invalid JSON — create without context metadata */
}
const chatId = generateFriendlyId("chat");
try {
await createChat(dashboardAgentDb, {
id: chatId,
organizationId: project.organizationId,
userId,
...(clientData ? { metadata: { context: clientData } } : {}),
});
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
where: { projectId: project.id, slug: envParam },
select: { type: true },
});
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
if (headStarted) {
// Head start runs the warm step-1 with this first message and injects the
// delegated token + context into the run's payload server-side.
await startDashboardAgentHeadStart({
chatId,
messages: [firstMessage],
mode: repoSnapshot ? "code" : "assistant",
metadata: {
// The agent validates the run metadata against its clientDataSchema
// (userId, organizationId, …), so the per-turn clientData has to be
// present alongside the injected auth/context fields.
...(clientData ?? {}),
userActorToken: await mintDashboardAgentUserActorToken(userId),
apiOrigin: dashboardAgentApiOrigin(),
projectRef: project.externalRef,
environmentName,
...(repoSnapshot ? { repoSnapshot } : {}),
},
});
} else {
// Cold start: create the session (preload); the client sends the first
// message through the transport, where the `in` proxy injects the token.
await startDashboardAgentSession({ chatId, clientData });
}
const publicAccessToken = await mintDashboardAgentToken(chatId);
return json({ chatId, publicAccessToken, headStarted });
} catch (error) {
logger.error("Failed to create dashboard agent chat", { chatId, error });
return json(
{ error: "The dashboard agent couldn't start. Please try again in a moment." },
{ status: 500 }
);
}
}
const { intent, chatId } = parsed.data;
if (!chatId) return json({ error: "chatId is required" }, { status: 400 });
switch (intent) {
case "start": {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
// Resume-only: new chats are created via the `create` intent (server-owned
// id). The transport falls back here to re-establish a session for an
// existing chat (e.g. after its token expired), so verify ownership before
// issuing one — a client-supplied chatId must belong to the caller.
if (!(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }))) {
return json({ error: "Chat not found" }, { status: 404 });
}
let clientData: Record<string, unknown> | undefined;
try {
clientData = parsed.data.clientData
? (JSON.parse(parsed.data.clientData) as Record<string, unknown>)
: undefined;
} catch {
/* invalid JSON — start without metadata */
}
try {
const { publicAccessToken } = await startDashboardAgentSession({ chatId, clientData });
return json({ publicAccessToken });
} catch (error) {
logger.error("Failed to start dashboard agent session", { chatId, error });
return json(
{ error: "The dashboard agent couldn't start. Please try again in a moment." },
{ status: 500 }
);
}
}
case "token": {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
// Only mint a session token for a chat the caller owns, so a client-supplied
// chatId can't be used to get a token for someone else's session.
if (!(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }))) {
return json({ error: "Chat not found" }, { status: 404 });
}
return json({ token: await mintDashboardAgentToken(chatId) });
}
case "rename": {
if (!parsed.data.title) return json({ error: "title is required" }, { status: 400 });
await renameChat(dashboardAgentDb, { chatId, userId, title: parsed.data.title });
return json({ ok: true });
}
case "pin": {
await setChatPinned(dashboardAgentDb, {
chatId,
userId,
pinned: parsed.data.pinned === "true",
});
return json({ ok: true });
}
case "delete": {
await softDeleteChat(dashboardAgentDb, { chatId, userId });
return json({ ok: true });
}
}
};
@@ -0,0 +1,118 @@
import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent";
import { ViewBlocks } from "~/components/dashboard-agent/view-catalog";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
// Storybook for the dashboard agent's view catalog — the blocks the agent emits
// via its render_view tool. Each example is a real block spec rendered through
// the same ViewBlocks registry the chat panel uses, at roughly panel width.
const fullDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_a1b2c3d4e5",
summary:
"The run failed because processOrder threw on an order with no line items. The payload had an empty items array.",
category: "user_code_error",
likelyCause:
"processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.",
confidence: "high",
evidence: [
{
type: "error",
detail: "TypeError: Cannot read properties of undefined (reading 'sku')",
reference: "run_a1b2c3d4e5",
},
{ type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" },
{
type: "source",
detail: "The throwing line reads order.items[0].sku with no guard.",
reference: "src/trigger/processOrder.ts:18",
},
{
type: "historical_match",
detail: "14 runs of this task hit the same error in the last 24h.",
reference: "error_emptyorder",
},
],
impact: "14 runs of process-order failed with this error in the last 24 hours, all in production.",
nextSteps: [
"Guard against an empty items array at the top of processOrder and return early.",
"Validate the payload before triggering so empty orders never reach the task.",
],
actions: [
{ label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" },
{ label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" },
],
};
const externalServiceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_f6g7h8i9j0",
summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.",
category: "external_service",
likelyCause:
"The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.",
confidence: "medium",
evidence: [
{ type: "error", detail: "TimeoutError: Stripe API timed out after 30s", reference: "run_f6g7h8i9j0" },
{ type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" },
],
impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.",
nextSteps: [
"Wrap the Stripe call in a retry with backoff.",
"Set an explicit request timeout shorter than the task's max duration.",
],
actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }],
};
const lowConfidenceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_k1l2m3n4o5",
summary: "The run crashed without a captured error, so the cause isn't conclusive from the available signals.",
category: "unknown",
likelyCause:
"The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.",
confidence: "low",
evidence: [
{ type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." },
{ type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." },
],
nextSteps: [
"Re-run with a larger machine to rule out out-of-memory.",
"Add logging around the last successful step to narrow where it stops.",
],
};
function Example({ title, block }: { title: string; block: ViewBlock }) {
return (
<div className="flex flex-col gap-2">
<Header2>{title}</Header2>
<div className="w-[26rem] max-w-full">
<ViewBlocks blocks={[block]} />
</div>
</div>
);
}
export default function Story() {
return (
<div className="flex flex-col gap-8 p-6">
<div className="flex flex-col gap-1">
<Header1>Dashboard agent UI</Header1>
<Paragraph variant="small">
Blocks the dashboard agent renders via its render_view tool, shown through the same
ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card,
shown here, and a chart block that runs a TRQL query live (only renders inside a
project/env, so it's not shown here). Run links resolve inside a project; here they render
as plain text.
</Paragraph>
</div>
<div className="flex flex-wrap gap-8">
<Example title="Diagnosis — full, high confidence" block={fullDiagnosis} />
<Example title="Diagnosis — external service, medium" block={externalServiceDiagnosis} />
<Example title="Diagnosis — low confidence, minimal" block={lowConfidenceDiagnosis} />
</div>
</div>
);
}
@@ -156,6 +156,12 @@ const stories: Story[] = [
name: "Usage",
slug: "usage",
},
// Dashboard agent section
{
sectionTitle: "Dashboard agent",
name: "Agent UI",
slug: "agent-ui",
},
// Forms section
{
sectionTitle: "Forms",
@@ -0,0 +1,216 @@
import { signUserActorToken } from "@trigger.dev/rbac";
import { TriggerClient } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { githubApp } from "./gitHub.server";
import { logger } from "./logger.server";
const TASK_ID = "dashboard-agent";
// Read-only cap on the agent's delegated user-actor token. `read:apiKeys` is
// what lets it exchange the token for an env JWT (the gate on the exchange
// route); the rest scope the actual reads. No write/admin scopes, so even a
// leaked token can't mutate anything.
const DASHBOARD_AGENT_UAT_CAP = [
"read:apiKeys",
"read:runs",
"read:deployments",
"read:environments",
"read:errors",
"read:query",
];
// Minted fresh on every turn (the `in` proxy injects it), so the lifetime only
// has to cover a single turn's tool calls. Short by design — a stale token in
// the agent's run payload expires quickly.
const DASHBOARD_AGENT_UAT_TTL_SECONDS = 10 * 60;
// The Trigger instance this webapp runs against — the same origin the agent
// task calls back to (as the user) for its read tools.
export function dashboardAgentApiOrigin(): string {
return env.API_ORIGIN ?? env.APP_ORIGIN;
}
// Mint a short-lived, read-only delegated token for the signed-in user. Self
// service from the dashboard session (never a PAT), so a user can only ever
// mint a token for themselves. The `in` proxy injects this into the turn's
// metadata so the token reaches the agent without ever touching the browser.
export function mintDashboardAgentUserActorToken(userId: string): Promise<string> {
return signUserActorToken(env.SESSION_SECRET, {
userId,
client: "dashboard-agent",
cap: DASHBOARD_AGENT_UAT_CAP,
expirationTime: Math.floor(Date.now() / 1000) + DASHBOARD_AGENT_UAT_TTL_SECONDS,
});
}
// The session is created in whatever env DASHBOARD_AGENT_SECRET_KEY belongs to.
// baseURL is the Trigger instance this webapp runs against (its own API origin).
function dashboardAgentConfig() {
const accessToken = env.DASHBOARD_AGENT_SECRET_KEY;
if (!accessToken) return null;
return { baseURL: dashboardAgentApiOrigin(), accessToken };
}
export function isDashboardAgentConfigured(): boolean {
return Boolean(env.DASHBOARD_AGENT_SECRET_KEY);
}
export async function startDashboardAgentSession(params: {
chatId: string;
clientData?: Record<string, unknown>;
}): Promise<{ publicAccessToken: string }> {
const config = dashboardAgentConfig();
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
const startSession = chat.createStartSessionAction(TASK_ID, { apiClient: config });
return startSession({ chatId: params.chatId, clientData: params.clientData });
}
export async function mintDashboardAgentToken(chatId: string): Promise<string> {
const config = dashboardAgentConfig();
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
const client = new TriggerClient(config);
return client.auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
expirationTime: "1h",
});
}
// A signed, short-lived pointer to the project's connected repo at a commit. Only
// the URL crosses to the agent; the GitHub token stays here. The agent's code
// tools download + extract it on their own filesystem (see @internal/dashboard-agent).
export type DashboardAgentRepoSnapshot = {
tarballUrl: string;
owner: string;
repo: string;
sha: string;
defaultBranch?: string;
};
// The GitHub archive redirect URL is valid for a few minutes; cache the resolved
// pointer briefly so multi-turn chats don't re-mint a token + re-resolve on every
// message. Keyed by project + ref.
const repoSnapshotCache = new Map<string, { snapshot: DashboardAgentRepoSnapshot; expiresAt: number }>();
const REPO_SNAPSHOT_TTL_MS = 60_000;
const REPO_SNAPSHOT_MAX_ENTRIES = 1_000;
// Drop expired entries (key cardinality grows with each unique project + pinned
// SHA), then evict oldest-first if still over the cap, so the cache can't grow
// unbounded over a process lifetime.
function pruneRepoSnapshotCache(now = Date.now()) {
for (const [key, value] of repoSnapshotCache) {
if (value.expiresAt <= now) repoSnapshotCache.delete(key);
}
let overflow = repoSnapshotCache.size - REPO_SNAPSHOT_MAX_ENTRIES;
if (overflow <= 0) return;
for (const key of repoSnapshotCache.keys()) {
repoSnapshotCache.delete(key);
if (--overflow <= 0) break;
}
}
/**
* Resolve the code-mode repo snapshot for a project, or null when the GitHub App
* is disabled / no repo is connected (which keeps the agent in assistant mode).
*
* Mints a `contents:read` installation token scoped to the one repo, resolves the
* signed archive URL, and returns just that URL. The token never leaves the
* server. `opts.ref` pins a specific commit (run-SHA pinning); without it, the
* tracked prod branch (or the repo default) head is used.
*/
export async function resolveDashboardAgentRepoSnapshot(
projectId: string,
opts: { ref?: string } = {}
): Promise<DashboardAgentRepoSnapshot | null> {
if (!githubApp) return null;
// Cache per project + ref so HEAD and each pinned commit are cached separately.
const cacheKey = `${projectId}:${opts.ref ?? "HEAD"}`;
const cached = repoSnapshotCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.snapshot;
if (cached) repoSnapshotCache.delete(cacheKey);
const connected = await prisma.connectedGithubRepository.findFirst({
where: { projectId },
select: {
branchTracking: true,
repository: {
select: {
fullName: true,
defaultBranch: true,
installation: { select: { appInstallationId: true } },
},
},
},
});
if (!connected) return null;
const [owner, repo] = connected.repository.fullName.split("/");
if (!owner || !repo) return null;
const installationId = Number(connected.repository.installation.appInstallationId);
const defaultBranch = connected.repository.defaultBranch;
const tracking = connected.branchTracking as { prod?: { branch?: string } } | null;
// An explicit 40-char commit SHA is used directly (run-SHA pinning); otherwise
// resolve the requested branch, the tracked prod branch, or the repo default.
const requested = opts.ref;
const isSha = !!requested && /^[0-9a-f]{40}$/i.test(requested);
const branchRef = requested && !isSha ? requested : tracking?.prod?.branch || defaultBranch;
try {
const octokit = await githubApp.getInstallationOctokit(installationId);
const sha = isSha
? requested!
: (await octokit.rest.repos.getBranch({ owner, repo, branch: branchRef })).data.commit.sha;
const token = await githubApp.octokit.rest.apps.createInstallationAccessToken({
installation_id: installationId,
repositories: [repo],
permissions: { contents: "read" },
});
// Resolve the signed archive URL without downloading the bytes server-side.
const redirect = await fetch(`https://api.github.com/repos/${owner}/${repo}/tarball/${sha}`, {
headers: {
Authorization: `Bearer ${token.data.token}`,
Accept: "application/vnd.github+json",
"User-Agent": "trigger-dashboard-agent",
},
redirect: "manual",
});
const tarballUrl = redirect.headers.get("location");
if (!tarballUrl) return null;
const snapshot: DashboardAgentRepoSnapshot = { tarballUrl, owner, repo, sha, defaultBranch };
pruneRepoSnapshotCache();
repoSnapshotCache.set(cacheKey, { snapshot, expiresAt: Date.now() + REPO_SNAPSHOT_TTL_MS });
return snapshot;
} catch (error) {
logger.error("Failed to resolve dashboard agent repo snapshot", { error, projectId });
return null;
}
}
// Map a run (by friendly id) to the commit its deployed version came from, for
// run-SHA pinning. A run locks to a BackgroundWorker (`lockedToVersionId`), whose
// WorkerDeployment carries the commit. Null for runs with no deployed version
// (e.g. dev runs), so the agent falls back to the branch head.
export async function resolveRunCommit(
environmentId: string,
runFriendlyId: string
): Promise<{ sha: string; version: string; dirty: boolean } | null> {
const run = await prisma.taskRun.findFirst({
where: { friendlyId: runFriendlyId, runtimeEnvironmentId: environmentId },
select: { lockedToVersionId: true },
});
if (!run?.lockedToVersionId) return null;
const deployment = await prisma.workerDeployment.findFirst({
where: { workerId: run.lockedToVersionId },
select: { commitSHA: true, version: true, git: true },
});
if (!deployment?.commitSHA) return null;
const dirty = (deployment.git as { dirty?: boolean } | null)?.dirty ?? false;
return { sha: deployment.commitSHA, version: deployment.version, dirty };
}
@@ -0,0 +1,20 @@
import { createDashboardAgentDb, type DashboardAgentDb } from "@internal/dashboard-agent-db";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
/**
* The webapp's connection to the dashboard-agent conversation store (the History
* tab + the chat panel's create / rename / delete / resume actions). Same Drizzle
* client the agent task uses, pointed at the same database.
*
* This is the agent's OWN datastore — NOT the main Prisma database, which the
* agent has no access to. Cloud uses the dedicated PlanetScale database; OSS
* falls back to DATABASE_URL with tables isolated in the `trigger_dashboard_agent`
* schema.
*/
export const dashboardAgentDb: DashboardAgentDb = singleton("dashboardAgentDb", () => {
const connectionString = env.DASHBOARD_AGENT_DATABASE_URL ?? env.DATABASE_URL;
return createDashboardAgentDb(connectionString, {
max: env.DATABASE_CONNECTION_LIMIT,
}).db;
});
@@ -0,0 +1,69 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import {
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
DASHBOARD_AGENT_MODEL,
DASHBOARD_AGENT_SYSTEM_PROMPT,
dashboardAgentCodeToolSchemas,
dashboardAgentToolSchemas,
} from "@internal/dashboard-agent/tool-schemas";
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
import { streamText, type UIMessage } from "ai";
import { env } from "~/env.server";
import { dashboardAgentApiOrigin } from "~/services/dashboardAgent.server";
import { logger } from "~/services/logger.server";
const TASK_ID = "dashboard-agent";
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
/**
* Server-owned head start. The webapp generates the chatId and owns the chat
* record, then kicks off step 1 here via `chat.startHeadStart` (the detached
* flow): it creates the session (externalId = chatId), triggers the
* handover-prepare run, and streams step 1 into `session.out` in the background.
* The browser resumes that stream rather than streaming step 1 inline. Step 1
* runs the agent's SCHEMA-ONLY tools + the shared model/prompt for the mode the
* agent run will be in; the agent run picks up tool execution and step 2+.
*
* `metadata` (the delegated UAT + context) is merged into the run's wire payload
* server-side, so it reaches the agent without touching the browser.
*/
export async function startDashboardAgentHeadStart(params: {
chatId: string;
messages: UIMessage[];
mode: "assistant" | "code";
metadata: Record<string, unknown>;
}): Promise<void> {
const tools =
params.mode === "code" ? dashboardAgentCodeToolSchemas : dashboardAgentToolSchemas;
const system =
params.mode === "code" ? DASHBOARD_AGENT_CODE_SYSTEM_PROMPT : DASHBOARD_AGENT_SYSTEM_PROMPT;
const { completion } = await chatServer.startHeadStart({
agentId: TASK_ID,
chatId: params.chatId,
messages: params.messages,
metadata: params.metadata,
// Scope session creation + the agent trigger to the agent's project/env. The
// Anthropic key here only powers the warm step-1 call.
apiClient: {
baseURL: dashboardAgentApiOrigin(),
accessToken: env.DASHBOARD_AGENT_SECRET_KEY,
},
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools }),
model: anthropic(DASHBOARD_AGENT_MODEL),
system,
}),
});
// The webapp is long-lived, so step 1's drain + the handover dispatch run in
// the background after this resolves (createSession + trigger have completed).
// Log a warm-step failure for observability: startHeadStart has already fired
// handover-skip so the agent run exits cleanly, but the client (mounted as
// streaming) then resumes an empty session.out, so the turn looks lost.
completion.catch((error) => {
logger.error("Dashboard agent head start failed", { chatId: params.chatId, error });
});
}
@@ -0,0 +1,51 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
/**
* Whether the in-dashboard AI agent is available to this user in this org.
* Mirrors `canAccessAi`: admins/impersonators always pass, then the global /
* per-org feature flag with `DASHBOARD_AGENT_ENABLED` as the global default, so
* a per-org override (incl. disabling it) wins. Enforced server-side so a
* non-flagged user can't start sessions by hitting the resource route directly.
*/
export async function canAccessDashboardAgent(options: {
userId: string;
isAdmin: boolean;
isImpersonating: boolean;
organizationSlug: string;
// When the caller already has the org's `featureFlags` loaded (e.g. a layout
// loader that queried the org with a membership check), pass them to skip the
// extra org lookup. Omit it and we query the org ourselves.
orgFeatureFlags?: Record<string, unknown> | null;
}): Promise<boolean> {
const { userId, isAdmin, isImpersonating, organizationSlug, orgFeatureFlags } = options;
if (isAdmin || isImpersonating) {
return true;
}
let overrides = orgFeatureFlags;
if (overrides === undefined) {
const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
overrides = (org?.featureFlags as Record<string, unknown>) ?? {};
}
const flag = makeFlag();
const flagResult = await flag({
key: FEATURE_FLAG.hasDashboardAgentAccess,
defaultValue: env.DASHBOARD_AGENT_ENABLED === "1",
overrides: overrides ?? {},
});
return Boolean(flagResult);
}
+4
View File
@@ -6,6 +6,7 @@ export const FEATURE_FLAG = {
hasQueryAccess: "hasQueryAccess",
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasDashboardAgentAccess: "hasDashboardAgentAccess",
hasComputeAccess: "hasComputeAccess",
hasPrivateConnections: "hasPrivateConnections",
hasSso: "hasSso",
@@ -24,6 +25,9 @@ export const FeatureFlagCatalog = {
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
// Gates the in-dashboard AI agent panel. Controllable globally and per-org
// (org wins); admins/impersonators always see it. Defaults off via DASHBOARD_AGENT_ENABLED.
[FEATURE_FLAG.hasDashboardAgentAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
[FEATURE_FLAG.hasSso]: z.coerce.boolean(),
@@ -894,9 +894,18 @@ async function createWorkerPrompts(
},
});
// Compute content hash for dedup
// Compute the version-definition hash for dedup. Includes the model and
// config, not just the prompt text, so changing a code prompt's model or
// config creates a new version — otherwise a model-only change is silently
// skipped and the old model keeps serving.
const contentString = promptResource.content ?? "";
const contentHash = hashContent(contentString);
const contentHash = hashContent(
JSON.stringify({
content: contentString,
model: promptResource.model ?? null,
config: promptResource.config ?? null,
})
);
// Find the latest version overall (for version numbering) and the latest
// code-sourced version (for content dedup). We compare against the latest
@@ -914,7 +923,8 @@ async function createWorkerPrompts(
});
if (latestCodeVersion?.contentHash === contentHash) {
// Code content unchanged since last deploy — skip creating a new version
// Code definition (text + model + config) unchanged since last deploy —
// skip creating a new version.
continue;
}
+3
View File
@@ -27,6 +27,7 @@
"/public/build"
],
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.0",
"@ariakit/react": "^0.4.6",
@@ -58,6 +59,8 @@
"@heroicons/react": "^2.0.12",
"@internal/cache": "workspace:*",
"@internal/compute": "workspace:*",
"@internal/dashboard-agent": "workspace:*",
"@internal/dashboard-agent-db": "workspace:*",
"@internal/llm-model-catalog": "workspace:*",
"@internal/redis": "workspace:*",
"@internal/run-engine": "workspace:*",
+8
View File
@@ -13,6 +13,14 @@ else
echo "SKIP_POSTGRES_MIGRATIONS=1, skipping Postgres migrations."
fi
if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then
echo "Running dashboard agent migrations"
pnpm --filter @internal/dashboard-agent-db db:migrate:deploy
echo "Dashboard agent migrations done"
else
echo "SKIP_DASHBOARD_AGENT_MIGRATIONS=1, skipping dashboard agent migrations."
fi
if [ -n "$CLICKHOUSE_URL" ] && [ "$SKIP_CLICKHOUSE_MIGRATIONS" != "1" ]; then
# Run ClickHouse migrations
echo "Running ClickHouse migrations..."
+86
View File
@@ -112,6 +112,8 @@ Head Start runs step 1's LLM call in your warm server process while the agent ru
`chat.headStart` returns a standard [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) handler — `(req: Request) => Promise<Response>` — so it slots into any runtime that speaks Web Fetch.
Drive it one of two ways: wire the handler into the transport's [`headStart` option](#the-transport-option) so the browser's first message POSTs to it (the setup below), or call [`chat.startHeadStart`](#detached-head-start) from a backend that creates the chat and triggers the run in one request, then resumes on a separate page.
**Verified runtimes:** Node 18+, Bun, Deno, Cloudflare Workers, Vercel (Node and Edge), Netlify (Functions and Edge). The handler uses only `fetch` and Web `ReadableStream` / `TransformStream` (no `node:*` imports), and the S2 streaming dependency picks the right transport for each runtime automatically (HTTP/2 on Node/Deno, HTTP/1.1 on Bun/Workers/browsers).
**Compatible frameworks (native Web Fetch):** Next.js App Router, Hono, SvelteKit, Remix, React Router v7, TanStack Start, Astro, Nitro/Nuxt, Elysia. Mount the handler directly.
@@ -545,6 +547,10 @@ Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemes
Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.
#### With `prepareMessages`
When the first turn's handover carries a pending tool call, the runtime reshapes it into a tool-approval round: the partial assistant gets a `tool-approval-request` and the chain ends with a `tool` message holding the matching `tool-approval-response`. The agent's `streamText` reads that trailing row to execute the handed-over call before step 2. `chat.agent` keeps that tail intact across your [`prepareMessages`](/ai-chat/reference#chatagentoptions) hook, so the common [prompt-caching](/ai-chat/prompt-caching) pattern of rolling a cache breakpoint onto the last message is safe on a resume turn (the breakpoint lands on the next user or assistant message instead). If you hand-roll a backend with [`chat.customAgent`](#chatcustomagent) or [`chat.createSession`](#chatcreatesession), preserve that trailing approval row yourself.
### Handover with custom agents
The route handler is backend-agnostic: `agentId` can point at a `chat.agent`, a [`chat.customAgent`](/ai-chat/custom-agents), or a [`chat.createSession`](/ai-chat/custom-agents#managed-loop-chatcreatesession) loop. With `chat.agent` the handover is consumed for you (the steps above). The two hand-rolled backends consume it explicitly on turn 0.
@@ -655,6 +661,86 @@ Optional. When set, the FIRST message of a brand-new chat (no existing session s
This is **not** a stock `useChat` `endpoint` — it's not the canonical request URL for every turn, just the first-turn shortcut.
### Detached head start
`chat.startHeadStart` runs the same head start as the [`chat.headStart` handler above](#setup); the difference is how step 1 reaches the browser. `chat.headStart` streams step 1 back over the live connection the browser opens when it sends the first message. `chat.startHeadStart` has no open browser connection to stream to, so it drains step 1 into the durable session stream and the browser **resumes** it when the chat page loads.
Use it when there's no open connection at first-turn time, because the first message is captured outside the chat UI and the conversation renders on a separate page. A typical flow: a "new chat" form posts the prompt to your backend, which creates the chat row, starts the run with `chat.startHeadStart`, and returns a `chatId`; the browser then navigates to `/chats/{chatId}` and resumes. You still get the first-turn TTFC win; the browser picks step 1 up on resume instead of live.
**1. Start the head start in your create endpoint.** Call `chat.startHeadStart`, keep `completion` alive past the response (`waitUntil` / Next.js `after`), and return the `chatId`.
```ts app/api/chat/create/route.ts (your backend)
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { after } from "next/server";
// Schema-only tools; same bundle-isolation rule as chat.headStart.
import { headStartTools } from "@/lib/chat-tools/schemas";
export async function POST(req: Request) {
const { chatId, messages } = await req.json();
// Persist your own chat row + the first user message here.
const { completion } = await chat.startHeadStart({
agentId: "my-chat",
chatId, // session externalId; reuse it on the destination page
messages, // first-turn user history
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools: headStartTools }),
model: anthropic("claude-sonnet-4-6"),
system: "You are a helpful assistant.",
}),
});
// Keep the function warm until step 1 drains and the handover dispatches.
after(completion);
return Response.json({ chatId });
}
```
**2. Resume the chat on the destination page.** Set no `headStart` and no `startSession`: the run is already in flight, so the transport resumes `session.out` and replays step 1 (warm) then step 2+ (agent) under one assistant message.
```tsx app/chats/[chatId]/page.tsx
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
});
const { messages } = useChat({ id: chatId, transport, resume: true });
```
<Warning>
`completion` must run to completion, or step 1 never finishes draining and the turn stalls. On serverless, hand it to the platform's run-after-response primitive (`waitUntil`, Next.js `after`). On a long-lived server you can ignore it; it runs in the background.
</Warning>
<Warning>
If you hydrate the transport's session state yourself (the `sessions` option, e.g. so the same page can also resume already-stored chats from your session store), a head-started session is still mid-turn, so mark it `isStreaming: true`. A session hydrated as not streaming is treated as settled: the transport skips reconnecting to `session.out`, so the browser never sees the turn even though the run completed. The minimal example above sidesteps this by not hydrating a session at all (`resume: true` alone reconnects).
</Warning>
The `run` callback and bundle-isolation rule are the same as `chat.headStart`. Pass `metadata` to attach auth tokens or context to the run; it never reaches the browser. The cost over the transport-routed handler is one extra round trip: the create request, then the resume.
#### The `chat.startHeadStart` API
```ts
chat.startHeadStart<TTools>({
agentId: string, // chat.agent / chat.customAgent / chat.createSession id
chatId: string, // session externalId; reuse on the destination page
messages: UIMessage[], // first-turn user history
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
idleTimeoutInSeconds?: number, // how long the agent waits for the handover signal. Default: 60
triggerConfig?: Partial<SessionTriggerConfig>, // tags, queue, machine, …
apiClient?: ApiClientConfiguration, // when the agent lives in another project/env
metadata?: Record<string, unknown>, // merged into the run payload; never sent to the browser
}): Promise<{ chatId: string; completion: Promise<void> }>
```
`completion` resolves once the head start finishes; `await` it or hand it to `waitUntil`. It rejects if the warm step or the dispatch fails.
### Limitations
- **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.
+4
View File
@@ -135,6 +135,10 @@ The system breakpoint and the conversation breakpoint compose: the system block
Anthropic allows **at most 4** cache breakpoints per request, and a prefix must be at least ~1024 tokens (model-dependent) to cache at all — shorter prefixes silently don't cache. One system breakpoint plus one rolling message breakpoint is the typical setup and leaves headroom.
</Note>
<Note>
This rolling-breakpoint pattern composes with [Head Start](/ai-chat/fast-starts#head-start). On a head-start handover, the first turn's pending tool call is handed to the agent as a tool-approval round whose trailing `tool` message must reach `streamText` untouched for that call to execute. `chat.agent` preserves that tail across `prepareMessages` automatically, so rewriting the last message here is safe: on a resume turn the breakpoint just lands on the next user or assistant message instead of the transient approval row.
</Note>
## Caching and compaction
Compaction rewrites the conversation prefix — it replaces earlier turns with a summary — so it necessarily invalidates the cached message prefix at that point. That's a one-time reset, not a regression: because `prepareMessages` also runs on the compaction rebuild and result paths, the new (shorter) prefix gets a fresh breakpoint and re-warms on the next turn. Your system-prompt cache is unaffected — compaction never touches the system block. See [Compaction](/ai-chat/compaction) for how the summary is produced.
@@ -0,0 +1,43 @@
# @internal/dashboard-agent-db
The conversation datastore for the in-dashboard agent, isolated from the main
Prisma database. Drizzle (postgres-js) over a dedicated `trigger_dashboard_agent`
Postgres schema.
- **Cloud:** a separate PlanetScale Postgres database (`DASHBOARD_AGENT_DATABASE_URL`),
reached over a standard pooled connection.
- **OSS / self-host:** falls back to the main `DATABASE_URL`; the tables live in
the dedicated `trigger_dashboard_agent` schema, isolated from Prisma's `public`.
The schema is **foreign-key-free** — it references main entities (`organizationId`,
`userId`) by id only, because in cloud it lives in a different database.
## Why a separate store
The agent runs as an ephemeral Trigger task and must have **no access to the main
database or ClickHouse** (those go through the API). This is its own low-blast-radius
store: the agent connects directly here to persist conversations, and the webapp
connects here for the History tab. Conversation history *correctness* is owned by
`chat.agent`'s built-in object-store snapshot — this DB is a display read-model
(list chats, render a past chat, resume the transport), never the model's source
of truth.
## Tables
- `chats` — one row per conversation: org/user scope, title, a `messages` JSONB
display copy of the transcript, and `metadata` (the project/env context the chat
ran in). Soft-deleted via `deleted_at`, pinned via `pinned_at`.
- `chat_sessions` — live transport state keyed by `chat_id`: the session-scoped
`public_access_token` and `last_event_id` for resume. Separate table so the
secret token is isolated from list queries and the hot per-turn write stays off
the conversation row's indexes.
## Migrations
```bash
pnpm run db:generate # generate SQL migration from src/schema.ts (offline)
pnpm run db:migrate # apply migrations (needs DASHBOARD_AGENT_DATABASE_URL or DATABASE_URL)
```
drizzle-kit is scoped to the `trigger_dashboard_agent` schema (`schemaFilter`), so
pointing it at the main OSS database never touches Prisma's tables.
@@ -0,0 +1,17 @@
import { defineConfig } from "drizzle-kit";
// Cloud points at the dedicated PlanetScale database; OSS falls back to the main
// DATABASE_URL (tables still land in the trigger_dashboard_agent schema).
const url =
process.env.DASHBOARD_AGENT_DATABASE_URL ??
process.env.DATABASE_URL ??
"postgres://placeholder"; // generate is offline; a real url is only needed for migrate/studio
export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
// Only manage our schema — never introspect or diff Prisma's `public` schema.
schemaFilter: ["trigger_dashboard_agent"],
dbCredentials: { url },
});
@@ -0,0 +1,25 @@
CREATE SCHEMA "trigger_dashboard_agent";
--> statement-breakpoint
CREATE TABLE "trigger_dashboard_agent"."chat_sessions" (
"chat_id" text PRIMARY KEY NOT NULL,
"public_access_token" text NOT NULL,
"last_event_id" text,
"run_id" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "trigger_dashboard_agent"."chats" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"title" text DEFAULT 'New chat' NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"pinned_at" timestamp with time zone,
"deleted_at" timestamp with time zone,
"last_message_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "chats_org_user_last_msg_idx" ON "trigger_dashboard_agent"."chats" USING btree ("organization_id","user_id","last_message_at" DESC NULLS LAST) WHERE "trigger_dashboard_agent"."chats"."deleted_at" is null;
@@ -0,0 +1,38 @@
CREATE TABLE "trigger_dashboard_agent"."chat_turn_evals" (
"chat_id" text NOT NULL,
"turn" integer NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"agent_run_id" text,
"eval_run_id" text,
"project_ref" text,
"environment" text,
"current_page" text,
"model" text,
"prompt_slug" text,
"prompt_version" integer,
"tools_used" jsonb DEFAULT '[]'::jsonb NOT NULL,
"tool_error" boolean DEFAULT false NOT NULL,
"judge_model" text,
"score_grounded" smallint,
"score_answered" smallint,
"score_concise" smallint,
"passed" boolean,
"intent_category" text,
"outcome" text,
"sentiment" text,
"capability_gap" boolean DEFAULT false NOT NULL,
"docs_gap" boolean DEFAULT false NOT NULL,
"support_opportunity" boolean DEFAULT false NOT NULL,
"feature_request" boolean DEFAULT false NOT NULL,
"topics" jsonb DEFAULT '[]'::jsonb NOT NULL,
"signals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"summary" text,
"user_text" text,
"judge" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_turn_evals_chat_id_turn_pk" PRIMARY KEY("chat_id","turn")
);
--> statement-breakpoint
CREATE INDEX "chat_turn_evals_org_created_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "chat_turn_evals_org_opps_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST) WHERE "trigger_dashboard_agent"."chat_turn_evals"."capability_gap" or "trigger_dashboard_agent"."chat_turn_evals"."docs_gap" or "trigger_dashboard_agent"."chat_turn_evals"."support_opportunity" or "trigger_dashboard_agent"."chat_turn_evals"."feature_request";
@@ -0,0 +1,178 @@
{
"id": "512ce1a7-b31d-4644-9639-3e124b22e52e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"trigger_dashboard_agent.chat_sessions": {
"name": "chat_sessions",
"schema": "trigger_dashboard_agent",
"columns": {
"chat_id": {
"name": "chat_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_access_token": {
"name": "public_access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_event_id": {
"name": "last_event_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"trigger_dashboard_agent.chats": {
"name": "chats",
"schema": "trigger_dashboard_agent",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'New chat'"
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pinned_at": {
"name": "pinned_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_message_at": {
"name": "last_message_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"chats_org_user_last_msg_idx": {
"name": "chats_org_user_last_msg_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_message_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {
"trigger_dashboard_agent": "trigger_dashboard_agent"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,444 @@
{
"id": "7a42a0cf-9933-4381-adad-98c25105465b",
"prevId": "512ce1a7-b31d-4644-9639-3e124b22e52e",
"version": "7",
"dialect": "postgresql",
"tables": {
"trigger_dashboard_agent.chat_sessions": {
"name": "chat_sessions",
"schema": "trigger_dashboard_agent",
"columns": {
"chat_id": {
"name": "chat_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_access_token": {
"name": "public_access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_event_id": {
"name": "last_event_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"trigger_dashboard_agent.chat_turn_evals": {
"name": "chat_turn_evals",
"schema": "trigger_dashboard_agent",
"columns": {
"chat_id": {
"name": "chat_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"turn": {
"name": "turn",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"agent_run_id": {
"name": "agent_run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"eval_run_id": {
"name": "eval_run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"project_ref": {
"name": "project_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"environment": {
"name": "environment",
"type": "text",
"primaryKey": false,
"notNull": false
},
"current_page": {
"name": "current_page",
"type": "text",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"prompt_slug": {
"name": "prompt_slug",
"type": "text",
"primaryKey": false,
"notNull": false
},
"prompt_version": {
"name": "prompt_version",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"tools_used": {
"name": "tools_used",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"tool_error": {
"name": "tool_error",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"judge_model": {
"name": "judge_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"score_grounded": {
"name": "score_grounded",
"type": "smallint",
"primaryKey": false,
"notNull": false
},
"score_answered": {
"name": "score_answered",
"type": "smallint",
"primaryKey": false,
"notNull": false
},
"score_concise": {
"name": "score_concise",
"type": "smallint",
"primaryKey": false,
"notNull": false
},
"passed": {
"name": "passed",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"intent_category": {
"name": "intent_category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"outcome": {
"name": "outcome",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sentiment": {
"name": "sentiment",
"type": "text",
"primaryKey": false,
"notNull": false
},
"capability_gap": {
"name": "capability_gap",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"docs_gap": {
"name": "docs_gap",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"support_opportunity": {
"name": "support_opportunity",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"feature_request": {
"name": "feature_request",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"topics": {
"name": "topics",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"signals": {
"name": "signals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_text": {
"name": "user_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"judge": {
"name": "judge",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"chat_turn_evals_org_created_idx": {
"name": "chat_turn_evals_org_created_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_turn_evals_org_opps_idx": {
"name": "chat_turn_evals_org_opps_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"chat_turn_evals_chat_id_turn_pk": {
"name": "chat_turn_evals_chat_id_turn_pk",
"columns": [
"chat_id",
"turn"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"trigger_dashboard_agent.chats": {
"name": "chats",
"schema": "trigger_dashboard_agent",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'New chat'"
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pinned_at": {
"name": "pinned_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_message_at": {
"name": "last_message_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"chats_org_user_last_msg_idx": {
"name": "chats_org_user_last_msg_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_message_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {
"trigger_dashboard_agent": "trigger_dashboard_agent"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781711036274,
"tag": "0000_magenta_lilandra",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781990914081,
"tag": "0001_slimy_living_tribunal",
"breakpoints": true
}
]
}
@@ -0,0 +1,57 @@
// Production migration runner for the `trigger_dashboard_agent` schema.
//
// Runs under plain `node migrate.mjs` in the built image: `drizzle-orm` and
// `postgres` are runtime dependencies, so this needs no `drizzle-kit`, `tsx`,
// or build step (keeps the image lean). The OSS container runs this from its
// entrypoint; cloud runs it out-of-band against its own database.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
// Cloud points at the dedicated dashboard-agent database; OSS falls back to the
// main DATABASE_URL (tables still land in the `trigger_dashboard_agent` schema).
const connectionString = process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL;
if (!connectionString) {
console.error(
"[dashboard-agent-db] DASHBOARD_AGENT_DATABASE_URL / DATABASE_URL not set; cannot migrate."
);
process.exit(1);
}
// Prisma-style URLs carry `?schema=...`; postgres.js forwards unknown query
// params as server startup config and Postgres rejects `schema`. Our tables are
// schema-qualified, so the param is unnecessary — drop it.
function normalizeConnectionString(value) {
try {
const url = new URL(value);
url.searchParams.delete("schema");
return url.toString();
} catch {
return value;
}
}
const migrationsFolder = join(dirname(fileURLToPath(import.meta.url)), "drizzle");
const sql = postgres(normalizeConnectionString(connectionString), {
max: 1,
prepare: false,
// Silence the "schema/relation already exists, skipping" notices the journal's
// idempotent CREATE IF NOT EXISTS emits on every re-run, so restart logs stay clean.
onnotice: () => {},
});
try {
// Journal lives in Drizzle's default `drizzle` schema (matching `drizzle-kit
// migrate`, so dev and deploy track migrations the same way). It must not be
// our data schema: the first migration runs `CREATE SCHEMA
// "trigger_dashboard_agent"`, which would collide with the journal schema the
// migrator pre-creates. The dashboard agent is the only Drizzle user of its
// database, so the `drizzle` schema stays exclusively ours.
await migrate(drizzle(sql), { migrationsFolder });
console.log("[dashboard-agent-db] migrations complete");
} finally {
await sql.end();
}
@@ -0,0 +1,22 @@
{
"name": "@internal/dashboard-agent-db",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"dependencies": {
"drizzle-orm": "^0.45.0",
"postgres": "^3.4.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.0"
},
"scripts": {
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:deploy": "node migrate.mjs",
"db:studio": "drizzle-kit studio"
}
}
@@ -0,0 +1,67 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres, { type Sql } from "postgres";
import * as schema from "./schema.js";
export type DashboardAgentSchema = typeof schema;
export type DashboardAgentDb = PostgresJsDatabase<DashboardAgentSchema>;
export interface DashboardAgentDbClient {
db: DashboardAgentDb;
sql: Sql;
/** Close the underlying connection pool. Call on agent run shutdown. */
close: () => Promise<void>;
}
export interface CreateDashboardAgentDbOptions {
/**
* Max client-side pool size. Keep small — the agent runs in many short-lived
* task containers and PlanetScale's pooler does the real connection pooling.
*/
max?: number;
/** Idle timeout (seconds) so suspended agent runs release connections. */
idleTimeoutSeconds?: number;
/** Connection timeout (seconds). */
connectTimeoutSeconds?: number;
}
/**
* Create a Drizzle client for the dashboard-agent datastore. Shared by the agent
* task (its own persistence) and the webapp (History tab + frontend actions).
*
* Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style),
* so prepared statements are disabled — they don't survive a connection being
* handed to a different client between checkouts.
*/
// Prisma-style URLs carry `?schema=...`; postgres.js forwards unknown query
// params as server startup config and Postgres rejects `schema`. Our tables are
// schema-qualified, so the param is unnecessary — drop it. Matters for the OSS
// fallback to the main DATABASE_URL.
function normalizeConnectionString(connectionString: string): string {
try {
const url = new URL(connectionString);
url.searchParams.delete("schema");
return url.toString();
} catch {
return connectionString;
}
}
export function createDashboardAgentDb(
connectionString: string,
options: CreateDashboardAgentDbOptions = {}
): DashboardAgentDbClient {
const sql = postgres(normalizeConnectionString(connectionString), {
max: options.max ?? 5,
idle_timeout: options.idleTimeoutSeconds ?? 20,
connect_timeout: options.connectTimeoutSeconds ?? 10,
prepare: false,
});
const db = drizzle(sql, { schema });
return {
db,
sql,
close: () => sql.end(),
};
}
@@ -0,0 +1,3 @@
export * from "./schema.js";
export * from "./client.js";
export * from "./queries.js";
@@ -0,0 +1,273 @@
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import type { DashboardAgentDb } from "./client.js";
import { chats, chatSessions, chatTurnEvals, type ChatSession, type NewChatTurnEval } from "./schema.js";
/**
* The access-pattern layer. Every query that touches user data is scoped by
* `organizationId` and/or `userId` so tenant isolation lives in one place —
* callers can't forget the `where`. Shared by the agent task and the webapp.
*/
/** Placeholder title for a chat with no generated or user-set title yet. */
export const DEFAULT_CHAT_TITLE = "New chat";
export interface ChatListItem {
id: string;
title: string;
pinnedAt: Date | null;
lastMessageAt: Date | null;
createdAt: Date;
updatedAt: Date;
metadata: Record<string, unknown>;
}
/**
* #1 History tab: a user's chats within an org, recent first, pinned on top.
* Deliberately selects metadata columns only — never `messages` (large blob) or
* the session token. Covered by `chats_org_user_last_msg_idx`.
*/
export async function listChats(
db: DashboardAgentDb,
params: { organizationId: string; userId: string; limit?: number }
): Promise<ChatListItem[]> {
return db
.select({
id: chats.id,
title: chats.title,
pinnedAt: chats.pinnedAt,
lastMessageAt: chats.lastMessageAt,
createdAt: chats.createdAt,
updatedAt: chats.updatedAt,
metadata: chats.metadata,
})
.from(chats)
.where(
and(
eq(chats.organizationId, params.organizationId),
eq(chats.userId, params.userId),
isNull(chats.deletedAt)
)
)
.orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt))
.limit(params.limit ?? 50);
}
/**
* #2 Open a chat: the stored transcript for `useChat`'s initialMessages.
* Scoped to the owner; returns null if missing/deleted/not theirs.
*/
export async function getChatMessages(
db: DashboardAgentDb,
params: { chatId: string; userId: string }
): Promise<unknown[] | null> {
const rows = await db
.select({ messages: chats.messages })
.from(chats)
.where(
and(
eq(chats.id, params.chatId),
eq(chats.userId, params.userId),
isNull(chats.deletedAt)
)
)
.limit(1);
return rows[0]?.messages ?? null;
}
/**
* #3 Resume the transport on first paint: the session-scoped token + stream
* cursor. Joins `chats` to scope by owner (chat_sessions has no userId).
*/
export async function getSession(
db: DashboardAgentDb,
params: { chatId: string; userId: string }
): Promise<ChatSession | null> {
const rows = await db
.select({
chatId: chatSessions.chatId,
publicAccessToken: chatSessions.publicAccessToken,
lastEventId: chatSessions.lastEventId,
runId: chatSessions.runId,
updatedAt: chatSessions.updatedAt,
})
.from(chatSessions)
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
.where(and(eq(chatSessions.chatId, params.chatId), eq(chats.userId, params.userId)))
.limit(1);
return rows[0] ?? null;
}
/**
* Owner check: true when a non-deleted chat with this id belongs to the user.
* Used to authorize chat-scoped actions (e.g. minting a session token) before
* a session row necessarily exists.
*/
export async function chatExists(
db: DashboardAgentDb,
params: { chatId: string; userId: string; organizationId: string }
): Promise<boolean> {
const rows = await db
.select({ id: chats.id })
.from(chats)
.where(
and(
eq(chats.id, params.chatId),
eq(chats.organizationId, params.organizationId),
eq(chats.userId, params.userId),
isNull(chats.deletedAt)
)
)
.limit(1);
return rows.length > 0;
}
/**
* #4 Create a chat. Idempotent (`onConflictDoNothing`) so the webapp's "new
* chat" insert and the agent's defensive `onChatStart` ensure can't race into a
* duplicate-key error.
*/
export async function createChat(
db: DashboardAgentDb,
params: {
id: string;
organizationId: string;
userId: string;
title?: string;
metadata?: Record<string, unknown>;
}
): Promise<void> {
await db
.insert(chats)
.values({
id: params.id,
organizationId: params.organizationId,
userId: params.userId,
title: params.title ?? DEFAULT_CHAT_TITLE,
metadata: params.metadata ?? {},
})
.onConflictDoNothing();
}
/** The agent's defensive ensure-exists in `onChatStart` / `onPreload`. */
export const ensureChat = createChat;
/** #5 Rename. */
export async function renameChat(
db: DashboardAgentDb,
params: { chatId: string; userId: string; title: string }
): Promise<void> {
await db
.update(chats)
.set({ title: params.title, updatedAt: sql`now()` })
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
}
/**
* #5 Set an auto-generated title, but only while the chat still has the default
* title. Conditional on `DEFAULT_CHAT_TITLE` so the background title write can't
* clobber a user rename, and so it's a safe no-op if it runs more than once.
*/
export async function setChatTitleIfDefault(
db: DashboardAgentDb,
params: { chatId: string; title: string }
): Promise<void> {
await db
.update(chats)
.set({ title: params.title, updatedAt: sql`now()` })
.where(
and(
eq(chats.id, params.chatId),
eq(chats.title, DEFAULT_CHAT_TITLE),
isNull(chats.deletedAt)
)
);
}
/** #5 Pin / unpin. */
export async function setChatPinned(
db: DashboardAgentDb,
params: { chatId: string; userId: string; pinned: boolean }
): Promise<void> {
await db
.update(chats)
.set({ pinnedAt: params.pinned ? sql`now()` : null, updatedAt: sql`now()` })
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
}
/** #5 Soft-delete. */
export async function softDeleteChat(
db: DashboardAgentDb,
params: { chatId: string; userId: string }
): Promise<void> {
await db
.update(chats)
.set({ deletedAt: sql`now()`, updatedAt: sql`now()` })
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
}
/**
* #6a Persist messages only (agent `onTurnStart` — make the user's message
* durable in the display copy before the model starts streaming).
*/
export async function persistMessages(
db: DashboardAgentDb,
params: { chatId: string; messages: unknown[] }
): Promise<void> {
await db
.update(chats)
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
.where(eq(chats.id, params.chatId));
}
/**
* #6b Persist a completed turn (agent `onTurnComplete`): the finalized transcript
* and the refreshed session state, in one transaction. Atomicity matters — on
* the next page load the frontend reads `messages` and `lastEventId` in parallel;
* a torn write can resume from a stale cursor and double-render the last turn.
*/
export async function persistTurn(
db: DashboardAgentDb,
params: {
chatId: string;
messages: unknown[];
session: {
publicAccessToken: string;
lastEventId?: string | null;
runId?: string | null;
};
}
): Promise<void> {
await db.transaction(async (tx) => {
await tx
.update(chats)
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
.where(eq(chats.id, params.chatId));
await tx
.insert(chatSessions)
.values({
chatId: params.chatId,
publicAccessToken: params.session.publicAccessToken,
lastEventId: params.session.lastEventId ?? null,
runId: params.session.runId ?? null,
})
.onConflictDoUpdate({
target: chatSessions.chatId,
set: {
publicAccessToken: params.session.publicAccessToken,
lastEventId: params.session.lastEventId ?? null,
runId: params.session.runId ?? null,
updatedAt: sql`now()`,
},
});
});
}
/**
* #11 Record a turn eval. Idempotent on `(chatId, turn)` so a re-delivered turn
* (the eval task is triggered with an idempotency key, and may still retry) can
* never write a second row.
*/
export async function insertTurnEval(db: DashboardAgentDb, row: NewChatTurnEval): Promise<void> {
await db.insert(chatTurnEvals).values(row).onConflictDoNothing();
}
@@ -0,0 +1,153 @@
import { sql } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgSchema,
primaryKey,
smallint,
text,
timestamp,
} from "drizzle-orm/pg-core";
/**
* All dashboard-agent tables live in a dedicated Postgres schema. In cloud this
* is a separate PlanetScale database; in OSS it isolates the agent's tables from
* Prisma's `public` schema inside the main database. Tables are schema-qualified
* explicitly, so no `search_path` configuration is required on the connection.
*/
export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent");
/**
* One row per conversation. Scope is **org + user** — a chat is not bound to a
* single project/env; the project/env it ran in (and any extra ones the user
* adds to context) live in `metadata`, because one conversation can range over
* several projects/envs.
*
* `messages` is a display copy of the `UIMessage[]` transcript. The model's
* source of truth for history is chat.agent's built-in object-store snapshot,
* not this column — a stale write here can make the History view lag a turn but
* can never corrupt what the model sees.
*
* Foreign-key-free: `organizationId` / `userId` are main-DB ids with no FK,
* because in cloud this table lives in a different database.
*/
export const chats = dashboardAgentSchema.table(
"chats",
{
// = chatId = the Session externalId. Stable for the life of the thread.
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(),
userId: text("user_id").notNull(),
title: text("title").notNull().default("New chat"),
// UIMessage[] display copy — never read to rebuild model context.
messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
// Project/env context + model choice + page snapshot. Flexible by design.
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
pinnedAt: timestamp("pinned_at", { withTimezone: true }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// History tab: "my chats in this org, recent first". Partial index keeps
// soft-deleted rows out of the hot path.
index("chats_org_user_last_msg_idx")
.on(t.organizationId, t.userId, t.lastMessageAt.desc())
.where(sql`${t.deletedAt} is null`),
]
);
/**
* Live transport state the frontend needs to resume a chat on first paint,
* keyed by chatId. Separate from `chats` so the secret token is isolated from
* list queries and the hot per-turn write stays off the conversation row.
*
* No `userId` here on purpose: the agent's `onTurnComplete` event doesn't carry
* `clientData`, and ownership is already enforced via the `chats` row — the
* resume query joins `chats` to scope by owner (see `getSession`).
*/
export const chatSessions = dashboardAgentSchema.table("chat_sessions", {
chatId: text("chat_id").primaryKey(), // = chats.id (FK-free, cross-db)
publicAccessToken: text("public_access_token").notNull(),
lastEventId: text("last_event_id"),
runId: text("run_id"), // telemetry / "view this run"
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
/**
* One row per evaluated turn, written by the `dashboard-agent-eval-turn` task
* that the agent triggers from `onTurnComplete`. Two kinds of data: quality
* scores (did the agent answer well, grounded in its tool results) and insight
* classification (what the user wanted, whether we have a product/docs/support
* gap). Append-only analytics; the higher-level views ("top capability gaps",
* "what users struggle with") are aggregations over these rows, not stored here.
*
* Structured columns are the things we filter, alert, and chart on; the evolving
* taxonomy (typed `signals`) and the raw judge output live in JSONB so adding a
* signal type is never a migration. Org + user scoped, FK-free (cross-db), with
* a composite `(chatId, turn)` key so a re-delivered turn can't double-insert.
*/
export const chatTurnEvals = dashboardAgentSchema.table(
"chat_turn_evals",
{
chatId: text("chat_id").notNull(), // = chats.id
turn: integer("turn").notNull(), // 0-indexed turn within the chat
organizationId: text("organization_id").notNull(),
userId: text("user_id").notNull(),
agentRunId: text("agent_run_id"), // the chat.agent run that produced the turn
evalRunId: text("eval_run_id"), // the eval task's own run, for tracing
// Per-turn context (the project/env/page the user was looking at).
projectRef: text("project_ref"),
environment: text("environment"),
currentPage: text("current_page"),
// Operational + model. `promptVersion` lets a quality drop be attributed to a
// dashboard-managed prompt edit that never went through CI.
model: text("model"),
promptSlug: text("prompt_slug"),
promptVersion: integer("prompt_version"),
toolsUsed: jsonb("tools_used").$type<string[]>().notNull().default([]),
toolError: boolean("tool_error").notNull().default(false),
// Quality (LLM judge), scored 1-5.
judgeModel: text("judge_model"),
scoreGrounded: smallint("score_grounded"),
scoreAnswered: smallint("score_answered"),
scoreConcise: smallint("score_concise"),
passed: boolean("passed"),
// Insight classification — the filterable summary of `signals`.
intentCategory: text("intent_category"),
outcome: text("outcome"), // resolved | partial | unresolved | deflected
sentiment: text("sentiment"),
capabilityGap: boolean("capability_gap").notNull().default(false),
docsGap: boolean("docs_gap").notNull().default(false),
supportOpportunity: boolean("support_opportunity").notNull().default(false),
featureRequest: boolean("feature_request").notNull().default(false),
// Rich / evolving.
topics: jsonb("topics").$type<string[]>().notNull().default([]),
signals: jsonb("signals").$type<unknown[]>().notNull().default([]),
summary: text("summary"),
userText: text("user_text"), // the user's question (clustering input)
judge: jsonb("judge").$type<Record<string, unknown>>(), // full raw verdict
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.chatId, t.turn] }),
// "what happened in this org lately", recent first.
index("chat_turn_evals_org_created_idx").on(t.organizationId, t.createdAt.desc()),
// The opportunities feed: gaps, struggles, support, feature asks.
index("chat_turn_evals_org_opps_idx")
.on(t.organizationId, t.createdAt.desc())
.where(
sql`${t.capabilityGap} or ${t.docsGap} or ${t.supportOpportunity} or ${t.featureRequest}`
),
]
);
export type Chat = typeof chats.$inferSelect;
export type NewChat = typeof chats.$inferInsert;
export type ChatSession = typeof chatSessions.$inferSelect;
export type NewChatSession = typeof chatSessions.$inferInsert;
export type ChatTurnEval = typeof chatTurnEvals.$inferSelect;
export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert;
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "Node16",
"moduleResolution": "Node16",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true
},
"exclude": ["node_modules", "drizzle"]
}
@@ -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,313 @@
// `@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,384 @@
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,184 @@
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,49 @@
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,128 @@
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,276 @@
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,426 @@
/**
* 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,503 @@
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",
},
});
+4
View File
@@ -92,6 +92,10 @@
"overrides": {
"typescript": "5.5.4",
"@types/node": "20.14.14",
"react@^18": "18.3.1",
"react-dom@^18": "18.3.1",
"ai@^6": "6.0.116",
"@ai-sdk/provider-utils@^4": "4.0.29",
"express@^4>body-parser": "1.20.3",
"@remix-run/dev@2.17.4>tar-fs": "2.1.4",
"tar@>=7 <7.5.11": "^7.5.11",
+78 -1
View File
@@ -18,6 +18,7 @@ import {
type PipeStreamResult,
type RealtimeDefinedInputStream,
type RealtimeDefinedStream,
type ApiClientConfiguration,
type ReadStreamOptions,
SemanticInternalAttributes,
type SendInputStreamOptions,
@@ -2979,6 +2980,63 @@ function isStepBoundarySafe(step: {
return ![...callIds].some((id) => !settledIds.has(id));
}
/**
* True when a model message is a `tool` message carrying a
* `tool-approval-response` part the trailing row a head-start handover
* reshapes a pending first-turn tool call into. AI SDK's `collectToolApprovals`
* only inspects the conversation's last message, so this row must survive to
* `streamText` intact for the agent to execute the handed-over call.
* @internal
*/
function hasToolApprovalResponse(message: ModelMessage | undefined): message is ModelMessage {
return (
message?.role === "tool" &&
Array.isArray(message.content) &&
message.content.some(
(part) =>
part != null &&
typeof part === "object" &&
(part as { type?: string }).type === "tool-approval-response"
)
);
}
/**
* Keep a head-start handover's tool-approval tail intact across `prepareMessages`.
*
* The handover reshapes the warm step-1's pending tool call into AI SDK's
* tool-approval round: a `tool-approval-request` on the assistant plus a
* trailing `tool` message with `tool-approval-response { approved: true }`. The
* agent's next `streamText` runs `collectToolApprovals`, which ONLY looks at the
* last message so that tool row must stay last and unmodified for the pending
* call to execute. A user `prepareMessages` hook that rewrites or drops the last
* message (e.g. rolling a provider cache breakpoint onto it) silently breaks the
* resume: the agent sends a bare `tool_use` and the turn dies with
* "tool_use ids were found without tool_result". If the hook's input ended with
* that approval tail, re-assert the original tail as the last message.
*
* No-op for every normal turn only fires when the input genuinely ended with a
* pending tool-approval response (i.e. a head-start resume).
* @internal
*/
function preserveToolApprovalTail(
original: ModelMessage[],
prepared: ModelMessage[]
): ModelMessage[] {
const originalTail = original[original.length - 1];
if (!hasToolApprovalResponse(originalTail)) return prepared;
// Hook left the exact tail object in place — nothing to do.
if (prepared[prepared.length - 1] === originalTail) return prepared;
// Otherwise drop only the trailing approval tail the hook produced (the
// original moved, or a rewritten copy) and re-append the original so it is
// last and intact. Older approval rounds deeper in history must survive.
const withoutMovedOriginal = prepared.filter((m) => m !== originalTail);
while (hasToolApprovalResponse(withoutMovedOriginal[withoutMovedOriginal.length - 1])) {
withoutMovedOriginal.pop();
}
return [...withoutMovedOriginal, originalTail];
}
/**
* Apply the prepareMessages hook if one is set in locals.
* @internal
@@ -2992,7 +3050,7 @@ async function applyPrepareMessages(
const turnCtx = locals.get(chatTurnContextKey);
return tracer.startActiveSpan(
const prepared = await tracer.startActiveSpan(
"prepareMessages()",
async () => {
return hook({
@@ -3012,6 +3070,10 @@ async function applyPrepareMessages(
},
}
);
// A user hook must never be able to break the head-start handover resume by
// disturbing the trailing tool-approval row (see preserveToolApprovalTail).
return preserveToolApprovalTail(messages, prepared);
}
/**
@@ -9989,6 +10051,12 @@ export type CreateChatStartSessionActionOptions = {
* custom retry. Applies to both session-create and JWT-claims POSTs.
*/
fetch?: ChatStartSessionFetchOverride;
/**
* API client config (baseURL / accessToken) to scope this action to a specific
* environment, for callers that can't set a global `TRIGGER_SECRET_KEY`. The
* returned action runs under this config.
*/
apiClient?: ApiClientConfiguration;
};
/**
@@ -10081,6 +10149,15 @@ function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
);
}
// Scope the action to `apiClient`'s env: re-enter without it so the body
// runs once, under that config (read via apiClientManager.accessToken/.baseURL).
if (options?.apiClient) {
const { apiClient, ...rest } = options;
return apiClientManager.runWithConfig(apiClient, () =>
createChatStartSessionAction<TChat>(taskId, rest)(params)
);
}
// The first run boots before the user's first message lands on
// `.in/append`, so it sees an empty `messages` array and `trigger:
// "preload"`. This matches the pre-Sessions preload semantics:
@@ -620,6 +620,11 @@ export class AgentChat<TAgent = unknown> {
// already committed, so a retried POST can't duplicate the record.
"X-Part-Id": crypto.randomUUID(),
};
// Preview-env sessions are branch-scoped, so the realtime in/out calls must
// carry the branch the session was created on. sessions.start sends it via
// the API client; these raw fetches have to set it themselves.
const branch = apiClientManager.branchName;
if (branch) headers["x-trigger-branch"] = branch;
const response = await this.doFetch(ctx, url, { method: "POST", headers, body });
if (!response.ok) {
const text = await response.text().catch(() => "");
@@ -753,6 +758,10 @@ export class AgentChat<TAgent = unknown> {
const subscription = new SSEStreamSubscription(streamUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
// Preview-env sessions are branch-scoped (see appendInputChunk).
...(apiClientManager.branchName
? { "x-trigger-branch": apiClientManager.branchName }
: {}),
},
signal: combinedSignal,
timeoutInSeconds: this.streamTimeoutSeconds,
@@ -479,6 +479,185 @@ describe("chat.headStart (route handler)", () => {
});
});
describe("chat.startHeadStart (detached)", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
function wireFetch(requests: CapturedRequest[]) {
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-1");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
}
const userMessages = [
{ id: "m1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] },
];
it("returns { chatId, completion } (no Response) and creates the session with handover-prepare + headStartMessages", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const result = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi back") }),
}),
}),
})
);
// Shape: a plain object, not a Response.
expect(result.chatId).toBe("chat-1");
expect(typeof result.completion.then).toBe("function");
expect(result).not.toBeInstanceOf(Response);
await result.completion;
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
expect(sessionCreate).toBeDefined();
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.type).toBe("chat.agent");
expect(body.externalId).toBe("chat-1");
expect(body.taskIdentifier).toBe("test-agent");
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
// Full first-turn history rides on headStartMessages (not /in/append).
expect(body.triggerConfig.basePayload.headStartMessages).toHaveLength(1);
expect(body.triggerConfig.basePayload.headStartMessages[0].id).toBe("m1");
});
it("dispatches a final handover (isFinal: true) on a pure-text step 1", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the answer") }),
}),
}),
})
);
await completion;
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
const appendBody = append!.init!.body as string;
expect(appendBody).toContain('"kind":"handover"');
expect(appendBody).toContain('"isFinal":true');
// A stable assistant messageId is carried across the handover boundary.
expect(appendBody).toContain('"messageId":');
});
it("dispatches a non-final handover (isFinal: false) on a tool-call step 1", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: toolCallStream() }),
}),
}),
})
);
await completion;
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
const appendBody = append!.init!.body as string;
expect(appendBody).toContain('"kind":"handover"');
expect(appendBody).toContain('"isFinal":false');
});
it("merges metadata into the handover-prepare run payload (never to the browser)", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
metadata: { userActorToken: "tr_uat_secret", projectRef: "proj_x" },
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
}),
}),
})
);
await completion;
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.triggerConfig.basePayload.metadata.userActorToken).toBe("tr_uat_secret");
expect(body.triggerConfig.basePayload.metadata.projectRef).toBe("proj_x");
});
it("signals handover-skip and rejects completion when the warm step throws", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async () => {
throw new Error("warm step boom");
},
})
);
await expect(completion).rejects.toThrow("warm step boom");
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
expect(append!.init!.body as string).toContain('"kind":"handover-skip"');
});
});
describe("chat.toNodeListener", () => {
/**
* Build a fake Node IncomingMessage that yields a JSON body.
+232 -15
View File
@@ -59,6 +59,7 @@ import {
SessionStreamInstance,
TRIGGER_CONTROL_SUBTYPE,
apiClientManager,
type ApiClientConfiguration,
type SessionTriggerConfig,
} from "@trigger.dev/core/v3";
// Runtime VALUES via the ESM/CJS shim so the CJS build can `require` ESM-only
@@ -202,6 +203,45 @@ export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
* The `chat:{chatId}` tag is prepended automatically.
*/
triggerConfig?: Partial<SessionTriggerConfig>;
/**
* API client config (base URL + access token) for creating the session
* and triggering the agent run. When set, the handler runs under this
* config instead of the ambient `apiClientManager` config — use it when
* the agent lives in a different project/env than the warm server's
* default (mirrors `chat.createStartSessionAction`'s `apiClient` option).
* The customer's LLM provider keys are unaffected; they stay in `run`.
*/
apiClient?: ApiClientConfiguration;
};
export type StartHeadStartOptions<TTools extends Record<string, Tool>> = {
/** The `chat.agent` / `chat.customAgent` / `chat.createSession` id to hand off to. */
agentId: string;
/** Stable chat id (the session externalId). You own it; reuse it on the destination page. */
chatId: string;
/** First-turn user history. Becomes the agent run's `headStartMessages`. */
messages: UIMessage[];
/** Your first-turn implementation — same shape as `chat.headStart`'s `run`. */
run: (args: HeadStartRunArgs<TTools>) => Promise<AnyStreamTextResult>;
/** Seconds the agent run waits for the handover signal before exiting. Default 60. */
idleTimeoutInSeconds?: number;
/** Run options for the auto-triggered `handover-prepare` run (tags, queue, machine, …). */
triggerConfig?: Partial<SessionTriggerConfig>;
/** API client config for session creation + trigger when the agent lives in another project/env. */
apiClient?: ApiClientConfiguration;
/** Metadata merged into the run's wire payload (auth tokens, context, …). Never sent to the browser. */
metadata?: Record<string, unknown>;
};
export type StartHeadStartResult = {
/** The chat id you passed in — echoed for convenience. */
chatId: string;
/**
* Resolves once step 1 has drained to `session.out` and the handover is
* dispatched. Hand to `waitUntil` / `after` on serverless; ignore it on a
* long-lived server. Rejects if the warm step or the dispatch fails.
*/
completion: Promise<void>;
};
// ---------------------------------------------------------------------------
@@ -222,9 +262,9 @@ export const chat = {
headStart<TTools extends Record<string, Tool>>(
opts: HeadStartHandlerOptions<TTools>
): (req: Request) => Promise<Response> {
return async (req: Request) => {
const handler = async (req: Request): Promise<Response> => {
const session = await openHandoverSession({
req,
...(await parseHandoverRequest(req)),
agentId: opts.agentId,
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
triggerConfig: opts.triggerConfig,
@@ -245,6 +285,112 @@ export const chat = {
return session.handle.handoverResponse(result);
};
// Scope session creation + the agent trigger to `apiClient`'s env when
// provided, so the agent can live in a different project/env than the warm
// server's ambient config. The `run` callback's LLM keys are unaffected.
const { apiClient } = opts;
if (apiClient) {
return async (req: Request) => apiClientManager.runWithConfig(apiClient, () => handler(req));
}
return handler;
},
/**
* Detached head start for backends that create the chat AND trigger the
* run in their own endpoint (e.g. a "create chat" API), then navigate the
* browser to a separate page that resumes the chat. Unlike
* `chat.headStart`, this does NOT return an SSE `Response`: it creates the
* session, triggers the `handover-prepare` run, then streams step 1 from
* your warm process straight into `session.out` and dispatches the
* handover — all as the returned `completion` promise. The destination
* page sees the whole turn (step 1 + the agent's step 2+) by resuming
* `session.out`; no `headStart` transport option is needed there.
*
* `createSession` is awaited before this resolves, so the returned
* `chatId` is immediately resumable. `completion` resolves once step 1 has
* drained and the handover is dispatched — hand it to the platform's
* "run after response" primitive (`waitUntil` / Next.js `after`) on
* serverless, or ignore it on a long-lived server.
*
* @example
* ```ts
* const { chatId, completion } = await chat.startHeadStart({
* agentId: "my-chat",
* chatId,
* messages,
* run: async ({ chat: helper }) =>
* streamText({ ...helper.toStreamTextOptions({ tools }), model, system }),
* });
* waitUntil(completion); // serverless: keep warm until step 1 + handover finish
* return Response.json({ chatId });
* ```
*/
async startHeadStart<TTools extends Record<string, Tool>>(
opts: StartHeadStartOptions<TTools>
): Promise<StartHeadStartResult> {
const open = () =>
openHandoverSession({
chatId: opts.chatId,
uiMessages: opts.messages,
wirePayload: {
chatId: opts.chatId,
trigger: "handover-prepare",
headStartMessages: opts.messages,
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
} as ChatTaskWirePayload,
agentId: opts.agentId,
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
triggerConfig: opts.triggerConfig,
});
// Scope session creation + the agent trigger to `apiClient`'s env when
// provided (mirrors `chat.headStart`). The client captured inside
// `openHandoverSession` is reused for the drain + handover dispatch, so
// `completion` needs no further config scoping. LLM keys in `run` are
// unaffected.
const session = opts.apiClient
? await apiClientManager.runWithConfig(opts.apiClient, open)
: await open();
const helper: HeadStartChatHelper<TTools> = {
toStreamTextOptions(spreadOpts) {
return session.buildStreamTextOptions(spreadOpts) as any;
},
session: session.handle,
};
const completion = (async () => {
let result: AnyStreamTextResult;
try {
result = await opts.run({
messages: session.uiMessages,
signal: session.combinedSignal,
chat: helper,
});
} catch (err) {
// The warm step never produced a result — tell the agent run to exit
// clean instead of idle-waiting the full handover timeout.
await session.handle.handoverSkip().catch(() => {});
throw err;
}
// Stamp step 1 with the turn's stable messageId so the agent's step 2+
// merges into the same assistant message, then drain it to session.out.
const stream = result.toUIMessageStream({
generateMessageId: () => session.turnMessageId,
});
session.drainToSessionOut(stream);
// Awaits the drain, then dispatches handover / handover-skip. Owns its
// own skip-on-error and idle-timer cleanup.
await session.handle.handoverWhenDone(result);
})();
// Unhandled-rejection guard: a long-lived server that ignores `completion`
// shouldn't crash under `--unhandled-rejections=throw`. Awaiting the
// returned promise still surfaces the error.
completion.catch(() => {});
return { chatId: opts.chatId, completion };
},
/**
@@ -259,7 +405,15 @@ export const chat = {
idleTimeoutInSeconds?: number;
triggerConfig?: Partial<SessionTriggerConfig>;
}): Promise<HeadStartSession> {
return openHandoverSession(opts).then((s) => s.handle);
return (async () => {
const session = await openHandoverSession({
...(await parseHandoverRequest(opts.req)),
agentId: opts.agentId,
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
triggerConfig: opts.triggerConfig,
});
return session.handle;
})();
},
/**
@@ -307,15 +461,29 @@ type InternalSession = {
combinedSignal: AbortSignal;
handle: HeadStartSession;
buildStreamTextOptions(spreadOpts?: { tools?: Record<string, Tool> }): Record<string, unknown>;
/** Stable assistant messageId for this turn — stamp the detached drain with it. */
turnMessageId: string;
/**
* Detached counterpart to `tee`: pump a UIMessage stream straight into
* `session.out` with no HTTP response branch. Used by `chat.startHeadStart`,
* where the browser picks the turn up by resuming `session.out` later.
*/
drainToSessionOut(stream: ReadableStream<UIMessageChunk>): void;
};
async function openHandoverSession(opts: {
req: Request;
agentId: string;
idleTimeoutInSeconds?: number;
triggerConfig?: Partial<SessionTriggerConfig>;
}): Promise<InternalSession> {
const wirePayload = (await opts.req.json()) as ChatTaskWirePayload;
/**
* Parse the AI SDK transport's wire payload out of the route-handler
* `Request` for `chat.headStart` / `chat.openSession`. The detached
* `chat.startHeadStart` path skips this — it's handed the chatId and
* messages directly.
*/
async function parseHandoverRequest(req: Request): Promise<{
chatId: string;
uiMessages: UIMessage[];
wirePayload: ChatTaskWirePayload;
requestSignal?: AbortSignal;
}> {
const wirePayload = (await req.json()) as ChatTaskWirePayload;
const chatId = wirePayload.chatId;
if (!chatId) {
throw new Error("[chat.handover] request body missing `chatId`");
@@ -323,9 +491,32 @@ async function openHandoverSession(opts: {
// Slim wire — head-start ships full history via `headStartMessages` (not
// `message`/`messages`) because the route handler runs on the customer's
// own HTTP endpoint and isn't subject to the 512 KiB `/in/append` cap.
const uiMessages = (wirePayload.headStartMessages ?? []) as UIMessage[];
return {
chatId,
uiMessages,
wirePayload,
requestSignal: (req as Request & { signal?: AbortSignal }).signal,
};
}
async function openHandoverSession(opts: {
chatId: string;
uiMessages: UIMessage[];
/** Becomes the base wire payload for the `handover-prepare` run. */
wirePayload: ChatTaskWirePayload;
agentId: string;
idleTimeoutInSeconds?: number;
triggerConfig?: Partial<SessionTriggerConfig>;
/** Request-lifecycle signal on the HTTP path; omitted on the detached path. */
requestSignal?: AbortSignal;
}): Promise<InternalSession> {
const { chatId, uiMessages, wirePayload } = opts;
if (!chatId) {
throw new Error("[chat.handover] missing `chatId`");
}
// The full UIMessage[] flows through `wirePayload` into the auto-trigger
// `basePayload` below, where the agent run boot consumes it on first turn.
const uiMessages = (wirePayload.headStartMessages ?? []) as UIMessage[];
// `convertToModelMessages` is async — resolve once up front so the
// synchronous `toStreamTextOptions` builder can hand back a fully
// formed object. AI SDK's `streamText` validates `messages` as a
@@ -392,7 +583,7 @@ async function openHandoverSession(opts: {
// mirroring the agent's idle wait so a hung handler doesn't sit
// forever.
const abortController = new AbortController();
const requestAbort = (opts.req as Request & { signal?: AbortSignal }).signal;
const requestAbort = opts.requestSignal;
if (requestAbort) {
if (requestAbort.aborted) abortController.abort();
else requestAbort.addEventListener("abort", () => abortController.abort(), { once: true });
@@ -446,6 +637,21 @@ async function openHandoverSession(opts: {
});
return a;
};
// Detached drain (no HTTP response branch): hand the WHOLE stream to the S2
// writer as its source. `StreamsWriterV2` self-pumps the source to S2, so the
// stream drains to `session.out` without any reader pulling it. `handoverWhenDone`
// awaits `flushSessionWriter()` before dispatching, so step 1 lands in order
// ahead of the agent's step 2+.
const drainToSessionOut = (stream: ReadableStream<UIMessageChunk>): void => {
sessionWriter = new SessionStreamInstance<UIMessageChunk>({
apiClient,
baseUrl: apiClient.baseUrl,
sessionId: chatId,
io: "out",
source: stream,
signal: abortController.signal,
});
};
/** Wait for the teed S2 writer to drain. Called before signaling handover. */
const flushSessionWriter = async (): Promise<void> => {
if (!sessionWriter) return;
@@ -477,9 +683,18 @@ async function openHandoverSession(opts: {
* `finishReason`). Normal pure-text and tool-call finishes go
* through `handover()` with the appropriate `isFinal` flag.
*/
// Clear the idle timer on every terminal path. The detached failure path
// (run() throws -> handoverSkip) otherwise leaves it armed until the idle
// timeout elapses, since only handoverWhenDone used to clear it.
const cleanup = () => clearTimeout(idleTimer);
const handoverSkip = async () => {
const chunk: ChatInputChunk = { kind: "handover-skip" };
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
try {
const chunk: ChatInputChunk = { kind: "handover-skip" };
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
} finally {
cleanup();
}
};
// A stable assistant messageId for this turn. The customer's
@@ -555,7 +770,7 @@ async function openHandoverSession(opts: {
}
throw err;
} finally {
clearTimeout(idleTimer);
cleanup();
}
};
@@ -766,6 +981,8 @@ async function openHandoverSession(opts: {
combinedSignal: abortController.signal,
handle,
buildStreamTextOptions,
turnMessageId,
drainToSessionOut,
};
}
+2394 -1786
View File
File diff suppressed because it is too large Load Diff