Skip to content

Commit 8a469a1

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
fix(dashboard-agent): hide watch wording from the model when watches are off
fix(dashboard-agent): don't mention watches to the model when watches are disabled Mono-RevId: eae114ad9978adb7d9b8f245bd42b4519fe6ee90
1 parent e27fd88 commit 8a469a1

6 files changed

Lines changed: 142 additions & 43 deletions

File tree

‎internal-packages/dashboard-agent-contracts/src/blocks.ts‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,26 +200,33 @@ export const chartBlockBodySchema = z.object({
200200
* Same lenient navigate `target` as `chartActionIntentSchema`. `propose_fix` is
201201
* absent on purpose: it is reserved, so a button can never carry it.
202202
*/
203+
const askActionIntentSchema = z.object({
204+
kind: z.literal("ask"),
205+
prompt: z.string().min(1),
206+
});
207+
208+
const navigateActionIntentSchema = z.object({
209+
kind: z.literal("navigate"),
210+
target: z.string().min(1),
211+
filters: runFiltersSchema.optional(),
212+
});
213+
203214
const actionIntentSchema = z.union([
204-
z.object({
205-
kind: z.literal("ask"),
206-
prompt: z.string().min(1),
207-
}),
208-
z.object({
209-
kind: z.literal("navigate"),
210-
target: z.string().min(1),
211-
filters: runFiltersSchema.optional(),
212-
}),
215+
askActionIntentSchema,
216+
navigateActionIntentSchema,
213217
z.object({
214218
kind: z.literal("watch"),
215219
spec: watchSpecSchema,
216220
}),
217221
]);
218222

223+
const ASK_NAVIGATE_INTENT_DOC =
224+
"`ask` sends the prompt as the user's next message, in their voice. `navigate` takes them to a page — ONLY with a canonical `trigger://` URI you already hold; an invalid target is silently dropped.";
225+
219226
export const actionsBlockActionSchema = z.object({
220227
label: z.string().min(1).describe("The button text, e.g. 'Set up a watch'."),
221228
intent: actionIntentSchema.describe(
222-
"What the button does. `watch` opens the watch configuration card pre-filled with your spec — the user confirming it is what starts the watch. `ask` sends the prompt as the user's next message, in their voice. `navigate` takes them to a page — ONLY with a canonical `trigger://` URI you already hold; an invalid target is silently dropped."
229+
`What the button does. \`watch\` opens the watch configuration card pre-filled with your spec — the user confirming it is what starts the watch. ${ASK_NAVIGATE_INTENT_DOC}`
223230
),
224231
});
225232

@@ -236,6 +243,24 @@ const actionsBlockBodySchema = z.object({
236243
),
237244
});
238245

246+
const actionsBlockBodyInputSchemaWithoutWatch = z.object({
247+
type: z.literal("actions"),
248+
actions: z
249+
.array(
250+
z.object({
251+
label: z.string().min(1).describe("The button text, e.g. 'See its failed runs'."),
252+
intent: z
253+
.union([askActionIntentSchema, navigateActionIntentSchema])
254+
.describe(`What the button does. ${ASK_NAVIGATE_INTENT_DOC}`),
255+
})
256+
)
257+
.min(1)
258+
.max(3)
259+
.describe(
260+
"1-3 buttons, the one to take first. Keep labels short and imperative ('See its failed runs')."
261+
),
262+
});
263+
239264
/**
240265
* Hand-mirrored wire contract for the webapp's `ReportViewModel`, which owns the
241266
* shape. The leniency below is deliberate: a presenter may grow a field.
@@ -622,6 +647,18 @@ export const viewBlockInputSchema = z.discriminatedUnion("type", [
622647
investigationBlockBodyInputSchema,
623648
]);
624649

650+
const viewBlockInputSchemaWithoutWatch = z.discriminatedUnion("type", [
651+
diagnosisBlockBodySchema,
652+
chartBlockBodySchema,
653+
actionsBlockBodyInputSchemaWithoutWatch,
654+
investigationBlockBodyInputSchema,
655+
]);
656+
657+
/** The model-facing input schema: with watches off it has no `watch` intent. */
658+
export function viewBlockInputSchemaFor(options: { watchEnabled?: boolean }) {
659+
return options.watchEnabled ? viewBlockInputSchema : viewBlockInputSchemaWithoutWatch;
660+
}
661+
625662
export type DiagnosisBlockBody = z.infer<typeof diagnosisBlockBodySchema>;
626663
export type ChartBlockBody = z.infer<typeof chartBlockBodySchema>;
627664
export type ActionsBlockBody = z.infer<typeof actionsBlockBodySchema>;

‎internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap‎

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal-packages/dashboard-agent/src/prompt-assembly.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DASHBOARD_AGENT_WATCH_TOOL_NAMES,
66
dashboardAgentCodeToolSchemas,
77
dashboardAgentToolSchemas,
8+
renderViewSchemaWithoutWatch,
89
} from "./tool-schemas";
910

