Skip to content

Commit ac4cde7

Browse files
authored
fix(research): bound nested model synthesis (#167)
## Why Production deep-research runs repeatedly failed in the per-query synthesis step after Anthropic returned HTTP 524. Each pass sent a large evidence pack with the default 4,096-token output budget, and the synthesis stage allowed 8,192 tokens. ## What changed - Bound Exa and Firecrawl evidence included in each model pass. - Use stage-appropriate 2,048-token pass and 4,096-token final synthesis budgets. - Bound every nested research generation to 75 seconds. - Retry one transient timeout, rate-limit, or 5xx failure in memory while preserving parent cancellation. - Emit a structured warning when that retry is used. - Document the request-scoped retry and non-persistence boundary. ## Architecture and data No schema, migration, environment, vendor, or deployment-topology changes. BYOK credentials remain request-scoped and no Mastra workflow snapshot contains secret-bearing state. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - Production Worker trace reproduced the original HTTP 524 twice before the fix. The repository pins Node 24.18.0; local verification ran on Node 26.4.0 and emitted only the existing engine warning.
1 parent a54b845 commit ac4cde7

2 files changed

Lines changed: 82 additions & 24 deletions

File tree

packages/agent-core/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ 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
6969
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.
70+
primary result. A single tool-free model pass structures each byte-bounded provider
71+
evidence pack. Nested model calls use stage-appropriate output bounds, an operational
72+
timeout, and one in-memory retry for transient provider failures; request cancellation
73+
always wins and no secret-bearing state is snapshotted. Claim citations and the final
74+
synthesis are schema-validated against that evidence; prose URL scraping is not an
75+
accepted provenance boundary.
7476
Successful top-level deep-research and fan-out tools render the validated report's
7577
canonical GitHub-flavored Markdown directly into a PDF artifact. The chat response
7678
and PDF therefore preserve the same headings, prose, lists, tables, links, citations,

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

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { APIError } from "@cheatcode/observability";
1+
import { APIError, createLogger } from "@cheatcode/observability";
22
import { createStep, createWorkflow } from "@mastra/core/workflows";
33
import { z } from "zod/v4";
44
import {
@@ -28,9 +28,14 @@ import {
2828
} from "./research-schemas";
2929

3030
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;
31+
const RESEARCH_RESULTS_PER_QUERY = 5;
32+
const RESEARCH_RESULT_TEXT_CHARACTERS = 1_600;
33+
const RESEARCH_EVIDENCE_CHARACTERS_PER_SOURCE = 3_000;
34+
const RESEARCH_SCRAPE_CHARACTERS = 6_000;
35+
const RESEARCH_PASS_MAX_OUTPUT_TOKENS = 2_048;
36+
const RESEARCH_SYNTHESIS_MAX_OUTPUT_TOKENS = 4_096;
37+
const RESEARCH_MODEL_TIMEOUT_MS = 75_000;
38+
const RESEARCH_MODEL_ATTEMPTS = 2;
3439
const RESEARCH_PROVIDER_OPTIONS = {
3540
anthropic: { structuredOutputMode: "outputFormat" as const },
3641
};
@@ -143,13 +148,16 @@ function createQueryStep(id: string, config: ResearchWorkflowPrompts) {
143148
research.requestContext,
144149
abortSignal,
145150
);
146-
const response = await agent.generate(researchPassPrompt(config, inputData.query, evidence), {
147-
activeTools: [],
148-
abortSignal,
149-
providerOptions: RESEARCH_PROVIDER_OPTIONS,
150-
requestContext: research.requestContext,
151-
structuredOutput: { schema: ResearchPassDraftSchema },
152-
});
151+
const response = await generateResearchOutput(abortSignal, (generationSignal) =>
152+
agent.generate(researchPassPrompt(config, inputData.query, evidence), {
153+
activeTools: [],
154+
abortSignal: generationSignal,
155+
modelSettings: { maxOutputTokens: RESEARCH_PASS_MAX_OUTPUT_TOKENS },
156+
providerOptions: RESEARCH_PROVIDER_OPTIONS,
157+
requestContext: research.requestContext,
158+
structuredOutput: { schema: ResearchPassDraftSchema },
159+
}),
160+
);
153161
return validateResearchPass(
154162
parseResearchPassDraft(response.object),
155163
inputData.query,
@@ -168,14 +176,16 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
168176
execute: async ({ abortSignal, inputData, mastra, requestContext }) => {
169177
const agent = mastra.getAgent("general");
170178
const sources = mergeResearchSources(inputData);
171-
const response = await agent.generate(researchSynthesisPrompt(config, inputData), {
172-
activeTools: [],
173-
abortSignal,
174-
modelSettings: { maxOutputTokens: 8_192 },
175-
providerOptions: RESEARCH_PROVIDER_OPTIONS,
176-
requestContext,
177-
structuredOutput: { schema: ResearchSynthesisDraftSchema },
178-
});
179+
const response = await generateResearchOutput(abortSignal, (generationSignal) =>
180+
agent.generate(researchSynthesisPrompt(config, inputData), {
181+
activeTools: [],
182+
abortSignal: generationSignal,
183+
modelSettings: { maxOutputTokens: RESEARCH_SYNTHESIS_MAX_OUTPUT_TOKENS },
184+
providerOptions: RESEARCH_PROVIDER_OPTIONS,
185+
requestContext,
186+
structuredOutput: { schema: ResearchSynthesisDraftSchema },
187+
}),
188+
);
179189
const draft = parseResearchSynthesisDraft(response.object);
180190
return ResearchReportSchema.parse({
181191
claims: validateSynthesisClaims(draft.claims, sources),
@@ -239,7 +249,8 @@ function exaEvidenceSource(
239249
): ResearchEvidenceSource {
240250
const content = [result.summary, ...result.highlights, result.text]
241251
.filter((value): value is string => Boolean(value))
242-
.join("\n\n");
252+
.join("\n\n")
253+
.slice(0, RESEARCH_EVIDENCE_CHARACTERS_PER_SOURCE);
243254
return {
244255
content,
245256
provider: "exa",
@@ -335,6 +346,51 @@ function invalidStructuredResearchOutput(stage: string): APIError {
335346
);
336347
}
337348

349+
async function generateResearchOutput<T>(
350+
abortSignal: AbortSignal,
351+
generate: (generationSignal: AbortSignal) => Promise<T>,
352+
): Promise<T> {
353+
let lastError: unknown;
354+
for (let attempt = 0; attempt < RESEARCH_MODEL_ATTEMPTS; attempt += 1) {
355+
abortSignal.throwIfAborted();
356+
const generationSignal = AbortSignal.any([
357+
abortSignal,
358+
AbortSignal.timeout(RESEARCH_MODEL_TIMEOUT_MS),
359+
]);
360+
try {
361+
return await generate(generationSignal);
362+
} catch (error) {
363+
abortSignal.throwIfAborted();
364+
lastError = error;
365+
if (!isRetriableModelError(error) || attempt === RESEARCH_MODEL_ATTEMPTS - 1) {
366+
throw error;
367+
}
368+
createLogger().warn("research_model_generation_retrying", {
369+
attempt: attempt + 1,
370+
error,
371+
});
372+
}
373+
}
374+
throw lastError;
375+
}
376+
377+
function isRetriableModelError(error: unknown): boolean {
378+
if (!(error instanceof Error)) {
379+
return false;
380+
}
381+
if (error.name === "TimeoutError") {
382+
return true;
383+
}
384+
const record = error as Error & { isRetryable?: unknown; statusCode?: unknown };
385+
if (record.isRetryable === true) {
386+
return true;
387+
}
388+
return (
389+
typeof record.statusCode === "number" &&
390+
(record.statusCode === 408 || record.statusCode === 429 || record.statusCode >= 500)
391+
);
392+
}
393+
338394
function providerStatusIsSuccessful(statusCode: number | undefined): boolean {
339395
return statusCode === undefined || (statusCode >= 200 && statusCode < 400);
340396
}

0 commit comments

Comments
 (0)