Skip to content

Commit 46290d1

Browse files
committed
refactor(dashboard-agent): move the watch wake and investigate lanes out of the agent
Pure move. The wake narration and the consented investigation now live in watch-actions.ts, reached from one call in onAction; the runtime they share with the agent's own hooks moves to agent-runtime.ts. Every export is re-exported from dashboard-agent.ts, so no import path changes.
1 parent 2ed5d52 commit 46290d1

3 files changed

Lines changed: 1003 additions & 916 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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

Comments
 (0)