|
| 1 | +import { anthropic } from "@ai-sdk/anthropic"; |
| 2 | +import { |
| 3 | + appendChatMessageOnce, |
| 4 | + createDashboardAgentDb, |
| 5 | + ensureChat, |
| 6 | + findOpenInvestigationForChat, |
| 7 | + persistMessages, |
| 8 | + persistTurn, |
| 9 | + setChatTitleIfDefault, |
| 10 | + upsertInvestigationRevision, |
| 11 | + type DashboardAgentDbClient, |
| 12 | + type UpsertInvestigationResult, |
| 13 | +} from "@internal/dashboard-agent-db"; |
| 14 | +import { locals, logger } from "@trigger.dev/sdk"; |
| 15 | +import { createProviderRegistry, type LanguageModel, type ModelMessage, type ToolSet } from "ai"; |
| 16 | +import { z } from "zod"; |
| 17 | +import { |
| 18 | + agentPageContextSchema, |
| 19 | + forceSettledInvestigationState, |
| 20 | + investigationStateSchema, |
| 21 | + type InvestigationState, |
| 22 | +} from "@internal/dashboard-agent-contracts"; |
| 23 | +import { codeSystemPrompt, systemPrompt } from "./prompts"; |
| 24 | +import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; |
| 25 | +import { buildDashboardAgentTools } from "./tools"; |
| 26 | + |
| 27 | +/** |
| 28 | + * The agent's runtime: its datastore, the investigation bookkeeping every lane |
| 29 | + * shares, and the model, prompt and tool plumbing a turn is assembled from. |
| 30 | + * |
| 31 | + * Split out of `dashboard-agent.ts` so the turn lanes that are not the agent's |
| 32 | + * own hooks — the watch actions — can reach it without importing the agent back. |
| 33 | + */ |
| 34 | + |
| 35 | +// One connection pool per worker process, established in onBoot (which fires on |
| 36 | +// every fresh worker) and reused across the run's turns. |
| 37 | +let dbClient: DashboardAgentDbClient | undefined; |
| 38 | + |
| 39 | +function getDb(): DashboardAgentDbClient { |
| 40 | + if (!dbClient) { |
| 41 | + const connectionString = process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL; |
| 42 | + if (!connectionString) { |
| 43 | + throw new Error( |
| 44 | + "DASHBOARD_AGENT_DATABASE_URL (or DATABASE_URL) must be set for the dashboard agent" |
| 45 | + ); |
| 46 | + } |
| 47 | + // Small pool: many short-lived containers, and the pooler does the real pooling. |
| 48 | + dbClient = createDashboardAgentDb(connectionString, { max: 2 }); |
| 49 | + } |
| 50 | + return dbClient; |
| 51 | +} |
| 52 | + |
| 53 | +// Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK |
| 54 | +// models. Add another @ai-sdk/* provider here to allow it on a prompt. |
| 55 | +export const registry = createProviderRegistry({ anthropic }); |
| 56 | + |
| 57 | +// The agent's persistence, behind an interface so tests can inject a fake via |
| 58 | +// `locals` and never need a real database. |
| 59 | +export interface DashboardAgentStore { |
| 60 | + ensureChat(args: Parameters<typeof ensureChat>[1]): Promise<unknown>; |
| 61 | + persistMessages(args: Parameters<typeof persistMessages>[1]): Promise<unknown>; |
| 62 | + /** |
| 63 | + * Id-deduped single-message append. The wake narration writes through this |
| 64 | + * rather than `persistMessages`: a wake runs without a client, so the session's |
| 65 | + * view can miss host-appended blocks and a wholesale write would drop them. |
| 66 | + */ |
| 67 | + appendMessage(args: Parameters<typeof appendChatMessageOnce>[1]): Promise<unknown>; |
| 68 | + persistTurn(args: Parameters<typeof persistTurn>[1]): Promise<unknown>; |
| 69 | + setChatTitleIfDefault(args: Parameters<typeof setChatTitleIfDefault>[1]): Promise<unknown>; |
| 70 | + /** Commit one investigation revision. The only write the tool lane performs. */ |
| 71 | + upsertInvestigationRevision( |
| 72 | + args: Parameters<typeof upsertInvestigationRevision>[1] |
| 73 | + ): Promise<UpsertInvestigationResult>; |
| 74 | + /** |
| 75 | + * The freshest card this chat still has open. A consented wake's investigating |
| 76 | + * turn must revise the row the wake seeded, not open a second one. |
| 77 | + */ |
| 78 | + findOpenInvestigation( |
| 79 | + args: Parameters<typeof findOpenInvestigationForChat>[1] |
| 80 | + ): Promise<{ id: string; projectRef: string; environmentRef: string } | null>; |
| 81 | +} |
| 82 | + |
| 83 | +export const dashboardAgentStoreKey = locals.create<DashboardAgentStore>("dashboard-agent.store"); |
| 84 | + |
| 85 | +/** |
| 86 | + * The investigations this turn left open, keyed by chat id. |
| 87 | + * |
| 88 | + * The prompt tells the model to render a terminal verdict last, but it can run |
| 89 | + * out of steps or have the render rejected. An `in_progress` row outlives the |
| 90 | + * run that wrote it, so the user is left watching a spinner forever. Every |
| 91 | + * committed revision is tracked here and anything still open is settled in |
| 92 | + * `onTurnComplete`. |
| 93 | + */ |
| 94 | +type OpenInvestigation = { projectRef: string; environmentRef: string; state: InvestigationState }; |
| 95 | + |
| 96 | +const openInvestigations = new Map<string, Map<string, OpenInvestigation>>(); |
| 97 | + |
| 98 | +function trackInvestigationOutcome( |
| 99 | + chatId: string, |
| 100 | + id: string, |
| 101 | + params: { projectRef: string; environmentRef: string; state: unknown } |
| 102 | +): void { |
| 103 | + const parsed = investigationStateSchema.safeParse(params.state); |
| 104 | + const open = openInvestigations.get(chatId); |
| 105 | + // A terminal outcome, or a state we can't read, drops the entry: only a card |
| 106 | + // known to be still running is worth force-settling. |
| 107 | + if (!parsed.success || parsed.data.outcome !== "in_progress") { |
| 108 | + open?.delete(id); |
| 109 | + if (open?.size === 0) openInvestigations.delete(chatId); |
| 110 | + return; |
| 111 | + } |
| 112 | + const forChat = open ?? new Map<string, OpenInvestigation>(); |
| 113 | + forChat.set(id, { |
| 114 | + projectRef: params.projectRef, |
| 115 | + environmentRef: params.environmentRef, |
| 116 | + state: parsed.data, |
| 117 | + }); |
| 118 | + openInvestigations.set(chatId, forChat); |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Force-settle whatever this turn left `in_progress`, as one more revision on |
| 123 | + * the same investigation. Best-effort: a failed settle must not fail a turn the |
| 124 | + * user already got an answer from, but it is logged. |
| 125 | + */ |
| 126 | +export async function settleOpenInvestigations( |
| 127 | + store: DashboardAgentStore, |
| 128 | + chatId: string |
| 129 | +): Promise<void> { |
| 130 | + const open = openInvestigations.get(chatId); |
| 131 | + if (!open || open.size === 0) return; |
| 132 | + openInvestigations.delete(chatId); |
| 133 | + |
| 134 | + for (const [id, entry] of open) { |
| 135 | + try { |
| 136 | + await store.upsertInvestigationRevision({ |
| 137 | + id, |
| 138 | + chatId, |
| 139 | + projectRef: entry.projectRef, |
| 140 | + environmentRef: entry.environmentRef, |
| 141 | + state: forceSettledInvestigationState(entry.state), |
| 142 | + }); |
| 143 | + } catch (error) { |
| 144 | + logger.error("Failed to settle an investigation left in progress", { chatId, id, error }); |
| 145 | + } |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +export function getStore(): DashboardAgentStore { |
| 150 | + const injected = locals.get(dashboardAgentStoreKey); |
| 151 | + if (injected) return injected; |
| 152 | + const { db } = getDb(); |
| 153 | + return locals.set(dashboardAgentStoreKey, { |
| 154 | + ensureChat: (args) => ensureChat(db, args), |
| 155 | + persistMessages: (args) => persistMessages(db, args), |
| 156 | + appendMessage: (args) => appendChatMessageOnce(db, args), |
| 157 | + persistTurn: (args) => persistTurn(db, args), |
| 158 | + setChatTitleIfDefault: (args) => setChatTitleIfDefault(db, args), |
| 159 | + upsertInvestigationRevision: (args) => upsertInvestigationRevision(db, args), |
| 160 | + findOpenInvestigation: (args) => findOpenInvestigationForChat(db, args), |
| 161 | + }); |
| 162 | +} |
| 163 | + |
| 164 | +// Optional language-model override. Unset in production; tests inject a mock so |
| 165 | +// `run()` and title generation never reach a provider. |
| 166 | +export const dashboardAgentModelKey = locals.create<LanguageModel>("dashboard-agent.model"); |
| 167 | + |
| 168 | +// Optional tool-set override. Unset in production; tests and evals inject a |
| 169 | +// fixture tool set (real schemas, stubbed executes). |
| 170 | +export const dashboardAgentToolsKey = locals.create<ToolSet>("dashboard-agent.tools"); |
| 171 | + |
| 172 | +// The system prompt is dashboard-managed. Resolving it is an API call, so it is |
| 173 | +// cached per worker process; workers are short-lived, so a dashboard edit lands |
| 174 | +// within a recycle. |
| 175 | +type DashboardAgentMode = "assistant" | "code"; |
| 176 | + |
| 177 | +// A turn is in `code` mode when the project has a connected repo. Drives both the |
| 178 | +// tool set and the prompt. |
| 179 | +export function modeFor(clientData: { repoSnapshot?: unknown } | undefined): DashboardAgentMode { |
| 180 | + return clientData?.repoSnapshot ? "code" : "assistant"; |
| 181 | +} |
| 182 | + |
| 183 | +let cachedSystemPrompt: Awaited<ReturnType<typeof systemPrompt.resolve>> | undefined; |
| 184 | +let cachedCodePrompt: Awaited<ReturnType<typeof codeSystemPrompt.resolve>> | undefined; |
| 185 | +export async function getSystemPrompt(mode: DashboardAgentMode = "assistant") { |
| 186 | + if (mode === "code") { |
| 187 | + cachedCodePrompt ??= await codeSystemPrompt.resolve({}); |
| 188 | + return cachedCodePrompt; |
| 189 | + } |
| 190 | + cachedSystemPrompt ??= await systemPrompt.resolve({}); |
| 191 | + return cachedSystemPrompt; |
| 192 | +} |
| 193 | + |
| 194 | +// A chat belongs to an org + user; project/env/page are per-turn context, since |
| 195 | +// one conversation can span several projects. Everything past the org + user pair |
| 196 | +// is optional because resumed chats replay older-shaped clientData and must keep |
| 197 | +// validating. |
| 198 | +export const clientDataSchema = z.object({ |
| 199 | + userId: z.string(), |
| 200 | + organizationId: z.string(), |
| 201 | + projectId: z.string().optional(), |
| 202 | + environmentId: z.string().optional(), |
| 203 | + currentPage: z.string().optional(), |
| 204 | + // Structured version of `currentPage`, injected by the `in` proxy. |
| 205 | + pageContext: agentPageContextSchema.optional(), |
| 206 | + // Injected server-side by the `in` proxy each turn, never sent from the |
| 207 | + // browser: a short-lived read-only delegated token, the API origin to call |
| 208 | + // back to, and the project ref + env its tools read. |
| 209 | + userActorToken: z.string().optional(), |
| 210 | + apiOrigin: z.string().optional(), |
| 211 | + projectRef: z.string().optional(), |
| 212 | + environmentName: z.string().optional(), |
| 213 | + // Injected only when the current project has a connected GitHub repo: a signed, |
| 214 | + // short-lived archive pointer the code-mode source tools read from. |
| 215 | + repoSnapshot: z |
| 216 | + .object({ |
| 217 | + tarballUrl: z.string(), |
| 218 | + owner: z.string(), |
| 219 | + repo: z.string(), |
| 220 | + sha: z.string(), |
| 221 | + defaultBranch: z.string().optional(), |
| 222 | + }) |
| 223 | + .optional(), |
| 224 | +}); |
| 225 | + |
| 226 | +/** |
| 227 | + * Coerce replayed tool-call inputs the Anthropic API would reject back to `{}`. |
| 228 | + * |
| 229 | + * The model occasionally emits a no-arg tool call with a non-object input (an |
| 230 | + * empty string, or `null`, which `typeof` also calls "object"), the SDK replays it |
| 231 | + * into history verbatim, and the API then fails the whole turn with |
| 232 | + * "tool_use.input: Input should be an object". |
| 233 | + */ |
| 234 | +export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessage[] { |
| 235 | + const isBadInput = (part: unknown) => |
| 236 | + typeof part === "object" && |
| 237 | + part !== null && |
| 238 | + (part as { type?: string }).type === "tool-call" && |
| 239 | + (typeof (part as { input?: unknown }).input !== "object" || |
| 240 | + (part as { input?: unknown }).input === null); |
| 241 | + |
| 242 | + return messages.map((message) => { |
| 243 | + if (message.role !== "assistant" || !Array.isArray(message.content)) return message; |
| 244 | + if (!message.content.some(isBadInput)) return message; |
| 245 | + return { |
| 246 | + ...message, |
| 247 | + content: message.content.map((part) => (isBadInput(part) ? { ...part, input: {} } : part)), |
| 248 | + }; |
| 249 | + }) as ModelMessage[]; |
| 250 | +} |
| 251 | + |
| 252 | +// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message. |
| 253 | +export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] { |
| 254 | + if (messages.length === 0) return messages; |
| 255 | + const last = messages[messages.length - 1]!; |
| 256 | + return [ |
| 257 | + ...messages.slice(0, -1), |
| 258 | + { |
| 259 | + ...last, |
| 260 | + providerOptions: { |
| 261 | + ...last.providerOptions, |
| 262 | + anthropic: { cacheControl: PROMPT_CACHE_CONTROL }, |
| 263 | + }, |
| 264 | + }, |
| 265 | + ]; |
| 266 | +} |
| 267 | + |
| 268 | +/** |
| 269 | + * The turn's tool set: the read tools built from the delegated token this record's |
| 270 | + * metadata carried, plus the one narrow write the investigation executor needs. |
| 271 | + * |
| 272 | + * Shared by the agent's `tools` hook and the investigating turn below, so a |
| 273 | + * consented investigation reads with the same tools and the same scope a |
| 274 | + * user-driven one does. |
| 275 | + */ |
| 276 | +export function buildTurnTools( |
| 277 | + chatId: string, |
| 278 | + clientData: z.infer<typeof clientDataSchema> | undefined |
| 279 | +): ToolSet { |
| 280 | + return ( |
| 281 | + locals.get(dashboardAgentToolsKey) ?? |
| 282 | + buildDashboardAgentTools({ |
| 283 | + ...(clientData ?? {}), |
| 284 | + chatId, |
| 285 | + investigations: { |
| 286 | + // Every committed revision is tracked, so the settle guard knows whether |
| 287 | + // the turn left a card running. |
| 288 | + upsert: async (params) => { |
| 289 | + const result = await getStore().upsertInvestigationRevision({ ...params, chatId }); |
| 290 | + if (result.ok) trackInvestigationOutcome(chatId, result.id, params); |
| 291 | + return result; |
| 292 | + }, |
| 293 | + }, |
| 294 | + }) |
| 295 | + ); |
| 296 | +} |
0 commit comments