Code
    App Growth AgentUpdated Jul 13, 2026TS

    App Growth Agent Orchestrator source

    Imported TypeScript file from src/lib/agent/orchestrator.ts.

    Source file

    src/lib/agent/orchestrator.ts

    TypeScript371 lines
    1. import { prisma } from '@/lib/prisma';
    2. import { discoverLeads, createOutreachDraft } from '@/lib/agent/jobs';
    3. import { agentQueue } from '@/queue/client';
    4. import { TASKS } from '@/queue/tasks';
    5. import { loadAppContext, loadCampaignContext } from '@/lib/agent/context';
    6. import { runStructured } from '@/lib/llm/client';
    7. import { PlanSchema, QualificationSchema, DraftSchema, CritiqueSchema } from '@/lib/llm/schemas';
    8. import { buildPlanPrompt, buildQualificationPrompt, buildDraftPrompt, buildCritiquePrompt } from '@/lib/llm/prompts';
    9. import type { CampaignPlan, QualificationResult, DraftResult, CritiqueResult } from '@/lib/agent/types';
    10. import { scoreLead } from '@/lib/scoring';
    11. import { Prisma } from '@prisma/client';
    12. export async function runCampaignTick(campaignId: string) {
    13. const run = await prisma.agentRun.create({ data: { campaignId } });
    14. try {
    15. const campaign = await prisma.campaign.findUnique({
    16. where: { id: campaignId },
    17. include: { app: true, policies: true }
    18. });
    19. if (!campaign) throw new Error('campaign_not_found');
    20. const appContext = await loadAppContext(campaign.appId);
    21. const campaignContext = await loadCampaignContext(campaignId);
    22. const planRaw = await runStepWithFallback<CampaignPlan>(
    23. run.id,
    24. 'plan',
    25. { campaignId },
    26. async (stepId) => {
    27. const prompt = buildPlanPrompt({ campaign, appContext, campaignContext });
    28. return runStructured({
    29. system: prompt.system,
    30. user: prompt.user,
    31. schema: PlanSchema,
    32. runId: run.id,
    33. stepId
    34. });
    35. },
    36. buildDefaultPlan(campaign)
    37. );
    38. const plan = normalizePlan(planRaw);
    39. const discovery = await runStepWithFallback(
    40. run.id,
    41. 'discover',
    42. { queries: plan.queryVariants, limit: plan.maxCandidates },
    43. async () =>
    44. discoverLeads(campaignId, {
    45. queries: plan.queryVariants,
    46. limit: plan.maxCandidates,
    47. minScore: plan.minHeuristicScore
    48. }),
    49. { created: 0, error: 'discovery_failed' }
    50. );
    51. const leads = await prisma.lead.findMany({
    52. where: { campaignId, status: 'new' },
    53. orderBy: [{ qualityScore: 'desc' }, { createdAt: 'asc' }],
    54. take: plan.maxLeadsToContact
    55. });
    56. let qualifiedCount = 0;
    57. let draftedCount = 0;
    58. let queuedCount = 0;
    59. let skippedExisting = 0;
    60. for (const lead of leads) {
    61. const hasRecent = await hasRecentOutbound(lead.id, 14);
    62. if (hasRecent) {
    63. skippedExisting += 1;
    64. continue;
    65. }
    66. const source = await prisma.leadSource.findFirst({
    67. where: { leadId: lead.id },
    68. orderBy: { ingestedAt: 'desc' }
    69. });
    70. const excerpt = source?.sourceExcerpt ?? lead.painStatement ?? '';
    71. const qualification = await runStepWithFallback<QualificationResult>(
    72. run.id,
    73. 'qualify_lead',
    74. { leadId: lead.id, sourceUrl: source?.sourceUrl },
    75. async (stepId) => {
    76. const prompt = buildQualificationPrompt({
    77. lead,
    78. sourceExcerpt: excerpt,
    79. sourceUrl: source?.sourceUrl,
    80. appContext,
    81. campaignContext
    82. });
    83. return runStructured({
    84. system: prompt.system,
    85. user: prompt.user,
    86. schema: QualificationSchema,
    87. runId: run.id,
    88. stepId
    89. });
    90. },
    91. buildFallbackQualification(lead, excerpt)
    92. );
    93. if (!qualification.shouldContact || qualification.fitScore < plan.minHeuristicScore) {
    94. continue;
    95. }
    96. qualifiedCount += 1;
    97. await prisma.lead.update({
    98. where: { id: lead.id },
    99. data: {
    100. qualityScore: qualification.fitScore,
    101. painStatement: qualification.painSummary
    102. }
    103. });
    104. const channel = lead.platform === 'email' ? 'email' : 'reddit_dm';
    105. const draft = await runStepWithFallback<DraftResult>(
    106. run.id,
    107. 'draft_outreach',
    108. { leadId: lead.id, channel },
    109. async (stepId) => {
    110. const prompt = buildDraftPrompt({
    111. lead,
    112. qualification,
    113. appContext,
    114. campaignContext,
    115. channel
    116. });
    117. return runStructured({
    118. system: prompt.system,
    119. user: prompt.user,
    120. schema: DraftSchema,
    121. runId: run.id,
    122. stepId
    123. });
    124. },
    125. buildFallbackDraft(lead, channel)
    126. );
    127. const critique = await runStepWithFallback<CritiqueResult>(
    128. run.id,
    129. 'critic_outreach',
    130. { leadId: lead.id, channel },
    131. async (stepId) => {
    132. const prompt = buildCritiquePrompt({
    133. channel,
    134. draft,
    135. appContext,
    136. campaignContext
    137. });
    138. return runStructured({
    139. system: prompt.system,
    140. user: prompt.user,
    141. schema: CritiqueSchema,
    142. runId: run.id,
    143. stepId
    144. });
    145. },
    146. {
    147. riskLevel: 'medium',
    148. issues: ['llm_unavailable'],
    149. revisedBody: undefined,
    150. requiresApproval: false,
    151. confidence: 0.4
    152. }
    153. );
    154. const finalBody = critique.revisedBody ?? draft.body;
    155. // Only ever *raise* the approval requirement; never lower it.
    156. const requiresApprovalOverride =
    157. critique.requiresApproval || critique.riskLevel === 'high' ? true : undefined;
    158. const { messageId, lint, requiresApproval } = await createOutreachDraft(lead.id, {
    159. channel,
    160. subject: draft.subject,
    161. body: finalBody,
    162. metadata: {
    163. plan,
    164. qualification,
    165. draft,
    166. critique,
    167. evidence: qualification.evidence,
    168. sourceUrl: source?.sourceUrl
    169. },
    170. requiresApprovalOverride
    171. });
    172. draftedCount += 1;
    173. const outboundDisabled = isOutboundDisabled();
    174. if (lint.ok && !requiresApproval && !outboundDisabled) {
    175. await agentQueue.add(TASKS.MESSAGE_SEND, { messageId });
    176. queuedCount += 1;
    177. } else if (outboundDisabled) {
    178. await prisma.agentAction.create({
    179. data: {
    180. campaignId,
    181. actionType: 'send',
    182. status: 'blocked',
    183. reason: 'outbound_disabled',
    184. input: { messageId }
    185. }
    186. });
    187. }
    188. }
    189. await prisma.agentRun.update({
    190. where: { id: run.id },
    191. data: {
    192. status: 'success',
    193. endedAt: new Date(),
    194. summary: {
    195. ok: true,
    196. plan,
    197. discovery,
    198. totals: {
    199. leadsConsidered: leads.length,
    200. qualified: qualifiedCount,
    201. drafted: draftedCount,
    202. queued: queuedCount,
    203. skippedExisting
    204. }
    205. }
    206. }
    207. });
    208. return { ok: true };
    209. } catch (e: any) {
    210. await prisma.agentRun.update({
    211. where: { id: run.id },
    212. data: {
    213. status: 'failed',
    214. endedAt: new Date(),
    215. summary: { ok: false, error: e?.message ?? String(e) }
    216. }
    217. });
    218. throw e;
    219. }
    220. }
    221. async function runStepWithFallback<T>(
    222. runId: string,
    223. stepName: string,
    224. input: Record<string, unknown>,
    225. fn: (stepId: string) => Promise<T>,
    226. fallback: T
    227. ): Promise<T> {
    228. const toJson = (value: unknown) => {
    229. try {
    230. const str = JSON.stringify(value);
    231. if (str === undefined) return Prisma.JsonNull;
    232. return JSON.parse(str) as Prisma.InputJsonValue;
    233. } catch (err) {
    234. return {
    235. error: 'non_serializable_value',
    236. message: (err as Error).message
    237. } as unknown as Prisma.InputJsonValue;
    238. }
    239. };
    240. const step = await prisma.agentStep.create({
    241. data: {
    242. runId,
    243. stepName,
    244. status: 'running',
    245. input: toJson(input)
    246. }
    247. });
    248. try {
    249. const output = await fn(step.id);
    250. await prisma.agentStep.update({
    251. where: { id: step.id },
    252. data: {
    253. status: 'success',
    254. output: toJson(output),
    255. endedAt: new Date()
    256. }
    257. });
    258. return output;
    259. } catch (err: any) {
    260. await prisma.agentStep.update({
    261. where: { id: step.id },
    262. data: {
    263. status: 'failed',
    264. output: toJson(fallback),
    265. error: err?.message ?? String(err),
    266. endedAt: new Date()
    267. }
    268. });
    269. return fallback;
    270. }
    271. }
    272. function buildDefaultPlan(campaign: { keywords: unknown; dailyDmCap: number }): CampaignPlan {
    273. const keywords = Array.isArray(campaign.keywords) ? campaign.keywords.map(String) : [];
    274. const queryVariants = keywords.length
    275. ? keywords.map(k => `${k} shopify`)
    276. : ['shopify bulk edit', 'shopify bulk update'];
    277. return {
    278. goal: 'Find Shopify merchants with bulk editing pain',
    279. queryVariants,
    280. maxCandidates: 25,
    281. maxLeadsToContact: Math.max(1, Math.min(5, campaign.dailyDmCap || 5)),
    282. minHeuristicScore: 35,
    283. notes: 'fallback_plan'
    284. };
    285. }
    286. function buildFallbackQualification(lead: { painStatement: string | null }, excerpt: string): QualificationResult {
    287. const painText = excerpt || lead.painStatement || '';
    288. const isMerchantLikely = /my store|our store|we sell|my products/i.test(painText);
    289. const recencyDays = 14;
    290. const fitScore = scoreLead({ painText, isMerchantLikely, recencyDays });
    291. return {
    292. fitScore,
    293. isMerchantLikely,
    294. painSummary: painText.slice(0, 200),
    295. evidence: painText ? [painText.slice(0, 200)] : [],
    296. reasons: ['fallback_heuristic_scoring'],
    297. shouldContact: fitScore >= 35
    298. };
    299. }
    300. function buildFallbackDraft(lead: { painStatement: string | null }, channel: 'reddit_dm' | 'email'): DraftResult {
    301. const pain = (lead.painStatement ?? '').slice(0, 220);
    302. const subject = channel === 'email' ? 'Quick question about your Shopify store' : undefined;
    303. const body = `Hey — saw your post about struggling with bulk edits in Shopify.\n\n` +
    304. `I’m building a lightweight bulk editor that’s meant to help with the kind of issue you described ("${pain}").\n` +
    305. `I’m looking for a small number of early users to stress-test it and give honest feedback.\n\n` +
    306. `If you want, I can give you free access while we iterate. No obligation — just trying to make sure it actually solves this problem.\n\n` +
    307. `If you're interested, reply here and I’ll send setup details.`;
    308. return { subject, body, personalization: [], rationale: 'fallback_template' };
    309. }
    310. async function hasRecentOutbound(leadId: string, days: number) {
    311. const since = new Date();
    312. since.setDate(since.getDate() - days);
    313. const existing = await prisma.message.findFirst({
    314. where: {
    315. leadId,
    316. direction: 'outbound',
    317. createdAt: { gte: since },
    318. status: { in: ['draft', 'sent'] }
    319. },
    320. select: { id: true }
    321. });
    322. return !!existing;
    323. }
    324. function isOutboundDisabled() {
    325. return ['true', '1', 'yes'].includes((process.env.STOP_OUTBOUND_SEND ?? '').toLowerCase());
    326. }
    327. function normalizePlan(plan: CampaignPlan): CampaignPlan {
    328. const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    329. const queryVariants = (plan.queryVariants || []).map(q => q.trim()).filter(Boolean).slice(0, 12);
    330. return {
    331. ...plan,
    332. queryVariants: queryVariants.length ? queryVariants : ['shopify bulk edit'],
    333. maxCandidates: clamp(plan.maxCandidates, 5, 100),
    334. maxLeadsToContact: clamp(plan.maxLeadsToContact, 1, 20),
    335. minHeuristicScore: clamp(plan.minHeuristicScore, 0, 100)
    336. };
    337. }