Skip to content

Commit 1d13669

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,dashboard-agent): add a submit_feedback tool to the dashboard agent
The dashboard agent can now report problems with its own tools, the dashboard, or the docs to the Trigger.dev team with a new `submit_feedback` tool. ```ts submit_feedback({ message: "get_queue returned a 500 for a paused queue; expected the paused row", toolName: "get_queue", }); ``` The agent uses it when a tool error looks like a bug, the docs contradict the product, or it lacks a capability it needed, and it tells the user what it reported. It never reports the user's own failing runs, and never includes secrets, payloads, or logs. Reports go through a new `POST /api/v1/dashboard-agent/feedback` route, which only accepts the agent's delegated token and attributes the report to the chat's user, organization, project and environment. Reporting is fire-and-forget so it never slows the agent down: the event is queued like the rest of the dashboard's analytics, and a report that doesn't go through (including on an instance without analytics configured) is dropped quietly instead of surfacing as an error. Mono-RevId: b9c67c574d621ab1ec365046f401a9b031b386d2
1 parent 0c45a0a commit 1d13669

13 files changed

Lines changed: 403 additions & 18 deletions

File tree

‎apps/webapp/app/components/dashboard-agent/tool-labels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const TOOL_LABELS: Record<string, string> = {
2626
create_alert: "Creating an alert",
2727
delete_alert: "Deleting an alert",
2828
locate: "Locating the id",
29+
submit_feedback: "Sending feedback",
2930
// Code mode.
3031
get_repo_info: "Reading the repo",
3132
list_files: "Listing files",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { DASHBOARD_AGENT_FEEDBACK_LIMITS } from "@internal/dashboard-agent-contracts";
3+
import { z } from "zod";
4+
import { resolveAgentAlertContext } from "~/services/dashboardAgentAlertContext.server";
5+
import { logger } from "~/services/logger.server";
6+
import { telemetry } from "~/services/telemetry.server";
7+
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
8+
9+
/**
10+
* `POST` records the agent's report of a problem with its own tools or the docs. Only the
11+
* agent's delegated user-actor token is accepted, and the user, organization, project and
12+
* environment all come from it and the chat, never from the body.
13+
*/
14+
15+
const FeedbackBodySchema = z.object({
16+
chatId: z.string().min(1),
17+
message: z.string().trim().min(1).max(DASHBOARD_AGENT_FEEDBACK_LIMITS.message),
18+
toolName: z.string().min(1).max(DASHBOARD_AGENT_FEEDBACK_LIMITS.toolName).optional(),
19+
});
20+
21+
export async function action({ request }: ActionFunctionArgs) {
22+
if (request.method.toUpperCase() !== "POST") {
23+
return json({ error: "Method not allowed" }, { status: 405 });
24+
}
25+
26+
const authentication = await authenticateUatOrApiRequest(request);
27+
const actor = authentication?.userActor;
28+
if (!actor || actor.client !== "dashboard-agent") {
29+
return json({ error: "Invalid or missing access token" }, { status: 401 });
30+
}
31+
if (!actor.environmentId) {
32+
return json(
33+
{ error: "This chat has no environment context.", code: "invalid_target" },
34+
{ status: 400 }
35+
);
36+
}
37+
38+
const body = FeedbackBodySchema.safeParse(await request.json().catch(() => undefined));
39+
if (!body.success) {
40+
return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 });
41+
}
42+
43+
const context = await resolveAgentAlertContext({
44+
userId: actor.userId,
45+
environmentId: actor.environmentId,
46+
chatId: body.data.chatId,
47+
});
48+
if (!context.ok) {
49+
return json({ error: context.error, code: context.code }, { status: 404 });
50+
}
51+
52+
const recorded = telemetry.dashboardAgent.feedback({
53+
userId: actor.userId,
54+
organizationId: context.environment.organizationId,
55+
projectId: context.environment.project.id,
56+
environmentId: context.environment.id,
57+
chatId: body.data.chatId,
58+
message: body.data.message,
59+
toolName: body.data.toolName,
60+
});
61+
if (!recorded) {
62+
return json(
63+
{ error: "Feedback isn't collected on this instance.", code: "feedback_not_configured" },
64+
{ status: 501 }
65+
);
66+
}
67+
68+
logger.info("Dashboard agent feedback submitted", {
69+
chatId: body.data.chatId,
70+
toolName: body.data.toolName,
71+
messageLength: body.data.message.length,
72+
});
73+
return json({ recorded: true });
74+
}

‎apps/webapp/app/services/telemetry.server.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,39 @@ class Telemetry {
233233
},
234234
};
235235

