Skip to content

Commit ebad471

Browse files
committed
fix(webapp): cap the size of a message to the agent
1 parent a0d06e4 commit ebad471

7 files changed

Lines changed: 197 additions & 5 deletions

.server-changes/dashboard-agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ Meet the dashboard agent: a chat in every environment that answers questions abo
99

1010
**Watch…** on a run, queue, error or the health report tells you when things change: a run finishes, a queue clears or grows past a number you pick, an error comes back, an environment recovers. The answer arrives in the chat and, if you want, by email, Slack or webhook — and the agent can look into bad news on its own.
1111

12-
The health report reads the same everywhere — dashboard, terminal, editor: same sections, same wording, and a marker instead of colour where colour isn't available. Its "stale data" mark is amber rather than blue, and its metric rows stay inside the panel when the chat is narrow. If a message to the agent fails, the chat says so and keeps saying so when you reopen it. Unsubscribing from watch notifications takes effect immediately, and you can watch a run the moment you trigger it.
12+
The health report reads the same everywhere — dashboard, terminal, editor: same sections, same wording, and a marker instead of colour where colour isn't available. Its "stale data" mark is amber rather than blue, and its metric rows stay inside the panel when the chat is narrow. If a message to the agent fails, the chat says so and keeps saying so when you reopen it. A watch that has something to tell you reaches you on any browser you sign in from, without opening the chat first. Messages to the agent now have a length limit, with a counter as you approach it. Unsubscribing from watch notifications takes effect immediately, and you can watch a run the moment you trigger it.
1313

