Skip to content

Commit cbe0a91

Browse files
authored
fix(agent): stabilize research structured output (#159)
## Why Production research runs reached the evidence pass but every nested structured-output call returned an empty object. The installed AI SDK does not yet identify `claude-sonnet-5` as natively structured-output capable, so its automatic fallback used a synthetic JSON response tool that produced `{}`. ## What changed - Force Anthropic native `outputFormat` structured output for research query and synthesis passes. - Present a flat provider citation schema to the model so it remains compatible with native constrained decoding. - Preserve strict downstream Exa and Firecrawl provenance validation, including exact Exa result IDs and empty Firecrawl result IDs. No database, migration, dependency, environment, or deployment-topology changes. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` Production browser QA will be run after the Cloudflare deployment: natural-language research intent, PDF generation, project persistence, browser viewing, download validation, refresh persistence, and `/` file recall.
1 parent a5f3c02 commit cbe0a91

2 files changed

Lines changed: 33 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const RESEARCH_QUERY_CONCURRENCY = 3;
3131
const RESEARCH_RESULTS_PER_QUERY = 6;
3232
const RESEARCH_RESULT_TEXT_CHARACTERS = 2_500;
3333
const RESEARCH_SCRAPE_CHARACTERS = 12_000;
34+
const RESEARCH_PROVIDER_OPTIONS = {
35+
anthropic: { structuredOutputMode: "outputFormat" as const },
36+
};
3437

3538
interface ResearchEvidenceSource {
3639
content: string;
@@ -143,6 +146,7 @@ function createQueryStep(id: string, config: ResearchWorkflowPrompts) {
143146
const response = await agent.generate(researchPassPrompt(config, inputData.query, evidence), {
144147
activeTools: [],
145148
abortSignal,
149+
providerOptions: RESEARCH_PROVIDER_OPTIONS,
146150
requestContext: research.requestContext,
147151
structuredOutput: { schema: ResearchPassDraftSchema },
148152
});
@@ -167,6 +171,7 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
167171
const response = await agent.generate(config.synthesisPrompt(inputData), {
168172
activeTools: [],
169173
abortSignal,
174+
providerOptions: RESEARCH_PROVIDER_OPTIONS,
170175
requestContext,
171176
structuredOutput: { schema: ResearchSynthesisDraftSchema },
172177
});
@@ -283,6 +288,7 @@ function researchPassPrompt(
283288
return [
284289
config.queryPrompt(query),
285290
"Use only the provider evidence below. For Exa citations, copy providerResultId and URL exactly. For Firecrawl citations, copy the URL exactly.",
291+
"Set providerResultId to an empty string for every Firecrawl citation.",
286292
"Do not cite sourceId directly and do not add sources that are absent from this evidence pack.",
287293
"",
288294
JSON.stringify(evidence, null, 2),

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ const SourceReferenceSchema = z.discriminatedUnion("provider", [
2424
}),
2525
]);
2626

27+
const SourceReferenceDraftSchema = z.strictObject({
28+
provider: z.enum(["exa", "firecrawl"]),
29+
providerResultId: z.string().trim().max(500),
30+
url: z.string().trim().min(1),
31+
});
32+
2733
export const ResearchPassDraftSchema = z.strictObject({
2834
claims: z.array(
2935
z.strictObject({
3036
claim: z.string().trim().min(1),
31-
sources: z.array(SourceReferenceSchema).min(1),
37+
sources: z.array(SourceReferenceDraftSchema).min(1),
3238
}),
3339
),
3440
summary: z.string().trim().min(1),
@@ -45,6 +51,7 @@ export const ResearchSynthesisDraftSchema = z.strictObject({
4551
});
4652

4753
type SourceReference = z.infer<typeof SourceReferenceSchema>;
54+
type SourceReferenceDraft = z.infer<typeof SourceReferenceDraftSchema>;
4855
type ResearchPassDraft = z.infer<typeof ResearchPassDraftSchema>;
4956

5057
interface EvidenceCollector {
@@ -81,7 +88,11 @@ export function validateResearchPass(
8188
const citedSources = new Map<string, ResearchSource>();
8289
const claims = draft.claims.map((claim) => ({
8390
claim: claim.claim,
84-
sourceIds: resolveClaimSources(claim.sources, collector, citedSources),
91+
sourceIds: resolveClaimSources(
92+
claim.sources.map(sourceReferenceFromDraft),
93+
collector,
94+
citedSources,
95+
),
8596
}));
8697
return ResearchFindingSchema.parse({
8798
claims,
@@ -91,6 +102,20 @@ export function validateResearchPass(
91102
});
92103
}
93104

105+
function sourceReferenceFromDraft(draft: SourceReferenceDraft): SourceReference {
106+
if (draft.provider === "exa") {
107+
return SourceReferenceSchema.parse({
108+
provider: draft.provider,
109+
providerResultId: draft.providerResultId,
110+
url: draft.url,
111+
});
112+
}
113+
if (draft.providerResultId.length > 0) {
114+
throw new Error("A Firecrawl citation cannot include an Exa result identifier.");
115+
}
116+
return SourceReferenceSchema.parse({ provider: draft.provider, url: draft.url });
117+
}
118+
94119
export function validateSynthesisClaims(
95120
claims: ResearchClaim[],
96121
sources: ResearchSource[],

0 commit comments

Comments
 (0)