fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638)

## Problem

We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.

The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.

## Fix

**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.

**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).

No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.

## Test plan

- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)

> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
This commit is contained in:
ConnorYoh
2026-06-12 11:38:07 +01:00
committed by GitHub
parent eb2527fc7f
commit 87723d3ce2
10 changed files with 251 additions and 94 deletions
@@ -2740,6 +2740,7 @@ need_clarification = "Could you clarify your request?"
not_found = "I couldn't find the requested information."
processing = "Processing ({{outcome}})..."
unsupported_capability = "Unsupported capability: {{capability}}"
usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going."
[chat.toolsUsed]
summary = "Ran {{count}} tools"
@@ -12,6 +12,7 @@ import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
@@ -179,6 +180,25 @@ interface AiWorkflowResponse {
fileId?: string;
fileName?: string;
contentType?: string;
/**
* Structured error code when a tool call inside the workflow was blocked (e.g.
* PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the
* client can pop the usage-limit modal. See {@link isPaygLimitCode}.
*/
errorCode?: string;
/** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */
errorSubscribed?: boolean;
}
/**
* Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient
* interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather
* than render the failure as chat text.
*/
const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]);
function isPaygLimitCode(code: string | null | undefined): boolean {
return code != null && PAYG_LIMIT_CODES.has(code);
}
interface ChatState {
@@ -512,18 +532,46 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!response.ok) {
let detail: string | undefined;
let limitHandled = false;
try {
const body = await response.json();
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
const code = typeof body?.error === "string" ? body.error : null;
// A 402 carrying a usage-limit sentinel means the agent call itself was gated.
// Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a
// brief line below — not a generic "engine failed" error.
if (response.status === 402 && isPaygLimitCode(code)) {
dispatchPaygLimitReached(
typeof body?.subscribed === "boolean" ? body.subscribed : null,
);
limitHandled = true;
} else {
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
}
} catch {
// non-JSON body — ignore
}
if (limitHandled) {
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content: t(
"chat.responses.usage_limit_reached",
"You've reached your usage limit. Check your plan options to keep going.",
),
timestamp: Date.now(),
},
});
return;
}
throw new Error(
detail ?? `AI engine request failed: ${response.status}`,
);
@@ -553,7 +601,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data, t);
// The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the
// result (not via the apiClient interceptor that pops the modal for direct calls).
// Fire the matching modal and replace the raw "tool failed: 402…" reason with a
// brief, non-alarming line.
const isLimit = isPaygLimitCode(data.errorCode);
if (isLimit) {
dispatchPaygLimitReached(data.errorSubscribed ?? null);
}
const replyContent = isLimit
? t(
"chat.responses.usage_limit_reached",
"You've reached your usage limit. Check your plan options to keep going.",
)
: formatWorkflowResponse(data, t);
dispatch({
type: "ADD_MESSAGE",
message: {
@@ -27,9 +27,8 @@ import {
import type {
PolicyRunStatus,
PolicyRunView,
PolicyLimitReachedDetail,
} from "@app/services/policyPipeline";
import { POLICY_LIMIT_REACHED_EVENT } from "@app/services/policyPipeline";
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import type { FileId } from "@app/types/file";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
@@ -87,15 +86,7 @@ export function usePolicyAutoRun(): void {
if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return;
if (firedLimitModal.current.has(view.runId)) return;
firedLimitModal.current.add(view.runId);
try {
window.dispatchEvent(
new CustomEvent<PolicyLimitReachedDetail>(POLICY_LIMIT_REACHED_EVENT, {
detail: { subscribed: view.errorSubscribed ?? null },
}),
);
} catch {
// non-browser env (tests / SSR) — no-op.
}
dispatchPaygLimitReached(view.errorSubscribed ?? null);
}, []);
// Dispatch: for each active policy × each session file not yet run, fire a run.
@@ -56,24 +56,6 @@ export interface BackendPolicy {
output: BackendOutputSpec;
}
/**
* Window event fired when a policy run is blocked by a usage limit (its tool call got a 402
* entitlement sentinel). Dispatched from the proprietary auto-run poll; a saas-layer listener
* reads the wallet and opens the matching usage-limit modal. Kept here (proprietary) so both
* layers share one name — proprietary can't import the saas modal API directly.
*/
export const POLICY_LIMIT_REACHED_EVENT = "payg:policyLimitReached";
/** Detail carried on {@link POLICY_LIMIT_REACHED_EVENT}. */
export interface PolicyLimitReachedDetail {
/**
* Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free
* allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs
* free-limit modal. Null when unknown → treat as free-limit.
*/
subscribed: boolean | null;
}
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
export type PolicyRunStatus =
| "PENDING"
@@ -0,0 +1,35 @@
/**
* Bridge for usage-limit (PAYG 402) signals raised by server-side runs — the policy auto-run path
* and the AI agent — neither of which flows through the apiClient interceptor that pops the
* usage-limit modal for direct calls. Their tool calls execute server-side, so the blocking 402
* never reaches the browser's HTTP client.
*
* Proprietary code can't import the saas modal API (layering: proprietary ↛ saas), so it broadcasts
* this window event instead; the saas-layer UsageLimitModalHost listens and opens the matching
* modal (free → "subscribe", subscribed → "raise cap"). Kept here (proprietary) so both layers
* share one name.
*/
export const PAYG_LIMIT_REACHED_EVENT = "payg:limitReached";
/** Detail carried on {@link PAYG_LIMIT_REACHED_EVENT}. */
export interface PaygLimitReachedDetail {
/**
* Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free
* allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs
* free-limit modal. Null when unknown → treat as free-limit.
*/
subscribed: boolean | null;
}
/** Fire {@link PAYG_LIMIT_REACHED_EVENT}. No-op outside a browser (tests / SSR). */
export function dispatchPaygLimitReached(subscribed: boolean | null): void {
try {
window.dispatchEvent(
new CustomEvent<PaygLimitReachedDetail>(PAYG_LIMIT_REACHED_EVENT, {
detail: { subscribed },
}),
);
} catch {
// non-browser env (tests / SSR) — no-op.
}
}
@@ -6,9 +6,9 @@ import {
SPEND_CAP_MODAL_EVENT,
} from "@app/components/usageLimitModals";
import {
POLICY_LIMIT_REACHED_EVENT,
type PolicyLimitReachedDetail,
} from "@app/services/policyPipeline";
PAYG_LIMIT_REACHED_EVENT,
type PaygLimitReachedDetail,
} from "@app/services/usageLimitBridge";
/**
* Always-mounted host for the usage-limit warning modals. Mount once (in
@@ -17,11 +17,11 @@ import {
* only while open, so it reads the wallet (and animates in) on open rather
* than on app load.
*
* <p>Also bridges the policy auto-run path: a policy's tool calls run server-side,
* so their usage-limit 402 never reaches the apiClient interceptor that pops these
* modals for direct calls. The proprietary auto-run hook broadcasts {@link
* POLICY_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag)
* instead; we open the matching modal here.
* <p>Also bridges the server-side run paths (policy auto-run, AI agent): their tool
* calls run server-side, so a usage-limit 402 never reaches the apiClient interceptor
* that pops these modals for direct calls. Those proprietary paths broadcast {@link
* PAYG_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) instead;
* we open the matching modal here.
*/
export default function UsageLimitModalHost() {
const [freeOpen, setFreeOpen] = useState(false);
@@ -30,19 +30,19 @@ export default function UsageLimitModalHost() {
useEffect(() => {
const onFree = () => setFreeOpen(true);
const onSpend = () => setSpendOpen(true);
const onPolicyLimit = (e: Event) => {
const subscribed = (e as CustomEvent<PolicyLimitReachedDetail>).detail
const onServerLimit = (e: Event) => {
const subscribed = (e as CustomEvent<PaygLimitReachedDetail>).detail
?.subscribed;
if (subscribed) setSpendOpen(true);
else setFreeOpen(true);
};
window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
window.addEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit);
window.addEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit);
return () => {
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
window.removeEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit);
window.removeEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit);
};
}, []);