Skip to content

Commit 4b2d3f3

Browse files
authored
fix(agent): bound deep research subrequests (#157)
## Why Production deep-research runs could exhaust Cloudflare's per-invocation subrequest budget. Each query launched a nested tool-using agent loop, invalid structured output retried the workflow step three times, and the outer agent could call the workflow again. The run then failed before it could package the expected PDF deliverable. ## What changed - collect a bounded provider evidence pack per query with one Exa search and at most one best-effort Firecrawl extraction - structure each evidence pack with one tool-free model call - cap query concurrency at three and disable workflow-step retries - turn invalid nested structured output into a stable upstream error instead of leaking a Zod failure - instruct the agent and deep-research skill to call the selected research workflow once per request - document the bounded provenance and retry boundary The existing validated citation, PDF packaging, Daytona project-file persistence, and R2 generated-output paths remain unchanged. ## Architecture and migration effects - no database or migration changes - no environment changes - no new provider or dependency - moves provider network I/O out of nested agent tool loops and into the existing bounded research adapters ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` Production acceptance will be run against the exact merged SHA after Cloudflare deployment, covering natural-language research intent, one workflow invocation, PDF delivery, project file persistence, download validity, reload persistence, and `/` file recall.
1 parent 59058ff commit 4b2d3f3

6 files changed

Lines changed: 195 additions & 33 deletions

File tree

packages/agent-core/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,16 @@ Nested research workflows bind the calling tool's abort signal idempotently to
6666
the Mastra workflow run, forward each workflow step signal through every nested
6767
`agent.generate`, and remove abort listeners before deleting the ephemeral run.
6868
Each concurrent research pass gets an isolated evidence collector populated only
69-
from parsed Exa result IDs/URLs and Firecrawl result URLs. Claim citations and the
70-
final synthesis are schema-validated against that evidence; prose URL scraping
71-
is not an accepted provenance boundary. Successful top-level deep-research and
72-
fan-out tools deterministically package that validated report, its claim-to-source
73-
map, and its source list as a PDF artifact. The project workspace is resolved only
74-
after remote research succeeds; the PDF is then stored both in the live project
75-
files and the durable generated-output store.
69+
from one bounded Exa discovery call and an optional Firecrawl extraction of its
70+
primary result. A single tool-free model pass structures each provider evidence
71+
pack, and workflow steps do not retry failed model output inside the same Worker
72+
invocation. Claim citations and the final synthesis are schema-validated against
73+
that evidence; prose URL scraping is not an accepted provenance boundary.
74+
Successful top-level deep-research and fan-out tools deterministically package
75+
that validated report, its claim-to-source map, and its source list as a PDF
76+
artifact. The project workspace is resolved only after remote research succeeds;
77+
the PDF is then stored both in the live project files and the durable
78+
generated-output store.
7679

7780
Composio REST tool discovery and execution responses are byte-bounded before
7881
parsing, then projected into bounded, valid JSON before entering model context.

packages/agent-core/src/mastra/system-prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ Load the generate-media skill before creating or editing an image or generating
232232

233233
const RESEARCH_MODULE = `## Research
234234
235-
Gather sources with search_web / search_web_advanced / search_company / search_web_content, then use search_scrape or search_extract for source retrieval. For an explicit deep-research request, cited report, market analysis, due diligence, or comprehensive investigation of one topic, use research_deep with 3 queries for a concise/narrow report, 4 by default, and 5-6 only when the user explicitly asks for deeper coverage. Use research_fanout when the request compares many entities or independent angles. Those workflows produce the complete cited PDF deliverable automatically, so do not regenerate it with a document tool. Treat search snippets as leads, not sources — open the real pages and cross-check. Cite as you go: attribute each claim to its source inline with the page title and its URL, and make sure every citation resolves. End a research answer with a short Sources list of the URLs you actually used.`;
235+
Gather sources with search_web / search_web_advanced / search_company / search_web_content, then use search_scrape or search_extract for source retrieval. For an explicit deep-research request, cited report, market analysis, due diligence, or comprehensive investigation of one topic, use research_deep with 3 queries for a concise/narrow report, 4 by default, and 5-6 only when the user explicitly asks for deeper coverage. Use research_fanout when the request compares many entities or independent angles. Call the selected research workflow once per user request; if it fails, explain the failure instead of immediately rerunning it. Those workflows produce the complete cited PDF deliverable automatically, so do not regenerate it with a document tool. Treat search snippets as leads, not sources — open the real pages and cross-check. Cite as you go: attribute each claim to its source inline with the page title and its URL, and make sure every citation resolves. End a research answer with a short Sources list of the URLs you actually used.`;
236236

237237
/** Compact all-domains pointer for an ambiguous general request — keeps the model aware without the full modules. */
238238
const GENERALIST_MODULE = `## Choosing your approach

packages/agent-core/src/mastra/workflows/deep-research-fanout.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ export const deepResearchFanout = createDeepResearchWorkflow({
1010

1111
function fanoutResearchPrompt(query: string): string {
1212
return [
13-
"Run a breadth-first research pass for the query below.",
14-
"Prefer search_web or search_company for discovery, then search_scrape for official pages.",
15-
"Do not call research_deep or research_fanout from inside this workflow step.",
16-
"Return structured claims only from provider results. Cite every claim with the exact Exa result ID and URL or exact Firecrawl URL returned by the tools.",
13+
"Analyze the breadth-first provider evidence pack for the query below.",
14+
"Return structured claims only from that evidence. Cite every claim with the exact Exa result ID and URL or exact Firecrawl URL present in the pack.",
1715
"Do not infer citation IDs from prose and do not cite a URL that no tool returned.",
1816
"",
1917
`Query: ${query}`,

packages/agent-core/src/mastra/workflows/deep-research-workflow.ts

Lines changed: 179 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import { APIError } from "@cheatcode/observability";
12
import { createStep, createWorkflow } from "@mastra/core/workflows";
2-
import { stepCountIs } from "ai";
33
import { z } from "zod/v4";
4+
import {
5+
executeExaSearch,
6+
executeFirecrawlScrape,
7+
ResearchRuntimeContextSchema,
8+
} from "../../tools/research";
9+
import { CONTEXT } from "../context";
410
import {
511
createResearchStepContext,
12+
exaSource,
13+
firecrawlSource,
614
mergeResearchSources,
715
ResearchPassDraftSchema,
816
ResearchSynthesisDraftSchema,
17+
registerResearchSources,
918
validateResearchPass,
1019
validateSynthesisClaims,
1120
} from "./research-provenance";
@@ -18,14 +27,21 @@ import {
1827
ResearchReportSchema,
1928
} from "./research-schemas";
2029

21-
const RESEARCH_CHILD_TOOLS = [
22-
"search_extract",
23-
"search_scrape",
24-
"search_web_content",
25-
"search_company",
26-
"search_web",
27-
"search_web_advanced",
28-
] as const;
30+
const RESEARCH_QUERY_CONCURRENCY = 3;
31+
const RESEARCH_RESULTS_PER_QUERY = 6;
32+
const RESEARCH_RESULT_TEXT_CHARACTERS = 2_500;
33+
const RESEARCH_SCRAPE_CHARACTERS = 12_000;
34+
35+
interface ResearchEvidenceSource {
36+
content: string;
37+
provider: "exa" | "firecrawl";
38+
providerResultId?: string | undefined;
39+
sourceId: string;
40+
title?: string | null | undefined;
41+
url: string;
42+
}
43+
44+
type RequestContextReader = { get(key: string): unknown };
2945

3046
interface ResearchWorkflowPrompts {
3147
queryPrompt(query: string): string;
@@ -64,7 +80,9 @@ function buildDeepResearchWorkflow(config: DeepResearchWorkflowConfig) {
6480
outputSchema: ResearchReportSchema,
6581
})
6682
.then(createDeepPlanStep(config))
67-
.foreach(createQueryStep("run-deep-research-query", config), { concurrency: 5 })
83+
.foreach(createQueryStep("run-deep-research-query", config), {
84+
concurrency: RESEARCH_QUERY_CONCURRENCY,
85+
})
6886
.then(createSynthesisStep("synthesize-deep-research", config))
6987
.commit();
7088
}
@@ -77,7 +95,9 @@ function buildFanoutResearchWorkflow(config: FanoutResearchWorkflowConfig) {
7795
outputSchema: ResearchReportSchema,
7896
})
7997
.then(createFanoutPlanStep(config))
80-
.foreach(createQueryStep("run-deep-research-fanout-query", config), { concurrency: 5 })
98+
.foreach(createQueryStep("run-deep-research-fanout-query", config), {
99+
concurrency: RESEARCH_QUERY_CONCURRENCY,
100+
})
81101
.then(createSynthesisStep("synthesize-deep-research-fanout", config))
82102
.commit();
83103
}
@@ -111,18 +131,23 @@ function createQueryStep(id: string, config: ResearchWorkflowPrompts) {
111131
id,
112132
inputSchema: ResearchQuerySchema,
113133
outputSchema: ResearchFindingSchema,
134+
retries: 0,
114135
execute: async ({ abortSignal, inputData, mastra, requestContext }) => {
115136
const agent = mastra.getAgent("general");
116137
const research = createResearchStepContext(requestContext);
117-
const response = await agent.generate(config.queryPrompt(inputData.query), {
138+
const evidence = await fetchResearchEvidence(
139+
inputData.query,
140+
research.requestContext,
141+
abortSignal,
142+
);
143+
const response = await agent.generate(researchPassPrompt(config, inputData.query, evidence), {
118144
abortSignal,
119145
requestContext: research.requestContext,
120-
activeTools: [...RESEARCH_CHILD_TOOLS],
121-
stopWhen: stepCountIs(6),
122146
structuredOutput: { schema: ResearchPassDraftSchema },
147+
toolChoice: "none",
123148
});
124149
return validateResearchPass(
125-
ResearchPassDraftSchema.parse(response.object),
150+
parseResearchPassDraft(response.object),
126151
inputData.query,
127152
research.collector,
128153
);
@@ -135,17 +160,17 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
135160
id,
136161
inputSchema: z.array(ResearchFindingSchema),
137162
outputSchema: ResearchReportSchema,
163+
retries: 0,
138164
execute: async ({ abortSignal, inputData, mastra, requestContext }) => {
139165
const agent = mastra.getAgent("general");
140166
const sources = mergeResearchSources(inputData);
141167
const response = await agent.generate(config.synthesisPrompt(inputData), {
142168
abortSignal,
143169
requestContext,
144-
stopWhen: stepCountIs(6),
145170
structuredOutput: { schema: ResearchSynthesisDraftSchema },
146171
toolChoice: "none",
147172
});
148-
const draft = ResearchSynthesisDraftSchema.parse(response.object);
173+
const draft = parseResearchSynthesisDraft(response.object);
149174
return ResearchReportSchema.parse({
150175
claims: validateSynthesisClaims(draft.claims, sources),
151176
findings: inputData,
@@ -155,3 +180,140 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
155180
},
156181
});
157182
}
183+
184+
async function fetchResearchEvidence(
185+
query: string,
186+
requestContext: RequestContextReader,
187+
abortSignal: AbortSignal,
188+
): Promise<ResearchEvidenceSource[]> {
189+
abortSignal.throwIfAborted();
190+
const runtime = ResearchRuntimeContextSchema.parse({
191+
exaApiKey: requestContext.get(CONTEXT.exaApiKey),
192+
firecrawlApiKey: requestContext.get(CONTEXT.firecrawlApiKey),
193+
});
194+
const search = await executeExaSearch(
195+
{
196+
highlightMaxCharacters: 800,
197+
highlightQuery: query,
198+
includeHighlights: true,
199+
includeSummary: true,
200+
numResults: RESEARCH_RESULTS_PER_QUERY,
201+
query,
202+
summaryQuery: query,
203+
textMaxCharacters: RESEARCH_RESULT_TEXT_CHARACTERS,
204+
type: "auto",
205+
},
206+
runtime,
207+
abortSignal,
208+
);
209+
const primary = search.results[0];
210+
if (!primary) {
211+
throw new APIError(502, "upstream_provider_outage", "Research search returned no sources", {
212+
retriable: true,
213+
});
214+
}
215+
const evidence = search.results.map((result) => exaEvidenceSource(search.requestId, result));
216+
registerResearchSources(
217+
{ requestContext },
218+
search.results.map((result) => exaSource({ ...result, requestId: search.requestId })),
219+
);
220+
const scraped = await fetchPrimaryPageEvidence(primary, runtime, abortSignal);
221+
if (scraped) {
222+
evidence.push(scraped);
223+
registerResearchSources({ requestContext }, [
224+
firecrawlSource({ title: scraped.title ?? undefined, url: scraped.url }),
225+
]);
226+
}
227+
return evidence;
228+
}
229+
230+
function exaEvidenceSource(
231+
requestId: string,
232+
result: Awaited<ReturnType<typeof executeExaSearch>>["results"][number],
233+
): ResearchEvidenceSource {
234+
const content = [result.summary, ...result.highlights, result.text]
235+
.filter((value): value is string => Boolean(value))
236+
.join("\n\n");
237+
return {
238+
content,
239+
provider: "exa",
240+
providerResultId: result.id,
241+
sourceId: exaSource({ ...result, requestId }).id,
242+
title: result.title,
243+
url: result.url,
244+
};
245+
}
246+
247+
async function fetchPrimaryPageEvidence(
248+
primary: Awaited<ReturnType<typeof executeExaSearch>>["results"][number],
249+
runtime: z.output<typeof ResearchRuntimeContextSchema>,
250+
abortSignal: AbortSignal,
251+
): Promise<ResearchEvidenceSource | undefined> {
252+
if (!runtime.firecrawlApiKey) {
253+
return undefined;
254+
}
255+
try {
256+
const page = await executeFirecrawlScrape(
257+
{ formats: ["markdown"], onlyMainContent: true, timeout: 30_000, url: primary.url },
258+
runtime,
259+
abortSignal,
260+
);
261+
if (!providerStatusIsSuccessful(page.metadata?.statusCode)) {
262+
return undefined;
263+
}
264+
const content = (page.markdown ?? page.description ?? "").slice(0, RESEARCH_SCRAPE_CHARACTERS);
265+
return {
266+
content,
267+
provider: "firecrawl",
268+
sourceId: firecrawlSource({ url: page.url }).id,
269+
title: page.title ?? page.metadata?.title,
270+
url: page.url,
271+
};
272+
} catch {
273+
abortSignal.throwIfAborted();
274+
return undefined;
275+
}
276+
}
277+
278+
function researchPassPrompt(
279+
config: ResearchWorkflowPrompts,
280+
query: string,
281+
evidence: ResearchEvidenceSource[],
282+
): string {
283+
return [
284+
config.queryPrompt(query),
285+
"Use only the provider evidence below. For Exa citations, copy providerResultId and URL exactly. For Firecrawl citations, copy the URL exactly.",
286+
"Do not cite sourceId directly and do not add sources that are absent from this evidence pack.",
287+
"",
288+
JSON.stringify(evidence, null, 2),
289+
].join("\n");
290+
}
291+
292+
function parseResearchPassDraft(value: unknown) {
293+
const parsed = ResearchPassDraftSchema.safeParse(value);
294+
if (parsed.success) {
295+
return parsed.data;
296+
}
297+
throw invalidStructuredResearchOutput("research pass");
298+
}
299+
300+
function parseResearchSynthesisDraft(value: unknown) {
301+
const parsed = ResearchSynthesisDraftSchema.safeParse(value);
302+
if (parsed.success) {
303+
return parsed.data;
304+
}
305+
throw invalidStructuredResearchOutput("research synthesis");
306+
}
307+
308+
function invalidStructuredResearchOutput(stage: string): APIError {
309+
return new APIError(
310+
502,
311+
"upstream_provider_outage",
312+
`The ${stage} returned invalid structured output`,
313+
{ retriable: true },
314+
);
315+
}
316+
317+
function providerStatusIsSuccessful(statusCode: number | undefined): boolean {
318+
return statusCode === undefined || (statusCode >= 200 && statusCode < 400);
319+
}

packages/agent-core/src/mastra/workflows/deep-research.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ export const deepResearch = createDeepResearchWorkflow({
1010

1111
function deepResearchPrompt(query: string): string {
1212
return [
13-
"Run a focused research pass for the query below.",
14-
"Use search_web_advanced for discovery and search_scrape for source pages that need extraction.",
15-
"Do not call research_deep or research_fanout from inside this workflow step.",
16-
"Return structured claims only from provider results. Cite every claim with the exact Exa result ID and URL or exact Firecrawl URL returned by the tools.",
13+
"Analyze the focused provider evidence pack for the query below.",
14+
"Return structured claims only from that evidence. Cite every claim with the exact Exa result ID and URL or exact Firecrawl URL present in the pack.",
1715
"Do not infer citation IDs from prose and do not cite a URL that no tool returned.",
1816
"",
1917
`Query: ${query}`,

skills/deep-research/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Answer complex questions with sourced synthesis. The output should read like an
1717
2. Run the request through `research_deep`; use 3 queries for concise or narrow reports, 4 by default, and 5-6 only when the user explicitly asks for deeper coverage. It validates citations and creates the PDF deliverable.
1818
3. Return the key conclusion and important caveats in chat, with a short source list.
1919
4. Refer to the PDF naturally as ready below. Do not call a separate document tool or recreate the report.
20+
5. Call the workflow once per user request. If it fails, explain the failure instead of immediately rerunning it.
2021

2122
## Fan-out Mode
2223

0 commit comments

Comments
 (0)