1414
Separately: a queue's wait times, peak depth, throughput and throttling can now be read from the API, and the Docs button has been removed from page headers.

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DashboardAgentComposer } from "./DashboardAgentComposer";
1111
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
1212
import { DashboardAgentHero } from "./DashboardAgentHero";
1313
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
14+
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
1415
import { createTranscriptOrder, orderTranscript } from "./message-order";
1516
import { appendRunFilters } from "./navigate-target";
1617
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -101,10 +102,19 @@ export function DashboardAgentChat({
101102
baseURL: apiOrigin,
102103
// Only `in` goes through the same-origin proxy, which injects the delegated user
103104
// token server-side. `baseURL` stays a string so `out` keeps the SDK's realtime routing.
104-
fetch: (url, init, ctx) => {
105+
fetch: async (url, init, ctx) => {
105106
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
106107
const { pathname, search } = new URL(url);
107-
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
108+
const res = await globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
109+
// A refused message never succeeds on a retry, so it surfaces as the turn's error.
110+
if (res.status === 413) {
111+
const data = (await res
112+
.clone()
113+
.json()
114+
.catch(() => null)) as { error?: string } | null;
115+
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
116+
}
117+
return res;
108118
},
109119
clientData,
110120
sessions: session

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
22
import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
5+
import { MAX_MESSAGE_CHARS, MESSAGE_CHARS_WARN_AT } from "./message-limits";
56

67
export type DashboardAgentComposerLayout = "docked" | "hero";
78

@@ -82,7 +83,9 @@ export function DashboardAgentComposer({
8283
ref={ref}
8384
rows={isHero ? 3 : 1}
8485
value={value}
85-
onChange={(e) => onChange(e.target.value)}
86+
// Clamped as well as `maxLength`, so a programmatic paste can't exceed the cap.
87+
maxLength={MAX_MESSAGE_CHARS}
88+
onChange={(e) => onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS))}
8689
onKeyDown={(e) => {
8790
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
8891
e.preventDefault();
@@ -115,6 +118,18 @@ export function DashboardAgentComposer({
115118
)}
116119
</div>
117120
</div>
121+
{/* Only near the limit: a normal message never sees a counter. */}
122+
{value.length >= MESSAGE_CHARS_WARN_AT ? (
123+
<p
124+
className={cn(
125+
"self-end text-xxs tabular-nums",
126+
value.length >= MAX_MESSAGE_CHARS ? "text-error" : "text-text-dimmed"
127+
)}
128+
aria-live="polite"
129+
>
130+
{value.length} / {MAX_MESSAGE_CHARS}
131+
</p>
132+
) : null}
118133
</div>
119134
);
120135
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
checkMessageParts,
4+
declaredBodyBytes,
5+
exceedsMessageBodyBytes,
6+
MAX_MESSAGE_BODY_BYTES,
7+
MAX_MESSAGE_CHARS,
8+
MAX_MESSAGE_PARTS,
9+
} from "./message-limits";
10+
11+
describe("message limits", () => {
12+
it("lets a long real question through", () => {
13+
const text = "why did this fail?\n".repeat(50);
14+
15+
expect(exceedsMessageBodyBytes(Buffer.byteLength(text, "utf8"))).toBe(false);
16+
expect(checkMessageParts([{ type: "text", text }])).toBeNull();
17+
});
18+
19+
it("refuses a pasted dump by bytes", () => {
20+
expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES)).toBe(false);
21+
expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES + 1)).toBe(true);
22+
});
23+
24+
it("counts multi-byte characters as bytes, not characters", () => {
25+
// Under the char cap, over the byte cap: 4 bytes each.
26+
const emoji = "🙂".repeat(MAX_MESSAGE_BODY_BYTES / 4 + 1);
27+
28+
expect(emoji.length).toBeLessThan(MAX_MESSAGE_BODY_BYTES);
29+
expect(exceedsMessageBodyBytes(Buffer.byteLength(emoji, "utf8"))).toBe(true);
30+
});
31+
32+
it("refuses a dump split across parts", () => {
33+
const parts = Array.from({ length: 4 }, () => ({
34+
type: "text",
35+
text: "x".repeat(MAX_MESSAGE_CHARS / 2),
36+
}));
37+
38+
expect(checkMessageParts(parts)).toBe("too_long");
39+
});
40+
41+
it("refuses too many parts", () => {
42+
const parts = Array.from({ length: MAX_MESSAGE_PARTS + 1 }, () => ({
43+
type: "text",
44+
text: "x",
45+
}));
46+
47+
expect(checkMessageParts(parts)).toBe("too_many_parts");
48+
expect(checkMessageParts(parts.slice(0, MAX_MESSAGE_PARTS))).toBeNull();
49+
});
50+
51+
it("leaves a shape that isn't a parts array to the schema", () => {
52+
expect(checkMessageParts(undefined)).toBeNull();
53+
expect(checkMessageParts("nope")).toBeNull();
54+
});
55+
56+
it("reads the declared size, or nothing when it isn't declared", () => {
57+
expect(declaredBodyBytes(new Headers({ "content-length": "1234" }))).toBe(1234);
58+
expect(declaredBodyBytes(new Headers())).toBeNull();
59+
expect(declaredBodyBytes(new Headers({ "content-length": "nope" }))).toBeNull();
60+
// An undeclared size can't be refused here; the body's own length is.
61+
expect(exceedsMessageBodyBytes(null)).toBe(false);
62+
});
63+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Caps on one message to the agent, shared by the composer and the two server paths a message
3+
* can arrive through. Generous for a real question with a pasted stack trace, stingy for a dump:
4+
* an unbounded paste is a large model bill and a permanently fat transcript.
5+
*/
6+
7+
/** ~2 pages of text, or a long stack trace. */
8+
export const MAX_MESSAGE_CHARS = 8_000;
9+
10+
/** The counter only shows near the limit, so a normal message never sees it. */
11+
export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9);
12+
13+
/** A composed message is a handful of parts; dozens means something is wrong. */
14+
export const MAX_MESSAGE_PARTS = 20;
15+
16+
/**
17+
* The whole request body, in bytes: headroom for {@link MAX_MESSAGE_CHARS} of any script plus
18+
* the per-turn metadata, and nothing like a pasted file.
19+
*/
20+
export const MAX_MESSAGE_BODY_BYTES = 64 * 1024;
21+
22+
export const MESSAGE_TOO_LARGE_CODE = "message_too_large";
23+
24+
export const MESSAGE_TOO_LARGE_ERROR = "That message is too long. Shorten it and send again.";
25+
26+
export type MessagePartsProblem = "too_many_parts" | "too_long";
27+
28+
/** Counts the parts and their text. Anything that isn't a parts array is left to the schema. */
29+
export function checkMessageParts(parts: unknown): MessagePartsProblem | null {
30+
if (!Array.isArray(parts)) return null;
31+
if (parts.length > MAX_MESSAGE_PARTS) return "too_many_parts";
32+
33+
let chars = 0;
34+
for (const part of parts) {
35+
const text = (part as { text?: unknown } | null)?.text;
36+
if (typeof text === "string") chars += text.length;
37+
}
38+
return chars > MAX_MESSAGE_CHARS ? "too_long" : null;
39+
}
40+
41+
/** The declared body size, or null when the client didn't declare one. */
42+
export function declaredBodyBytes(headers: Headers): number | null {
43+
const raw = headers.get("content-length");
44+
if (!raw) return null;
45+
const bytes = Number.parseInt(raw, 10);
46+
return Number.isFinite(bytes) ? bytes : null;
47+
}
48+
49+
export function exceedsMessageBodyBytes(bytes: number | null | undefined): boolean {
50+
return typeof bytes === "number" && bytes > MAX_MESSAGE_BODY_BYTES;
51+
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import {
3+
checkMessageParts,
4+
declaredBodyBytes,
5+
exceedsMessageBodyBytes,
6+
MESSAGE_TOO_LARGE_CODE,
7+
MESSAGE_TOO_LARGE_ERROR,
8+
} from "~/components/dashboard-agent/message-limits";
29
import { $replica } from "~/db.server";
310
import { findProjectBySlug } from "~/models/project.server";
411
import {
@@ -23,6 +30,10 @@ const FORWARDED_HEADERS = [
2330
"x-trigger-branch",
2431
];
2532

33+
function tooLarge() {
34+
return json({ error: MESSAGE_TOO_LARGE_ERROR, code: MESSAGE_TOO_LARGE_CODE }, { status: 413 });
35+
}
36+
2637
export async function action({ request, params }: ActionFunctionArgs) {
2738
const user = await requireUser(request);
2839
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
@@ -38,6 +49,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
3849
return json({ error: "Not found" }, { status: 404 });
3950
}
4051

52+
// Refused before any lookup, so an oversized body costs nothing.
53+
if (exceedsMessageBodyBytes(declaredBodyBytes(request.headers))) {
54+
return tooLarge();
55+
}
56+
4157
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
4258
if (!project) return json({ error: "Project not found" }, { status: 404 });
4359

@@ -61,18 +77,31 @@ export async function action({ request, params }: ActionFunctionArgs) {
6177
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
6278

6379
const raw = await request.text();
80+
// The header is advisory; the body it actually sent is what counts.
81+
if (exceedsMessageBodyBytes(Buffer.byteLength(raw, "utf8"))) {
82+
return tooLarge();
83+
}
84+
6485
let body = raw;
6586
try {
6687
const parsed = JSON.parse(raw) as {
6788
kind?: string;
68-
payload?: { trigger?: string; metadata?: Record<string, unknown> };
89+
payload?: {
90+
trigger?: string;
91+
metadata?: Record<string, unknown>;
92+
message?: { parts?: unknown };
93+
};
6994
};
7095
// Actions are placed by the server only, and this proxy is the one path a browser
7196
// can reach `.in` through.
7297
if (parsed.payload?.trigger === "action") {
7398
return json({ error: "Not allowed" }, { status: 403 });
7499
}
75100
if (parsed.kind === "message" && parsed.payload) {
101+
// A body under the byte cap can still be one huge part or hundreds of small ones.
102+
if (checkMessageParts(parsed.payload.message?.parts) !== null) {
103+
return tooLarge();
104+
}
76105
parsed.payload.metadata = {
77106
...(parsed.payload.metadata ?? {}),
78107
userActorToken: await mintDashboardAgentUserActorToken(user.id, {

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ import {
2525
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
2626
import type { UIMessage } from "ai";
2727
import { z } from "zod";
28+
import {
29+
checkMessageParts,
30+
declaredBodyBytes,
31+
exceedsMessageBodyBytes,
32+
MESSAGE_TOO_LARGE_CODE,
33+
MESSAGE_TOO_LARGE_ERROR,
34+
} from "~/components/dashboard-agent/message-limits";
2835
import { $replica } from "~/db.server";
2936
import { env } from "~/env.server";
3037
import { findProjectBySlug } from "~/models/project.server";
@@ -200,6 +207,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
200207
});
201208
};
202209

210+
function messageTooLarge() {
211+
return json({ error: MESSAGE_TOO_LARGE_ERROR, code: MESSAGE_TOO_LARGE_CODE }, { status: 413 });
212+
}
213+
203214
export const action = async ({ request, params }: ActionFunctionArgs) => {
204215
const user = await requireUser(request);
205216
const userId = user.id;
@@ -216,6 +227,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
216227
return json({ error: "Not found" }, { status: 404 });
217228
}
218229

230+
// Refused before any lookup, so an oversized body costs nothing.
231+
if (exceedsMessageBodyBytes(declaredBodyBytes(request.headers))) {
232+
return messageTooLarge();
233+
}
234+
219235
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
220236
if (!project) return json({ error: "Project not found" }, { status: 404 });
221237

@@ -238,6 +254,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
238254
}
239255
if (!firstMessage) return json({ error: "message is required" }, { status: 400 });
240256

257+
// A body under the byte cap can still be one huge part or hundreds of small ones.
258+
if (
259+
exceedsMessageBodyBytes(Buffer.byteLength(parsed.data.message ?? "", "utf8")) ||
260+
checkMessageParts(firstMessage.parts) !== null
261+
) {
262+
return messageTooLarge();
263+
}
264+
241265
let clientData: Record<string, unknown> | undefined;
242266
try {
243267
clientData = parsed.data.clientData

0 commit comments

Comments
 (0)