fix: invoice contract test

This commit is contained in:
johnyeo
2026-06-09 16:55:28 +01:00
parent f0196b455a
commit c2ca3e1c90
67 changed files with 2871 additions and 645 deletions

3
.gitignore vendored
View File

@@ -154,3 +154,6 @@ server/.turbo
notes.txt
server/experiments/results/*
!server/experiments/results/.gitkeep
# Leaf contract fixtures are synced from private S3.
apps/leaf/contracts/

View File

@@ -6,16 +6,17 @@ import { Mastra } from "@mastra/core/mastra";
import { InMemoryStore } from "@mastra/core/storage";
import { z } from "zod";
import { createLeafTracingOptions } from "../internal/observability/leafTracingOptions.js";
import { leafChatAgentDefaults } from "../lib/chatAgentConfig.js";
import { env as chatEnv } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { createMastraBraintrustObservability } from "../providers/braintrust/index.js";
import { createE2bSandboxProvider } from "../providers/e2b/e2bSandboxProvider.js";
import type { ChatContextMessage } from "../types.js";
import { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
import { sandboxConfig } from "./sandbox/config.js";
import { createSandboxTools } from "./sandbox/createSandboxTools.js";
import { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
export { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
@@ -203,7 +204,7 @@ export const runChatAgent = async ({
const chatAgent = mastra.getAgent("chat");
const output = await chatAgent.generate(message, {
maxSteps: 8,
maxSteps: leafChatAgentDefaults.maxSteps,
context: [
{
role: "system",

View File

@@ -1,15 +1,16 @@
import type { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import type { ToolsInput } from "@mastra/core/agent";
import { Agent } from "@mastra/core/agent";
import { leafChatAgentDefaults } from "../lib/chatAgentConfig.js";
export const agentDocUris = [
"autumn://docs/tool-composition",
"autumn://docs/feature-catalog",
"autumn://docs/querying-plans",
"autumn://docs/querying-customers",
"autumn://docs/billing-safety",
"autumn://docs/schedules",
"autumn://docs/balances",
"autumn://docs/billing-safety",
"autumn://docs/request-logs",
"autumn://docs/request-log-customers",
"autumn://docs/request-log-balances",
@@ -18,27 +19,43 @@ export const agentDocUris = [
"autumn://docs/request-log-analytics",
];
export const autumnChatInstructions = `You are Autumn Chat.
Use Autumn MCP tools for customer, plan, balance, schedule, and billing work.
Use web search only for current or external web context. Never use web search for Autumn customer, plan, billing, balance, or schedule state.
When web content influences the answer, cite the source URLs.
Prefer searchWeb first, then scrapeUrl only for the most relevant result.
Use listFeatures only when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids/types are not already known; never invent feature ids.
Use the sandbox only for short parsing, calculation, transformation, and file-analysis tasks. Never send secrets to the sandbox, never use it for Autumn writes, and treat sandbox output as advisory.
Preview billing-impacting changes first, summarize the preview in short Slack-friendly bullets, then call the matching write tool with the same request args.
When Autumn responses include epoch millisecond timestamps, use epochMillisecondsToDate before explaining those timestamps to a user.
Treat Slack PDFs and images attached to the latest message as part of the user's request. If an attachment was skipped or unavailable, say so briefly instead of pretending to have read it.
The runtime pauses destructive tools for approval before execution, so do not ask for confirmation in plain text.`;
export const autumnChatInstructions = `
Identity:
- You are Autumn Chat.
- Be concise: fewest words, no fluff. No emojis.
Autumn:
- Use Autumn MCP tools for customer, plan, balance, schedule, and billing work.
- Call getAgentRules before org-specific behavior affects Autumn work.
Autumn billing:
- Follow Billing Safety for billing actions, including preview-before-write, invoice defaults, custom item mapping, and contract feature diffs.
- For paid billing previews, default to draft invoices and if-required checkout unless the user asks to finalize, charge, pay, or disable checkout.
- Summarize previews as awaiting approval, then call the matching write tool with the same args.
- For invoice_mode previews, mention draft/finalized status and immediate access.
- Use raw epoch milliseconds for API timestamp args; use epochMillisecondsToDate when explaining them.
- Destructive tools pause for approval, so do not ask for confirmation in plain text.
Sandbox:
- Use sandbox only for short parsing/calculation/transforms/file analysis.
- Never send secrets to sandbox or use it for Autumn writes.
Web search:
- Use web search only for current or external web context.
- Never use web search for Autumn customer, plan, billing, balance, or schedule state.
- Cite source URLs when web content influences the answer.
- Prefer searchWeb first, then scrapeUrl only for the most relevant result.
`.trim();
export const createAutumnChatAgent = ({
docsText,
env,
model,
model = leafChatAgentDefaults.model,
tools,
}: {
docsText: string;
env: AppEnv;
model: string;
model?: string;
tools: ToolsInput;
}) =>
new Agent({

View File

@@ -0,0 +1,6 @@
export const DEFAULT_CHAT_MODEL = "anthropic/claude-opus-4-8";
export const leafChatAgentDefaults = {
maxSteps: 8,
model: DEFAULT_CHAT_MODEL,
} as const;

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { DEFAULT_CHAT_MODEL } from "./chatAgentConfig.js";
const optionalString = z.preprocess(
(value) => (value === "" ? undefined : value),
@@ -10,7 +11,7 @@ const envSchema = z
MCP_SERVER_URL: optionalString,
BETTER_AUTH_SECRET: optionalString,
BETTER_AUTH_URL: optionalString,
CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4-6"),
CHAT_MODEL: z.string().min(1).default(DEFAULT_CHAT_MODEL),
CHAT_NAME: z.string().min(1).default("Autumn"),
CHAT_STATE_DATABASE_URL: optionalString,
CHAT_STATE_SECRET: optionalString,

View File

@@ -1,4 +1,5 @@
import {
api,
billing,
response,
tools,
@@ -25,9 +26,18 @@ const setup = withCustomers({
name: "Northstar Labs",
}),
}),
entities: ({ customers, entities, features }) => ({
workspace: entities.base({
customer: customers.account,
feature: features.workspaces,
id: "workspace_northstar",
name: "Northstar Workspace",
}),
}),
});
const customer = setup.refs.customers.account;
const enterprisePlan = setup.refs.plans.enterprise;
const workspace = setup.refs.entities.workspace;
const expectedAttachRequest = {
customer_id: customer.id,
@@ -38,13 +48,13 @@ const expectedAttachRequest = {
},
},
enable_plan_immediately: true,
entity_id: workspace.id,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
plan_id: enterprisePlan.id,
redirect_mode: "if_required",
};
initEval<EvalMetadata>({
@@ -61,14 +71,26 @@ initEval<EvalMetadata>({
conversation: [
user({
message:
"Please attach the Enterprise plan to Northstar Labs with a custom base price of $49/month.",
"Please attach the Enterprise plan to Northstar Labs for Northstar Workspace with a custom base price of $49/month.",
}),
user({ message: "Looks good, attach it." }),
approve(),
],
expect: [
tools.called({
toolNames: ["listCustomers", "listPlans"],
toolNames: ["listCustomers", "listPlans", "listEntities"],
}),
api.calledInOrder({
calls: [
{
body: { customer_id: customer.id },
toolName: "listEntities",
},
{
body: expectedAttachRequest,
toolName: "previewAttach",
},
],
}),
billing.previewBeforeWrite({
preview: {
@@ -80,9 +102,16 @@ initEval<EvalMetadata>({
toolName: "attach",
},
}),
api.calledAfterApproval({
call: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
response.mentions({
phrases: [
"Northstar Labs",
"Northstar Workspace",
"Enterprise",
"$49",
"invoice",

View File

@@ -0,0 +1,128 @@
import {
api,
billing,
response,
tools,
} from "../../../fixtures/expectations/index.js";
import { withCustomers } from "../../../fixtures/createSetup.js";
import { orgSetups } from "../../../fixtures/orgSetups.js";
import { approve, initEval, user } from "../../../harness/index.js";
import { billingAttachScores } from "../../../utils/scorers.js";
type EvalMetadata = {
domain: "billing";
flow: "attach";
};
const experimentName = "attach-entity-rules";
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers }) => ({
account: customers.base({
email: "billing@alder.example",
id: "cus_attach_entity_rules",
name: "Alder Systems",
}),
}),
entities: ({ customers, entities, features }) => ({
workspaceAlpha: entities.base({
customer: customers.account,
feature: features.workspaces,
id: "workspace_alpha",
name: "Workspace Alpha",
}),
workspaceBeta: entities.base({
customer: customers.account,
feature: features.workspaces,
id: "workspace_beta",
name: "Workspace Beta",
}),
}),
});
const customer = setup.refs.customers.account;
const scalePlan = setup.refs.plans.scale;
const workspace = setup.refs.entities.workspaceAlpha;
const expectedAttachRequest = {
customer_id: customer.id,
enable_plan_immediately: true,
entity_id: workspace.id,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
plan_id: scalePlan.id,
};
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "billing",
flow: "attach",
},
scores: billingAttachScores(),
cases: [
{
name: "entity attach rules require entity selection before preview",
conversation: [
user({
message: "Please attach the Scale plan to Alder Systems.",
}),
user({ message: "Use Workspace Alpha." }),
user({ message: "Looks good, attach it." }),
approve(),
],
expect: [
tools.called({
toolNames: [
"getAgentRules",
"listCustomers",
"listPlans",
"listEntities",
"previewAttach",
"attach",
],
}),
api.calledInOrder({
calls: [
{
body: { customer_id: customer.id },
toolName: "listEntities",
},
{
body: expectedAttachRequest,
toolName: "previewAttach",
},
],
}),
billing.previewBeforeWrite({
preview: {
body: expectedAttachRequest,
toolName: "previewAttach",
},
write: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
api.calledAfterApproval({
call: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
response.mentions({
phrases: [
"Workspace Alpha",
"Workspace Beta",
"Alder Systems",
"Scale",
],
}),
],
},
],
});

View File

@@ -0,0 +1,135 @@
import {
api,
billing,
response,
tools,
} from "../../../fixtures/expectations/index.js";
import { orgSetups } from "../../../fixtures/orgSetups.js";
import { approve, initEval, user } from "../../../harness/index.js";
import { billingAttachScores } from "../../../utils/scorers.js";
type EvalMetadata = {
domain: "billing";
flow: "attach";
};
const experimentName = "attach-new-customer";
const setup = orgSetups.knowledgePlatform();
const customerId = "cus_attach_new_customer";
const customerEmail = "billing@cobalt.example";
const entityId = "workspace_cobalt";
const entityName = "Cobalt Workspace";
const scalePlan = setup.refs.plans.scale;
const entityFeature = setup.refs.features.workspaces;
const expectedCreateRequest = {
customer_id: customerId,
email: customerEmail,
};
const expectedCreateEntityRequest = {
customer_id: customerId,
entity_id: entityId,
feature_id: entityFeature.id,
name: entityName,
};
const expectedAttachRequest = {
customer_id: customerId,
enable_plan_immediately: true,
entity_id: entityId,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
plan_id: scalePlan.id,
};
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "billing",
flow: "attach",
},
scores: billingAttachScores(),
cases: [
{
name: "create missing customer and entity before attach",
conversation: [
user({
message:
"Please attach the Scale plan to a new customer that is not in Autumn yet.",
}),
user({
message:
"Use customer id cus_attach_new_customer, email billing@cobalt.example, entity id workspace_cobalt, and entity name Cobalt Workspace.",
}),
user({ message: "Looks good, attach it." }),
approve(),
],
expect: [
response.askedBeforeTool({
phrases: ["customer", "id", "email", "entity", "name"],
notPhrases: ["deployment"],
toolName: "getOrCreateCustomer",
}),
tools.called({
toolNames: [
"getAgentRules",
"listPlans",
"getOrCreateCustomer",
"createEntity",
"previewAttach",
"attach",
],
}),
api.calledInOrder({
calls: [
{
body: expectedCreateRequest,
toolName: "getOrCreateCustomer",
},
{
body: expectedCreateEntityRequest,
toolName: "createEntity",
},
{
body: expectedAttachRequest,
toolName: "previewAttach",
},
],
}),
billing.previewBeforeWrite({
preview: {
body: expectedAttachRequest,
toolName: "previewAttach",
},
write: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
api.calledAfterApproval({
call: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
api.bodyExcludes({
fields: ["entity_data"],
toolName: "previewAttach",
}),
api.bodyExcludes({
fields: ["entity_data"],
toolName: "attach",
}),
response.mentions({
phrases: [entityName, customerId, "Scale"],
}),
],
},
],
});

View File

@@ -0,0 +1,225 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { ResetInterval } from "@models/productModels/intervals/resetInterval";
import { withCustomers } from "../../../fixtures/createSetup.js";
import {
api,
billing,
response,
tools,
} from "../../../fixtures/expectations/index.js";
import { orgSetups } from "../../../fixtures/orgSetups.js";
import { approve, initEval, user } from "../../../harness/index.js";
import { billingScheduleScores } from "../../../utils/scorers.js";
type EvalMetadata = {
domain: "billing";
flow: "schedule";
};
const experimentName = "backdated-schedule";
const time = (value: string) => new Date(value).getTime();
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers }) => ({
northstar: customers.base({
email: "billing@northstar.example",
id: "cus_northstar_contract",
name: "Northstar Labs",
}),
}),
entities: ({ customers, entities, features }) => ({
workspace: entities.base({
customer: customers.northstar,
feature: features.workspaces,
id: "workspace_northstar_platform",
name: "Northstar Platform Workspace",
}),
}),
});
const expectedScheduleRequest = {
customer_id: setup.refs.customers.northstar.id,
enable_plan_immediately: true,
entity_id: setup.refs.entities.workspace.id,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
redirect_mode: "if_required",
phases: [
{
starts_at: time("2027-04-01T00:00:00.000Z"),
plans: [
{
plan_id: setup.refs.plans.launch.id,
customize: {
items: [
{ feature_id: "member_slots", included: 25 },
{
feature_id: "credits",
included: 100_000,
reset: { interval: ResetInterval.Month },
},
],
},
},
{
plan_id: setup.refs.plans.automationPack.id,
},
],
},
{
starts_at: time("2027-07-01T00:00:00.000Z"),
plans: [
{
plan_id: setup.refs.plans.scale.id,
customize: {
items: [
{ feature_id: "member_slots", included: 40 },
{
feature_id: "credits",
included: 250_000,
reset: { interval: ResetInterval.Month },
},
],
},
},
{ plan_id: setup.refs.plans.automationPack.id },
{ plan_id: setup.refs.plans.securityPack.id },
],
},
{
starts_at: time("2028-01-01T00:00:00.000Z"),
plans: [
{
plan_id: setup.refs.plans.enterprise.id,
customize: {
price: { amount: 2_400, interval: BillingInterval.Month },
items: [
{ feature_id: "member_slots", included: 75 },
{ feature_id: "project_slots", included: 500 },
{
feature_id: "credits",
included: 1_000_000,
reset: { interval: ResetInterval.Month },
},
{ feature_id: "platform_api", unlimited: true },
{ feature_id: "approval_chains", unlimited: true },
{ feature_id: "compliance_controls", unlimited: true },
{ feature_id: "brand_controls", unlimited: true },
],
},
},
{ plan_id: setup.refs.plans.securityPack.id },
{ plan_id: setup.refs.plans.whiteLabelPack.id },
],
},
],
};
const extractedContractText = [
"MASTER SERVICES AGREEMENT",
"Order Form OF-2027-041 | Prepared for Northstar Labs Ltd.",
"Effective date: March 12, 2027. Governing law: New York. Payment terms: Net 30. Notices should be sent to legal@northstar.example.",
"Extracted service dates: initial ramp starts 2027-04-01; expansion starts 2027-07-01; enterprise conversion starts 2028-01-01.",
"Autumn entity scope: Northstar Platform Workspace.",
"Billing contact: billing@northstar.example. Customer reference in Autumn should be resolved from this account name or billing contact before any schedule is prepared.",
"This is a backdated schedule: today's eval date is 2027-04-15, but the contract start date is 2027-04-01. Preserve the exact April 1, 2027 start date; do not use now for the first phase.",
"Section 2. Initial ramp. On April 1, 2027, start the Launch plan with 25 member slots and 100,000 credits per month. Add the Automation Pack.",
"Section 3. Expansion. On July 1, 2027, move to Scale, increase to 40 member slots and 250,000 credits per month, and keep Automation Pack. Add Security Pack.",
"Section 4. Enterprise conversion. On January 1, 2028, move to Enterprise at a custom $2,400/month base rate with 75 member slots, 500 project slots, and 1,000,000 credits per month. Keep Security Pack and add White Label Pack.",
"Enterprise conversion also includes contract-specific feature overrides that are not part of the standard Enterprise plan: unlimited API access, unlimited approval flows, unlimited compliance cntrls, and unlimited brand controls.",
"Section 8. Confidentiality. Neither party may disclose pricing or implementation details except to auditors, investors, or legal advisors under confidentiality obligations.",
"Section 11. Service levels. Support response targets are commercially reasonable and do not create service credits unless separately stated in an SLA exhibit.",
"Signature block: Northstar Labs Ltd. / Autumn Software Inc.",
].join("\n");
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "billing",
flow: "schedule",
},
scores: billingScheduleScores(),
today: new Date("2027-04-15T00:00:00.000Z"),
timeout: 75_000,
cases: [
{
name: "contract text to backdated schedule",
conversation: [
user({
message: [
"A PDF text extractor returned the contract text below.",
"Please handle this in Autumn using only the extracted text.",
"This is a backdated schedule; preserve the contract's original phase dates exactly.",
"Make sure all feature limits and overrides from the contract are reflected in the schedule.",
"Apply the contract-specific feature overrides to the Enterprise phase.",
extractedContractText,
].join("\n"),
}),
user({ message: "Looks good, create the schedule." }),
approve(),
],
expect: [
tools.called({
toolNames: [
"getAgentRules",
"listCustomers",
"listEntities",
"listPlans",
"previewCreateSchedule",
"createSchedule",
],
}),
billing.previewBeforeWrite({
preview: {
body: expectedScheduleRequest,
toolName: "previewCreateSchedule",
},
write: {
body: expectedScheduleRequest,
toolName: "createSchedule",
},
}),
api.calledInOrder({
calls: [
{
body: { customer_id: setup.refs.customers.northstar.id },
toolName: "listEntities",
},
{
body: expectedScheduleRequest,
toolName: "previewCreateSchedule",
},
],
}),
api.calledAfterApproval({
call: {
body: expectedScheduleRequest,
toolName: "createSchedule",
},
}),
api.bodyNumberFields({
paths: ["phases.*.starts_at"],
toolName: "previewCreateSchedule",
}),
api.bodyNumberFields({
paths: ["phases.*.starts_at"],
toolName: "createSchedule",
}),
response.mentions({
phrases: [
"cus_northstar_contract",
"workspace_northstar_platform",
"Launch",
"Scale",
"Enterprise",
],
}),
],
},
],
});

View File

@@ -0,0 +1,170 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { withCustomers } from "../../../fixtures/createSetup.js";
import {
api,
billing,
response,
tools,
} from "../../../fixtures/expectations/index.js";
import { orgSetups } from "../../../fixtures/orgSetups.js";
import {
approve,
contractAttachment,
initEval,
user,
} from "../../../harness/index.js";
import { billingScheduleScores } from "../../../utils/scorers.js";
type EvalMetadata = {
domain: "billing";
flow: "schedule";
};
const experimentName = "custom-boolean-schedule";
const now = new Date("2027-04-01T00:00:00.000Z");
const time = (value: string) => new Date(value).getTime();
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers }) => ({
northstar: customers.base({
email: "ap@northstarlabs.example",
id: "northstar-labs",
name: "Northstar Labs, Inc.",
}),
}),
entities: ({ customers, entities, features }) => ({
workspace: entities.base({
customer: customers.northstar,
feature: features.workspaces,
id: "northstar-labs-production",
name: "Production",
}),
}),
});
const enterpriseCustomize = (amount: number) => ({
price: { amount, interval: BillingInterval.Year },
remove_items: [{ feature_id: setup.refs.features.revision_history.id }],
add_items: [
{
feature_id: setup.refs.features.hosted_solution.id,
unlimited: true,
},
{
feature_id: setup.refs.features.unlimited_seats.id,
unlimited: true,
},
],
});
const enterprisePhase = ({
amount,
startsAt,
}: {
amount: number;
startsAt: number;
}) => ({
starts_at: startsAt,
plans: [
{
plan_id: setup.refs.plans.enterprise.id,
customize: enterpriseCustomize(amount),
},
],
});
const expectedScheduleRequest = {
customer_id: setup.refs.customers.northstar.id,
enable_plan_immediately: true,
entity_id: setup.refs.entities.workspace.id,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
net_terms_days: 30,
},
redirect_mode: "if_required",
phases: [
enterprisePhase({ amount: 7_500, startsAt: now.getTime() }),
enterprisePhase({
amount: 20_000,
startsAt: time("2028-04-01T00:00:00.000Z"),
}),
],
};
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "billing",
flow: "schedule",
},
scores: billingScheduleScores(),
today: now,
timeout: 150_000,
cases: [
{
name: "slack pdf contract to custom enterprise schedule",
conversation: [
user({
attachments: [
contractAttachment({ fixtureId: "custom-booleans-schedule" }),
],
message:
"I uploaded the signed order form for Northstar Labs. Please provision it in Autumn.",
}),
user({
message:
"Use customer_id northstar-labs and entity_id northstar-labs-production.",
}),
user({ message: "Looks good. Create the schedule." }),
approve({ optional: false }),
],
expect: [
tools.called({
toolNames: [
"getAgentRules",
"listPlans",
"listFeatures",
"previewCreateSchedule",
"createSchedule",
],
}),
billing.previewBeforeWrite({
preview: {
body: expectedScheduleRequest,
toolName: "previewCreateSchedule",
},
write: {
body: expectedScheduleRequest,
toolName: "createSchedule",
},
}),
api.calledAfterApproval({
call: {
body: expectedScheduleRequest,
toolName: "createSchedule",
},
}),
api.bodyNumberFields({
paths: ["phases.*.starts_at"],
toolName: "previewCreateSchedule",
}),
api.bodyNumberFields({
paths: ["phases.*.starts_at"],
toolName: "createSchedule",
}),
response.mentions({
phrases: [
"Northstar Labs",
"Enterprise",
"Hosted Solution",
"Unlimited Seats",
],
}),
],
},
],
});

View File

@@ -0,0 +1,104 @@
import {
api,
billing,
response,
tools,
} from "../../fixtures/expectations/index.js";
import { createSetup } from "../../fixtures/createSetup.js";
import { approve, initEval, user } from "../../harness/index.js";
import { billingAttachScores } from "../../utils/scorers.js";
type EvalMetadata = {
domain: "mcp";
flow: "approval";
};
const experimentName = "mcp-attach-approval";
const setup = createSetup({
tag: "mcp-attach-approval",
features: ({ features }) => ({
dashboard: features.boolean({ featureId: "dashboard" }),
}),
plans: ({ basePrice, features, items, plan }) => ({
pro: plan.monthly({
basePrice: basePrice.monthly({ amount: 79 }),
items: [items.boolean({ feature: features.dashboard })],
planId: "pro",
}),
}),
customers: ({ customers }) => ({
account: customers.base({
email: "billing@atlas.example",
id: "cus_mcp_attach_approval",
name: "Atlas Labs",
}),
}),
});
const customer = setup.refs.customers.account;
const proPlan = setup.refs.plans.pro;
const expectedAttachRequest = {
customer_id: customer.id,
enable_plan_immediately: true,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
plan_id: proPlan.id,
};
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "mcp",
flow: "approval",
},
scores: billingAttachScores(),
cases: [
{
name: "destructive attach waits for approval",
conversation: [
user({
message:
"Please attach the Pro plan to Atlas Labs. Preview it first, then attach it after approval.",
}),
user({ message: "Looks good, attach it." }),
approve(),
],
expect: [
tools.called({
toolNames: [
"getAgentRules",
"listCustomers",
"listPlans",
"previewAttach",
"attach",
],
}),
billing.previewBeforeWrite({
preview: {
body: expectedAttachRequest,
toolName: "previewAttach",
},
write: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
api.calledAfterApproval({
call: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
response.mentions({
phrases: ["Atlas Labs", "Pro"],
}),
],
},
],
});

View File

@@ -0,0 +1,36 @@
import {
type AgentRules,
type EntityRules,
defaultAgentRules,
} from "@autumn/shared";
export const entityRules = ({
attachToEntities = false,
entityFeatureId = "",
}: {
attachToEntities?: boolean;
entityFeatureId?: string;
} = {}): EntityRules => ({
attach_to_entities: attachToEntities,
entity_feature_id: entityFeatureId,
});
export const notes = ({ value = "" }: { value?: string } = {}) => value;
const base = ({
entityRules: entityRuleOverrides,
notes: ruleNotes = "",
}: {
entityRules?: EntityRules;
notes?: string;
} = {}): AgentRules => ({
...defaultAgentRules(),
...(entityRuleOverrides ? { entity_rules: entityRuleOverrides } : {}),
notes: ruleNotes,
});
export const agentRules = {
base,
entityRules,
notes,
} as const;

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { createSetup } from "./createSetup.js";
import { orgSetups } from "./orgSetups.js";
describe("Leaf eval setup fixtures", () => {
test("knowledge platform includes entity agent rules", () => {
const setup = orgSetups.knowledgePlatform();
expect(setup.agentRules.entity_rules).toEqual({
attach_to_entities: true,
entity_feature_id: "workspaces",
});
expect(setup.agentRules.notes).toBe("");
expect(setup.refs.features.workspaces.id).toBe("workspaces");
});
test("flattens entity refs into setup entities and ids", () => {
const setup = createSetup({
tag: "entity-fixture",
features: ({ features }) => ({
deployments: features.allocated({ featureId: "deployments" }),
}),
plans: () => ({}),
customers: ({ customers }) => ({
account: customers.base({
id: "cus_entity_fixture",
name: "Entity Fixture Customer",
}),
}),
entities: ({ customers, entities, features }) => ({
deployment: entities.base({
customer: customers.account,
feature: features.deployments,
id: "dep_fixture",
name: "Production",
}),
}),
});
expect(setup.entities).toHaveLength(1);
expect(setup.refs.entities.deployment.feature_id).toBe("deployments");
expect(setup.ids.entities.deployment).toBe("dep_fixture");
});
});

View File

@@ -1,7 +1,10 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
import type { ApiEntityV2 } from "@api/entities/apiEntityV2.js";
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import type { AgentRules } from "@autumn/shared";
import { agentRules as agentRulesFixture } from "./agentRules/index.js";
import {
balances as balanceFixtures,
customers as customerFixtures,
@@ -18,7 +21,14 @@ import {
plan as planFixture,
planList as planListFixture,
} from "./plans/index.js";
import type { EvalSetup, EvalSetupIds, PlanRef, ScheduleRef } from "./types.js";
import { entities as entityFixtures } from "./entities/index.js";
import type {
EntityRef,
EvalSetup,
EvalSetupIds,
PlanRef,
ScheduleRef,
} from "./types.js";
const flattenRecordValues = <Value>(record: Record<string, Value | Value[]>) =>
Object.values(record).flatMap((value) =>
@@ -40,13 +50,16 @@ const setupIds = <
Plans extends Record<string, PlanRef>,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef>,
Entities extends Record<string, EntityRef>,
>({
customers,
entities,
features,
plans,
schedules,
}: {
customers: Customers;
entities: Entities;
features: Features;
plans: Plans;
schedules: Schedules;
@@ -56,7 +69,14 @@ const setupIds = <
features: refIds(features),
plans: refIds(plans),
schedules: refIds(schedules),
}) as unknown as EvalSetupIds<Features, Plans, Customers, Schedules>;
entities: refIds(entities),
}) as unknown as EvalSetupIds<
Features,
Plans,
Customers,
Schedules,
Entities
>;
/**
* Compose a mock Autumn org for evals from keyed feature, plan, and customer refs.
@@ -67,14 +87,22 @@ export const createSetup = <
Plans extends Record<string, PlanRef>,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef> = Record<string, never>,
Entities extends Record<string, EntityRef> = Record<string, never>,
>({
agentRules: createAgentRules = ({ agentRules }) => agentRules.base(),
customers: createCustomers,
entities: createEntities,
features: createFeatures,
plans: createPlans,
schedules: createSchedules,
tag,
}: {
tag: string;
agentRules?: ({
agentRules,
}: {
agentRules: typeof agentRulesFixture;
}) => AgentRules;
features: ({
featureList,
features,
@@ -121,7 +149,19 @@ export const createSetup = <
plans: Plans;
schedules: typeof scheduleFixtures;
}) => Schedules;
}): EvalSetup<Features, Plans, Customers, Schedules> => {
entities?: ({
customers,
entities,
features,
}: {
customers: Customers;
entities: typeof entityFixtures;
features: Features;
}) => Entities;
}): EvalSetup<Features, Plans, Customers, Schedules, Entities> => {
const agentRuleRefs = createAgentRules({
agentRules: agentRulesFixture,
});
const featureRefs = createFeatures({
featureList: featureListFixture,
features: featureFixtures,
@@ -147,26 +187,37 @@ export const createSetup = <
plans: planRefs,
schedules: scheduleFixtures,
});
const entityRefs =
createEntities?.({
customers: customerRefs,
entities: entityFixtures,
features: featureRefs,
}) ?? ({} as Entities);
return {
tag,
ids: setupIds({
customers: customerRefs,
entities: entityRefs,
features: featureRefs,
plans: planRefs,
schedules: (scheduleRefs ?? {}) as Schedules,
}),
agentRules: agentRuleRefs,
features: Object.values(featureRefs),
plans: flattenRecordValues<ApiPlanV1>(planRefs),
customers: flattenRecordValues<BaseApiCustomerV5>(customerRefs),
schedules: flattenRecordValues<ApiCustomerSchedule>(
(scheduleRefs ?? {}) as Schedules,
),
entities: flattenRecordValues<ApiEntityV2>(entityRefs),
refs: {
agentRules: agentRuleRefs,
features: featureRefs,
plans: planRefs,
customers: customerRefs,
schedules: (scheduleRefs ?? {}) as Schedules,
entities: entityRefs,
},
};
};
@@ -175,8 +226,10 @@ export const createSetup = <
export const withCustomers = <
Setup extends EvalSetup,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Entities extends Record<string, EntityRef> = Record<string, never>,
>({
customers: createCustomers,
entities: createEntities,
setup,
}: {
setup: Setup;
@@ -195,11 +248,21 @@ export const withCustomers = <
plans: Setup["refs"]["plans"];
subscriptions: typeof subscriptionFixtures;
}) => Customers;
entities?: ({
customers,
entities,
features,
}: {
customers: Customers;
entities: typeof entityFixtures;
features: Setup["refs"]["features"];
}) => Entities;
}): EvalSetup<
Setup["refs"]["features"],
Setup["refs"]["plans"],
Customers,
Setup["refs"]["schedules"]
Setup["refs"]["schedules"],
Setup["refs"]["entities"] & Entities
> => {
const customerRefs = createCustomers({
balances: balanceFixtures,
@@ -209,11 +272,22 @@ export const withCustomers = <
plans: setup.refs.plans,
subscriptions: subscriptionFixtures,
});
const entityRefs =
createEntities?.({
customers: customerRefs,
entities: entityFixtures,
features: setup.refs.features,
}) ?? ({} as Entities);
const allEntityRefs = {
...setup.refs.entities,
...entityRefs,
} as Setup["refs"]["entities"] & Entities;
return {
...setup,
ids: setupIds({
customers: customerRefs,
entities: allEntityRefs,
features: setup.refs.features,
plans: setup.refs.plans,
schedules: setup.refs.schedules,
@@ -222,9 +296,14 @@ export const withCustomers = <
...setup.customers,
...flattenRecordValues<BaseApiCustomerV5>(customerRefs),
],
entities: [
...setup.entities,
...flattenRecordValues<ApiEntityV2>(entityRefs),
],
refs: {
...setup.refs,
customers: customerRefs,
entities: allEntityRefs,
},
};
};

View File

@@ -0,0 +1,54 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiEntityV2 } from "@api/entities/apiEntityV2.js";
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z");
export const entities = {
base: ({
createdAt = defaultCreatedAt,
customer,
feature,
id = "entity_fixture",
name = "Entity Fixture",
}: {
customer: BaseApiCustomerV5;
feature: ApiFeatureV1;
id?: string | null;
name?: string | null;
createdAt?: Date;
}): ApiEntityV2 => ({
balances: {},
billing_controls: {},
created_at: createdAt.getTime(),
customer_id: customer.id,
env: customer.env,
feature_id: feature.id,
flags: {},
id,
name,
purchases: [],
subscriptions: [],
}),
list: ({
count,
customer,
feature,
idPrefix = "entity",
namePrefix = "Entity",
}: {
count: number;
customer: BaseApiCustomerV5;
feature: ApiFeatureV1;
idPrefix?: string;
namePrefix?: string;
}) =>
Array.from({ length: count }, (_, index) =>
entities.base({
customer,
feature,
id: `${idPrefix}_${index + 1}`,
name: `${namePrefix} ${index + 1}`,
}),
),
} as const;

View File

@@ -0,0 +1 @@
export { entities } from "./entities.js";

View File

@@ -1,4 +1,7 @@
import type {
ApiBodyExcludesExpectation,
ApiBodyNumberFieldsExpectation,
ApiCalledAfterApprovalExpectation,
ApiCalledExpectation,
ApiCalledInOrderExpectation,
ExpectedApiCall,
@@ -31,4 +34,34 @@ export const api = {
calls,
type: "api.calledInOrder",
}),
calledAfterApproval: ({
call,
}: {
call: ExpectedApiCall;
}): ApiCalledAfterApprovalExpectation => ({
call,
type: "api.calledAfterApproval",
}),
bodyExcludes: ({
fields,
toolName,
}: {
fields: string[];
toolName: ExpectedApiCall["toolName"];
}): ApiBodyExcludesExpectation => ({
fields,
toolName,
type: "api.bodyExcludes",
}),
bodyNumberFields: ({
paths,
toolName,
}: {
paths: string[];
toolName: ExpectedApiCall["toolName"];
}): ApiBodyNumberFieldsExpectation => ({
paths,
toolName,
type: "api.bodyNumberFields",
}),
};

View File

@@ -3,12 +3,17 @@ export { billing } from "./billing.js";
export { response } from "./response.js";
export { tools } from "./tools.js";
export type {
ApiBodyExcludesExpectation,
ApiBodyNumberFieldsExpectation,
ApiCalledAfterApprovalExpectation,
ApiCalledExpectation,
ApiCalledInOrderExpectation,
EvalExpectation,
EvalExpected,
ExpectedApiCall,
LegacyEvalExpected,
ResponseAskedBeforeToolExpectation,
ResponseAskedExpectation,
ResponseMentionsExpectation,
ToolsCalledExpectation,
} from "./types.js";

View File

@@ -1,6 +1,35 @@
import type { ResponseMentionsExpectation } from "./types.js";
import type {
ResponseAskedExpectation,
ResponseAskedBeforeToolExpectation,
ResponseMentionsExpectation,
} from "./types.js";
export const response = {
asked: ({
notPhrases,
phrases,
}: {
phrases: string[];
notPhrases?: string[];
}): ResponseAskedExpectation => ({
...(notPhrases ? { notPhrases } : {}),
phrases,
type: "response.asked",
}),
askedBeforeTool: ({
notPhrases,
phrases,
toolName,
}: {
phrases: string[];
toolName: ResponseAskedBeforeToolExpectation["toolName"];
notPhrases?: string[];
}): ResponseAskedBeforeToolExpectation => ({
...(notPhrases ? { notPhrases } : {}),
phrases,
toolName,
type: "response.askedBeforeTool",
}),
mentions: ({
phrases,
}: {

View File

@@ -26,14 +26,49 @@ export type ApiCalledInOrderExpectation = {
type: "api.calledInOrder";
};
export type ApiCalledAfterApprovalExpectation = {
call: ExpectedApiCall;
type: "api.calledAfterApproval";
};
export type ApiBodyExcludesExpectation = {
fields: string[];
toolName: AutumnEvalToolName;
type: "api.bodyExcludes";
};
export type ApiBodyNumberFieldsExpectation = {
paths: string[];
toolName: AutumnEvalToolName;
type: "api.bodyNumberFields";
};
export type ResponseMentionsExpectation = {
phrases: string[];
type: "response.mentions";
};
export type ResponseAskedExpectation = {
notPhrases?: string[];
phrases: string[];
type: "response.asked";
};
export type ResponseAskedBeforeToolExpectation = {
notPhrases?: string[];
phrases: string[];
toolName: AutumnEvalToolName;
type: "response.askedBeforeTool";
};
export type EvalExpectation =
| ApiBodyExcludesExpectation
| ApiBodyNumberFieldsExpectation
| ApiCalledAfterApprovalExpectation
| ApiCalledExpectation
| ApiCalledInOrderExpectation
| ResponseAskedExpectation
| ResponseAskedBeforeToolExpectation
| ResponseMentionsExpectation
| ToolsCalledExpectation;

View File

@@ -1,7 +1,38 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
const asRecord = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
const asArray = (value: unknown) => (Array.isArray(value) ? value : []);
const planAmount = (plan: ApiPlanV1) => plan.price?.amount ?? 0;
const amountFromCustomize = (value: unknown) => {
const price = asRecord(asRecord(value).price);
return typeof price.amount === "number" ? price.amount : 0;
};
const customLineItemsTotal = (value: unknown) =>
asArray(asRecord(value).custom_line_items).reduce(
(total, item) =>
total +
(typeof asRecord(item).amount === "number"
? (asRecord(item).amount as number)
: 0),
0,
);
const attachPreviewTotal = ({
plan,
request,
}: {
plan: ApiPlanV1;
request?: unknown;
}) =>
customLineItemsTotal(request) ||
amountFromCustomize(asRecord(request).customize) ||
planAmount(plan);
const phaseTotal = (phase: unknown) =>
asArray(asRecord(phase).plans).reduce(
(total, plan) => total + amountFromCustomize(asRecord(plan).customize),
0,
);
const schedulePhases = (phases: unknown) =>
Array.isArray(phases)
? phases.map((phase, index) => {
@@ -11,6 +42,7 @@ const schedulePhases = (phases: unknown) =>
phase_id: `phase_${index + 1}`,
starts_at:
typeof record.starts_at === "number" ? record.starts_at : null,
total: phaseTotal(record),
};
})
: [];
@@ -19,17 +51,22 @@ export const responses = {
attachPreview: ({
customer,
plan,
request,
}: {
customer: BaseApiCustomerV5;
plan: ApiPlanV1;
request?: unknown;
}) => ({
customer_id: customer.id,
plan_id: plan.id,
currency: "usd",
line_items: [
{ description: `${plan.name} annual`, total: planAmount(plan) },
{
description: `${plan.name} annual`,
total: attachPreviewTotal({ plan, request }),
},
],
total: planAmount(plan),
total: attachPreviewTotal({ plan, request }),
}),
attachSuccess: ({
customer,
@@ -54,9 +91,12 @@ export const responses = {
line_items: schedulePhases(phases).map((phase, index) => ({
description: `Schedule phase ${index + 1}`,
starts_at: phase.starts_at,
total: 0,
total: phase.total,
})),
total: 0,
total: schedulePhases(phases).reduce(
(total, phase) => total + phase.total,
0,
),
}),
createScheduleSuccess: ({
customerId,

View File

@@ -8,6 +8,7 @@ const featureIds = {
compliance_controls: "compliance_controls",
credits: "credits",
export_center: "export_center",
hosted_solution: "hosted_solution",
insight_reports: "insight_reports",
member_slots: "member_slots",
outbound_hooks: "outbound_hooks",
@@ -17,6 +18,8 @@ const featureIds = {
project_slots: "project_slots",
revision_history: "revision_history",
team_policies: "team_policies",
unlimited_seats: "unlimited_seats",
workspaces: "workspaces",
} as const;
const planIds = {
@@ -45,10 +48,22 @@ const platformFeatureIds = [
featureIds.revision_history,
] as const;
const contractFeatureIds = [
featureIds.hosted_solution,
featureIds.unlimited_seats,
] as const;
/** Anonymized org setup with credits, many feature flags, core plans, and add-ons. */
export const knowledgePlatformSetup = () =>
createSetup({
tag: "knowledge-platform",
agentRules: ({ agentRules }) =>
agentRules.base({
entityRules: agentRules.entityRules({
attachToEntities: true,
entityFeatureId: featureIds.workspaces,
}),
}),
features: ({ featureList, features }) => ({
activity_events: features.consumable({
featureId: featureIds.activity_events,
@@ -62,7 +77,18 @@ export const knowledgePlatformSetup = () =>
project_slots: features.allocated({
featureId: featureIds.project_slots,
}),
workspaces: features.allocated({
featureId: featureIds.workspaces,
name: "Workspaces",
}),
...featureList.boolean({ featureIds: platformFeatureIds }),
...featureList.boolean({
featureIds: contractFeatureIds,
names: {
hosted_solution: "Hosted Solution",
unlimited_seats: "Unlimited Seats",
},
}),
}),
plans: ({ basePrice, features, itemList, items, plan, planList }) => {
const creditItems = [

View File

@@ -1,10 +1,13 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
import type { ApiEntityV2 } from "@api/entities/apiEntityV2.js";
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import type { AgentRules } from "@autumn/shared";
export type PlanRef = ApiPlanV1 | ApiPlanV1[];
export type ScheduleRef = ApiCustomerSchedule | ApiCustomerSchedule[];
export type EntityRef = ApiEntityV2 | ApiEntityV2[];
type RefId<Value extends { id?: string | null }> = Value["id"];
type RefIds<Value extends { id?: string | null }> = Value extends unknown[]
? never
@@ -18,6 +21,7 @@ export type EvalSetupIds<
BaseApiCustomerV5 | BaseApiCustomerV5[]
> = Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef> = Record<string, ScheduleRef>,
Entities extends Record<string, EntityRef> = Record<string, EntityRef>,
> = {
features: {
[Key in keyof Features]: RefIds<Features[Key]>;
@@ -43,6 +47,13 @@ export type EvalSetupIds<
? RefIds<Schedules[Key]>
: never;
};
entities: {
[Key in keyof Entities]: Entities[Key] extends ApiEntityV2[]
? Array<RefIds<Entities[Key][number]>>
: Entities[Key] extends ApiEntityV2
? RefIds<Entities[Key]>
: never;
};
};
export type EvalSetupRefs<
@@ -53,11 +64,14 @@ export type EvalSetupRefs<
BaseApiCustomerV5 | BaseApiCustomerV5[]
> = Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef> = Record<string, ScheduleRef>,
Entities extends Record<string, EntityRef> = Record<string, EntityRef>,
> = {
agentRules: AgentRules;
features: Features;
plans: Plans;
customers: Customers;
schedules: Schedules;
entities: Entities;
};
export type EvalSetup<
@@ -68,12 +82,15 @@ export type EvalSetup<
BaseApiCustomerV5 | BaseApiCustomerV5[]
> = Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef> = Record<string, ScheduleRef>,
Entities extends Record<string, EntityRef> = Record<string, EntityRef>,
> = {
tag: string;
ids: EvalSetupIds<Features, Plans, Customers, Schedules>;
ids: EvalSetupIds<Features, Plans, Customers, Schedules, Entities>;
agentRules: AgentRules;
features: ApiFeatureV1[];
plans: ApiPlanV1[];
customers: BaseApiCustomerV5[];
schedules: ApiCustomerSchedule[];
refs: EvalSetupRefs<Features, Plans, Customers, Schedules>;
entities: ApiEntityV2[];
refs: EvalSetupRefs<Features, Plans, Customers, Schedules, Entities>;
};

View File

@@ -1,3 +1,5 @@
import { leafChatAgentDefaults } from "../../../../src/lib/chatAgentConfig.js";
export type GenericMcpAgentDriverConfig = {
maxSteps?: number;
model?: string;
@@ -7,6 +9,6 @@ export const genericMcpAgentInstructions =
"Use Autumn MCP tools. Call getAgentRules before customer, billing, balance, entity, or plan work. Preview destructive writes before applying them.";
export const defaultGenericMcpAgentConfig = {
maxSteps: 6,
model: "anthropic/claude-sonnet-4-6",
maxSteps: leafChatAgentDefaults.maxSteps,
model: leafChatAgentDefaults.model,
} satisfies Required<GenericMcpAgentDriverConfig>;

View File

@@ -1,4 +1,6 @@
import { mergeAgentRules, type PartialAgentRules } from "@autumn/shared";
import { customers } from "../../fixtures/customers/index.js";
import { entities } from "../../fixtures/entities/index.js";
import { responses } from "../../fixtures/responses.js";
import type { EvalTrace } from "../tracing/types.js";
import type { AutumnApiMock, AutumnApiMockOverrides } from "./types.js";
@@ -7,6 +9,8 @@ const serverURL = "http://localhost:8080";
const endpointToTool = {
"/v1/balances.create": "createBalance",
"/v1/agent.get_rules": "getAgentRules",
"/v1/agent.update_rules": "updateAgentRules",
"/v1/billing.attach": "attach",
"/v1/billing.create_schedule": "createSchedule",
"/v1/billing.preview_attach": "previewAttach",
@@ -15,7 +19,11 @@ const endpointToTool = {
"/v1/customers.get_or_create": "getOrCreateCustomer",
"/v1/customers.list": "listCustomers",
"/v1/customers.update": "updateCustomer",
"/v1/entities.create": "createEntity",
"/v1/entities.get": "getEntity",
"/v1/entities.list": "listEntities",
"/v1/features.list": "listFeatures",
"/v1/organization/me": "getCurrentOrganization",
"/v1/plans.get": "getPlan",
"/v1/plans.list": "listPlans",
} as const;
@@ -23,6 +31,28 @@ const endpointToTool = {
const getString = (body: Record<string, unknown>, key: string) =>
typeof body[key] === "string" ? body[key] : "";
const parseParenthesizedEpoch = (value: string) => {
const epoch = value.match(/\((\d{12,})\)/)?.[1];
return epoch ? Number(epoch) : value;
};
const normalizeScheduleBody = (body: Record<string, unknown>) => ({
...body,
phases: Array.isArray(body.phases)
? body.phases.map((phase) =>
phase && typeof phase === "object" && "starts_at" in phase
? {
...phase,
starts_at:
typeof phase.starts_at === "string"
? parseParenthesizedEpoch(phase.starts_at)
: phase.starts_at,
}
: phase,
)
: body.phases,
});
const defaultHandlers = {
attach: ({ body, setup }) => {
const customer = setup.customers.find(
@@ -53,6 +83,29 @@ const defaultHandlers = {
return responses.attachSuccess({ customer, plan });
},
createBalance: () => ({ status: "created" }),
createEntity: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
const featureId = getString(body, "feature_id");
const customer = setup.customers.find(
(customer) => customer.id === customerId,
);
const feature = setup.features.find((feature) => feature.id === featureId);
if (!customer || !feature) return { error: "missing customer or feature" };
const existing = setup.entities.find(
(entity) =>
entity.customer_id === customerId &&
entity.id === getString(body, "entity_id"),
);
if (existing) return existing;
const created = entities.base({
customer,
feature,
id: getString(body, "entity_id"),
name: getString(body, "name") || getString(body, "entity_id"),
});
setup.entities.push(created);
return created;
},
createSchedule: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
const customer = setup.customers.find(
@@ -71,6 +124,21 @@ const defaultHandlers = {
);
return customer ?? { error: "customer not found" };
},
getEntity: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
const entity = setup.entities.find(
(entity) =>
entity.id === getString(body, "entity_id") &&
(!customerId || entity.customer_id === customerId),
);
return entity ?? { error: "entity not found" };
},
getAgentRules: ({ setup }) => setup.agentRules,
getCurrentOrganization: () => ({
env: "sandbox",
name: "Acme Knowledge Systems",
slug: "acme-knowledge-systems",
}),
getOrCreateCustomer: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
const customer = setup.customers.find(
@@ -78,7 +146,11 @@ const defaultHandlers = {
);
if (customer) return customer;
const created = customers.active({ id: customerId });
const created = customers.active({
email: getString(body, "email") || undefined,
id: customerId,
name: getString(body, "name") || undefined,
});
setup.customers.push(created);
return created;
},
@@ -108,6 +180,30 @@ const defaultHandlers = {
total_filtered_count: list.length,
};
},
listEntities: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
const search = getString(body, "search").toLowerCase();
const list = setup.entities.filter((entity) => {
const matchesCustomer = customerId
? entity.customer_id === customerId
: true;
const matchesSearch = search
? [entity.id, entity.name].some(
(value) =>
typeof value === "string" && value.toLowerCase().includes(search),
)
: true;
return matchesCustomer && matchesSearch;
});
const limit = typeof body.limit === "number" ? body.limit : list.length;
return {
has_more: false,
limit,
list: list.slice(0, limit),
next_cursor: null,
};
},
listFeatures: ({ setup }) => ({ list: setup.features }),
listPlans: ({ setup }) => ({
list: setup.plans,
@@ -120,7 +216,7 @@ const defaultHandlers = {
(plan) => plan.id === getString(body, "plan_id"),
);
if (!customer || !plan) return { error: "missing customer or plan" };
return responses.attachPreview({ customer, plan });
return responses.attachPreview({ customer, plan, request: body });
},
previewCreateSchedule: ({ body, setup }) => {
const customerId = getString(body, "customer_id");
@@ -142,6 +238,13 @@ const defaultHandlers = {
if (typeof body.name === "string") customer.name = body.name;
return customer;
},
updateAgentRules: ({ body, setup }) => {
setup.agentRules = mergeAgentRules({
base: setup.agentRules,
updates: body as PartialAgentRules,
});
return setup.agentRules;
},
} satisfies AutumnApiMockOverrides;
export const createAutumnApiMock = ({
@@ -164,7 +267,11 @@ export const createAutumnApiMock = ({
const endpoint = url.pathname;
const toolName =
endpointToTool[endpoint as keyof typeof endpointToTool] ?? null;
const body = JSON.parse(String(init?.body ?? "{}"));
const rawBody = JSON.parse(String(init?.body ?? "{}"));
const body =
toolName === "previewCreateSchedule" || toolName === "createSchedule"
? normalizeScheduleBody(rawBody)
: rawBody;
const call = { body, endpoint, toolName };
calls.push(call);
trace?.event({ call, type: "api_call" });

View File

@@ -4,15 +4,20 @@ import type { EvalSetup } from "../../fixtures/types.js";
export type AutumnEvalToolName =
| "attach"
| "createBalance"
| "createEntity"
| "createSchedule"
| "getAgentRules"
| "getCustomer"
| "getEntity"
| "getOrCreateCustomer"
| "getPlan"
| "listCustomers"
| "listEntities"
| "listFeatures"
| "listPlans"
| "previewAttach"
| "previewCreateSchedule"
| "updateAgentRules"
| "updateCustomer";
export type AutumnApiCall = {

View File

@@ -0,0 +1,21 @@
import { statSync } from "node:fs";
import { resolve } from "node:path";
import type { EvalAttachment } from "./createEvalContext.js";
export const contractAttachment = ({
filename = "document.pdf",
fixtureId,
}: {
filename?: string;
fixtureId: string;
}): EvalAttachment => {
const path = resolve(process.cwd(), "contracts", fixtureId, filename);
const stats = statSync(path);
return {
mimeType: "application/pdf",
name: `${fixtureId}.pdf`,
path,
size: stats.size,
};
};

View File

@@ -1,4 +1,7 @@
import { readFile } from "node:fs/promises";
import type { Attachment } from "chat";
import type { AutumnMcpAuth } from "../../../../../packages/mcp/src/server/auth/auth.js";
import { prepareAttachmentMessage } from "../../../src/agent/attachments.js";
import type { EvalSetup } from "../fixtures/types.js";
import { createEvalRuntimeContext } from "./context/createEvalRuntimeContext.js";
import type {
@@ -9,12 +12,28 @@ import type { EvalAgentDriver } from "./drivers/types.js";
import { createEvalTrace } from "./tracing/createEvalTrace.js";
import type { EvalTrace, EvalTraceLevel } from "./tracing/types.js";
export type EvalAttachment = {
mimeType: string;
name?: string;
path: string;
size?: number;
};
export type EvalTurn =
| { maxSteps?: number; message: string; type: "user" }
| {
attachments?: EvalAttachment[];
maxSteps?: number;
message: string;
type: "user";
}
| { maxSteps?: number; optional?: boolean; type: "approve" };
export type EvalTurnResult = {
apiCalls: EvalRuntimeContext["autumnApi"]["calls"];
text?: string;
toolCalls: ReturnType<
Awaited<ReturnType<EvalAgentDriver["start"]>>["getToolCalls"]
>;
type: EvalTurn["type"];
};
@@ -60,27 +79,66 @@ export const createEvalContext = async ({
trace,
});
const toChatAttachment = (attachment: EvalAttachment): Attachment =>
({
fetchData: () => readFile(attachment.path),
mimeType: attachment.mimeType,
name: attachment.name,
size: attachment.size,
}) as Attachment;
const runConversation = async (turns: EvalTurn[]): Promise<EvalRunResult> => {
const turnResults: EvalTurnResult[] = [];
for (const turn of turns) {
if (turn.type === "user") {
trace.event({ message: turn.message, type: "user_turn" });
const output = await runningDriver.send(turn.message, {
trace.event({
attachments: turn.attachments?.map((attachment) => ({
mimeType: attachment.mimeType,
name: attachment.name,
path: attachment.path,
size: attachment.size,
})),
message: turn.message,
type: "user_turn",
});
const driverMessage = turn.attachments?.length
? (
await prepareAttachmentMessage({
attachments: turn.attachments.map(toChatAttachment),
text: turn.message,
})
).message
: turn.message;
const output = await runningDriver.send(driverMessage, {
maxSteps: turn.maxSteps,
});
turnResults.push({ text: output.text, type: turn.type });
turnResults.push({
apiCalls: [...runtimeContext.autumnApi.calls],
text: output.text,
toolCalls: runningDriver.getToolCalls(),
type: turn.type,
});
continue;
}
if (!runningDriver.hasPendingApproval()) {
if (turn.optional) {
turnResults.push({ type: turn.type });
turnResults.push({
apiCalls: [...runtimeContext.autumnApi.calls],
toolCalls: runningDriver.getToolCalls(),
type: turn.type,
});
continue;
}
throw new Error("No pending approval to approve.");
}
const output = await runningDriver.approve({ maxSteps: turn.maxSteps });
turnResults.push({ text: output.text, type: turn.type });
turnResults.push({
apiCalls: [...runtimeContext.autumnApi.calls],
text: output.text,
toolCalls: runningDriver.getToolCalls(),
type: turn.type,
});
}
trace.event({ type: "eval_finished" });

View File

@@ -13,6 +13,7 @@ import {
} from "../configs/genericMcpAgentConfig.js";
import type {
EvalAgentDriver,
EvalDriverMessage,
EvalDriverStartInput,
EvalToolCall,
} from "./types.js";
@@ -67,6 +68,20 @@ const instrumentToolCalls = ({
}
};
const appendUserMessage = ({
input,
messages,
}: {
input: EvalDriverMessage;
messages: MessageListItem[];
}) => {
if (typeof input === "string") {
messages.push({ content: input, role: "user" });
return;
}
messages.push(...(input as MessageListItem[]));
};
export const createGenericMcpAgentDriver = ({
maxSteps = defaultGenericMcpAgentConfig.maxSteps,
model = defaultGenericMcpAgentConfig.model,
@@ -168,7 +183,7 @@ export const createGenericMcpAgentDriver = ({
getToolCalls: () => [...toolCalls],
hasPendingApproval: () => pendingApproval !== null,
send: async (message, { maxSteps: stepLimit } = {}) => {
messages.push({ content: message, role: "user" });
appendUserMessage({ input: message, messages });
const output = await evalAgent.generate(messages, options(stepLimit));
messages = output.messages;
rememberApproval(output);

View File

@@ -1,6 +1,6 @@
import { AppEnv } from "@autumn/shared";
import type { MessageListItem } from "@mastra/core/agent/message-list";
import type { ToolsInput } from "@mastra/core/agent";
import type { MessageListItem } from "@mastra/core/agent/message-list";
import { Mastra } from "@mastra/core/mastra";
import { InMemoryStore } from "@mastra/core/storage";
import { MCPClient } from "@mastra/mcp";
@@ -14,6 +14,7 @@ import { createMastraBraintrustObservability } from "../../../../src/providers/b
import { defaultGenericMcpAgentConfig } from "../configs/genericMcpAgentConfig.js";
import type {
EvalAgentDriver,
EvalDriverMessage,
EvalDriverStartInput,
EvalToolCall,
} from "./types.js";
@@ -80,6 +81,20 @@ const readDocs = async (mcpClient: MCPClient) => {
.join("\n\n");
};
const appendUserMessage = ({
input,
messages,
}: {
input: EvalDriverMessage;
messages: MessageListItem[];
}) => {
if (typeof input === "string") {
messages.push({ content: input, role: "user" });
return;
}
messages.push(...(input as MessageListItem[]));
};
export const createLeafAgentDriver = ({
maxSteps = defaultGenericMcpAgentConfig.maxSteps,
model = defaultGenericMcpAgentConfig.model,
@@ -104,8 +119,7 @@ export const createLeafAgentDriver = ({
throw new Error(`MCP tool discovery failed: ${JSON.stringify(errors)}`);
}
const env =
context.auth.env === AppEnv.Live ? AppEnv.Live : AppEnv.Sandbox;
const env = context.auth.env === AppEnv.Live ? AppEnv.Live : AppEnv.Sandbox;
const tools = (toolsets.autumn ?? {}) as Record<string, ToolWithApproval>;
applyToolApprovalPolicy(tools);
const toolCalls: EvalToolCall[] = [];
@@ -186,7 +200,7 @@ export const createLeafAgentDriver = ({
getToolCalls: () => [...toolCalls],
hasPendingApproval: () => pendingApproval !== null,
send: async (message, { maxSteps: stepLimit } = {}) => {
messages.push({ content: message, role: "user" });
appendUserMessage({ input: message, messages });
const output = await evalAgent.generate(messages, options(stepLimit));
messages = output.messages;
rememberApproval(output);

View File

@@ -1,3 +1,4 @@
import type { MessageListInput } from "@mastra/core/agent/message-list";
import type { EvalSetup } from "../../fixtures/types.js";
import type { EvalRuntimeContext } from "../context/types.js";
import type { EvalTrace } from "../tracing/types.js";
@@ -11,6 +12,8 @@ export type EvalAgentOutput = {
text?: string;
};
export type EvalDriverMessage = string | MessageListInput;
export type EvalDriverStartInput = {
context: EvalRuntimeContext;
name?: string;
@@ -25,7 +28,7 @@ export type RunningEvalDriver = {
getToolCalls(): EvalToolCall[];
hasPendingApproval(): boolean;
send(
message: string,
message: EvalDriverMessage,
options?: { maxSteps?: number },
): Promise<EvalAgentOutput>;
};

View File

@@ -15,6 +15,7 @@ export type {
EvalMcpServer,
EvalRuntimeContext,
} from "./context/types.js";
export { contractAttachment } from "./contracts.js";
export type {
EvalRunResult,
EvalTurn,

View File

@@ -2,16 +2,17 @@ import { Eval } from "braintrust";
import type { AutumnMcpAuth } from "../../../../../packages/mcp/src/server/auth/auth.js";
import type { EvalSetup } from "../fixtures/types.js";
import {
standardEvalScores,
type EvalExpected,
type EvalScorer,
standardEvalScores,
} from "../utils/scorers.js";
import type { AutumnApiMockOverrides } from "./context/types.js";
import {
createEvalContext,
type EvalAttachment,
type EvalRunResult,
type EvalTurn,
} from "./createEvalContext.js";
import type { AutumnApiMockOverrides } from "./context/types.js";
import { createLeafAgentDriver } from "./drivers/leafAgent.js";
import type { EvalAgentDriver } from "./drivers/types.js";
import type { EvalTraceLevel } from "./tracing/types.js";
@@ -44,12 +45,15 @@ type InitEvalOptions<Metadata extends EvalCaseMetadata> = {
};
export const user = ({
attachments,
maxSteps,
message,
}: {
attachments?: EvalAttachment[];
maxSteps?: number;
message: string;
}): EvalTurn => ({
...(attachments === undefined ? {} : { attachments }),
...(maxSteps === undefined ? {} : { maxSteps }),
message,
type: "user",

View File

@@ -5,7 +5,16 @@ export type EvalTraceLevel = "off" | "steps";
export type EvalTraceEvent =
| { type: "eval_started"; name?: string }
| { type: "user_turn"; message: string }
| {
attachments?: Array<{
mimeType: string;
name?: string;
path: string;
size?: number;
}>;
type: "user_turn";
message: string;
}
| { type: "agent_text"; text: string }
| { type: "tool_call"; call: EvalToolCall }
| { type: "api_call"; call: AutumnApiCall }

View File

@@ -1,106 +0,0 @@
import { Eval } from "braintrust";
import { withCustomers } from "../../fixtures/createSetup.js";
import { orgSetups } from "../../fixtures/orgSetups.js";
import {
createEvalContext,
createGenericMcpAgentDriver,
} from "../../harness/index.js";
import {
type EvalExpected,
type EvalOutput,
expectedApiCalls,
expectedToolCalls,
finalTextIncludes,
} from "../../utils/scorers.js";
type EvalInput = {
confirmation: string;
prompt: string;
};
type EvalMetadata = {
domain: "billing";
setup: string;
};
const experimentName = "customer-plan";
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers, plans, subscriptions }) => ({
joe: customers.active({
id: "joe_customer",
name: "Joe",
subscriptions: [
subscriptions.active({
currentPeriodEnd: new Date("2026-02-07T00:00:00.000Z"),
currentPeriodStart: new Date("2026-01-01T00:00:00.000Z"),
id: "sub_joe_scale_custom",
plan: plans.scale,
}),
],
}),
}),
});
const customer = setup.refs.customers.joe;
Eval<EvalInput, EvalOutput, EvalExpected, EvalMetadata>(
"leaf",
{
experimentName,
data: [
{
expected: {
apiCalls: [{ toolName: "listCustomers" }],
finalTextIncludes: [
"Joe",
"Scale",
"$500",
"Credits",
"Insight Reports",
],
toolCalls: ["listCustomers"],
},
input: {
confirmation: `Yes, use ${customer.id}.`,
prompt: "what plan is Joe on?",
},
metadata: {
domain: "billing",
setup: setup.tag,
},
},
],
scores: [
(args) => ({
name: "Expected tool calls",
score: expectedToolCalls(args),
}),
(args) => ({
name: "Expected API calls",
score: expectedApiCalls(args),
}),
(args) => ({
name: "Final text includes",
score: finalTextIncludes(args),
}),
],
task: async (input) => {
const context = await createEvalContext({
driver: createGenericMcpAgentDriver(),
name: experimentName,
setup,
});
try {
return await context.runConversation([
{ message: input.prompt, type: "user" },
{ message: input.confirmation, type: "user" },
]);
} finally {
await context.cleanup();
}
},
timeout: 45_000,
},
{ noSendLogs: !process.env.BRAINTRUST_API_KEY },
);

View File

@@ -1,329 +0,0 @@
import { Eval } from "braintrust";
import { withCustomers } from "../../fixtures/createSetup.js";
import { orgSetups } from "../../fixtures/orgSetups.js";
import {
createEvalContext,
createGenericMcpAgentDriver,
} from "../../harness/index.js";
import {
type EvalExpected,
type EvalOutput,
expectedApiCalls,
expectedToolCalls,
finalTextIncludes,
noCreateScheduleBeforePreview,
noScheduleCalls,
} from "../../utils/scorers.js";
type EvalInput = {
approval?: string;
details?: string;
prompt: string;
};
type EvalMetadata = {
domain: "billing";
scenario: "multi-year-sales-led-schedule";
setup: string;
source: "customer-slack-scenario-mining";
};
type EvalScoreArgs = {
expected?: EvalExpected;
output: EvalOutput;
};
const experimentName = "multi-year-schedule";
const evalToday = new Date("2026-06-08T00:00:00.000Z");
const addUtcYears = ({
date,
years,
}: {
date: Date;
years: number;
}) =>
new Date(
Date.UTC(
date.getUTCFullYear() + years,
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
),
);
const phaseStart = (yearsFromToday: number) =>
addUtcYears({ date: evalToday, years: yearsFromToday }).getTime();
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers, plans, subscriptions }) => ({
account: customers.active({
email: "finance@northwind.example",
id: "cus_sales_led_schedule",
name: "Northwind Labs",
subscriptions: [
subscriptions.active({
currentPeriodEnd: addUtcYears({ date: evalToday, years: 1 }),
currentPeriodStart: evalToday,
id: "sub_sales_led_enterprise",
plan: plans.enterprise,
}),
],
}),
}),
});
const customer = setup.refs.customers.account;
const enterprisePlan = setup.refs.plans.enterprise;
const expectedPhases = [
{
plans: [
{
customize: {
price: { amount: 100_000, interval: "year" },
},
plan_id: enterprisePlan.id,
},
],
starts_at: phaseStart(0),
},
{
plans: [
{
customize: {
price: { amount: 125_000, interval: "year" },
},
plan_id: enterprisePlan.id,
},
],
starts_at: phaseStart(1),
},
{
plans: [
{
customize: {
price: { amount: 150_000, interval: "year" },
},
plan_id: enterprisePlan.id,
},
],
starts_at: phaseStart(2),
},
];
const usesOnlyPriceOverrides = ({ output }: { output: EvalOutput }) => {
const scheduleCalls = output.apiCalls.filter(
(call) =>
call.toolName === "previewCreateSchedule" ||
call.toolName === "createSchedule",
);
if (!scheduleCalls.length) return 0;
return scheduleCalls.every((call) =>
Array.isArray(call.body.phases)
? call.body.phases.every((phase) => {
const phaseRecord = phase as Record<string, unknown>;
return Array.isArray(phaseRecord.plans)
? phaseRecord.plans.every((plan) => {
const planRecord = plan as Record<string, unknown>;
const customize = planRecord.customize as
| Record<string, unknown>
| undefined;
return (
planRecord.feature_quantities === undefined &&
customize?.items === undefined &&
customize?.price !== undefined
);
})
: false;
})
: false,
)
? 1
: 0;
};
Eval<EvalInput, EvalOutput, EvalExpected, EvalMetadata>(
"leaf",
{
experimentName,
data: [
{
expected: {
apiCalls: [
{ toolName: "listCustomers" },
{ toolName: "listPlans" },
{ toolName: "listFeatures" },
{
body: { customer_id: customer.id },
toolName: "getCustomer",
},
{
body: {
customer_id: customer.id,
phases: expectedPhases,
redirect_mode: "if_required",
},
toolName: "previewCreateSchedule",
},
{
body: {
customer_id: customer.id,
phases: expectedPhases,
redirect_mode: "if_required",
},
toolName: "createSchedule",
},
],
finalTextIncludes: [
"Northwind Labs",
"Enterprise",
"2026",
"2027",
"2028",
"credits",
"unchanged",
],
toolCalls: [
"listCustomers",
"listPlans",
"listFeatures",
"getCustomer",
"previewCreateSchedule",
"createSchedule",
],
},
input: {
approval: "Looks good, create the schedule.",
details: [
"Use customer Northwind Labs, customer id cus_sales_led_schedule.",
"Use the Enterprise plan at customer level.",
"Contract starts today, June 8, 2026.",
"Year 1 is $100,000/year starting June 8, 2026.",
"Year 2 is $125,000/year starting one year from today, June 8, 2027.",
"Year 3 is $150,000/year starting two years from today, June 8, 2028.",
"Credits and feature access stay unchanged in every year.",
"Do not send an invoice or checkout now; preview the schedule first.",
].join(" "),
prompt:
"Northwind Labs has a three-year sales-led Enterprise schedule. Please provision it in Autumn; the annual price changes each year, but credits do not change.",
},
metadata: {
domain: "billing",
scenario: "multi-year-sales-led-schedule",
setup: setup.tag,
source: "customer-slack-scenario-mining",
},
},
{
expected: {
finalTextIncludes: ["Northwind Labs", "now", "past"],
},
input: {
prompt: [
"Please provision a three-year Enterprise schedule for Northwind Labs, customer id cus_sales_led_schedule.",
"Year 1 is $100,000/year, year 2 is $125,000/year, and year 3 is $150,000/year.",
"Credits and feature access stay unchanged in every year.",
].join(" "),
},
metadata: {
domain: "billing",
scenario: "multi-year-sales-led-schedule",
setup: setup.tag,
source: "customer-slack-scenario-mining",
},
},
],
scores: [
(args: EvalScoreArgs) => ({
name: "Expected tool calls",
score: expectedToolCalls(args),
}),
(args: EvalScoreArgs) => ({
name: "Expected API calls",
score: expectedApiCalls(args),
}),
(args: EvalScoreArgs) => ({
name: "Final text includes",
score: finalTextIncludes(args),
}),
(args: EvalScoreArgs) => ({
name: "Preview before create schedule",
score: noCreateScheduleBeforePreview(args),
}),
(args: EvalScoreArgs) => ({
name: "Only price overrides",
score:
args.expected?.apiCalls?.some(
(call) => call.toolName === "previewCreateSchedule",
) ||
args.expected?.apiCalls?.some(
(call) => call.toolName === "createSchedule",
)
? usesOnlyPriceOverrides(args)
: 1,
}),
(args: EvalScoreArgs) => ({
name: "No schedule calls without start clarification",
score:
args.expected?.apiCalls || args.expected?.toolCalls
? 1
: noScheduleCalls(args),
}),
],
task: async (input: EvalInput) => {
const context = await createEvalContext({
autumnApiOverrides: {
createSchedule: ({ body }) => ({
customer_id: body.customer_id,
entity_id: null,
invoice: null,
payment_url: null,
phases: expectedPhases.map((phase, index) => ({
customer_product_ids: [`cp_schedule_${index + 1}`],
phase_id: `phase_schedule_${index + 1}`,
starts_at: phase.starts_at,
})),
schedule_id: "sched_sales_led_multiyear",
status: "created",
}),
previewCreateSchedule: ({ body }) => ({
currency: "usd",
customer_id: body.customer_id,
line_items: expectedPhases.map((phase, index) => ({
description: `Enterprise year ${index + 1}`,
starts_at: phase.starts_at,
total: phase.plans[0].customize.price.amount,
})),
total: 375_000,
}),
},
driver: createGenericMcpAgentDriver(),
name: experimentName,
setup,
today: evalToday,
});
try {
const turns = [
{ message: input.prompt, type: "user" as const },
...(input.details
? [{ message: input.details, type: "user" as const }]
: []),
...(input.approval
? [
{ message: input.approval, type: "user" as const },
{ optional: true, type: "approve" as const },
]
: []),
];
return await context.runConversation(turns);
} finally {
await context.cleanup();
}
},
timeout: 60_000,
},
{ noSendLogs: !process.env.BRAINTRUST_API_KEY },
);

View File

@@ -1,14 +1,14 @@
import type { AutumnApiCall } from "../harness/context/types.js";
import type {
EvalExpected,
EvalExpectation,
EvalExpected,
ExpectedApiCall,
LegacyEvalExpected,
} from "../fixtures/expectations/types.js";
import type { AutumnApiCall } from "../harness/context/types.js";
export type {
EvalExpected,
EvalExpectation,
EvalExpected,
ExpectedApiCall,
LegacyEvalExpected,
} from "../fixtures/expectations/types.js";
@@ -17,6 +17,12 @@ export type EvalOutput = {
apiCalls: AutumnApiCall[];
finalText: string;
toolCalls: Array<{ name: string; args: Record<string, unknown> }>;
turns?: Array<{
apiCalls?: AutumnApiCall[];
text?: string;
toolCalls?: Array<{ name: string; args: Record<string, unknown> }>;
type: "approve" | "user";
}>;
};
export type EvalScoreArgs = {
@@ -29,15 +35,53 @@ export type EvalScorer = (args: EvalScoreArgs) => {
score: number;
};
const namedScorer = ({
name,
score,
}: {
name: string;
score: (args: EvalScoreArgs) => number;
}): EvalScorer => {
const scorer: EvalScorer = (args) => ({ name, score: score(args) });
Object.defineProperty(scorer, "name", { value: name });
return scorer;
};
const includesValue = ({
actual,
expected,
}: {
actual: unknown;
expected: unknown;
}): boolean => {
if (Array.isArray(expected)) {
return (
Array.isArray(actual) &&
expected.length === actual.length &&
expected.every((value, index) =>
includesValue({ actual: actual[index], expected: value }),
)
);
}
if (expected && typeof expected === "object") {
return (
actual !== null &&
typeof actual === "object" &&
Object.entries(expected).every(([key, value]) =>
includesValue({
actual: (actual as Record<string, unknown>)[key],
expected: value,
}),
)
);
}
return actual === expected;
};
const includesObject = (
actual: Record<string, unknown>,
expected: Record<string, unknown>,
) =>
Object.entries(expected).every(([key, value]) =>
typeof value === "object" && value !== null
? JSON.stringify(actual[key]) === JSON.stringify(value)
: actual[key] === value,
);
) => includesValue({ actual, expected });
const isExpectationList = (
expected?: EvalExpected,
@@ -73,6 +117,21 @@ const getExpectedApiCallOrder = (expected?: EvalExpected) =>
expectation.type === "api.calledInOrder" ? [expectation.calls] : [],
);
const getExpectedApiCallsAfterApproval = (expected?: EvalExpected) =>
getExpectationList(expected).flatMap((expectation) =>
expectation.type === "api.calledAfterApproval" ? [expectation.call] : [],
);
const getExpectedApiBodyExclusions = (expected?: EvalExpected) =>
getExpectationList(expected).flatMap((expectation) =>
expectation.type === "api.bodyExcludes" ? [expectation] : [],
);
const getExpectedApiBodyNumberFields = (expected?: EvalExpected) =>
getExpectationList(expected).flatMap((expectation) =>
expectation.type === "api.bodyNumberFields" ? [expectation] : [],
);
const getExpectedResponsePhrases = (expected?: EvalExpected) => [
...(getLegacyExpected(expected)?.finalTextIncludes ?? []),
...getExpectationList(expected).flatMap((expectation) =>
@@ -80,6 +139,37 @@ const getExpectedResponsePhrases = (expected?: EvalExpected) => [
),
];
const getExpectedQuestions = (expected?: EvalExpected) =>
getExpectationList(expected).flatMap((expectation) =>
expectation.type === "response.asked" ? [expectation] : [],
);
const getExpectedQuestionsBeforeTool = (expected?: EvalExpected) =>
getExpectationList(expected).flatMap((expectation) =>
expectation.type === "response.askedBeforeTool" ? [expectation] : [],
);
const normalizeText = (value: string) =>
value.toLowerCase().replace(/[_-]/g, " ");
const textMatches = ({
notPhrases = [],
phrases,
text,
}: {
phrases: string[];
text: string;
notPhrases?: string[];
}) => {
const normalizedText = normalizeText(text);
return (
phrases.every((phrase) => normalizedText.includes(normalizeText(phrase))) &&
notPhrases.every(
(phrase) => !normalizedText.includes(normalizeText(phrase)),
)
);
};
const matchesApiCall = ({
actual,
expected,
@@ -90,10 +180,27 @@ const matchesApiCall = ({
actual.toolName === expected.toolName &&
(!expected.body || includesObject(actual.body, expected.body));
export const expectedApiCalls = ({
expected,
output,
}: EvalScoreArgs) => {
const valuesAtPath = ({ path, value }: { path: string; value: unknown }) => {
const parts = path.split(".");
const walk = ({ index, current }: { index: number; current: unknown }) => {
if (index === parts.length) return [current];
const part = parts[index];
if (part === "*") {
return Array.isArray(current)
? current.flatMap((item) => walk({ current: item, index: index + 1 }))
: [];
}
return current && typeof current === "object"
? walk({
current: (current as Record<string, unknown>)[part],
index: index + 1,
})
: [];
};
return walk({ current: value, index: 0 });
};
export const expectedApiCalls = ({ expected, output }: EvalScoreArgs) => {
const expectedCalls = getExpectedApiCalls(expected);
if (!expectedCalls.length) return 1;
return expectedCalls.every((expectedCall) =>
@@ -129,10 +236,35 @@ export const expectedApiCallsInOrder = ({
: 0;
};
export const expectedToolCalls = ({
export const expectedApiCallsAfterApproval = ({
expected,
output,
}: EvalScoreArgs) => {
const expectedCalls = getExpectedApiCallsAfterApproval(expected);
if (!expectedCalls.length) return 1;
const firstApproveIndex =
output.turns?.findIndex((turn) => turn.type === "approve") ?? -1;
if (firstApproveIndex === -1) return 0;
const turnsBeforeApproval = output.turns?.slice(0, firstApproveIndex) ?? [];
return expectedCalls.every((expectedCall) => {
const calledBeforeApproval = turnsBeforeApproval.some((turn) =>
(turn.apiCalls ?? []).some((call) =>
matchesApiCall({ actual: call, expected: expectedCall }),
),
);
if (calledBeforeApproval) return false;
return output.apiCalls.some((call) =>
matchesApiCall({ actual: call, expected: expectedCall }),
);
})
? 1
: 0;
};
export const expectedToolCalls = ({ expected, output }: EvalScoreArgs) => {
const expectedTools = getExpectedToolNames(expected);
if (!expectedTools.length) return 1;
return expectedTools.every((toolName) =>
@@ -142,16 +274,98 @@ export const expectedToolCalls = ({
: 0;
};
export const finalTextIncludes = ({
export const expectedApiBodyExclusions = ({
expected,
output,
}: EvalScoreArgs) => {
const exclusions = getExpectedApiBodyExclusions(expected);
if (!exclusions.length) return 1;
return exclusions.every((exclusion) =>
output.apiCalls
.filter((call) => call.toolName === exclusion.toolName)
.every((call) =>
exclusion.fields.every((field) => !(field in call.body)),
),
)
? 1
: 0;
};
export const expectedApiBodyNumberFields = ({
expected,
output,
}: EvalScoreArgs) => {
const expectations = getExpectedApiBodyNumberFields(expected);
if (!expectations.length) return 1;
return expectations.every((expectation) => {
const matchingCalls = output.apiCalls.filter(
(call) => call.toolName === expectation.toolName,
);
return (
matchingCalls.length > 0 &&
matchingCalls.every((call) =>
expectation.paths.every((path) => {
const values = valuesAtPath({ path, value: call.body });
return (
values.length > 0 &&
values.every((value) => typeof value === "number")
);
}),
)
);
})
? 1
: 0;
};
export const finalTextIncludes = ({ expected, output }: EvalScoreArgs) => {
const phrases = getExpectedResponsePhrases(expected);
if (!phrases.length) return 1;
const text = output.finalText.toLowerCase();
return phrases.every((phrase) => text.includes(phrase.toLowerCase())) ? 1 : 0;
};
export const askedClarification = ({ expected, output }: EvalScoreArgs) => {
const questions = getExpectedQuestions(expected);
if (!questions.length) return 1;
const turns = output.turns?.filter((turn) => turn.type === "user") ?? [];
return questions.every((question) =>
turns.some((turn) =>
textMatches({
notPhrases: question.notPhrases,
phrases: question.phrases,
text: turn.text ?? "",
}),
),
)
? 1
: 0;
};
export const askedClarificationBeforeTool = ({
expected,
output,
}: EvalScoreArgs) => {
const questions = getExpectedQuestionsBeforeTool(expected);
if (!questions.length) return 1;
const turns = output.turns?.filter((turn) => turn.type === "user") ?? [];
return questions.every((question) =>
turns.some((turn) => {
const hasAsked = textMatches({
notPhrases: question.notPhrases,
phrases: question.phrases,
text: turn.text ?? "",
});
const hasTargetTool =
turn.toolCalls?.some((call) => call.name === question.toolName) ??
false;
return hasAsked && !hasTargetTool;
}),
)
? 1
: 0;
};
export const noAttachBeforePreview = ({ output }: { output: EvalOutput }) => {
const attachIndex = output.apiCalls.findIndex(
(call) => call.toolName === "attach",
@@ -196,21 +410,41 @@ export const noScheduleCalls = ({ output }: { output: EvalOutput }) =>
: 0;
export const standardEvalScores = (): EvalScorer[] => [
(args) => ({
namedScorer({
name: "Expected tool calls",
score: expectedToolCalls(args),
score: expectedToolCalls,
}),
(args) => ({
namedScorer({
name: "Expected API calls",
score: expectedApiCalls(args),
score: expectedApiCalls,
}),
(args) => ({
namedScorer({
name: "Expected API call order",
score: expectedApiCallsInOrder(args),
score: expectedApiCallsInOrder,
}),
(args) => ({
namedScorer({
name: "Expected API calls after approval",
score: expectedApiCallsAfterApproval,
}),
namedScorer({
name: "Expected API body exclusions",
score: expectedApiBodyExclusions,
}),
namedScorer({
name: "Expected API body number fields",
score: expectedApiBodyNumberFields,
}),
namedScorer({
name: "Final text includes",
score: finalTextIncludes(args),
score: finalTextIncludes,
}),
namedScorer({
name: "Asked clarification",
score: askedClarification,
}),
namedScorer({
name: "Asked clarification before tool",
score: askedClarificationBeforeTool,
}),
];
@@ -218,8 +452,8 @@ export const billingAttachScores = (): EvalScorer[] => standardEvalScores();
export const billingScheduleScores = (): EvalScorer[] => [
...standardEvalScores(),
(args) => ({
namedScorer({
name: "Preview before create schedule",
score: noCreateScheduleBeforePreview(args),
score: noCreateScheduleBeforePreview,
}),
];

View File

@@ -9,7 +9,11 @@ import {
import { orgSetups } from "../../evals/fixtures/orgSetups.js";
import { createAutumnApiMock } from "../../evals/harness/index.js";
import {
askedClarification,
askedClarificationBeforeTool,
expectedApiBodyNumberFields,
expectedApiCalls,
expectedApiCallsAfterApproval,
expectedToolCalls,
} from "../../evals/utils/scorers.js";
@@ -227,7 +231,9 @@ describe("eval mock Autumn server", () => {
}),
plans: ({ features, items, plan }) => ({
yearOne: plan.annual({
items: [items.included({ feature: features.credits, included: 5_000 })],
items: [
items.included({ feature: features.credits, included: 5_000 }),
],
planId: "year_one",
}),
yearTwo: plan.annual({
@@ -269,8 +275,7 @@ describe("eval mock Autumn server", () => {
expect(setup.ids.schedules.joeContract).toBe("sched_joe_contract");
expect(setup.schedules[0]?.phases.map((phase) => phase.starts_at)).toEqual([
1_767_225_600_000,
1_798_761_600_000,
1_767_225_600_000, 1_798_761_600_000,
]);
expect(setup.refs.customers.joe.subscriptions).toEqual(
expect.arrayContaining([
@@ -350,6 +355,18 @@ describe("eval mock Autumn server", () => {
endpoint: "/v1/customers.get_or_create",
toolName: "getOrCreateCustomer" as const,
},
{
body: {
customer_id: "joe_customer",
invoice_mode: {
enabled: true,
enable_plan_immediately: true,
finalize: false,
},
},
endpoint: "/v1/billing.preview_attach",
toolName: "previewAttach" as const,
},
],
finalText: "Joe is on Pro for $79 per month.",
toolCalls: [
@@ -377,5 +394,172 @@ describe("eval mock Autumn server", () => {
output,
}),
).toBe(1);
expect(
expectedApiCalls({
expected: {
apiCalls: [
{
body: {
customer_id: "joe_customer",
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
},
toolName: "previewAttach",
},
],
},
output,
}),
).toBe(1);
expect(
askedClarification({
expected: [
{
phrases: ["customer id", "entity name"],
notPhrases: ["deployment"],
type: "response.asked",
},
],
output: {
...output,
turns: [
{
text: "Please provide the customer_id, email, entity_id, and entity name.",
type: "user",
},
],
},
}),
).toBe(1);
expect(
askedClarificationBeforeTool({
expected: [
{
phrases: ["customer id", "entity name"],
toolName: "getOrCreateCustomer",
type: "response.askedBeforeTool",
},
],
output: {
...output,
turns: [
{
text: "Please provide the customer_id, email, entity_id, and entity name.",
toolCalls: [{ args: {}, name: "listPlans" }],
type: "user",
},
],
},
}),
).toBe(1);
expect(
expectedApiCallsAfterApproval({
expected: [
{
call: {
body: { customer_id: "joe_customer" },
toolName: "attach",
},
type: "api.calledAfterApproval",
},
],
output: {
...output,
apiCalls: [
...output.apiCalls,
{
body: { customer_id: "joe_customer" },
endpoint: "/v1/billing.attach",
toolName: "attach",
},
],
turns: [
{
apiCalls: output.apiCalls,
type: "user",
},
{
apiCalls: [
...output.apiCalls,
{
body: { customer_id: "joe_customer" },
endpoint: "/v1/billing.attach",
toolName: "attach",
},
],
type: "approve",
},
],
},
}),
).toBe(1);
expect(
expectedApiBodyNumberFields({
expected: [
{
paths: ["phases.*.starts_at"],
toolName: "previewCreateSchedule",
type: "api.bodyNumberFields",
},
],
output: {
...output,
apiCalls: [
...output.apiCalls,
{
body: {
phases: [
{ starts_at: 1_806_537_600_000 },
{ starts_at: 1_814_400_000_000 },
],
},
endpoint: "/v1/billing.preview_create_schedule",
toolName: "previewCreateSchedule",
},
],
},
}),
).toBe(1);
expect(
expectedApiBodyNumberFields({
expected: [
{
paths: ["phases.*.starts_at"],
toolName: "previewCreateSchedule",
type: "api.bodyNumberFields",
},
],
output: {
...output,
apiCalls: [
...output.apiCalls,
{
body: {
phases: [
{ starts_at: "1 April 2027 00:00 UTC (1806537600000)" },
],
},
endpoint: "/v1/billing.preview_create_schedule",
toolName: "previewCreateSchedule",
},
],
},
}),
).toBe(0);
expect(
expectedApiBodyNumberFields({
expected: [
{
paths: ["phases.*.starts_at"],
toolName: "createSchedule",
type: "api.bodyNumberFields",
},
],
output,
}),
).toBe(0);
});
});

View File

@@ -102,6 +102,7 @@
"s": "ENV_FILE=.env.staging infisical run --env=staging --recursive -- bun scripts/dev.ts",
"l": "bash ./scripts/dev-local.sh",
"setup": "node scripts/setup/setup.js",
"contracts": "infisical run --env=dev --recursive -- bun scripts/s3/contracts.ts",
"setup:s3-admin": "bun scripts/setup/setupS3Admin.ts",
"setup:test": "infisical run --env=dev --recursive -- bun scripts/setup/setup-test.ts",
"stripe:link-test": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/setup/link-test-stripe-account.ts",

View File

@@ -1,30 +1,50 @@
---
name: billing-safety
title: Billing Safety
description: Preview-first rules for Autumn billing changes.
title: Billing Safety and Customization
description: Shared rules for Autumn billing actions, previews, and plan customization.
priority: 0.8
audience:
- assistant
---
# Billing Safety
# Billing Safety and Customization
Billing mutations must be preview-first.
Billing mutations must be preview-first and must carry the exact intended plan customization through preview and write.
- Use previewAttach before attach.
- Use previewUpdateSubscription before updateSubscription.
- Use previewCreateSchedule before createSchedule.
- Use previewCreateBalance before createBalance.
- Use createSchedule only after the user confirms the ordered phases, timing, and preview.
- Default paid billing changes should use a draft invoice: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. Only change if the user specifies otherwise.
- Use createSchedule, not attach, for order forms with multiple years, phases, or future phase fees.
- Do not call a write tool to prepare a billing change. Call the matching preview tool, summarize the material billing impact, then apply only after explicit confirmation of that exact previewed change.
- For confirmed writes, preserve the exact previewed request unless the user asks to change it.
- If a contract gives enough information to build a reasonable billing preview, preview the inferred request instead of asking the user to confirm each inference first.
- Default paid billing changes should use a draft invoice: explicitly set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. Net terms do not imply finalize true. Only change if the user asks to finalize, charge, or pay now.
- Explicitly set redirect_mode if_required unless the user asks to force or disable checkout.
- If the contract says Net N payment terms, set invoice_mode.net_terms_days to N.
- invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.
- Use listFeatures only when customizing plan items or passing non-zero prepaid feature_quantities and the required feature ids/types are not already known.
- Use previewAttach before attach, including feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.
- Use createPlan only after the user confirms the plan configuration.
- Show the user the material billing impact before applying a change.
- Apply a write only after explicit confirmation of the exact previewed change.
- Never claim a billing change was applied unless the write tool succeeds.
Custom plan mapping applies to attach, updateSubscription, and createSchedule:
- Keep commercial terms separate from entitlements: selected plan or phase fees go in plan.customize.price; add_items, remove_items, and update_items are only for feature entitlements.
- Year 1 / Year 2 fees in a 24-month order form are annual phase prices unless the contract says otherwise; do not ask for billing cadence.
- Matching the plan name is not enough when the contract lists fees, limits, or features that define the purchased package.
- Compare contract-listed features and limits to the selected base plan before previewing.
- Boolean feature additions use unlimited true, not included 1.
- "unlimited X" -> unlimited = true.
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
- Credit lines such as "5,000 per month" are often restating the selected base plan. If the base plan already has the same credit grant, reset, and overage rate, omit credits from customize entirely.
- Only customize credits when the contract differs from the base plan. Then set included = N, reset.interval = "month"/"year", and customize the credit_system feature, not each underlying metered feature.
- If a base-plan feature is missing from the contract package, use plan.customize.remove_items for that feature.
- If a contract-listed feature exists in the catalog but is not included in the base plan, use plan.customize.add_items for that feature.
- Do not ask the user to confirm add/remove feature deltas that follow directly from the listed contract package; put them in the preview for approval.
- Do not remove, re-add, or update a listed feature whose amount, reset, and price already match the selected base plan.
- Do not add proration, rollover, reset, or pricing fields to item patches unless the contract or requested change specifies them.
- Prefer patch-style add_items and remove_items for feature differences. Never combine customize.items with add_items, remove_items, or update_items.
- Use customize.items only when the contract fully replaces the plan item set.
Useful docs:
- https://docs.useautumn.com/api-reference/billing/attach
- https://docs.useautumn.com/documentation/concepts/plan-items

View File

@@ -9,7 +9,7 @@ audience:
# Billing Schedules
Use previewCreateSchedule and createSchedule for multi-phase future billing changes.
Use previewCreateSchedule and createSchedule for multi-phase future billing changes. Follow Billing Safety for shared preview-first, invoice mode, and plan customization rules.
Before creating a schedule, resolve:
- customer_id and optional entity_id
@@ -17,20 +17,11 @@ Before creating a schedule, resolve:
- plans in each phase, including versions, feature quantities, and customizations
- redirect_mode, success_url, invoice_mode, and checkout behavior if payment may be required
Default paid schedule billing should use a draft invoice: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false.
invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.
When an agreement gives different recurring fees for different periods, create one schedule phase per priced period and put each fee in that phase's plans[].customize.price. There is no top-level phase plan.price field for schedules; fees are commercial terms, not feature entitlements. Infer the price interval from the contract's period labels and term length unless the agreement states a different billing cadence.
Use listFeatures only when a phase customizes plan items or sets non-zero prepaid feature_quantities and the exact feature ids or types are not already known. Scheduling an existing plan as-is does not need feature lookup.
Use the exact calendar date from the user or contract. Convert date-only schedule starts to midnight UTC unless the user or contract specifies a timezone. Do not shift years when converting dates.
Use the exact calendar date from the user or contract. If a provisioning request or signed order form has no effective/start date, use the current date for the first phase and state that assumption in the preview. Convert date-only schedule starts to midnight UTC unless the user or contract specifies a timezone. If the first phase starts now, pass the current date as epoch milliseconds; never pass literal "now" to createSchedule. Do not shift years when converting dates.
When preview or response data includes starts_at or billing period timestamps, use epochMillisecondsToDate before explaining those timestamps to the user.
If the user says year 1 is already paid or should have no billing changes, do not create an immediate/year-1 phase with a null price or billing_behavior "none". Start the schedule at the first future billing change (for example year 2), then add later phases such as year 3.
Custom feature mapping:
- "N credits per month/year" -> customize.items[].included = N and reset.interval = "month"/"year".
- "unlimited X" -> customize.items[].unlimited = true.
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
- Credit systems should customize the credit_system feature, not each underlying metered feature.
There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation.

View File

@@ -20,3 +20,4 @@ Prefer server-side filters before local filtering:
Use limit 1000 for broad scans; that is the maximum page size.
Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers.
For operational billing requests, search by the customer name or email from the user or contract before saying customer_id is missing. If customer search does not resolve a match, ask for customer_id. If agent rules require entity-scoped billing, call listEntities with the resolved customer_id; if that does not resolve a match, ask for entity_id.

View File

@@ -29,7 +29,7 @@ Plan and billing usage:
- Credit system: grant the credit_system feature, not each underlying metered feature.
- Prepaid quantity changes belong in feature_quantities; custom contract grants or item-level prices belong in customize.
For attach and updateSubscription, prefer patch-style customize.add_items, customize.remove_items, or customize.update_items when changing only part of an existing plan. customize.items replaces the full custom item list. createSchedule currently supports replacement-style customize.items for each phase.
Follow Billing Safety for endpoint-specific customize semantics such as patch-style add_items/remove_items versus full customize.items replacement.
Useful docs:
- https://docs.useautumn.com/documentation/pricing/features

View File

@@ -20,7 +20,6 @@ Use Autumn tools as composable primitives.
- Use createPlan for confirmed plan configuration writes.
- Use previewCreateBalance before createBalance for standalone balance or credit grants.
- Use previewCreateSchedule before createSchedule for multi-phase billing schedules.
- For custom feature grants, map "per month/year" to customize.items[].reset.interval.
- Use epochMillisecondsToDate before explaining epoch millisecond response fields such as starts_at, expires_at, next_reset_at, or billing period timestamps.
- For billing writes, always preview first and wait for explicit user confirmation before applying.

View File

@@ -7,9 +7,9 @@ const resourceFiles = [
"./plans/querying-plans.md",
"./plans/creating-plans.md",
"./customers/querying-customers.md",
"./billing/billing-safety.md",
"./billing/schedules.md",
"./balances/standalone-balances.md",
"./billing/billing-safety.md",
"./logs/request-logs.md",
"./logs/customers.md",
"./logs/balances.md",

View File

@@ -1,3 +1,5 @@
import { InvoiceModeParamsSchema } from "@api/billing/common/invoiceModeParams";
import { RedirectModeSchema } from "@api/billing/common/redirectMode";
import {
AttachParamsV1Schema,
CreateScheduleParamsV0Schema,
@@ -16,10 +18,51 @@ const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
}),
});
const invoiceModeMcpSchema = InvoiceModeParamsSchema.extend({
finalize: z.boolean().default(true).meta({
description:
"Set false for preview-first agent workflows unless the user explicitly asks to finalize, charge, pay, or send the invoice now.",
}),
});
const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
invoice_mode: invoiceModeMcpSchema.optional().meta({
description:
"Invoice mode for billing schedules. For paid agent previews, set enabled true, enable_plan_immediately true, finalize false, and include net_terms_days when specified.",
}),
phases: z
.tuple([createSchedulePhaseMcpSchema])
.rest(createSchedulePhaseMcpSchema),
redirect_mode: RedirectModeSchema.default("if_required").meta({
description:
"Set if_required for paid agent previews unless the user explicitly asks to force or disable checkout.",
}),
}).superRefine((data, ctx) => {
if (data.invoice_mode?.enabled !== true) return;
if (data.invoice_mode.finalize !== false) {
ctx.addIssue({
code: "custom",
message:
"Paid schedule previews must use invoice_mode.finalize false unless a supported override path is added.",
path: ["invoice_mode", "finalize"],
});
}
if (data.redirect_mode !== "if_required") {
ctx.addIssue({
code: "custom",
message:
"Paid schedule previews must use redirect_mode if_required unless a supported override path is added.",
path: ["redirect_mode"],
});
}
if (data.enable_plan_immediately !== true) {
ctx.addIssue({
code: "custom",
message:
"Paid schedule previews must set top-level enable_plan_immediately true.",
path: ["enable_plan_immediately"],
});
}
});
const endpoints = {
@@ -51,11 +94,9 @@ const domain = {
id: "previewAttach",
description: `
- Preview attaching a plan before attach.
- Include feature_quantities and custom items/prices.
- Map recurring custom grants like 'per month/year' to reset.interval.
- Default paid attach billing: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false.
- Only change the default invoice mode if the user asks for checkout, immediate finalization/payment, no invoice, or delayed access.
- invoice_mode requires customer email; if missing, call updateCustomer first.
- Follow Billing Safety for preview-first, invoice, and customization rules.
- Monetary amounts are major units: $49 is amount 49, not 4900.
- Preview summary should mention draft invoice, not finalized, and immediate access.
`.trim(),
writeToolName: "attach",
}),
@@ -63,27 +104,18 @@ const domain = {
id: "previewUpdateSubscription",
description: `
- Preview updating a subscription before updateSubscription.
- Include quantity and custom item changes.
- Recurring custom grants need reset.interval.
- Follow Billing Safety for preview-first, invoice, and customization rules.
`.trim(),
writeToolName: "updateSubscription",
}),
billingPreview({
id: "previewCreateSchedule",
description: `
- Preview billing impact of a multi-phase schedule before createSchedule.
- First phase starts_at must be explicit: now or a past/backdated date.
- Do not infer first starts_at from 'year 1' or use a future first phase.
- Ask before previewing if first phase start is unclear.
- Preserve exact user/contract dates for later phases.
- Use redirect_mode if_required unless user asks otherwise.
- Default paid schedule billing: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false.
- Only change the default invoice mode if the user asks for checkout, immediate finalization/payment, no invoice, or delayed access.
- invoice_mode requires customer email; if missing, call updateCustomer first.
- Preview billing impact of a multi-phase schedule or multi-year order form before createSchedule.
- Follow Billing Safety and Billing Schedules.
- Set Billing Safety invoice and redirect defaults explicitly.
- Put phase fees in plans[].customize.price; use customize item patches only for feature entitlements.
- Inspect customer first when changing an existing/customer contract schedule.
- Put schedule feature overrides in plan.customize.items, not feature_quantities.
- Map recurring grants like 'per month/year' to reset.interval month/year.
- If year 1 is already paid/no billing changes, omit it.
`.trim(),
writeToolName: "createSchedule",
}),
@@ -93,35 +125,24 @@ const domain = {
id: "attach",
description: `
- Attach a plan to a customer.
- Destructive: preview first.
- Preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.
- Preserve the previewed billing mode. Default paid attach billing uses enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false.
- invoice_mode requires customer email; if missing, call updateCustomer first.
- Follow Billing Safety for preview-first, invoice, and customization rules.
- Monetary amounts are major units: $49 is amount 49, not 4900.
`.trim(),
}),
confirmedWrite({
id: "updateSubscription",
description: `
- Update a subscription.
- Destructive: preview first.
- Preserve quantity/custom item changes and reset intervals from the previewed request.
- Follow Billing Safety for preview-first, invoice, and customization rules.
`.trim(),
}),
confirmedWrite({
id: "createSchedule",
description: `
- Create a multi-phase billing schedule.
- Destructive: preview first.
- Preserve phase starts_at and redirect_mode values from the previewed request.
- First phase starts_at must be explicit: now or a past/backdated date.
- Do not infer first starts_at from 'year 1' or use a future first phase.
- Ask before creating if first phase start is unclear.
- Use redirect_mode if_required unless user asks otherwise.
- Preserve the previewed billing mode. Default paid schedule billing uses enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false.
- invoice_mode requires customer email; if missing, call updateCustomer first.
- Inspect customer first when changing an existing/customer contract schedule.
- Put schedule feature overrides in plan.customize.items, not feature_quantities.
- If year 1 is already paid/no billing changes, omit it.
- Create a multi-phase billing schedule for phased or multi-year order forms.
- Follow Billing Safety and Billing Schedules.
- Preserve explicit Billing Safety invoice and redirect defaults from the preview.
- Preserve phase fees in plans[].customize.price and feature changes in customize item patches.
`.trim(),
}),
],

View File

@@ -42,8 +42,11 @@ const domain = {
}),
operation({
id: "getOrCreateCustomer",
description:
"Get an existing Autumn customer by id, or create it if missing. Use when the user explicitly wants a customer record created.",
description: `
- Get an existing Autumn customer by id, or create it if missing.
- Use only when the user explicitly wants a customer record created.
- Include email when creating a customer for invoice-mode billing.
`.trim(),
idempotent: true,
}),
operation({

View File

@@ -0,0 +1,52 @@
import {
CreateEntityParamsV1Schema,
GetEntityParamsV0Schema,
ListEntitiesV2_3ParamsSchema,
} from "@autumn/shared";
import { createDomainTools } from "./utils/builders.js";
import type { ToolDomain } from "./utils/types.js";
const endpoints = {
createEntity: "/v1/entities.create",
getEntity: "/v1/entities.get",
listEntities: "/v1/entities.list",
} as const;
const schemas = {
createEntity: CreateEntityParamsV1Schema,
getEntity: GetEntityParamsV0Schema,
listEntities: ListEntitiesV2_3ParamsSchema,
} as const;
const { operation } = createDomainTools({ endpoints, schemas });
const domain = {
operations: [
operation({
id: "createEntity",
description: `
- Create an entity under a customer.
- Use when the user provides customer_id, entity_id, and entity name.
- For entity-scoped attach, create missing entities before previewAttach.
`.trim(),
idempotent: true,
}),
operation({
id: "listEntities",
description: `
- List entities across the current org.
- Pass customer_id to list entities for one customer.
- Use before entity-scoped billing or balance work when entity ids are unknown.
`.trim(),
}),
operation({
id: "getEntity",
description: `
- Fetch one entity by entity_id.
- Include customer_id when known to avoid lookup ambiguity.
`.trim(),
}),
],
} satisfies ToolDomain;
export const entities = { endpoints, schemas, domain };

View File

@@ -18,8 +18,11 @@ const domain = {
operations: [
operation({
id: "listFeatures",
description:
"List Autumn features. Use when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids, types, credit systems, or consumable behavior are not already known.",
description: `
- List Autumn features for the current org.
- Use before custom plan/schedule items from feature names, aliases, or typos.
- Use when feature IDs, types, credit systems, or consumable behavior are unknown.
`.trim(),
}),
],
} satisfies ToolDomain;

View File

@@ -7,6 +7,7 @@ import { agent } from "./agent.js";
import { balances } from "./balances.js";
import { billing } from "./billing.js";
import { customers } from "./customers.js";
import { entities } from "./entities.js";
import { features } from "./features.js";
import { logs } from "./logs.js";
import { orgTools } from "./org.js";
@@ -37,6 +38,7 @@ export {
export const endpointByTool = {
...agent.endpoints,
...customers.endpoints,
...entities.endpoints,
...features.endpoints,
...plans.endpoints,
...billing.endpoints,
@@ -48,6 +50,7 @@ export const endpointByTool = {
export const schemaByTool = {
...agent.schemas,
...customers.schemas,
...entities.schemas,
...features.schemas,
...plans.schemas,
...billing.schemas,
@@ -61,6 +64,7 @@ export const schemaByTool = {
const domains: ToolDomain[] = [
agent.domain,
customers.domain,
entities.domain,
features.domain,
plans.domain,
billing.domain,

View File

@@ -2,12 +2,11 @@ import { createTool } from "@mastra/core/tools";
import { isValid, parseISO } from "date-fns";
import * as z from "zod/v4";
/**
* Parses an ISO date/timestamp string to UTC epoch milliseconds. Date-only
* values (`YYYY-MM-DD`) and zone-less timestamps are treated as UTC. Returns
* `null` when the input is not a valid date.
*/
/** Parses ISO-like values to UTC epoch milliseconds. */
const parseToEpochMilliseconds = (value: string): number | null => {
const parenthesizedEpoch = value.match(/\((\d{12,})\)/)?.[1];
if (parenthesizedEpoch) return Number(parenthesizedEpoch);
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
? `${value}T00:00:00.000`
: value;
@@ -16,7 +15,7 @@ const parseToEpochMilliseconds = (value: string): number | null => {
return isValid(parsed) ? parsed.getTime() : null;
};
/** Accepts epoch milliseconds or an ISO date/timestamp string; outputs epoch ms. */
/** Accepts epoch milliseconds or an ISO date/timestamp string. */
export const epochMillisecondsSchema = z
.union([z.number(), z.string()])
.transform((value, context) => {

View File

@@ -42,6 +42,8 @@ describe("Autumn operation tools", () => {
expect(tools.listFeatures.description).toContain("List Autumn features");
expect(tools.listCustomers.description).toContain("plans");
expect(tools.listCustomers.description).toContain("paginate");
expect(tools.createEntity.description).toContain("entity_id");
expect(tools.createEntity.description).toContain("entity name");
expect(tools.updateCustomer.description).toContain("invoice_mode");
expect(tools.updateCustomer.description).toContain("Stripe");
expect(tools.createPlan.description).toContain("confirmation");
@@ -51,6 +53,9 @@ describe("Autumn operation tools", () => {
expect(tools.getAgentRules.description).toContain("agent rules");
expect(tools.getAgentRules.description).toContain("Use before customer");
expect(tools.updateAgentRules.description).toContain("agent rules");
expect(tools.listEntities.description).toContain("customer_id");
expect(tools.listEntities.description).toContain("one customer");
expect(tools.getEntity.description).toContain("entity_id");
expect(tools.previewCreateBalance.description).toContain("Does not mutate");
expect(tools.createSchedule.description).toContain("starts_at");
expect(tools.previewCreateSchedule.description).toContain("billing impact");
@@ -112,6 +117,9 @@ describe("Autumn operation tools", () => {
"getCurrentOrganization",
"getAgentRules",
"updateAgentRules",
"createEntity",
"listEntities",
"getEntity",
] as const) {
expect(tools[name].mcp?.annotations?.destructiveHint).toBe(false);
}
@@ -137,6 +145,49 @@ describe("Autumn operation tools", () => {
expect(createAgentAutumnOperationTools().getAgentRules).toBeDefined();
});
test("entity tools expose create, list, and get schemas", () => {
expect(endpointByTool.createEntity).toBe("/v1/entities.create");
expect(endpointByTool.listEntities).toBe("/v1/entities.list");
expect(endpointByTool.getEntity).toBe("/v1/entities.get");
expect(
schemaByTool.createEntity.parse({
customer_id: "cus_123",
entity_id: "workspace_1",
feature_id: "workspaces",
name: "Workspace 1",
}),
).toEqual({
customer_id: "cus_123",
entity_id: "workspace_1",
feature_id: "workspaces",
name: "Workspace 1",
});
expect(
schemaByTool.listEntities.parse({
customer_id: "cus_123",
limit: 10,
start_cursor: "",
}),
).toMatchObject({
customer_id: "cus_123",
limit: 10,
start_cursor: "",
});
expect(
schemaByTool.getEntity.parse({
customer_id: "cus_123",
entity_id: "workspace_1",
}),
).toEqual({
customer_id: "cus_123",
entity_id: "workspace_1",
});
expect(createAgentAutumnOperationTools().createEntity).toBeDefined();
expect(createAgentAutumnOperationTools().listEntities).toBeDefined();
expect(createAgentAutumnOperationTools().getEntity).toBeDefined();
});
test("updateAgentRules accepts partial rules and rejects unknown fields", () => {
expect(endpointByTool.updateAgentRules).toBe("/v1/agent.update_rules");
expect(

View File

@@ -10,6 +10,11 @@ import {
type AutumnMcpAuth,
createRequestContext,
} from "../../src/server/auth/auth.js";
const leafChatAgentDefaults = {
maxSteps: 8,
model: "anthropic/claude-opus-4-8",
} as const;
import { createAutumnOperationsMCPServer } from "../../src/server/server.js";
import { endpointByTool, schemaByTool } from "../../src/tools/index.js";
@@ -140,7 +145,7 @@ const createMcpConsumerAgent = async (auth: AutumnMcpAuth) => {
name: "MCP Consumer Eval",
description: "A generic agent using MCP tools.",
instructions: "You are a helpful assistant.",
model: "anthropic/claude-sonnet-4-6",
model: leafChatAgentDefaults.model,
tools,
});
const mastra = new Mastra({
@@ -264,7 +269,10 @@ export const initMcpEval = ({
}
: null;
};
const generate = async (message: string | string[], maxSteps = 4) => {
const generate = async (
message: string | string[],
maxSteps = leafChatAgentDefaults.maxSteps,
) => {
messages.push({
role: "user",
content: Array.isArray(message) ? message.join("\n") : message,
@@ -283,7 +291,10 @@ export const initMcpEval = ({
auth: resolvedAuth,
toolCalls,
generate,
approve: async (message: string, maxSteps = 4) => {
approve: async (
message: string,
maxSteps = leafChatAgentDefaults.maxSteps,
) => {
if (!pendingApproval) await generate(message, maxSteps);
if (!pendingApproval) {
throw new Error("No pending MCP tool approval to approve.");

4
run.sh
View File

@@ -29,9 +29,9 @@ run_leaf_eval() {
cd "$repo_root/apps/leaf"
if [[ -n "$filter" && "$filter" != ".*" ]]; then
exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core --filter "evalName=$filter" "${args[@]}"
exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core pino thread-stream --filter "evalName=$filter" "${args[@]}"
fi
exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core "${args[@]}"
exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core pino thread-stream "${args[@]}"
}
if [[ "$resolved" == "$repo_root/server/"* ]]; then

405
scripts/s3/contracts.ts Normal file
View File

@@ -0,0 +1,405 @@
#!/usr/bin/env bun
import { createHash } from "node:crypto";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import {
BucketAlreadyExists,
BucketAlreadyOwnedByYou,
type BucketLocationConstraint,
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
const DEFAULT_BUCKET = "leaf-dev-contracts";
const DEFAULT_LOCAL_DIR = "apps/leaf/contracts";
const DEFAULT_PREFIX = "contracts";
const DEFAULT_REGION = "eu-west-2";
type ContractsCommand = "pull" | "push";
type ContractsArgs = {
bucket: string;
command: ContractsCommand;
localDir: string;
prefix: string;
region: string;
};
type LocalFile = {
bytes: Buffer;
path: string;
relativePath: string;
sha256: string;
};
type RemoteObject = {
key: string;
relativePath: string;
size: number;
};
const usage = () => {
console.log(`Usage:
bun scripts/s3/contracts.ts pull [options]
bun scripts/s3/contracts.ts push [options]
Options:
--local-dir <path> Local contracts directory (default: ${DEFAULT_LOCAL_DIR})
--bucket <bucket> S3 bucket (default: ${DEFAULT_BUCKET})
--prefix <prefix> S3 prefix (default: ${DEFAULT_PREFIX})
--region <region> AWS region (default: ${DEFAULT_REGION})
Package commands:
bun contracts pull
bun contracts push`);
};
const getHttpStatusCode = ({ error }: { error: unknown }) => {
if (!error || typeof error !== "object") return undefined;
return (error as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode;
};
const isMissingS3ResourceError = ({ error }: { error: unknown }) => {
if (error instanceof Error) {
if (error.name === "NotFound" || error.name === "NoSuchBucket") {
return true;
}
}
return getHttpStatusCode({ error }) === 404;
};
const createS3Client = ({ region }: { region: string }) => {
return new S3Client({ region });
};
type BucketState = "exists" | "missing" | "unknown";
const getBucketState = async ({
bucket,
region,
s3Client,
}: {
bucket: string;
region: string;
s3Client: S3Client;
}): Promise<BucketState> => {
try {
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
return "exists";
} catch (error) {
if (isMissingS3ResourceError({ error })) return "missing";
const statusCode = getHttpStatusCode({ error });
if (statusCode === 301 || statusCode === 403) {
console.warn(
`Could not confirm bucket ${bucket} in ${region}; trying CreateBucket before failing.`,
);
return "unknown";
}
throw error;
}
};
const ensureBucketExists = async ({
bucket,
region,
s3Client,
}: {
bucket: string;
region: string;
s3Client: S3Client;
}) => {
const state = await getBucketState({ bucket, region, s3Client });
if (state === "exists") {
console.log(`Bucket already exists: ${bucket}`);
return;
}
try {
await s3Client.send(
new CreateBucketCommand({
Bucket: bucket,
...(region === "us-east-1"
? {}
: {
CreateBucketConfiguration: {
LocationConstraint: region as BucketLocationConstraint,
},
}),
}),
);
console.log(`Created bucket: ${bucket}`);
} catch (error) {
if (error instanceof BucketAlreadyOwnedByYou) {
console.log(`Bucket already owned by this account: ${bucket}`);
return;
}
if (error instanceof BucketAlreadyExists) {
throw new Error(
`Bucket ${bucket} already exists globally but is not owned by this AWS account. Set LEAF_CONTRACTS_BUCKET to an owned bucket name.`,
);
}
if (getHttpStatusCode({ error }) === 403) {
throw new Error(
`Current AWS credentials cannot create or access bucket ${bucket}. Check IAM permissions or set LEAF_CONTRACTS_BUCKET.`,
);
}
throw error;
}
};
const normalizePrefix = ({ prefix }: { prefix: string }) =>
prefix.replace(/^\/+|\/+$/g, "");
const keyForRelativePath = ({
prefix,
relativePath,
}: {
prefix: string;
relativePath: string;
}) => [normalizePrefix({ prefix }), relativePath].filter(Boolean).join("/");
const relativePathForKey = ({
key,
prefix,
}: {
key: string;
prefix: string;
}) => {
const normalizedPrefix = normalizePrefix({ prefix });
if (!normalizedPrefix) return key;
return key.startsWith(`${normalizedPrefix}/`)
? key.slice(normalizedPrefix.length + 1)
: key;
};
const contentTypeForPath = ({ path }: { path: string }) => {
if (path.endsWith(".pdf")) return "application/pdf";
if (path.endsWith(".html")) return "text/html; charset=utf-8";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".md")) return "text/markdown; charset=utf-8";
if (path.endsWith(".txt")) return "text/plain; charset=utf-8";
return "application/octet-stream";
};
const toPosixPath = ({ path }: { path: string }) => path.split(sep).join("/");
const collectLocalFiles = async ({
baseDir,
currentDir = baseDir,
}: {
baseDir: string;
currentDir?: string;
}): Promise<LocalFile[]> => {
const entries = await readdir(currentDir, { withFileTypes: true }).catch(
(error: unknown) => {
if ((error as { code?: string }).code === "ENOENT") return [];
throw error;
},
);
const files: LocalFile[] = [];
for (const entry of entries) {
const path = join(currentDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectLocalFiles({ baseDir, currentDir: path })));
continue;
}
if (!entry.isFile()) continue;
const bytes = Buffer.from(await Bun.file(path).arrayBuffer());
files.push({
bytes,
path,
relativePath: toPosixPath({ path: relative(baseDir, path) }),
sha256: createHash("sha256").update(bytes).digest("hex"),
});
}
return files;
};
const listRemoteObjects = async ({
bucket,
prefix,
s3Client,
}: {
bucket: string;
prefix: string;
s3Client: S3Client;
}) => {
const normalizedPrefix = normalizePrefix({ prefix });
const objects: RemoteObject[] = [];
let continuationToken: string | undefined;
do {
const response = await s3Client.send(
new ListObjectsV2Command({
Bucket: bucket,
ContinuationToken: continuationToken,
Prefix: normalizedPrefix ? `${normalizedPrefix}/` : undefined,
}),
);
for (const object of response.Contents ?? []) {
if (!object.Key || object.Key.endsWith("/")) continue;
objects.push({
key: object.Key,
relativePath: relativePathForKey({ key: object.Key, prefix }),
size: object.Size ?? 0,
});
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return objects;
};
const pullContracts = async ({ args }: { args: ContractsArgs }) => {
const s3Client = createS3Client({ region: args.region });
await ensureBucketExists({
bucket: args.bucket,
region: args.region,
s3Client,
});
const localDir = resolve(args.localDir);
const objects = await listRemoteObjects({
bucket: args.bucket,
prefix: args.prefix,
s3Client,
});
await rm(localDir, { force: true, recursive: true });
await mkdir(localDir, { recursive: true });
for (const object of objects) {
const response = await s3Client.send(
new GetObjectCommand({ Bucket: args.bucket, Key: object.key }),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) continue;
const targetPath = join(localDir, object.relativePath);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, Buffer.from(bytes));
console.log(`Pulled s3://${args.bucket}/${object.key} -> ${targetPath}`);
}
console.log("");
console.log(`Pulled ${objects.length} file(s) into ${localDir}`);
};
const pushContracts = async ({ args }: { args: ContractsArgs }) => {
const s3Client = createS3Client({ region: args.region });
await ensureBucketExists({
bucket: args.bucket,
region: args.region,
s3Client,
});
const localDir = resolve(args.localDir);
const [localFiles, remoteObjects] = await Promise.all([
collectLocalFiles({ baseDir: localDir }),
listRemoteObjects({ bucket: args.bucket, prefix: args.prefix, s3Client }),
]);
const localKeys = new Set(
localFiles.map((file) =>
keyForRelativePath({
prefix: args.prefix,
relativePath: file.relativePath,
}),
),
);
for (const file of localFiles) {
const key = keyForRelativePath({
prefix: args.prefix,
relativePath: file.relativePath,
});
await s3Client.send(
new PutObjectCommand({
Body: file.bytes,
Bucket: args.bucket,
ContentType: contentTypeForPath({ path: file.relativePath }),
Key: key,
Metadata: { sha256: file.sha256 },
}),
);
console.log(`Pushed ${file.path} -> s3://${args.bucket}/${key}`);
}
const staleObjects = remoteObjects.filter(
(object) => !localKeys.has(object.key),
);
for (let index = 0; index < staleObjects.length; index += 1000) {
const batch = staleObjects.slice(index, index + 1000);
await s3Client.send(
new DeleteObjectsCommand({
Bucket: args.bucket,
Delete: {
Objects: batch.map((object) => ({ Key: object.key })),
Quiet: true,
},
}),
);
for (const object of batch) {
console.log(`Deleted s3://${args.bucket}/${object.key}`);
}
}
console.log("");
console.log(`Pushed ${localFiles.length} file(s) from ${localDir}`);
console.log(`Deleted ${staleObjects.length} stale remote file(s)`);
};
const parseArgs = ({ argv }: { argv: string[] }): ContractsArgs => {
const [command, ...options] = argv;
if (!command || command === "--help" || command === "-h") {
usage();
process.exit(0);
}
if (command !== "pull" && command !== "push") {
throw new Error(`Unknown command: ${command}`);
}
const args: ContractsArgs = {
bucket: process.env.LEAF_CONTRACTS_BUCKET ?? DEFAULT_BUCKET,
command,
localDir: process.env.LEAF_CONTRACTS_LOCAL_DIR ?? DEFAULT_LOCAL_DIR,
prefix: process.env.LEAF_CONTRACTS_PREFIX ?? DEFAULT_PREFIX,
region: process.env.LEAF_CONTRACTS_REGION ?? DEFAULT_REGION,
};
for (let index = 0; index < options.length; index += 1) {
const option = options[index];
const value = options[index + 1];
if (!option?.startsWith("--")) continue;
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${option}`);
}
index += 1;
if (option === "--local-dir") args.localDir = value;
else if (option === "--bucket") args.bucket = value;
else if (option === "--prefix") args.prefix = value;
else if (option === "--region") args.region = value;
else throw new Error(`Unknown option: ${option}`);
}
return args;
};
const main = async () => {
const args = parseArgs({ argv: Bun.argv.slice(2) });
if (args.command === "pull") {
await pullContracts({ args });
return;
}
await pushContracts({ args });
};
await main();

View File

@@ -10,6 +10,14 @@ export type HttpMethodFilter =
| "PATCH"
| "DELETE";
export const isBillingUrl = ({
urlExpression = "['req.url']",
}: { urlExpression?: string } = {}) => {
const path = `tostring(parse_url(${urlExpression}).path)`;
return `(${path} startswith '/v1/billing' or ${path} startswith '/billing' or ${path} startswith '/v1/attach' or ${path} startswith '/v1/cancel')`;
};
const statusBucketClause = (bucket: StatusBucket): string | null => {
switch (bucket) {
case "2xx":

View File

@@ -16,3 +16,22 @@ export const axiomNumberFrom = (value: unknown) => {
export const axiomStringFrom = (value: unknown) =>
typeof value === "string" ? value : "";
export const getAxiomResultDebug = ({ result }: { result: unknown }) => {
if (!result || typeof result !== "object") {
return { result_type: typeof result };
}
const record = result as {
datasetNames?: unknown;
matches?: unknown;
status?: unknown;
};
return {
dataset_names: record.datasetNames,
match_count: Array.isArray(record.matches) ? record.matches.length : null,
result_keys: Object.keys(result),
status: record.status,
};
};

View File

@@ -2,6 +2,32 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { generateAgentRules } from "../../workflows/generateAgentRules/generateAgentRules.js";
import { agentRulesRepo } from "../repos/index.js";
const mergeGeneratedRules = ({
existing,
generated,
}: {
existing: Awaited<ReturnType<typeof agentRulesRepo.get>>;
generated: Awaited<ReturnType<typeof generateAgentRules>>["rules"];
}) => {
const generatedEntityFeatureId = generated.entity_rules.entity_feature_id;
return {
credit_rules: {
credit_feature_id:
generated.credit_rules.credit_feature_id ||
existing.credit_rules.credit_feature_id,
},
entity_rules: {
attach_to_entities: generatedEntityFeatureId
? generated.entity_rules.attach_to_entities
: existing.entity_rules.attach_to_entities,
entity_feature_id:
generatedEntityFeatureId || existing.entity_rules.entity_feature_id,
},
notes: existing.notes,
};
};
export const generateAndUpdateAgentRules = async ({
ctx,
endTime,
@@ -12,12 +38,19 @@ export const generateAndUpdateAgentRules = async ({
startTime?: string;
}) => {
const generated = await generateAgentRules({ ctx, endTime, startTime });
const existing = await agentRulesRepo.get({
db: ctx.db,
orgId: ctx.org.id,
});
const rules = await agentRulesRepo.upsert({
db: ctx.db,
metadata: generated.metadata,
orgId: ctx.org.id,
orgSlug: ctx.org.slug,
rules: generated.rules,
rules: mergeGeneratedRules({
existing,
generated: generated.rules,
}),
});
return {

View File

@@ -1,5 +1,6 @@
import { mergeAgentRules, type PartialAgentRules } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cleanAgentNotes } from "../../workflows/generateAgentRules/cleanAgentNotes.js";
import { agentRulesRepo } from "../repos/index.js";
export const updateAgentRules = async ({
@@ -13,13 +14,20 @@ export const updateAgentRules = async ({
db: ctx.db,
orgId: ctx.org.id,
});
const cleanUpdates =
updates.notes === undefined
? updates
: {
...updates,
notes: await cleanAgentNotes({ ctx, notes: updates.notes }),
};
const rules = mergeAgentRules({
base: {
credit_rules: existing.credit_rules,
entity_rules: existing.entity_rules,
notes: existing.notes,
},
updates,
updates: cleanUpdates,
});
return agentRulesRepo.upsert({

View File

@@ -0,0 +1,60 @@
import { generateText } from "ai";
import { anthropicClient } from "@/external/ai/initAi.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
const stripMarkdownFence = ({ text }: { text: string }) =>
text
.trim()
.replace(/^```(?:md|markdown)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const buildPrompt = ({ notes }: { notes: string }) => `
You format org-specific instructions for an Autumn billing and entitlements agent.
The user may have typed rough thoughts, fragments, or spitballs about how the agent should behave for this organization.
Rules:
- Keep only instructions that affect agent behavior.
- Be super concise.
- Use markdown bullets, one line per bullet.
- Preserve concrete IDs, feature names, product names, and policy terms.
- Do not add policies, assumptions, or explanations.
- Return an empty string if there is nothing actionable.
- Return only the cleaned notes.
<notes>
${notes}
</notes>
`.trim();
export const cleanAgentNotes = async ({
ctx,
notes,
}: {
ctx: AutumnContext;
notes: string;
}) => {
const trimmedNotes = notes.trim();
if (!trimmedNotes || !anthropicClient) return trimmedNotes;
try {
const result = await generateText({
model: anthropicClient("claude-opus-4-6"),
prompt: buildPrompt({ notes: trimmedNotes }),
});
return stripMarkdownFence({ text: result.text });
} catch (error) {
ctx.logger.warn(
{
data2: {
error: error instanceof Error ? error.message : String(error),
},
},
"[AgentRules] Failed to clean notes",
);
return trimmedNotes;
}
};

View File

@@ -20,13 +20,29 @@ export const generateAgentRules = async ({
generateEntityRules({ ctx, endTime, startTime }),
generateCreditRules({ ctx, endTime, startTime }),
]);
return {
rules: AgentRulesSchema.parse({
const rules = AgentRulesSchema.parse({
credit_rules: creditResult.creditRules,
entity_rules: entityResult.entityRules,
notes: "",
}),
});
ctx.logger.info(
{
data2: {
env: ctx.env,
org_id: ctx.org.id,
org_slug: ctx.org.slug,
rules,
time_range: { endTime, startTime },
unconfigured:
entityResult.unconfigured || creditResult.unconfigured || undefined,
},
},
"[AgentRules] Generated rules",
);
return {
rules,
metadata: {
credit_rules: creditResult.metadata,
entity_rules: entityResult.metadata,

View File

@@ -10,6 +10,7 @@ import { queryAxiom } from "@/external/axiom/queryAxiom.js";
import { escapeApl } from "@/external/axiom/utils/aplUtils.js";
import {
axiomStringFrom,
getAxiomResultDebug,
getAxiomMatchData,
} from "@/external/axiom/utils/resultUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
@@ -61,6 +62,9 @@ const resolveCreditFeatureId = ({
return "";
};
const resolveFallbackCreditFeatureId = ({ features }: { features: Feature[] }) =>
features.find(isConsumableCreditSystem)?.id ?? "";
const getFeatures = async ({ ctx }: { ctx: AutumnContext }) =>
ctx.features.length > 0
? ctx.features
@@ -107,18 +111,41 @@ export const generateCreditRules = async ({
const trackedFeatureIds = getAxiomMatchData(trackedFeatureResult).map(
(match) => axiomStringFrom(match.selected_feature_id),
);
const creditRules = {
credit_feature_id: resolveCreditFeatureId({
const trackedCreditFeatureId = resolveCreditFeatureId({
features,
trackedFeatureIds,
}),
});
const fallbackCreditFeatureId = resolveFallbackCreditFeatureId({ features });
const inferenceSource = trackedCreditFeatureId
? "axiom_tracked_features"
: "features_fallback";
const creditRules = {
credit_feature_id: trackedCreditFeatureId || fallbackCreditFeatureId,
} satisfies CreditRules;
ctx.logger.info(
{
data2: {
axiom_result: getAxiomResultDebug({ result: trackedFeatureResult }),
credit_rules: creditRules,
env: ctx.env,
fallback_credit_feature_id: fallbackCreditFeatureId,
inference_source: inferenceSource,
org_id: ctx.org.id,
org_slug: ctx.org.slug,
time_range: { endTime, startTime },
top_tracked_feature_ids: trackedFeatureIds,
},
},
"[AgentRules] Generated credit rules",
);
return {
creditRules,
metadata: {
credit_feature_id: creditRules.credit_feature_id,
generated_from: "axiom",
inference_source: inferenceSource,
top_tracked_feature_ids: trackedFeatureIds,
},
};

View File

@@ -1,10 +1,11 @@
import { AgentRulesSchema, type EntityRules } from "@autumn/shared";
import { AgentRulesSchema, entities, type EntityRules } from "@autumn/shared";
import { and, count, desc, eq, isNotNull } from "drizzle-orm";
import { isAxiomConfigured } from "@/external/axiom/initAxiom.js";
import { queryAxiom } from "@/external/axiom/queryAxiom.js";
import { escapeApl } from "@/external/axiom/utils/aplUtils.js";
import { escapeApl, isBillingUrl } from "@/external/axiom/utils/aplUtils.js";
import {
axiomNumberFrom,
axiomStringFrom,
getAxiomResultDebug,
getAxiomMatchData,
} from "@/external/axiom/utils/resultUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
@@ -15,24 +16,33 @@ const attachScopeApl = ({ ctx }: { ctx: AutumnContext }) =>
| where isnotnull(statusCode)
| where ['context.org_id'] == '${escapeApl(ctx.org.id)}'
| where ['context.env'] == '${ctx.env}'
| where (['req.url'] endswith '/v1/billing.attach' or ['req.url'] endswith '/v1/billing.update' or ['req.url'] endswith '/v1/billing.preview_attach' or ['req.url'] endswith '/v1/billing.preview_update' or ['req.url'] endswith '/v1/attach')
| where ${isBillingUrl()}
| summarize total=count(), with_entity=countif(isnotempty(tostring(['req.body']['entity_id'])))
| extend entity_ratio = todouble(with_entity) / todouble(total)
`.trim();
const entityFeatureApl = ({ ctx }: { ctx: AutumnContext }) =>
`
['express']
| where isnotnull(statusCode)
| where ['context.org_id'] == '${escapeApl(ctx.org.id)}'
| where ['context.env'] == '${ctx.env}'
| where isnotempty(tostring(['req.body']['entity_id'])) or isnotempty(['context.entity_id']) or isnotempty(['req.entity_id'])
| extend body_feature_id = tostring(['req.body']['feature_id'])
| extend selected_feature_id = case(isnotempty(body_feature_id), body_feature_id, isnotempty(feature_id), feature_id, isnotempty(featureId), featureId, '')
| where isnotempty(selected_feature_id)
| summarize total=count() by selected_feature_id
| top 1 by total
`.trim();
const getTopEntityFeature = async ({ ctx }: { ctx: AutumnContext }) => {
const rows = await ctx.db
.select({
entity_count: count(),
feature_id: entities.feature_id,
})
.from(entities)
.where(
and(
eq(entities.org_id, ctx.org.id),
eq(entities.env, ctx.env),
eq(entities.deleted, false),
isNotNull(entities.id),
isNotNull(entities.feature_id),
),
)
.groupBy(entities.feature_id)
.orderBy(desc(count()))
.limit(1);
return rows[0] ?? { entity_count: 0, feature_id: "" };
};
export const generateEntityRules = async ({
ctx,
@@ -60,15 +70,12 @@ export const generateEntityRules = async ({
};
}
const [attachScopeResult, entityFeatureResult] = await Promise.all([
const [attachScopeResult, topEntityFeature] = await Promise.all([
queryAxiom({
apl: attachScopeApl({ ctx }),
options: { endTime, startTime },
}),
queryAxiom({
apl: entityFeatureApl({ ctx }),
options: { endTime, startTime },
}),
getTopEntityFeature({ ctx }),
]);
const attachScope = getAxiomMatchData(attachScopeResult)[0] ?? {};
@@ -77,15 +84,43 @@ export const generateEntityRules = async ({
const entityRatio =
totalAttachCalls > 0 ? entityAttachCalls / totalAttachCalls : 0;
const entityFeature = getAxiomMatchData(entityFeatureResult)[0] ?? {};
const attachToEntities = entityRatio > 0.5;
const hasEntityFeature = Boolean(topEntityFeature.feature_id);
const inferenceSource =
totalAttachCalls > 0 ? "axiom_attach_scope" : "entities_fallback";
const attachToEntities =
totalAttachCalls > 0 ? entityRatio > 0.5 : hasEntityFeature;
const entityRules = {
attach_to_entities: attachToEntities,
entity_feature_id: attachToEntities
? axiomStringFrom(entityFeature.selected_feature_id)
? (topEntityFeature.feature_id ?? "")
: "",
} satisfies EntityRules;
ctx.logger.info(
{
data2: {
attach_scope: {
entity_calls: entityAttachCalls,
ratio: entityRatio,
total_calls: totalAttachCalls,
},
axiom_result: getAxiomResultDebug({ result: attachScopeResult }),
entity_feature: {
count: topEntityFeature.entity_count,
feature_id: topEntityFeature.feature_id ?? "",
source: "entities",
},
entity_rules: entityRules,
env: ctx.env,
inference_source: inferenceSource,
org_id: ctx.org.id,
org_slug: ctx.org.slug,
time_range: { endTime, startTime },
},
},
"[AgentRules] Generated entity rules",
);
return {
entityRules,
metadata: {
@@ -94,8 +129,14 @@ export const generateEntityRules = async ({
ratio: entityRatio,
total_calls: totalAttachCalls,
},
entity_feature: {
count: topEntityFeature.entity_count,
feature_id: topEntityFeature.feature_id ?? "",
source: "entities",
},
env: ctx.env,
generated_from: "axiom",
inference_source: inferenceSource,
time_range: { endTime, startTime },
top_entity_feature_id: entityRules.entity_feature_id,
},

View File

@@ -1,29 +1,38 @@
import { FeatureQuantityParamsV0Schema } from "@api/billing/common/featureQuantity/featureQuantityParamsV0";
import { InvoiceModeParamsSchema } from "@api/billing/common/invoiceModeParams";
import { RedirectModeSchema } from "@api/billing/common/redirectMode";
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice";
import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1";
import { z } from "zod/v4";
import { AttachDiscountSchema } from "../attachV2/attachDiscount";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor";
import { CustomizePlanV1Schema } from "../common/customizePlan/customizePlanV1";
const CreateScheduleCustomizePlanSchema = z
.object({
price: BasePriceParamsSchema.nullable().optional().meta({
description:
"Override the base price of the plan. Pass null to remove the base price.",
}),
items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
description: "Override the items in the plan.",
}),
const CreateScheduleCustomizePlanSchema = CustomizePlanV1Schema.omit({
free_trial: true,
})
.strict()
.refine(
(customize) =>
customize.items !== undefined || customize.price !== undefined,
(data) =>
data.items !== undefined ||
data.price !== undefined ||
data.add_items !== undefined ||
data.remove_items !== undefined ||
data.update_items !== undefined,
{
message: "When using customize, either items or price must be provided",
message:
"When using customize, at least one of price, items, add_items, remove_items, or update_items must be provided",
},
)
.refine(
(data) =>
!(
data.items !== undefined &&
(data.add_items !== undefined ||
data.remove_items !== undefined ||
data.update_items !== undefined)
),
{
message:
"customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach",
},
);
@@ -39,7 +48,7 @@ export const CreateSchedulePlanSchema = z.object({
}),
customize: CreateScheduleCustomizePlanSchema.optional().meta({
description:
"Customize the plan to schedule. Can override the price, items, or both.",
"Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.",
}),
subscription_id: z.string().optional().meta({
description:

View File

@@ -9,6 +9,7 @@ export { CreateCustomerParamsV1Schema } from "./customers/crud/createCustomerPar
export { GetCustomerParamsV1Schema } from "./customers/crud/getCustomerParams.js";
export { ListCustomersV2_3ParamsSchema } from "./customers/crud/listCustomersParamsV2_3.js";
export { UpdateCustomerParamsV1Schema } from "./customers/crud/updateCustomerParams.js";
export { CreateEntityParamsV1Schema } from "./entities/crud/createEntityParams.js";
export { CreatePlanParamsV2Schema } from "./products/crud/createPlanParamsV1.js";
export { GetPlanParamsV0Schema } from "./products/crud/getPlanParamsV0.js";
export { ListPlanParamsSchema } from "./products/crud/listPlanParams.js";

View File

@@ -20,6 +20,9 @@ export * from "./api/billing/updateSubscription/previewUpdateSubscriptionRespons
export * from "./api/common/cursorPaginationSchemas";
export * from "./api/common/paginationConfigs";
export * from "./api/customers/components/customerExpand/customerExpand";
export * from "./api/entities/crud/createEntityParams";
export * from "./api/entities/crud/getEntityParams";
export * from "./api/entities/crud/listEntitiesParamsV2_3";
// Migrations v2 (operations + entity schemas)
export * from "./api/migrations/filters/index";
export * from "./api/migrations/operations/index";

View File

@@ -148,7 +148,7 @@ export const AgentRulesForm = ({ agent, features }: AgentRulesFormProps) => {
<Textarea
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="e.g. Always recommend the annual plan when usage is steady."
placeholder="Jot your thoughts, we'll format it for you."
className="min-h-24"
/>
)}