236+
dashboardAgent = {
237+
/** Returns whether the report was captured, so the agent never claims one that was dropped. */
238+
feedback: ({
239+
userId,
240+
organizationId,
241+
projectId,
242+
environmentId,
243+
chatId,
244+
message,
245+
toolName,
246+
}: {
247+
userId: string;
248+
organizationId: string;
249+
projectId: string;
250+
environmentId: string;
251+
chatId: string;
252+
message: string;
253+
toolName?: string;
254+
}): boolean => {
255+
if (this.#posthogClient === undefined) return false;
256+
if (!IS_MANAGED_CLOUD) return false;
257+
this.#capture({
258+
userId,
259+
event: "dashboard_agent_feedback_submitted",
260+
organizationId,
261+
projectId,
262+
environmentId,
263+
eventProperties: { chatId, message, toolName },
264+
});
265+
return true;
266+
},
267+
};
268+
236269
#capture(event: CaptureEvent) {
237270
if (this.#posthogClient === undefined) return;
238271
let groups: Record<string, string> = {};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
authenticate: vi.fn(),
5+
resolveContext: vi.fn(),
6+
feedback: vi.fn(),
7+
}));
8+
9+
vi.mock("~/services/uatRoutePreamble.server", () => ({
10+
authenticateUatOrApiRequest: mocks.authenticate,
11+
}));
12+
vi.mock("~/services/dashboardAgentAlertContext.server", () => ({
13+
resolveAgentAlertContext: mocks.resolveContext,
14+
}));
15+
vi.mock("~/services/telemetry.server", () => ({
16+
telemetry: { dashboardAgent: { feedback: mocks.feedback } },
17+
}));
18+
vi.mock("~/services/logger.server", () => ({
19+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
20+
}));
21+
22+
import { action } from "~/routes/api.v1.dashboard-agent.feedback";
23+
24+
const AGENT_ACTOR = { userId: "user_1", client: "dashboard-agent", environmentId: "env_1" };
25+
const ENVIRONMENT = { id: "env_1", organizationId: "org_1", project: { id: "proj_1" } };
26+
27+
function post(body: unknown) {
28+
return action({
29+
request: new Request("https://app.trigger.dev/api/v1/dashboard-agent/feedback", {
30+
method: "POST",
31+
headers: { "content-type": "application/json", authorization: "Bearer uat" },
32+
body: JSON.stringify(body),
33+
}),
34+
params: {},
35+
context: {},
36+
} as any);
37+
}
38+
39+
beforeEach(() => {
40+
mocks.authenticate.mockReset().mockResolvedValue({ userActor: AGENT_ACTOR });
41+
mocks.resolveContext.mockReset().mockResolvedValue({ ok: true, environment: ENVIRONMENT });
42+
mocks.feedback.mockReset().mockReturnValue(true);
43+
});
44+
45+
describe("POST /api/v1/dashboard-agent/feedback", () => {
46+
it("attributes the report from the token and the chat, never from the body", async () => {
47+
const response = await post({
48+
chatId: "chat_1",
49+
message: "get_queue 500s on a paused queue",
50+
toolName: "get_queue",
51+
userId: "user_spoofed",
52+
organizationId: "org_spoofed",
53+
});
54+
55+
expect(response.status).toBe(200);
56+
expect(mocks.resolveContext).toHaveBeenCalledWith({
57+
userId: "user_1",
58+
environmentId: "env_1",
59+
chatId: "chat_1",
60+
});
61+
expect(mocks.feedback).toHaveBeenCalledWith({
62+
userId: "user_1",
63+
organizationId: "org_1",
64+
projectId: "proj_1",
65+
environmentId: "env_1",
66+
chatId: "chat_1",
67+
message: "get_queue 500s on a paused queue",
68+
toolName: "get_queue",
69+
});
70+
});
71+
72+
it.each([
73+
["no token", undefined],
74+
["a token from another client", { userActor: { ...AGENT_ACTOR, client: "mcp" } }],
75+
])("refuses %s", async (_label, authentication) => {
76+
mocks.authenticate.mockResolvedValue(authentication);
77+
78+
const response = await post({ chatId: "chat_1", message: "hi" });
79+
80+
expect(response.status).toBe(401);
81+
expect(mocks.feedback).not.toHaveBeenCalled();
82+
});
83+
84+
it("records nothing for a chat the user doesn't own", async () => {
85+
mocks.resolveContext.mockResolvedValue({
86+
ok: false,
87+
code: "chat_not_found",
88+
error: "Chat not found",
89+
});
90+
91+
const response = await post({ chatId: "chat_other", message: "hi" });
92+
93+
expect(response.status).toBe(404);
94+
expect(mocks.feedback).not.toHaveBeenCalled();
95+
});
96+
97+
it("rejects an empty or oversized message", async () => {
98+
expect((await post({ chatId: "chat_1", message: " " })).status).toBe(400);
99+
expect((await post({ chatId: "chat_1", message: "x".repeat(4001) })).status).toBe(400);
100+
expect(mocks.feedback).not.toHaveBeenCalled();
101+
});
102+
103+
it("answers 501 when the instance doesn't collect feedback", async () => {
104+
mocks.feedback.mockReturnValue(false);
105+
106+
const response = await post({ chatId: "chat_1", message: "docs say X, product does Y" });
107+
108+
expect(response.status).toBe(501);
109+
expect(await response.json()).toMatchObject({ code: "feedback_not_configured" });
110+
});
111+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* The bounds on a `submit_feedback` report. Shared so the tool's schema and the route that
3+
* records it can't drift apart and turn a valid report into a 400.
4+
*/
5+
export const DASHBOARD_AGENT_FEEDBACK_LIMITS = {
6+
message: 4000,
7+
toolName: 100,
8+
} as const;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export * from "./agent-records.js";
66
export * from "./blocks.js";
77
export * from "./evidence.js";
8+
export * from "./feedback.js";
89
export * from "./intent.js";
910
export * from "./locate.js";
1011
export * from "./page-context.js";

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

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

‎internal-packages/dashboard-agent/src/dashboard-agent.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,7 @@ describe("buildDashboardAgentTools", () => {
12341234
"run_query",
12351235
"render_view",
12361236
"search_docs",
1237+
"submit_feedback",
12371238
].sort()
12381239
);
12391240

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ describe("the head-start and agent prefixes are the same prefix", () => {
130130
* drift. The snapshot below is the itemised diff a reviewer reads.
131131
*/
132132
const PREFIX_BUDGET = {
133-
assistant: { chars: 85_300, estimatedTokens: 21_350, tools: 25, promptChars: 34_000 },
134-
code: { chars: 92_500, estimatedTokens: 23_150, tools: 29, promptChars: 36_700 },
133+
assistant: { chars: 86_250, estimatedTokens: 21_600, tools: 26, promptChars: 34_100 },
134+
code: { chars: 93_450, estimatedTokens: 23_400, tools: 30, promptChars: 36_850 },
135135
} as const;
136136

137137
// Measured with watches on: the biggest prefix a turn can hand the provider.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createApiClient } from "./tool-api-client";
3+
import type { DashboardAgentToolContext } from "./tool-context";
4+
import { buildFeedbackTool } from "./tool-feedback";
5+
6+
const ORIGIN = "https://api.example.com";
7+
const CTX: DashboardAgentToolContext = {
8+
userActorToken: "uat",
9+
apiOrigin: ORIGIN,
10+
chatId: "chat_1",
11+
};
12+
13+
function submit(input: { message: string; toolName?: string }, ctx = CTX) {
14+
const client = createApiClient(ctx);
15+
const execute = (buildFeedbackTool({ ctx, client }).submit_feedback as any).execute;
16+
return execute(input, {} as any) as Promise<any>;
17+
}
18+
19+
function stubFetch(response: Response | Error) {
20+
const fetch = vi.fn(async (_input: any, _init?: RequestInit) => {
21+
if (response instanceof Error) throw response;
22+
return response;
23+
});
24+
vi.stubGlobal("fetch", fetch);
25+
return fetch;
26+
}
27+
28+
afterEach(() => vi.unstubAllGlobals());
29+
30+
describe("submit_feedback", () => {
31+
it("posts the report with the chat it came from, under the delegated token", async () => {
32+
const fetch = stubFetch(new Response(JSON.stringify({ recorded: true }), { status: 200 }));
33+
34+
const result = await submit({
35+
message: "get_queue 500s on a paused queue",
36+
toolName: "get_queue",
37+
});
38+
39+
expect(result).toMatchObject({ recorded: true });
40+
const [url, init] = fetch.mock.calls[0]!;
41+
expect(url).toBe(`${ORIGIN}/api/v1/dashboard-agent/feedback`);
42+
expect(init?.method).toBe("POST");
43+
expect((init!.headers as Record<string, string>).Authorization).toBe("Bearer uat");
44+
expect(JSON.parse(String(init?.body))).toEqual({
45+
chatId: "chat_1",
46+
message: "get_queue 500s on a paused queue",
47+
toolName: "get_queue",
48+
});
49+
});
50+
51+
it.each([
52+
["an instance that doesn't collect feedback", new Response("{}", { status: 501 })],
53+
["a server error", new Response("{}", { status: 500 })],
54+
["a transport failure", new Error("socket hang up")],
55+
])("carries on quietly on %s, without an error", async (_label, response) => {
56+
stubFetch(response);
57+
58+
const result = await submit({ message: "something broke" });
59+
60+
expect(result.recorded).toBe(false);
61+
expect(result.error).toBeUndefined();
62+
});
63+
64+
it("sends nothing without a chat to attribute it to", async () => {
65+
const fetch = stubFetch(new Response("{}", { status: 200 }));
66+
67+
const result = await submit({ message: "something broke" }, { ...CTX, chatId: undefined });
68+
69+
expect(result.error).toBeDefined();
70+
expect(fetch).not.toHaveBeenCalled();
71+
});
72+
});

0 commit comments

Comments
 (0)