From 664a055c9d0421d83c7459e63b0c4318c1050dea Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:42:27 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(hosted):=20the=20plan=20line=20?= =?UTF-8?q?=E2=80=94=2030=20hosted=20turns=20a=20day=20free,=20200=20on=20?= =?UTF-8?q?Pro=20(TASK-268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cofounder's ruling (2026-10-15): free and individual accounts get 30 hosted turns a day, Pro 200. hostedCaps({ pro }) returns the line for a plan and the free caps when no plan is given; hostedCapsForOwner reads the owner's entitlements.pro (written only by the signed Stripe webhook), and a failed lookup reads as free. Every hosted limit now follows the owner: - the native engine's per-install cap and its zero-turns rule; - the Durable Object's events-feed meter, for installs still on it; - the availability, provision and status answers the BYO page shows. A Pro owner's per-user daily run ceiling rises to 300 (AGENT_USER_DAILY_RUN_CEILING_PRO), so 200 hosted turns plus Scout fit; free stays 120. The quickstart states both lines, pinned by the docs fact test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- backend/__tests__/unit/routes/hosted.test.js | 9 ++++ .../unit/scripts/docsSiteFacts.test.js | 2 + .../services/hostedRuntimeService.test.js | 9 +++- .../nativeRuntimeService.hostedEngine.test.js | 32 ++++++++++++++ backend/routes/agentsRuntime.ts | 3 +- backend/routes/hosted.ts | 14 +++--- backend/services/hostedEngine.ts | 16 +++---- backend/services/hostedRuntimeService.ts | 43 ++++++++++++++++--- backend/services/nativeRuntimeService.ts | 20 +++++++-- docs-site/quickstart.mdx | 2 +- 10 files changed, 124 insertions(+), 26 deletions(-) diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a9af45ec3a..f5cab6bf4d 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -13,6 +13,7 @@ const mockHosted = { isConfigured: jest.fn(), isHostedInstallation: jest.fn(), hostedCaps: jest.fn(() => ({ agentsPerUser: 1, turnsPerDay: 200 })), + hostedCapsForOwner: jest.fn(async () => ({ agentsPerUser: 1, turnsPerDay: 200 })), countHostedAgentsForUser: jest.fn(), listHostedInstallationsForUser: jest.fn(), meterAllowsTurn: jest.fn(), @@ -105,6 +106,14 @@ describe('/api/hosted', () => { expect(off.body.configured).toBe(false); }); + // TASK-268: the page states the caller's own plan line, not a global number. + it("reports the caller's own plan caps", async () => { + mockHosted.hostedCapsForOwner.mockResolvedValueOnce({ agentsPerUser: 1, turnsPerDay: 30 }); + const res = await request(app).get('/api/hosted/availability'); + expect(mockHosted.hostedCapsForOwner).toHaveBeenCalledWith('owner-1'); + expect(res.body.caps).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }); + }); + it("records the caller's first visit to Bring your own (the mark contains its own failures)", async () => { const res = await request(app).get('/api/hosted/availability'); expect(res.status).toBe(200); diff --git a/backend/__tests__/unit/scripts/docsSiteFacts.test.js b/backend/__tests__/unit/scripts/docsSiteFacts.test.js index a72cc5d424..2d9196146a 100644 --- a/backend/__tests__/unit/scripts/docsSiteFacts.test.js +++ b/backend/__tests__/unit/scripts/docsSiteFacts.test.js @@ -51,8 +51,10 @@ describe('docs.commonly.me says what the product does', () => { test('the hosted limits match the code', () => { const turns = hostedDefault('DEFAULT_TURNS_PER_DAY'); + const proTurns = hostedDefault('DEFAULT_TURNS_PER_DAY_PRO'); const agents = hostedDefault('DEFAULT_AGENTS_PER_USER'); expect(quickstart).toContain(`${turns} turns a day`); + expect(quickstart).toContain(`${proTurns} turns a day on Pro`); expect(agents).toBe(1); expect(quickstart).toMatch(/run one hosted agent/); }); diff --git a/backend/__tests__/unit/services/hostedRuntimeService.test.js b/backend/__tests__/unit/services/hostedRuntimeService.test.js index 4c5fc9ae7a..e6c7126a8e 100644 --- a/backend/__tests__/unit/services/hostedRuntimeService.test.js +++ b/backend/__tests__/unit/services/hostedRuntimeService.test.js @@ -42,10 +42,15 @@ describe('hostedRuntimeService', () => { }); it('applies defaults and ignores garbage cap values', () => { - expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 200 }); + // The plan line (TASK-268): 30 a day free, 200 on pro, and free when no plan is given. + expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }); + expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 1, turnsPerDay: 200 }); process.env.HOSTED_AGENTS_PER_USER = '3'; process.env.HOSTED_TURNS_PER_DAY = 'lots'; - expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 200 }); + process.env.HOSTED_TURNS_PER_DAY_PRO = 'more'; + expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 30 }); + expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 3, turnsPerDay: 200 }); + delete process.env.HOSTED_TURNS_PER_DAY_PRO; }); }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index baee2069a0..4a9370de48 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -23,8 +23,10 @@ jest.mock('../../../models/Pod', () => ({ jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +let mockOwnerPro = false; jest.mock('../../../models/User', () => ({ findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), + findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ entitlements: { pro: mockOwnerPro } }) }) })), })); jest.mock('../../../services/agentTypingService', () => ({ @@ -189,4 +191,34 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => expect(result.status).toBe('failed'); expect(axios.post).not.toHaveBeenCalled(); }); + + // TASK-268: the plan line. Free owners get 30 hosted turns a day, pro 200, + // and a pro owner's per-user ceiling sits above the free 120. + describe('the owner\'s plan sets the line', () => { + const counts = ({ install, user }) => AgentRun.countDocuments.mockImplementation( + async (query) => (query.userId ? user : install), + ); + afterEach(() => { mockOwnerPro = false; }); + + test.each([ + ['free', false, 29, true], + ['free', false, 30, false], + ['pro', true, 30, true], + ['pro', true, 200, false], + ])('a %s owner with %i runs today: runs=%s', async (_plan, pro, installRuns, runs) => { + mockOwnerPro = pro; + counts({ install: installRuns, user: 0 }); + await runAgent(hostedInstall(), mention); + expect(axios.post).toHaveBeenCalledTimes(runs ? 1 : 0); + }); + + test('a pro owner past the free per-user ceiling (120) still runs; a free one is refused', async () => { + counts({ install: 0, user: 150 }); + mockOwnerPro = true; + expect((await runAgent(hostedInstall(), mention)).status).toBe('succeeded'); + mockOwnerPro = false; + const refused = await runAgent(hostedInstall(), mention); + expect(refused).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'user_ceiling' })); + }); + }); }); diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 9c731a1aae..f1bcf1bf44 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -611,7 +611,8 @@ router.get('/events', longPollIpLimit, agentRuntimeAuth, async (req: any, res: a // daily turn budget the feed goes empty for it — the worker idles instead // of spending. Events stay pending and deliver after the UTC reset. if (installation && HostedRuntimeMeter.isHostedInstallation(installation)) { - const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId); + const { turnsPerDay } = await HostedRuntimeMeter.hostedCapsForOwner(installation.installedBy); + const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId, turnsPerDay); if (!meter.allowed) { return res.json({ events: [], meter }); } diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index 81bdb413b6..c6c2bc132b 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -179,7 +179,9 @@ const relayWorkerError = (res: express.Response, error: any) => { router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => { markByoOpened(getUserId(req)); const engine = engineForNewHostedInstall(); - return res.json({ configured: engineAvailable(engine), engine, caps: hostedRuntime.hostedCaps() }); + // The caller's own plan line (TASK-268), so the page states the limit they get. + const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req)); + return res.json({ configured: engineAvailable(engine), engine, caps }); }); /** @@ -242,7 +244,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { agentName, instanceId, podId: installation.podId, - caps: hostedRuntime.hostedCaps(), + caps: await hostedRuntime.hostedCapsForOwner(userId), }); } @@ -265,7 +267,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { agentName, instanceId, podId: installation.podId, - caps: hostedRuntime.hostedCaps(), + caps: await hostedRuntime.hostedCapsForOwner(userId), }); } catch (error) { return relayWorkerError(res, error); @@ -321,7 +323,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => { // No loop to report on: a native-engine agent is ready once provisioned, // and the runtime it needs is available (checked just above). const { provisionedAt } = readHostedConfig(installation); - const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId); + const { turnsPerDay } = await hostedRuntime.hostedCapsForOwner(getUserId(req)); + const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay); return res.json({ agentName, instanceId, @@ -331,7 +334,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => { } const [runtime, meter] = await Promise.all([ hostedRuntime.getAgentStatus(agentName, instanceId), - hostedRuntime.meterAllowsTurn(agentName, instanceId), + hostedRuntime.hostedCapsForOwner(getUserId(req)) + .then(({ turnsPerDay }: { turnsPerDay: number }) => hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay)), ]); return res.json({ agentName, instanceId, runtime, meter }); } catch (error) { diff --git a/backend/services/hostedEngine.ts b/backend/services/hostedEngine.ts index 428aeed4e9..2683d7ee37 100644 --- a/backend/services/hostedEngine.ts +++ b/backend/services/hostedEngine.ts @@ -17,7 +17,7 @@ * * Every site that has to tell the two engines apart asks this module. */ -import { hostedCaps, isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService'; +import { isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService'; export const HOSTED_ENGINE_NATIVE = 'native'; export const HOSTED_ENGINE_DURABLE_OBJECT = 'durable-object'; @@ -93,9 +93,9 @@ export const HOSTED_NATIVE_EVENT_TYPES: ReadonlySet = new Set(['chat.men * Object's meter, while the native runtime reads a `dailyRunCap` of 0 as no * cap, so the zero is honoured here rather than passed through. */ -export const hostedNativeDecline = (eventType: string): string | null => { +export const hostedNativeDecline = (eventType: string, turnsPerDay: number): string | null => { if (!HOSTED_NATIVE_EVENT_TYPES.has(eventType)) return `event type ${eventType || 'unset'} is not answered by hosted agents`; - if (hostedCaps().turnsPerDay <= 0) return 'hosted turns are switched off (HOSTED_TURNS_PER_DAY=0)'; + if (turnsPerDay <= 0) return 'hosted turns are switched off for this plan (HOSTED_TURNS_PER_DAY*=0)'; return null; }; @@ -137,7 +137,7 @@ export const hostedNativeSystemPrompt = (displayName: string, about?: string): s * installs can carry those fields because only entitled operators make them. * The model is left unset so the runtime's default applies. */ -export const hostedNativeRunConfig = (installation: any): Record => { +export const hostedNativeRunConfig = (installation: any, turnsPerDay: number): Record => { const hosted = readHostedConfig(installation); const displayName = String(installation?.displayName || installation?.agentName || 'Commonly Agent'); const about = typeof hosted.about === 'string' ? hosted.about : undefined; @@ -147,9 +147,9 @@ export const hostedNativeRunConfig = (installation: any): Record { @@ -43,11 +45,37 @@ const intEnv = (name: string, fallback: number): number => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; -export const hostedCaps = () => ({ +/** + * The hosted caps for a plan: 30 turns a day free, 200 on pro + * (`entitlements.pro`, written only by the signed Stripe webhook). Called + * without a plan it returns the free caps: a cap never defaults to the paid + * line. + */ +export const hostedCaps = ({ pro = false }: { pro?: boolean } = {}) => ({ agentsPerUser: intEnv('HOSTED_AGENTS_PER_USER', DEFAULT_AGENTS_PER_USER), - turnsPerDay: intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY), + turnsPerDay: pro + ? intEnv('HOSTED_TURNS_PER_DAY_PRO', DEFAULT_TURNS_PER_DAY_PRO) + : intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY), }); +/** Whether a user is on pro. A missing user or a failed lookup reads as free. */ +export const ownerIsPro = async (userId: unknown): Promise => { + const id = String(userId ?? ''); + if (!/^[a-f\d]{24}$/i.test(id)) return false; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const User = require('../models/User'); + const user = await User.findById(id).select('entitlements.pro').lean(); + return user?.entitlements?.pro === true; + } catch (error) { + console.warn('[hosted] plan lookup failed; using the free caps:', (error as Error).message); + return false; + } +}; + +/** The hosted caps for whoever owns (installed) a hosted agent. */ +export const hostedCapsForOwner = async (userId: unknown) => hostedCaps({ pro: await ownerIsPro(userId) }); + export const hostedRuntimeConfig = () => ({ url: String(process.env.HOSTED_RUNTIME_URL || '').trim().replace(/\/+$/, ''), adminToken: String(process.env.HOSTED_RUNTIME_ADMIN_TOKEN || '').trim(), @@ -129,8 +157,11 @@ export interface MeterResult { resetsAt: string; } -export const meterAllowsTurn = async (agentName: string, instanceId: string): Promise => { - const { turnsPerDay } = hostedCaps(); +export const meterAllowsTurn = async ( + agentName: string, + instanceId: string, + turnsPerDay: number = hostedCaps().turnsPerDay, +): Promise => { const used = await turnsToday(agentName, instanceId); const tomorrow = new Date(utcDayStart().getTime() + 24 * 60 * 60 * 1000); return { @@ -223,6 +254,8 @@ export const getAgentStatus = async (agentName: string, instanceId: string): Pro module.exports = { HOSTED_RUNTIME_TYPE, hostedCaps, + ownerIsPro, + hostedCapsForOwner, hostedRuntimeConfig, isConfigured, readRuntimeConfig, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index fdb7f5003c..e3acc4dfb4 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -35,6 +35,7 @@ import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy'; import { isSandboxConfigured, TurnSandbox } from './turnSandboxService'; import { untrustedConnectorMarker } from './brokerResultMarker'; import { hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative } from './hostedEngine'; +import { hostedCapsForOwner, ownerIsPro } from './hostedRuntimeService'; // --- public surface -------------------------------------------------------- @@ -807,6 +808,10 @@ export async function runAgent( // too, on a configuration built server-side: its stored config came from the // browser and must not choose the model, the tools or the cap. const hostedNative = hostedRunsNative(installation); + // The owner's plan line (TASK-268): 30 hosted turns a day free, 200 on pro. + const hostedTurnsPerDay = hostedNative + ? (await hostedCapsForOwner(installation.installedBy)).turnsPerDay + : 0; if (runtimeType !== 'native' && !hostedNative) { return failedResult( '', @@ -815,7 +820,7 @@ export async function runAgent( ); } if (hostedNative) { - const decline = hostedNativeDecline(String(trigger?.type || '')); + const decline = hostedNativeDecline(String(trigger?.type || ''), hostedTurnsPerDay); if (decline) { // Acked by the caller, with no AgentRun: nothing ran. return { @@ -823,7 +828,7 @@ export async function runAgent( }; } } - const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation)) : installedCfg; + const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation, hostedTurnsPerDay)) : installedCfg; const podId = String(installation.podId); const agentName = String(installation.agentName || '').toLowerCase(); @@ -873,8 +878,8 @@ export async function runAgent( // a runaway guard and a spend ceiling have opposite safe directions. // Forward-only: rows without userId predate the stamp and cannot be // counted; the window inherits the cap's fixed-UTC-midnight semantics. - const userCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); - if (Number.isFinite(userCeiling) && userCeiling > 0) { + const freeCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); + if (Number.isFinite(freeCeiling) && freeCeiling > 0) { const installedBy = installation.installedBy ? String(installation.installedBy) : ''; if (!installedBy) { console.warn( @@ -882,6 +887,13 @@ export async function runAgent( + 'per-user ceiling unenforceable for this install', ); } else { + // A pro owner's hosted line (200 turns a day, TASK-268) sits above the + // free ceiling, so pro owners get the pro ceiling; a failed plan lookup + // reads as free, the lower one. + const proCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING_PRO ?? 300); + const userCeiling = (await ownerIsPro(installedBy)) && Number.isFinite(proCeiling) && proCeiling > 0 + ? proCeiling + : freeCeiling; try { const AgentRunModel = require('../models/AgentRun'); const dayStart = new Date(); diff --git a/docs-site/quickstart.mdx b/docs-site/quickstart.mdx index ce2b46a3bf..808671ea61 100644 --- a/docs-site/quickstart.mdx +++ b/docs-site/quickstart.mdx @@ -31,7 +31,7 @@ run an agent: pod in about ten seconds. During the beta, every signed-in account can run one hosted agent, with - 200 turns a day. + 30 turns a day, or 200 turns a day on Pro. Choose **Bring your own runtime**, name the agent, pick the pod, and click From 3d97bb62f99d153ca8c7ade0aff09fbaad76b003 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:50:50 -0700 Subject: [PATCH 2/2] docs(hosted): name both authorized writers of entitlements.pro Rhea's HOLD on #2237 (5536742280): the PR and its comments said only the signed Stripe webhook makes an owner Pro, but an instance admin's entitlements PATCH also grants it (how pilots and design partners get Pro), and she confirmed it through the handler and the cap resolver. Admin-granted Pro is meant to lift the hosted line, so the code stands and the wording is corrected: hostedRuntimeService, the billing webhook's 'the ONLY writer' comment and the User model's comment now name both paths. No client field sets it; that part was and is true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- backend/models/User.ts | 7 ++++--- backend/routes/billing.ts | 5 ++++- backend/services/hostedRuntimeService.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/models/User.ts b/backend/models/User.ts index 56d3765d5b..e5e1f4a41c 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -99,9 +99,10 @@ export interface IUser extends Document { // BYO agents are NEVER gated by this: "agents you bring connect free and // unlimited" is the product's standing promise, and a per-agent cap is the // thing this pricing model exists to avoid. - // NEVER set from a client response — `billingService` writes it from a - // signature-verified Stripe webhook, the only source of truth about - // whether money actually moved. + // NEVER set from a client response. Two authorized writers: `billingService` + // from a signature-verified Stripe webhook (the source of truth about + // whether money moved), and an instance admin's entitlements PATCH + // (routes/admin/users.ts) for pilots and design partners. pro: boolean; }; // Stripe linkage. `customerId` is the join key from webhook -> user; it is diff --git a/backend/routes/billing.ts b/backend/routes/billing.ts index 2fb253054e..da5d9e8b6b 100644 --- a/backend/routes/billing.ts +++ b/backend/routes/billing.ts @@ -154,7 +154,10 @@ router.post('/portal', checkoutLimit, auth, async (req: any, res: any) => { }); /** - * POST /api/billing/webhook — the ONLY writer of entitlements.pro. + * POST /api/billing/webhook — the writer of entitlements.pro for paying + * customers. The other authorized writer is an instance admin's PATCH + * /api/admin/users/:userId/entitlements (pilots, design partners); no client + * request sets it. * * NO `auth` middleware by design: Stripe is the caller, and the signature is * the authentication. Mounted with `express.raw` in server.ts before the diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index bae8b5d813..0f56649bdb 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -46,10 +46,12 @@ const intEnv = (name: string, fallback: number): number => { }; /** - * The hosted caps for a plan: 30 turns a day free, 200 on pro - * (`entitlements.pro`, written only by the signed Stripe webhook). Called - * without a plan it returns the free caps: a cap never defaults to the paid - * line. + * The hosted caps for a plan: 30 turns a day free, 200 on pro. Pro is + * `entitlements.pro`, which two authorized paths write: the signed Stripe + * webhook (routes/billing.ts) and an instance admin's entitlements PATCH + * (routes/admin/users.ts, how pilots and design partners get Pro). No client + * field sets it. Called without a plan it returns the free caps: a cap never + * defaults to the paid line. */ export const hostedCaps = ({ pro = false }: { pro?: boolean } = {}) => ({ agentsPerUser: intEnv('HOSTED_AGENTS_PER_USER', DEFAULT_AGENTS_PER_USER),