Skip to content

Commit 08945fc

Browse files
authored
fix(research): retry bounded structured output (#168)
## Why Production QA after the timeout fix showed that per-query structured output could still exceed its 2,048-token budget. The provider returned truncated JSON with missing `sources` and `summary` fields, so one malformed branch aborted the research workflow. ## What changed - Constrain each research pass to 3-4 short claims, two sources per claim, and a short summary. - Encode those bounds in the structured schemas as well as the model prompt. - Keep the final canonical Markdown report concise enough for its bounded synthesis response. - Move pass and final validation inside the in-memory retry boundary. - Treat Mastra structured-output validation and repository invalid-output errors as retryable once. ## Architecture and data No schema migration, environment, vendor, or deployment-topology changes. BYOK credentials remain request-scoped and no secret-bearing workflow state is persisted. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - Production Worker trace captured the truncated structured payload and exact missing fields before this 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 ac4cde7 commit 08945fc

3 files changed

Lines changed: 54 additions & 39 deletions

File tree

packages/agent-core/README.md

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

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

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -148,21 +148,24 @@ function createQueryStep(id: string, config: ResearchWorkflowPrompts) {
148148
research.requestContext,
149149
abortSignal,
150150
);
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-
);
161-
return validateResearchPass(
162-
parseResearchPassDraft(response.object),
163-
inputData.query,
164-
research.collector,
165-
);
151+
return generateResearchOutput(abortSignal, async (generationSignal) => {
152+
const response = await agent.generate(
153+
researchPassPrompt(config, inputData.query, evidence),
154+
{
155+
activeTools: [],
156+
abortSignal: generationSignal,
157+
modelSettings: { maxOutputTokens: RESEARCH_PASS_MAX_OUTPUT_TOKENS },
158+
providerOptions: RESEARCH_PROVIDER_OPTIONS,
159+
requestContext: research.requestContext,
160+
structuredOutput: { schema: ResearchPassDraftSchema },
161+
},
162+
);
163+
return validateResearchPass(
164+
parseResearchPassDraft(response.object),
165+
inputData.query,
166+
research.collector,
167+
);
168+
});
166169
},
167170
});
168171
}
@@ -176,22 +179,22 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
176179
execute: async ({ abortSignal, inputData, mastra, requestContext }) => {
177180
const agent = mastra.getAgent("general");
178181
const sources = mergeResearchSources(inputData);
179-
const response = await generateResearchOutput(abortSignal, (generationSignal) =>
180-
agent.generate(researchSynthesisPrompt(config, inputData), {
182+
return generateResearchOutput(abortSignal, async (generationSignal) => {
183+
const response = await agent.generate(researchSynthesisPrompt(config, inputData), {
181184
activeTools: [],
182185
abortSignal: generationSignal,
183186
modelSettings: { maxOutputTokens: RESEARCH_SYNTHESIS_MAX_OUTPUT_TOKENS },
184187
providerOptions: RESEARCH_PROVIDER_OPTIONS,
185188
requestContext,
186189
structuredOutput: { schema: ResearchSynthesisDraftSchema },
187-
}),
188-
);
189-
const draft = parseResearchSynthesisDraft(response.object);
190-
return ResearchReportSchema.parse({
191-
claims: validateSynthesisClaims(draft.claims, sources),
192-
findings: inputData,
193-
report: draft.report,
194-
sources,
190+
});
191+
const draft = parseResearchSynthesisDraft(response.object);
192+
return ResearchReportSchema.parse({
193+
claims: validateSynthesisClaims(draft.claims, sources),
194+
findings: inputData,
195+
report: draft.report,
196+
sources,
197+
});
195198
});
196199
},
197200
});
@@ -301,7 +304,8 @@ function researchPassPrompt(
301304
config.queryPrompt(query),
302305
"Use only the provider evidence below. For Exa citations, copy providerResultId and URL exactly. For Firecrawl citations, copy the URL exactly.",
303306
"Set providerResultId to an empty string for every Firecrawl citation.",
304-
"Return 4-6 distinct, synthesis-ready claims, no more than 3 sources per claim, and a concise summary. Prioritize the strongest guidance instead of exhaustively restating the evidence.",
307+
"Return 3-4 distinct, synthesis-ready claims. Keep each claim under 450 characters, use no more than 2 sources per claim, and keep the summary under 700 characters.",
308+
"Prioritize the strongest guidance instead of exhaustively restating the evidence.",
305309
"Do not cite sourceId directly and do not add sources that are absent from this evidence pack.",
306310
"",
307311
JSON.stringify(evidence, null, 2),
@@ -315,7 +319,7 @@ function researchSynthesisPrompt(
315319
return [
316320
config.synthesisPrompt(findings),
317321
"Consolidate overlapping evidence into at most 16 distinct claims with no more than 4 source IDs per claim.",
318-
"Keep the report focused and complete within 2,000 words while retaining actionable findings and citations.",
322+
"Keep the report focused and complete within 1,200 words while retaining actionable findings and citations.",
319323
"Write report as polished GitHub-flavored Markdown for direct display and PDF rendering. Preserve a clear heading hierarchy, lists, and comparison tables where useful.",
320324
"Cite factual claims with descriptive Markdown links to the exact source URLs in the findings, and finish with a Sources heading containing only sources used in the report.",
321325
].join("\n");
@@ -381,8 +385,19 @@ function isRetriableModelError(error: unknown): boolean {
381385
if (error.name === "TimeoutError") {
382386
return true;
383387
}
384-
const record = error as Error & { isRetryable?: unknown; statusCode?: unknown };
385-
if (record.isRetryable === true) {
388+
const record = error as Error & {
389+
code?: unknown;
390+
id?: unknown;
391+
isRetryable?: unknown;
392+
retriable?: unknown;
393+
statusCode?: unknown;
394+
};
395+
if (
396+
record.isRetryable === true ||
397+
record.retriable === true ||
398+
record.id === "STRUCTURED_OUTPUT_SCHEMA_VALIDATION_FAILED" ||
399+
record.code === "STRUCTURED_OUTPUT_SCHEMA_VALIDATION_FAILED"
400+
) {
386401
return true;
387402
}
388403
return (

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,26 @@ export const ResearchPassDraftSchema = z.strictObject({
3434
claims: z
3535
.array(
3636
z.strictObject({
37-
claim: z.string().trim().min(1),
38-
sources: z.array(SourceReferenceDraftSchema).min(1).max(3),
37+
claim: z.string().trim().min(1).max(600),
38+
sources: z.array(SourceReferenceDraftSchema).min(1).max(2),
3939
}),
4040
)
4141
.min(1)
42-
.max(6),
43-
summary: z.string().trim().min(1),
42+
.max(4),
43+
summary: z.string().trim().min(1).max(1_000),
4444
});
4545

4646
export const ResearchSynthesisDraftSchema = z.strictObject({
4747
claims: z
4848
.array(
4949
z.strictObject({
50-
claim: z.string().trim().min(1),
50+
claim: z.string().trim().min(1).max(1_000),
5151
sourceIds: z.array(z.string().trim().min(1).max(4_096)).min(1).max(4),
5252
}),
5353
)
5454
.min(1)
5555
.max(16),
56-
report: z.string().trim().min(1),
56+
report: z.string().trim().min(1).max(20_000),
5757
});
5858

5959
type SourceReference = z.infer<typeof SourceReferenceSchema>;

0 commit comments

Comments
 (0)