Skip to content

Commit f1b0f63

Browse files
authored
fix: render research PDFs from canonical Markdown (#164)
## Summary - Render the validated research report Markdown directly into the PDF instead of flattening it into a separate section/paragraph model. - Preserve the same headings, prose, lists, task items, tables, code, links, citations, and ordering shown in chat. - Keep structured claim provenance internal while returning a smaller user-facing research tool result. ## Architecture The research synthesis remains the single content source. The Worker parses that bounded Markdown with a pinned GFM parser, stages the Markdown and its semantic tokens in the existing hidden artifact-input path, and the sandbox's existing React PDF runtime renders those tokens into a durable workspace/R2 PDF. No database, storage, sandbox snapshot, or deployment-topology change is required. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Content source | Use `report.report` verbatim | Rebuild a document from claims and sources | Prevents chat/PDF drift and removes synthetic appendices the user did not see in chat. | | Markdown parsing | Pin `marked` in `agent-core` | Regex parsing; add runtime dependencies | Gives standards-based GFM tokens without requiring a new Daytona snapshot. | | PDF runtime | Reuse React PDF and baked Liberation fonts | Browser print-to-PDF; new PDF vendor | Keeps rendering deterministic, sandbox-local, and compatible with the current document boundary. | | Tool result | Return report + artifact only | Return all findings, claims, and sources | Provenance is validated internally; the smaller result makes verbatim chat presentation reliable. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Long multi-page reports | A4 wrapping, widow/orphan controls, and heading presence rules prevent clipping and orphaned headings. | | Nested, ordered, and task lists | Semantic list tokens retain nesting, numbering, and task state. | | Comparison tables | Header, alignment, cell borders, and row-safe pagination are preserved. | | Citations | Safe HTTP(S)/mailto links remain visible and clickable PDF annotations. | | Inline HTML or unsafe URLs | HTML is rendered as inert text and unsupported URL schemes are not made clickable. | ## How to Review 1. Start with `research-report-document-support.ts` and `research-tools.ts` for the one-source data flow. 2. Review `execute.ts` and `scripts.ts` for parsing, staging, and semantic PDF rendering. 3. Review the synthesis/system prompts and README for the chat/PDF contract. ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [x] Rendered a five-page hostile GFM fixture and visually inspected every page. - [x] Verified extracted text, A4 metadata, and clickable link annotations with Poppler.
1 parent 3057a01 commit f1b0f63

11 files changed

Lines changed: 236 additions & 132 deletions

File tree

packages/agent-core/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ primary result. A single tool-free model pass structures each provider evidence
7171
pack, and workflow steps do not retry failed model output inside the same Worker
7272
invocation. Claim citations and the final synthesis are schema-validated against
7373
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;
74+
Successful top-level deep-research and fan-out tools render the validated report's
75+
canonical GitHub-flavored Markdown directly into a PDF artifact. The chat response
76+
and PDF therefore preserve the same headings, prose, lists, tables, links, citations,
77+
and ordering; only print-safe pagination and document chrome differ. The project
78+
workspace is resolved only after remote research succeeds;
7779
the PDF is then stored both in the live project files and the durable
7880
generated-output store. Document generators stage their bounded structured input
7981
in a hidden, project-local temporary file instead of embedding it in the sandbox

packages/agent-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@openrouter/ai-sdk-provider": "catalog:",
4444
"ai": "catalog:",
4545
"arquero": "catalog:",
46+
"marked": "catalog:",
4647
"zod": "catalog:"
4748
},
4849
"devDependencies": {

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

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

234234
const RESEARCH_MODULE = `## Research
235235
236-
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.`;
236+
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. After a research workflow succeeds, present its returned report Markdown as the response body without summarizing, restructuring, or appending hidden claim-map data; the PDF is rendered from that same Markdown. 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.`;
237237

238238
/** Compact all-domains pointer for an ambiguous general request — keeps the model aware without the full modules. */
239239
const GENERALIST_MODULE = `## Choosing your approach
Lines changed: 19 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,21 @@
1-
import type { GenerateDocumentInput } from "../../tools/docs/schemas";
2-
import type { ResearchReport, ResearchSource } from "../workflows/research-schemas";
1+
import type { GenerateMarkdownPdfInput } from "../../tools/docs/schemas";
32

4-
const MAX_NARRATIVE_SECTIONS = 60;
5-
const MAX_APPENDIX_SECTIONS = 10;
6-
const MAX_PARAGRAPH_LENGTH = 5_000;
7-
const PARAGRAPHS_PER_SECTION = 20;
8-
9-
interface DocumentSection {
10-
heading: string;
11-
paragraphs: string[];
12-
}
13-
14-
export function buildResearchReportDocument(
15-
report: ResearchReport,
3+
export function buildResearchMarkdownPdfInput(
4+
markdown: string,
165
topic: string,
17-
): GenerateDocumentInput {
18-
const title = `Research report: ${cleanInlineMarkdown(topic)}`;
19-
const sourceById = new Map(report.sources.map((source) => [source.id, source]));
20-
const narrative = narrativeSections(report.report).slice(0, MAX_NARRATIVE_SECTIONS);
21-
const evidence = appendixSections(
22-
"Evidence map",
23-
report.claims.map((claim) => evidenceParagraph(claim.claim, claim.sourceIds, sourceById)),
24-
);
25-
const sources = appendixSections("Sources", report.sources.map(sourceParagraph));
26-
6+
): GenerateMarkdownPdfInput {
7+
const fallbackTitle = cleanTitle(topic) || "Research";
278
return {
289
filename: researchFilename(topic),
29-
sections: [...narrative, ...evidence, ...sources],
30-
title: clampText(title),
10+
markdown,
11+
title: markdownTitle(markdown) ?? `Research report: ${fallbackTitle}`,
3112
};
3213
}
3314

34-
function narrativeSections(markdown: string): DocumentSection[] {
35-
const sections: DocumentSection[] = [];
36-
let heading = "Research findings";
37-
let paragraphs: string[] = [];
38-
39-
for (const block of markdown.split(/\n\s*\n/u)) {
40-
const lines = block.split("\n").map((line) => line.trim());
41-
const firstLine = lines[0] ?? "";
42-
const headingMatch = /^(?:#{1,6})\s+(.+)$/u.exec(firstLine);
43-
if (headingMatch) {
44-
appendSection(sections, heading, paragraphs);
45-
heading = cleanInlineMarkdown(headingMatch[1] ?? "Research findings");
46-
paragraphs = lines.slice(1).flatMap(cleanDocumentLine);
47-
continue;
48-
}
49-
paragraphs.push(...lines.flatMap(cleanDocumentLine));
50-
}
51-
52-
appendSection(sections, heading, paragraphs);
53-
return sections.length > 0
54-
? sections
55-
: [{ heading: "Research findings", paragraphs: [clampText(cleanInlineMarkdown(markdown))] }];
56-
}
57-
58-
function appendSection(sections: DocumentSection[], heading: string, paragraphs: string[]): void {
59-
for (const [index, chunk] of chunks(paragraphs, PARAGRAPHS_PER_SECTION).entries()) {
60-
sections.push({
61-
heading: index === 0 ? clampText(heading) : clampText(`${heading} (continued)`),
62-
paragraphs: chunk,
63-
});
64-
}
65-
}
66-
67-
function appendixSections(heading: string, paragraphs: string[]): DocumentSection[] {
68-
return chunks(paragraphs, PARAGRAPHS_PER_SECTION)
69-
.slice(0, MAX_APPENDIX_SECTIONS)
70-
.map((chunk, index) => ({
71-
heading: index === 0 ? heading : `${heading} (continued)`,
72-
paragraphs: chunk,
73-
}));
74-
}
75-
76-
function evidenceParagraph(
77-
claim: string,
78-
sourceIds: string[],
79-
sourceById: ReadonlyMap<string, ResearchSource>,
80-
): string {
81-
const sources = sourceIds
82-
.map((sourceId) => sourceById.get(sourceId))
83-
.filter((source): source is ResearchSource => source !== undefined)
84-
.map(sourceLabel);
85-
return clampText(`${cleanInlineMarkdown(claim)}\nSources: ${sources.join("; ")}`);
86-
}
87-
88-
function sourceParagraph(source: ResearchSource): string {
89-
return clampText(`${source.title?.trim() || source.url}\n${source.url}`);
90-
}
91-
92-
function sourceLabel(source: ResearchSource): string {
93-
return source.title?.trim() ? `${source.title.trim()} (${source.url})` : source.url;
94-
}
95-
96-
function cleanDocumentLine(line: string): string[] {
97-
const cleaned = cleanInlineMarkdown(line)
98-
.replace(/^[-*+]\s+/u, "• ")
99-
.replace(/^\d+[.)]\s+/u, (prefix) => `${prefix} `)
100-
.replace(/^\|(.+)\|$/u, "$1")
101-
.replace(/\s*\|\s*/gu, " — ")
102-
.trim();
103-
return cleaned && !/^[-: ]+$/u.test(cleaned) ? [clampText(cleaned)] : [];
104-
}
105-
106-
function cleanInlineMarkdown(value: string): string {
107-
return value
108-
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
109-
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/gu, "$1 ($2)")
110-
.replace(/`([^`]+)`/gu, "$1")
111-
.replace(/[*_~]+/gu, "")
112-
.replace(/\s+/gu, " ")
113-
.trim();
15+
function markdownTitle(markdown: string): string | undefined {
16+
const heading = /^#\s+(.+)$/mu.exec(markdown)?.[1];
17+
const title = heading ? cleanTitle(heading) : "";
18+
return title || undefined;
11419
}
11520

11621
function researchFilename(topic: string): string {
@@ -123,17 +28,12 @@ function researchFilename(topic: string): string {
12328
return `research-${slug || "report"}.pdf`;
12429
}
12530

126-
function clampText(value: string): string {
127-
const normalized = value.trim();
128-
return normalized.length <= MAX_PARAGRAPH_LENGTH
129-
? normalized
130-
: `${normalized.slice(0, MAX_PARAGRAPH_LENGTH - 1)}…`;
131-
}
132-
133-
function chunks<T>(values: T[], size: number): T[][] {
134-
const output: T[][] = [];
135-
for (let index = 0; index < values.length; index += size) {
136-
output.push(values.slice(index, index + size));
137-
}
138-
return output;
31+
function cleanTitle(value: string): string {
32+
return value
33+
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
34+
.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1")
35+
.replace(/[`*_~]+/gu, "")
36+
.replace(/\s+/gu, " ")
37+
.trim()
38+
.slice(0, 5_000);
13939
}

