Skip to content

Commit 097bb5d

Browse files
committed
feat(webapp,dashboard-agent-db): server-side agent message quota
Enforce the Free plan's agent-message allowance on the server, not just as a client hint. A per-(organizationId, period) counter lives in its own table, not joined to chats, so deleting a chat can no longer free quota inside the period. The create path and the .in append path each count one user message and refuse over the cap with a typed 403 the client renders as the upgrade block; wakes (action turns) never count. Fails open: an absent limit (self-hosted, or before the cloud side ships) or a counter read that throws means no cap. TRI-12863.
1 parent 34996fd commit 097bb5d

12 files changed

Lines changed: 1781 additions & 23 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
The Free plan now includes a monthly allowance of agent messages. When you reach it, the chat shows an upgrade prompt in place of the composer; your existing chats stay readable.

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
1717
import { DashboardAgentHero } from "./DashboardAgentHero";
1818
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
1919
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
20+
import { FREE_PLAN_MESSAGE_LIMIT } from "./message-quota";
2021
import { createTranscriptOrder, orderTranscript } from "./message-order";
2122
import { navigateDestination } from "./navigate-target";
2223
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -102,6 +103,9 @@ export function DashboardAgentChat({
102103
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
103104
}) {
104105
const [input, setInput] = useState("");
106+
// Set when the server refuses a send over the cap, so the block shows at once rather than
107+
// waiting for the next quota poll.
108+
const [quotaReached, setQuotaReached] = useState<{ limit: number } | null>(null);
105109
const navigate = useNavigate();
106110
const location = useLocation();
107111
const toast = useToast();
@@ -128,6 +132,17 @@ export function DashboardAgentChat({
128132
.catch(() => null)) as { error?: string } | null;
129133
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
130134
}
135+
// Over the message cap: show the upgrade block instead of a generic turn error.
136+
if (res.status === 403) {
137+
const data = (await res
138+
.clone()
139+
.json()
140+
.catch(() => null)) as { error?: string; limit?: number } | null;
141+
if (data?.error === "message_quota_reached") {
142+
setQuotaReached({ limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT });
143+
throw new Error("You've reached your message limit.");
144+
}
145+
}
131146
return res;
132147
},
133148
clientData,
@@ -187,7 +202,10 @@ export function DashboardAgentChat({
187202

188203
// Counted here, not in the panel, so it includes the turn just sent.
189204
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
190-
const atMessageCap = quota.kind === "reached";
205+
// Either the poll saw the cap, or a send was just refused over it.
206+
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
207+
const messageCapLimit =
208+
quotaReached?.limit ?? (quota.kind === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT);
191209

192210
const isStreaming = status === "streaming";
193211
// From status, not the last part: the indicator must stay up through silent tool calls.
@@ -414,9 +432,9 @@ export function DashboardAgentChat({
414432
/>
415433
)}
416434
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
417-
{quota.kind === "reached" ? (
435+
{atMessageCap ? (
418436
<AgentUpgradeBlock
419-
limit={quota.limit}
437+
limit={messageCapLimit}
420438
context={
421439
<DashboardAgentContextBanner
422440
projectSlug={projectSlug}
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import type { UIMessage } from "@ai-sdk/react";
22
import { useEffect, useState } from "react";
3+
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
34
import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota";
45

5-
// Always undefined until billing supplies plan detection, which means no cap.
6+
// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired
7+
// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free.
68
function useIsFreePlan(): boolean | undefined {
7-
return undefined;
9+
const subscription = useCurrentPlan()?.v3Subscription;
10+
if (!subscription) return undefined;
11+
return subscription.isPaying === false;
812
}
913

10-
// Counted in two halves: the server aggregates other chats, this chat's own count
11-
// comes from the live transcript so the message just sent counts immediately.
14+
// `used` is the server's per-period count for the org. Re-read whenever the user sends, so
15+
// the running total tracks the message just sent without counting the transcript twice.
1216
export function useAgentMessageQuota({
1317
actionPath,
1418
chatId,
@@ -19,28 +23,24 @@ export function useAgentMessageQuota({
1923
messages: UIMessage[];
2024
}): MessageQuota {
2125
const isFreePlan = useIsFreePlan();
22-
const [usedElsewhere, setUsedElsewhere] = useState<number | undefined>(undefined);
26+
const [used, setUsed] = useState<number | undefined>(undefined);
27+
const sentCount = countUserMessages(messages);
2328

2429
useEffect(() => {
2530
if (isFreePlan !== true) return;
2631
const controller = new AbortController();
2732
void (async () => {
2833
try {
29-
const res = await fetch(`${actionPath}?quota=1&chatId=${encodeURIComponent(chatId)}`, {
30-
signal: controller.signal,
31-
});
34+
const res = await fetch(`${actionPath}?quota=1`, { signal: controller.signal });
3235
if (!res.ok) return;
3336
const data = (await res.json()) as { used?: number };
34-
if (typeof data.used === "number") setUsedElsewhere(data.used);
37+
if (typeof data.used === "number") setUsed(data.used);
3538
} catch {
3639
// Leave the count unknown, which means no cap. See `resolveMessageQuota`.
3740
}
3841
})();
3942
return () => controller.abort();
40-
}, [isFreePlan, actionPath, chatId]);
43+
}, [isFreePlan, actionPath, chatId, sentCount]);
4144

42-
return resolveMessageQuota({
43-
isFreePlan,
44-
used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages),
45-
});
45+
return resolveMessageQuota({ isFreePlan, used });
4646
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import {
1515
resolveDashboardAgentRepoSnapshot,
1616
} from "~/services/dashboardAgent.server";
1717
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
18+
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
19+
import {
20+
agentTurnCountsAgainstQuota,
21+
recordAgentMessageSent,
22+
resolveAgentMessageQuota,
23+
} from "~/services/dashboardAgentQuota.server";
1824
import { logger } from "~/services/logger.server";
1925
import { requireUser } from "~/services/session.server";
2026
import { readBoundedBodyText } from "~/utils/boundedRequestBody.server";
@@ -127,6 +133,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
127133
return tooLarge();
128134
}
129135

136+
// Only a real user message consumes quota; action turns were refused above.
137+
const countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
138+
if (countsAgainstQuota) {
139+
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
140+
organizationId: project.organizationId,
141+
});
142+
if (quota?.reached) {
143+
return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 });
144+
}
145+
}
146+
130147
let userActorToken: string;
131148
try {
132149
userActorToken = await mintDashboardAgentUserActorToken(user.id, {
@@ -153,6 +170,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
153170
...(repoSnapshot ? { repoSnapshot } : {}),
154171
};
155172
body = JSON.stringify(parsed);
173+
174+
if (countsAgainstQuota) {
175+
await recordAgentMessageSent(dashboardAgentDb, {
176+
organizationId: project.organizationId,
177+
});
178+
}
156179
}
157180
}
158181

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
chatExists,
44
countUnreadWatchWakes,
55
countChatsWithUnreadWork,
6-
countUserMessages,
6+
getAgentMessageUsage,
77
createChat,
88
getChatMessages,
99
getSession,
@@ -52,6 +52,11 @@ import {
5252
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
5353
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
5454
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
55+
import {
56+
currentAgentMessagePeriod,
57+
recordAgentMessageSent,
58+
resolveAgentMessageQuota,
59+
} from "~/services/dashboardAgentQuota.server";
5560
import { logger } from "~/services/logger.server";
5661
import { resolveTriggerUri } from "~/services/resolveTriggerUri.server";
5762
import { requireUser } from "~/services/session.server";
@@ -150,13 +155,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
150155
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
151156
if (!project) return json({ error: "Project not found" }, { status: 404 });
152157

153-
// The open chat is excluded and counted from the live transcript instead, so an
154-
// unpersisted turn still counts against the cap.
158+
// The per-period counter, org-wide: a deleted chat can't lower it within the period.
155159
if (searchParams.get("quota") === "1") {
156-
const used = await countUserMessages(dashboardAgentDb, {
160+
const used = await getAgentMessageUsage(dashboardAgentDb, {
157161
organizationId: project.organizationId,
158-
userId,
159-
excludeChatId: searchParams.get("chatId") ?? undefined,
162+
period: currentAgentMessagePeriod(),
160163
});
161164
return json({ used });
162165
}
@@ -290,6 +293,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
290293
return messageTooLarge();
291294
}
292295

296+
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
297+
organizationId: project.organizationId,
298+
});
299+
if (quota?.reached) {
300+
return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 });
301+
}
302+
293303
let clientData: Record<string, unknown> | undefined;
294304
try {
295305
clientData = parsed.data.clientData
@@ -383,6 +393,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
383393
throw error;
384394
}
385395

