Skip to content

Commit 086c8db

Browse files
authored
fix(research): preserve markdown report output (#169)
## Why Research synthesis required the model to emit hidden provenance bookkeeping before the user-visible report. When provider output was constrained, that bookkeeping could consume the response and leave the report truncated or absent. It also made the generated document pipeline harder to reason about. ## What changed - make the model-authored synthesis output only the canonical GitHub-flavored Markdown report - derive and validate the durable claim map deterministically from already validated research passes - derive internal pass summaries instead of asking the model to spend output on them - keep the same canonical Markdown as the source rendered into the PDF artifact - document the single-source report and provenance boundary No database schema, migration, environment, vendor, or deployment-topology changes. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` Production browser acceptance will be run against the merged deployment: natural-language research request, complete chat report, PDF download and text parity, `/` deliverable picker, console, and Worker logs.
1 parent 08945fc commit 086c8db

5 files changed

Lines changed: 30 additions & 20 deletions

File tree

packages/agent-core/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ primary result. A single tool-free model pass structures each byte-bounded provi
7171
evidence pack. Nested model calls use stage-appropriate output bounds, an operational
7272
timeout, and one in-memory retry for transient provider or invalid structured-output
7373
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.
74+
Claim citations are schema-validated against that evidence, and the durable claim map
75+
is assembled deterministically from those validated passes. The model-authored final
76+
synthesis contains only the canonical Markdown report; prose URL scraping is not an
77+
accepted provenance boundary.
7678
Successful top-level deep-research and fan-out tools render the validated report's
7779
canonical GitHub-flavored Markdown directly into a PDF artifact. The chat response
7880
and PDF therefore preserve the same headings, prose, lists, tables, links, citations,

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ function fanoutSynthesisPrompt(findings: unknown): string {
2222
return [
2323
"Synthesize the following parallel research findings into a comparison-oriented report.",
2424
"Include a comparison matrix when the findings cover multiple entities.",
25-
"The claim sourceIds must exactly match IDs in the input sources. Do not invent or rewrite IDs.",
26-
"The structured claim map is authoritative provenance; keep the report readable and evidence-bound.",
25+
"Use only the source URLs present in the findings, and keep the report readable and evidence-bound.",
2726
"",
2827
JSON.stringify(findings, null, 2),
2928
].join("\n");

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createResearchStepContext,
1212
exaSource,
1313
firecrawlSource,
14+
mergeResearchClaims,
1415
mergeResearchSources,
1516
ResearchPassDraftSchema,
1617
ResearchSynthesisDraftSchema,
@@ -179,6 +180,7 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
179180
execute: async ({ abortSignal, inputData, mastra, requestContext }) => {
180181
const agent = mastra.getAgent("general");
181182
const sources = mergeResearchSources(inputData);
183+
const claims = validateSynthesisClaims(mergeResearchClaims(inputData), sources);
182184
return generateResearchOutput(abortSignal, async (generationSignal) => {
183185
const response = await agent.generate(researchSynthesisPrompt(config, inputData), {
184186
activeTools: [],
@@ -190,7 +192,7 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
190192
});
191193
const draft = parseResearchSynthesisDraft(response.object);
192194
return ResearchReportSchema.parse({
193-
claims: validateSynthesisClaims(draft.claims, sources),
195+
claims,
194196
findings: inputData,
195197
report: draft.report,
196198
sources,
@@ -304,7 +306,7 @@ function researchPassPrompt(
304306
config.queryPrompt(query),
305307
"Use only the provider evidence below. For Exa citations, copy providerResultId and URL exactly. For Firecrawl citations, copy the URL exactly.",
306308
"Set providerResultId to an empty string for every Firecrawl citation.",
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.",
309+
"Return only 3-4 distinct, synthesis-ready claims. Keep each claim under 450 characters and use no more than 2 sources per claim.",
308310
"Prioritize the strongest guidance instead of exhaustively restating the evidence.",
309311
"Do not cite sourceId directly and do not add sources that are absent from this evidence pack.",
310312
"",
@@ -318,7 +320,7 @@ function researchSynthesisPrompt(
318320
): string {
319321
return [
320322
config.synthesisPrompt(findings),
321-
"Consolidate overlapping evidence into at most 16 distinct claims with no more than 4 source IDs per claim.",
323+
"Return only the report field requested by the output schema; the provenance index is assembled deterministically from the validated findings.",
322324
"Keep the report focused and complete within 1,200 words while retaining actionable findings and citations.",
323325
"Write report as polished GitHub-flavored Markdown for direct display and PDF rendering. Preserve a clear heading hierarchy, lists, and comparison tables where useful.",
324326
"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.",

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ function deepResearchPrompt(query: string): string {
2121
function synthesisPrompt(kind: string, findings: unknown): string {
2222
return [
2323
`Synthesize the following findings into a cited ${kind}.`,
24-
"The claim sourceIds must exactly match IDs in the input sources. Do not invent or rewrite IDs.",
25-
"The structured claim map is authoritative provenance; keep the report readable and evidence-bound.",
24+
"Use only the source URLs present in the findings, and keep the report readable and evidence-bound.",
2625
"",
2726
JSON.stringify(findings, null, 2),
2827
].join("\n");

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,9 @@ export const ResearchPassDraftSchema = z.strictObject({
4040
)
4141
.min(1)
4242
.max(4),
43-
summary: z.string().trim().min(1).max(1_000),
4443
});
4544

4645
export const ResearchSynthesisDraftSchema = z.strictObject({
47-
claims: z
48-
.array(
49-
z.strictObject({
50-
claim: z.string().trim().min(1).max(1_000),
51-
sourceIds: z.array(z.string().trim().min(1).max(4_096)).min(1).max(4),
52-
}),
53-
)
54-
.min(1)
55-
.max(16),
5646
report: z.string().trim().min(1).max(20_000),
5747
});
5848

@@ -104,10 +94,28 @@ export function validateResearchPass(
10494
claims,
10595
query,
10696
sources: [...citedSources.values()],
107-
summary: draft.summary,
97+
summary: claims
98+
.slice(0, 2)
99+
.map((claim) => claim.claim)
100+
.join(" "),
108101
});
109102
}
110103

104+
export function mergeResearchClaims(findings: Array<{ claims: ResearchClaim[] }>): ResearchClaim[] {
105+
const claims = new Map<string, ResearchClaim>();
106+
for (const finding of findings) {
107+
for (const claim of finding.claims) {
108+
const key = claim.claim.trim().replace(/\s+/g, " ").toLowerCase();
109+
const existing = claims.get(key);
110+
claims.set(key, {
111+
claim: existing?.claim ?? claim.claim,
112+
sourceIds: [...new Set([...(existing?.sourceIds ?? []), ...claim.sourceIds])].slice(0, 4),
113+
});
114+
}
115+
}
116+
return [...claims.values()].slice(0, 16);
117+
}
118+
111119
function sourceReferenceFromDraft(draft: SourceReferenceDraft): SourceReference {
112120
if (draft.provider === "exa") {
113121
return SourceReferenceSchema.parse({

0 commit comments

Comments
 (0)