packages/agent-core/src/mastra/tool-defs/research-tools.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createTool, type ToolExecutionContext } from "@mastra/core/tools";
2-
import { executeGeneratePdf } from "../../tools/docs/execute";
3-
import { GeneratePdfOutputSchema } from "../../tools/docs/schemas";
2+
import { z } from "zod/v4";
3+
import { executeGenerateMarkdownPdf } from "../../tools/docs/execute";
4+
import { GenerateMarkdownPdfOutputSchema } from "../../tools/docs/schemas";
45
import {
56
ExaSearchInputSchema,
67
ExaSearchOutputSchema,
@@ -27,12 +28,13 @@ import {
2728
firecrawlSource,
2829
registerResearchSources,
2930
} from "../workflows/research-provenance";
30-
import { buildResearchReportDocument } from "./research-report-document-support";
31+
import { buildResearchMarkdownPdfInput } from "./research-report-document-support";
3132
import { researchRuntimeFromContext, workspaceRuntimeFromContext } from "./tool-runtime-context";
3233
import { WorkflowResultSchema } from "./tool-schemas";
3334

34-
const ResearchReportArtifactSchema = ResearchReportSchema.extend({
35-
artifact: GeneratePdfOutputSchema,
35+
const ResearchReportArtifactSchema = z.strictObject({
36+
artifact: GenerateMarkdownPdfOutputSchema,
37+
report: ResearchReportSchema.shape.report,
3638
});
3739

3840
type RequestContextReader = { get(key: string): unknown };
@@ -297,11 +299,11 @@ async function createResearchReportArtifact(
297299
context: ToolExecutionContext,
298300
) {
299301
context.abortSignal?.throwIfAborted();
300-
const artifact = await executeGeneratePdf(
301-
buildResearchReportDocument(report, topic),
302+
const artifact = await executeGenerateMarkdownPdf(
303+
buildResearchMarkdownPdfInput(report.report, topic),
302304
await workspaceRuntimeFromContext(context),
303305
);
304-
return ResearchReportArtifactSchema.parse({ ...report, artifact });
306+
return ResearchReportArtifactSchema.parse({ artifact, report: report.report });
305307
}
306308

307309
async function executeExaTool(input: unknown, context: ToolExecutionContext) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ function researchSynthesisPrompt(
305305
config.synthesisPrompt(findings),
306306
"Consolidate overlapping evidence into at most 16 distinct claims with no more than 4 source IDs per claim.",
307307
"Keep the report focused and complete within 2,000 words while retaining actionable findings and citations.",
308+
"Write report as polished GitHub-flavored Markdown for direct display and PDF rendering. Preserve a clear heading hierarchy, lists, and comparison tables where useful.",
309+
"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.",
308310
].join("\n");
309311
}
310312

packages/agent-core/src/tools/docs/execute.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import type {
44
ArtifactUploadResult,
55
CodeRuntimeContext,
66
} from "@cheatcode/sandbox-contracts";
7+
import { lexer } from "marked";
78
import { z } from "zod";
89
import {
910
type GenerateDocumentInput,
1011
GenerateDocumentInputSchema,
1112
type GenerateDocxOutput,
1213
GenerateDocxOutputSchema,
14+
type GenerateMarkdownPdfInput,
15+
GenerateMarkdownPdfInputSchema,
16+
type GenerateMarkdownPdfOutput,
17+
GenerateMarkdownPdfOutputSchema,
1318
type GeneratePdfOutput,
1419
GeneratePdfOutputSchema,
1520
type GenerateSlidesInput,
@@ -21,7 +26,13 @@ import {
2126
type GenerateXlsxOutput,
2227
GenerateXlsxOutputSchema,
2328
} from "./schemas";
24-
import { buildDocxScript, buildPdfScript, buildSlidesScript, buildXlsxScript } from "./scripts";
29+
import {
30+
buildDocxScript,
31+
buildMarkdownPdfScript,
32+
buildPdfScript,
33+
buildSlidesScript,
34+
buildXlsxScript,
35+
} from "./scripts";
2536

2637
const SandboxArtifactSchema = z.strictObject({
2738
base64: z.string().min(1),
@@ -84,6 +95,26 @@ export async function executeGeneratePdf(
8495
});
8596
}
8697

98+
export async function executeGenerateMarkdownPdf(
99+
input: GenerateMarkdownPdfInput,
100+
runtimeContext: CodeRuntimeContext,
101+
): Promise<GenerateMarkdownPdfOutput> {
102+
const parsed = GenerateMarkdownPdfInputSchema.parse(input);
103+
const filename = normalizeFilename(parsed.filename ?? parsed.title ?? "research-report", "pdf");
104+
const tokens = lexer(parsed.markdown, { gfm: true });
105+
const artifact = await runArtifactScript(
106+
{ markdown: parsed.markdown, title: parsed.title, tokens },
107+
runtimeContext,
108+
"pdf",
109+
(inputPath) => buildMarkdownPdfScript(inputPath, filename),
110+
);
111+
return GenerateMarkdownPdfOutputSchema.parse({
112+
...artifact,
113+
blockCount: tokens.filter((token) => token.type !== "space").length,
114+
kind: "pdf",
115+
});
116+
}
117+
87118
export async function executeGenerateXlsx(
88119
input: GenerateSpreadsheetInput,
89120
runtimeContext: CodeRuntimeContext,

packages/agent-core/src/tools/docs/schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export const GenerateDocumentInputSchema = z.strictObject({
3434
title: TextValueSchema,
3535
});
3636

37+
export const GenerateMarkdownPdfInputSchema = z.strictObject({
38+
filename: z.string().trim().min(1).max(160).optional(),
39+
markdown: z.string().trim().min(1).max(120_000),
40+
title: TextValueSchema.optional(),
41+
});
42+
3743
const SpreadsheetCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
3844
const SpreadsheetRowSchema = z.record(z.string().min(1).max(80), SpreadsheetCellSchema);
3945

@@ -61,6 +67,12 @@ export const GeneratePdfOutputSchema = z.strictObject({
6167
sectionCount: z.number().int().positive(),
6268
});
6369

70+
export const GenerateMarkdownPdfOutputSchema = z.strictObject({
71+
...ArtifactOutputSchema.shape,
72+
blockCount: z.number().int().positive(),
73+
kind: z.literal("pdf"),
74+
});
75+
6476
export const GenerateSlidesOutputSchema = z.strictObject({
6577
...ArtifactOutputSchema.shape,
6678
kind: z.literal("slide"),
@@ -75,6 +87,8 @@ export const GenerateXlsxOutputSchema = z.strictObject({
7587

7688
export type GenerateDocumentInput = z.input<typeof GenerateDocumentInputSchema>;
7789
export type GenerateDocxOutput = z.output<typeof GenerateDocxOutputSchema>;
90+
export type GenerateMarkdownPdfInput = z.input<typeof GenerateMarkdownPdfInputSchema>;
91+
export type GenerateMarkdownPdfOutput = z.output<typeof GenerateMarkdownPdfOutputSchema>;
7892
export type GeneratePdfOutput = z.output<typeof GeneratePdfOutputSchema>;
7993
export type GenerateSlidesInput = z.input<typeof GenerateSlidesInputSchema>;
8094
export type GenerateSlidesOutput = z.output<typeof GenerateSlidesOutputSchema>;

0 commit comments

Comments
 (0)