396+
// Only the head start dispatches the first message here; a cold start sends it through
397+
// the `in` proxy, which counts it there. Counting both would double-count.
398+
if (headStarted) {
399+
await recordAgentMessageSent(dashboardAgentDb, {
400+
organizationId: project.organizationId,
401+
});
402+
}
403+
386404
let publicAccessToken: string;
387405
try {
388406
publicAccessToken = await mintDashboardAgentToken(chatId);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Limits } from "@trigger.dev/platform";
2+
import {
3+
getAgentMessageUsage,
4+
incrementAgentMessageUsage,
5+
type DashboardAgentDb,
6+
} from "@internal/dashboard-agent-db";
7+
import { getCachedLimit } from "./platform.v3.server";
8+
import { logger } from "./logger.server";
9+
10+
// The repo's unlimited sentinel. Never Infinity: it serializes to null in the limit cache.
11+
export const UNLIMITED_AGENT_MESSAGES = 100_000_000;
12+
13+
// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted,
14+
// so the fallback applies and the cap is effectively off.
15+
const AGENT_MESSAGE_LIMIT_KEY = "agentMessages" as keyof Limits;
16+
17+
/** The billing period the counter is scoped to: a UTC calendar month, "YYYY-MM". */
18+
export function currentAgentMessagePeriod(now: Date = new Date()): string {
19+
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
20+
}
21+
22+
/** Pure so the send routes and, later, the MCP path share one rule. */
23+
export function checkAgentMessageQuota({ used, limit }: { used: number; limit: number }): {
24+
reached: boolean;
25+
} {
26+
return { reached: used >= limit };
27+
}
28+
29+
export type AgentMessageQuota = { reached: boolean; used: number; limit: number };
30+
31+
/**
32+
* The period counter and the cached plan limit for one org. Fails open: an absent limit
33+
* (self-hosted, or before the cloud side ships) resolves to the unlimited sentinel, and a
34+
* counter read that throws returns `undefined` — either way there is no cap.
35+
*/
36+
export async function resolveAgentMessageQuota(
37+
db: DashboardAgentDb,
38+
params: {
39+
organizationId: string;
40+
now?: Date;
41+
readLimit?: (organizationId: string) => Promise<number>;
42+
}
43+
): Promise<AgentMessageQuota | undefined> {
44+
const readLimit =
45+
params.readLimit ??
46+
(async (organizationId: string) => {
47+
const cached = await getCachedLimit(
48+
organizationId,
49+
AGENT_MESSAGE_LIMIT_KEY,
50+
UNLIMITED_AGENT_MESSAGES
51+
);
52+
// A cache error leaves `val` empty; fall open to unlimited.
53+
return cached.val ?? UNLIMITED_AGENT_MESSAGES;
54+
});
55+
try {
56+
const [limit, used] = await Promise.all([
57+
readLimit(params.organizationId),
58+
getAgentMessageUsage(db, {
59+
organizationId: params.organizationId,
60+
period: currentAgentMessagePeriod(params.now),
61+
}),
62+
]);
63+
return { ...checkAgentMessageQuota({ used, limit }), used, limit };
64+
} catch (error) {
65+
logger.error("Failed to resolve dashboard agent message quota", {
66+
organizationId: params.organizationId,
67+
error,
68+
});
69+
return undefined;
70+
}
71+
}
72+
73+
/** Record one sent user message. Swallows errors: the cap is a nudge, never a send blocker. */
74+
export async function recordAgentMessageSent(
75+
db: DashboardAgentDb,
76+
params: { organizationId: string; now?: Date }
77+
): Promise<void> {
78+
try {
79+
await incrementAgentMessageUsage(db, {
80+
organizationId: params.organizationId,
81+
period: currentAgentMessagePeriod(params.now),
82+
});
83+
} catch (error) {
84+
logger.error("Failed to record a dashboard agent message against the quota", {
85+
organizationId: params.organizationId,
86+
error,
87+
});
88+
}
89+
}
90+
91+
/**
92+
* Whether an agent turn consumes quota. A wake/action turn is server-placed — the user never
93+
* spent it — so only a `message` turn that is not an action counts. The `.in` proxy already
94+
* refuses action turns; this keeps the rule explicit and testable.
95+
*/
96+
export function agentTurnCountsAgainstQuota(
97+
turn: { kind?: string; payload?: { trigger?: string } } | undefined
98+
): boolean {
99+
return turn?.kind === "message" && turn.payload?.trigger !== "action";
100+
}

0 commit comments

Comments
 (0)