1011
/**
@@ -43,8 +44,10 @@ export function toolSchemasFor(
4344
const schemas = mode === "code" ? dashboardAgentCodeToolSchemas : dashboardAgentToolSchemas;
4445
if (options.watchEnabled) return schemas;
4546
return Object.fromEntries(
46-
Object.entries(schemas).filter(
47-
([name]) => !(DASHBOARD_AGENT_WATCH_TOOL_NAMES as readonly string[]).includes(name)
48-
)
47+
Object.entries(schemas)
48+
.filter(([name]) => !(DASHBOARD_AGENT_WATCH_TOOL_NAMES as readonly string[]).includes(name))
49+
.map(([name, schema]) =>
50+
name === "render_view" ? [name, renderViewSchemaWithoutWatch] : [name, schema]
51+
)
4952
) as typeof schemas;
5053
}

‎internal-packages/dashboard-agent/src/tool-api.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
listRunsSchema,
2323
listTasksSchema,
2424
renderViewSchema,
25+
renderViewSchemaWithoutWatch,
2526
runQuerySchema,
2627
searchDocsSchema,
2728
} from "./tool-schemas";
@@ -783,7 +784,7 @@ export function buildApiTools(args: {
783784

784785
// A `chart` block's query runs here first — the panel would run it too late to report.
785786
render_view: tool({
786-
...renderViewSchema,
787+
...(ctx.watchEnabled ? renderViewSchema : renderViewSchemaWithoutWatch),
787788
execute: async (view) => {
788789
const scope = await resolveTarget({}, ctx, "render a chart");
789790
for (const block of view.blocks) {

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

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import {
66
DASHBOARD_AGENT_FEEDBACK_LIMITS,
77
runFiltersSchema,
8-
viewBlockInputSchema,
8+
viewBlockInputSchemaFor,
99
watchSpecSchema,
1010
} from "@internal/dashboard-agent-contracts";
1111
import { tool } from "ai";
@@ -342,19 +342,30 @@ export const navigateToSchema = tool({
342342
// A new block needs a schema in dashboard-agent-contracts (src/blocks.ts) and a
343343
// renderer entry in components/dashboard-agent/view-catalog.tsx.
344344

345-
export const renderViewSchema = tool({
346-
description:
347-
"Render a structured view in the dashboard panel: a stack of catalog blocks, instead of plain prose. The catalog has four blocks: `diagnosis` (the 'why did this run fail?' failure card, after gathering evidence with the read/source tools), `chart` (a line/bar chart of run_query results), `actions` (a row of 1-3 buttons offering next steps — a `watch` intent opens the watch configuration card pre-filled with the spec you composed, an `ask` intent sends the labelled question as the user's next message), and `investigation` (a live card for a hypothesis-driven investigation: report the state and the tool assigns and keeps its identity, so re-rendering it updates the same card). The result carries the `investigationId` it assigned — pass that back as `investigationId` when you render the same investigation again, including on a later turn. An investigation is rendered at least TWICE: once as `in_progress` when you open it, then again with the same `investigationId` carrying the final outcome (`concluded` or `inconclusive`), as the last tool call of the turn. A card left at `in_progress` is an unfinished answer whatever your prose says: the user is left watching a spinner. Keep any accompanying message to a one-line lead-in.",
348-
inputSchema: z.object({
349-
blocks: z.array(viewBlockInputSchema).min(1).describe("The blocks to render, top to bottom."),
350-
investigationId: z
351-
.string()
352-
.optional()
353-
.describe(
354-
"The investigationId a previous render_view returned, to revise that same investigation instead of opening a new one. Use it on follow-up turns about the same investigation. Omit to start a new one."
355-
),
356-
}),
357-
});
345+
const RENDER_VIEW_ACTIONS_WATCH_DOC =
346+
"a `watch` intent opens the watch configuration card pre-filled with the spec you composed, ";
347+
348+
function renderViewSchemaFor(options: { watchEnabled: boolean }) {
349+
const watchDoc = options.watchEnabled ? RENDER_VIEW_ACTIONS_WATCH_DOC : "";
350+
return tool({
351+
description: `Render a structured view in the dashboard panel: a stack of catalog blocks, instead of plain prose. The catalog has four blocks: \`diagnosis\` (the 'why did this run fail?' failure card, after gathering evidence with the read/source tools), \`chart\` (a line/bar chart of run_query results), \`actions\` (a row of 1-3 buttons offering next steps — ${watchDoc}an \`ask\` intent sends the labelled question as the user's next message), and \`investigation\` (a live card for a hypothesis-driven investigation: report the state and the tool assigns and keeps its identity, so re-rendering it updates the same card). The result carries the \`investigationId\` it assigned — pass that back as \`investigationId\` when you render the same investigation again, including on a later turn. An investigation is rendered at least TWICE: once as \`in_progress\` when you open it, then again with the same \`investigationId\` carrying the final outcome (\`concluded\` or \`inconclusive\`), as the last tool call of the turn. A card left at \`in_progress\` is an unfinished answer whatever your prose says: the user is left staring at a spinner. Keep any accompanying message to a one-line lead-in.`,
352+
inputSchema: z.object({
353+
blocks: z
354+
.array(viewBlockInputSchemaFor(options))
355+
.min(1)
356+
.describe("The blocks to render, top to bottom."),
357+
investigationId: z
358+
.string()
359+
.optional()
360+
.describe(
361+
"The investigationId a previous render_view returned, to revise that same investigation instead of opening a new one. Use it on follow-up turns about the same investigation. Omit to start a new one."
362+
),
363+
}),
364+
});
365+
}
366+
367+
export const renderViewSchema = renderViewSchemaFor({ watchEnabled: true });
368+
export const renderViewSchemaWithoutWatch = renderViewSchemaFor({ watchEnabled: false });
358369

359370
// `watchSpecSchema` enforces the cadence floors and 24h ceiling. `since` for error
360371
// recurrence is server-set on persist, so it is absent here.
@@ -633,7 +644,7 @@ You have read-only tools that act as the user against their own account:
633644
- get_query_schema: discover the analytics tables and columns you can query with TRQL (runs, metrics, llm_metrics, llm_models, concurrency_metrics, concurrency_metrics_by_key).
634645
- run_query: run a read-only TRQL query (SQL-style over ClickHouse) against the current environment's analytics data.
635646
- ask_support: ask the Trigger.dev support assistant about how Trigger.dev works (docs, concepts, features, configuration, how-tos).
636-
- 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), the "chart" block (a line/bar chart of run_query results), the "actions" block (a row of 1-3 buttons offering next steps — a watch intent opens the watch card pre-filled, an ask intent sends the labelled question as the user's next message), and the "investigation" block (a live card for a hypothesis-driven investigation).
647+
- 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), the "chart" block (a line/bar chart of run_query results), the "actions" block (a row of 1-3 buttons offering next steps — an ask intent sends the labelled question as the user's next message), and the "investigation" block (a live card for a hypothesis-driven investigation).
637648
- get_report: the composed health report for the current environment (flow, execution, liveness), with a severity and the metrics behind each.
638649
- get_queue: one queue's wait latency, peak depth, throughput, and throttling over a window, plus its live row and the scheduler's own grounding for where concurrency capacity is exhausted. Lead with paused when it is true: a paused queue explains its own emptiness, so say it is paused and only then the numbers. queuedNow is what is waiting right now, which a window of metrics cannot show. A custom queue's name is not a task id, so no task being named after it is not evidence about it — never conclude from list_tasks or a deployment that it is unconsumed, deleted, or renamed. consumerTasks is the answer to "who feeds this queue": empty means nothing deployed writes to it, and absent means you did not ask a custom queue.
639650
- list_deploys: recent deployments (versions) in the current environment, with status and commit message.
@@ -708,7 +719,7 @@ Investigations:
708719
2. Pose two hypotheses — three only if the evidence really demands it.
709720
3. Render. call render_view with an "investigation" block, outcome in_progress, BEFORE you test anything and no later than your third step — even when the answer already looks obvious. The result carries investigationId.
710721
4. Test — ONE round, one targeted check per hypothesis, issued together, read tools only. That round is all you get: a check that comes back empty, unavailable, or truncated is itself a finding. Never retry a search with different terms and never reach for a second tool to get the same answer.
711-
5. Render the verdict, immediately after that round — prose is never a substitute, and a card still reading in_progress when the turn ends leaves the user watching a spinner: render_view again, same investigationId, outcome concluded or inconclusive. This is your VERY NEXT call — before any other tool and before you write a word — and it is always the last tool call of the turn. If you find yourself about to call something that isn't a read of evidence, render the verdict instead. Then close with one short line of prose, and let the outcome decide what it says. concluded: name the cause concretely, in the user's own terms — the limit that's saturated, the file:line that broke. inconclusive: say what is NOT established and what to check first — no "the culprit is", no cause presented as found, and no fix, not even a fast one or a hedged one. "Here's what I found" is not an answer, and don't restate the card. The close is ONE sentence, never a list: if you're writing bullets after the verdict card, you are retyping the card's remediation or checkNext — everything list-shaped belongs on the card and only there.
722+
5. Render the verdict, immediately after that round — prose is never a substitute, and a card still reading in_progress when the turn ends leaves the user staring at a spinner: render_view again, same investigationId, outcome concluded or inconclusive. This is your VERY NEXT call — before any other tool and before you write a word — and it is always the last tool call of the turn. If you find yourself about to call something that isn't a read of evidence, render the verdict instead. Then close with one short line of prose, and let the outcome decide what it says. concluded: name the cause concretely, in the user's own terms — the limit that's saturated, the file:line that broke. inconclusive: say what is NOT established and what to check first — no "the culprit is", no cause presented as found, and no fix, not even a fast one or a hedged one. "Here's what I found" is not an answer, and don't restate the card. The close is ONE sentence, never a list: if you're writing bullets after the verdict card, you are retyping the card's remediation or checkNext — everything list-shaped belongs on the card and only there.
712723
- That is FOUR tool phases and there is no fifth: gather, open the card, one test round, verdict. You cannot count how many steps you have left and the ceiling is hard — a turn that hits it renders nothing and answers nothing — so anything outside those four phases is a step you cannot afford. Never call get_current_page, list_projects, or list_environments inside an investigation: your tools are already scoped and the card needs none of it.
713724
- You do not need every hypothesis settled to conclude. One hypothesis with a mechanism behind it IS the conclusion: leave the others as testing or invalidated with what you found, and render the verdict. Chasing the last unsettled hypothesis — for call sites, a type definition, a payload you can't see — is how a turn ends with no verdict at all.
714725
- When you investigate a run that is still executing or has been running for minutes, copy get_run_trace's timeline onto the investigation's own timeline verbatim — the user wants to see where the time has gone and what it is doing right now, not a prose table. Once the card carries a timeline, the reply doesn't restate its phases as a table or list — the card is the timeline, so close with at most one or two sentences of conclusion.
@@ -744,6 +755,7 @@ export const DASHBOARD_AGENT_WATCH_PROMPT = `You have the watch tools too:
744755
- list_alerts: the project's alert subscriptions for watch fires.
745756
- create_alert: subscribe the user to an email alert for watch fires in this project.
746757
- delete_alert: turn one alert subscription off.
758+
- render_view "actions" takes a watch intent too.
747759
748760
Watches — telling the user later:
749761
- When the user wants to be told when something happens ("tell me when this run finishes", "let me know when the backlog drains", "tell me when it's back under 100", "tell me if that queue stops moving", "ping me if runs start waiting more than 5 minutes", "ping me if that error comes back", "tell me when prod is healthy again"), call schedule_watch. Never poll: repeating a read tool until the thing happens is not a watch, and you cannot wait inside a turn.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { asSchema, type Tool } from "ai";
2+
import { describe, expect, it } from "vitest";
3+
import { systemPromptFor, toolSchemasFor } from "./prompt-assembly";
4+
import type { RepoSnapshot } from "./repo-tools";
5+
import { buildDashboardAgentTools } from "./tools";
6+
7+
const snapshot: RepoSnapshot = {
8+
tarballUrl: "http://unused.invalid/never-fetched",
9+
owner: "acme",
10+
repo: "demo",
11+
sha: "deadbeefdeadbeef",
12+
};
13+
14+
function serialize(tools: Record<string, Tool<any, any>>): string {
15+
return JSON.stringify(
16+
Object.entries(tools).map(([name, definition]) => ({
17+
name,
18+
description: definition.description ?? "",
19+
inputSchema: asSchema(definition.inputSchema as never).jsonSchema,
20+
}))
21+
);
22+
}
23+
24+
describe("what the model is told about watches", () => {
25+
for (const mode of ["assistant", "code"] as const) {
26+
for (const watchEnabled of [false, true]) {
27+
it(`${watchEnabled ? "mentions" : "never mentions"} watches in ${mode} mode`, () => {
28+
const ctx = {
29+
projectRef: "proj_abc",
30+
environmentId: "env_abc",
31+
watchEnabled,
32+
...(mode === "code" ? { repoSnapshot: snapshot } : {}),
33+
};
34+
const surfaces = {
35+
system: systemPromptFor(mode, { watchEnabled }),
36+
schemas: serialize(toolSchemasFor(mode, { watchEnabled })),
37+
tools: serialize(buildDashboardAgentTools(ctx)),
38+
renderView: serialize({ render_view: buildDashboardAgentTools(ctx).render_view }),
39+
};
40+
for (const text of Object.values(surfaces)) {
41+
expect(/watch/i.test(text)).toBe(watchEnabled);
42+
}
43+
});
44+
}
45+
}
46+
});

0 commit comments

Comments
 (0)