Skip to content

Commit 558e988

Browse files
authored
feat: generate PDF deliverables for deep research (#155)
## Summary - makes explicit deep-research and fan-out runs return a durable PDF deliverable by default - stores the same PDF in the live project workspace so it remains available from Files and slash file references - keeps quick factual research remote-only and projectless - updates the bundled Research skill and agent guidance so models consistently select the deterministic workflow ## Architecture The evidence-validated research workflow still owns search, provenance, and synthesis. After it succeeds, the top-level research tool deterministically converts that validated result into a structured document containing the narrative, claim-to-source evidence map, and authoritative source list. The existing PDF generator writes the file to the lazily resolved project workspace and durable generated-output storage, and the capability catalog emits it through the normal Deliverables stream. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | PDF creation | Generate inside the top-level research tools | Ask the model to call a second document tool | Makes delivery deterministic and prevents model-specific omissions | | Workspace lifecycle | Resolve the project after research succeeds | Create a project when research begins | Avoids leaving empty projects after provider or synthesis failures | | Report content | Package validated narrative, evidence map, and sources | Render only the chat prose | Keeps the downloadable report evidence-bound and independently useful | | Quick research | Leave direct search tools unchanged | Generate a PDF for every lookup | Preserves low latency for simple questions | ## Edge Cases Handled | Scenario | Handling | |---|---| | Long reports | Sections and paragraphs are bounded to the document schema limits | | Repeated or unsafe filenames | Existing normalized artifact filenames and unique output IDs remain authoritative | | Missing project selection | The existing lazy workspace resolver creates and attaches a project only when the PDF is ready | | Cancellation after research | The abort signal is checked before workspace and PDF work starts | | Markdown report formatting | Headings, links, lists, and tables are normalized into readable PDF sections | ## 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] exercised the report converter with representative headings, citations, evidence, and sources - [ ] production Research flow after deployment
1 parent 6966393 commit 558e988

6 files changed

Lines changed: 207 additions & 28 deletions

File tree

packages/agent-core/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ the Mastra workflow run, forward each workflow step signal through every nested
6868
Each concurrent research pass gets an isolated evidence collector populated only
6969
from parsed Exa result IDs/URLs and Firecrawl result URLs. Claim citations and the
7070
final synthesis are schema-validated against that evidence; prose URL scraping
71-
is not an accepted provenance boundary.
71+
is not an accepted provenance boundary. Successful top-level deep-research and
72+
fan-out tools deterministically package that validated report, its claim-to-source
73+
map, and its source list as a PDF artifact. The project workspace is resolved only
74+
after remote research succeeds; the PDF is then stored both in the live project
75+
files and the durable generated-output store.
7276

7377
Composio REST tool discovery and execution responses are byte-bounded before
7478
parsing, then projected into bounded, valid JSON before entering model context.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ Load the generate-media skill before creating or editing an image or generating
232232

233233
const RESEARCH_MODULE = `## Research
234234
235-
Gather sources with search_web / search_web_advanced / search_company / search_web_content, then use search_scrape or search_extract for source retrieval; use research_deep and research_fanout for cited multi-source reports. 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.`;
235+
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, broad market scan, or comprehensive investigation, use research_deep; use research_fanout when the request compares many entities or independent angles. 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.`;
236236

237237
/** Compact all-domains pointer for an ambiguous general request — keeps the model aware without the full modules. */
238238
const GENERALIST_MODULE = `## Choosing your approach
@@ -242,7 +242,7 @@ Pick the path that fits and load the matching skill (skill_invoke) for its full
242242
- Slides or documents → build with pptxgenjs / docx / @react-pdf / exceljs (or docs_generate_*), then render and eyeball every page; the file lands in the Deliverables.
243243
- Data → profile it (data_analyze_csv, or pandas / Node) and chart it (data_chart) when it adds insight; verify the numbers.
244244
- Image or video → load generate-media, then use generate_or_edit_media; the asset lands in the project and Deliverables.
245-
- Research → gather and cross-check real sources (search_web / firecrawl_* / research_deep); cite everything.
245+
- Research → gather and cross-check real sources (search_web / firecrawl_* / research_deep); cite everything, and use the deep-research workflow when the user asks for a report so its PDF is delivered automatically.
246246
- Acting in the user's connected apps → composio_list_tools then composio_execute, only when they ask.`;
247247

248248
const FINISHING = `## Finishing
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { GenerateDocumentInput } from "../../tools/docs/schemas";
2+
import type { ResearchReport, ResearchSource } from "../workflows/research-schemas";
3+
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,
16+
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+
27+
return {
28+
filename: researchFilename(topic),
29+
sections: [...narrative, ...evidence, ...sources],
30+
title: clampText(title),
31+
};
32+
}
33+
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();
114+
}
115+
116+
function researchFilename(topic: string): string {
117+
const slug = topic
118+
.trim()
119+
.toLowerCase()
120+
.replace(/[^a-z0-9]+/gu, "-")
121+
.replace(/^-+|-+$/gu, "")
122+
.slice(0, 110);
123+
return `research-${slug || "report"}.pdf`;
124+
}
125+
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;
139+
}

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

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { createTool, type ToolExecutionContext } from "@mastra/core/tools";
2+
import { executeGeneratePdf } from "../../tools/docs/execute";
3+
import { GeneratePdfOutputSchema } from "../../tools/docs/schemas";
24
import {
35
ExaSearchInputSchema,
46
ExaSearchOutputSchema,
@@ -25,9 +27,14 @@ import {
2527
firecrawlSource,
2628
registerResearchSources,
2729
} from "../workflows/research-provenance";
28-
import { researchRuntimeFromContext } from "./tool-runtime-context";
30+
import { buildResearchReportDocument } from "./research-report-document-support";
31+
import { researchRuntimeFromContext, workspaceRuntimeFromContext } from "./tool-runtime-context";
2932
import { WorkflowResultSchema } from "./tool-schemas";
3033

34+
const ResearchReportArtifactSchema = ResearchReportSchema.extend({
35+
artifact: GeneratePdfOutputSchema,
36+
});
37+
3138
type RequestContextReader = { get(key: string): unknown };
3239
type MutableRequestContext = RequestContextReader & {
3340
delete(key: string): boolean;
@@ -241,31 +248,50 @@ export const mastraFirecrawlExtract = createTool({
241248
export const mastraDeepResearch = createTool({
242249
id: "research_deep",
243250
description:
244-
"Run the Deep Research workflow for a complex topic. It fans out focused research queries and returns a cited report.",
251+
"Run the Deep Research workflow for a complex topic. It returns a cited report and saves the complete report as a PDF deliverable in the project.",
245252
inputSchema: DeepResearchInputSchema,
246-
outputSchema: ResearchReportSchema,
247-
execute: async (input, context) =>
248-
runResearchWorkflow({
253+
outputSchema: ResearchReportArtifactSchema,
254+
execute: async (input, context) => {
255+
const parsedInput = DeepResearchInputSchema.parse(input);
256+
const report = await runResearchWorkflow({
249257
context,
250-
inputData: DeepResearchInputSchema.parse(input),
258+
inputData: parsedInput,
251259
workflowName: "deepResearch",
252-
}),
260+
});
261+
return createResearchReportArtifact(report, parsedInput.topic, context);
262+
},
253263
});
254264

255265
export const mastraResearchFanout = createTool({
256266
id: "research_fanout",
257267
description:
258-
"Run the Deep Research fan-out workflow across multiple entities or angles and return a comparison matrix style report.",
268+
"Run the Deep Research fan-out workflow across multiple entities or angles. It returns a cited comparison report and saves the complete report as a PDF deliverable in the project.",
259269
inputSchema: DeepResearchFanoutInputSchema,
260-
outputSchema: ResearchReportSchema,
261-
execute: async (input, context) =>
262-
runResearchWorkflow({
270+
outputSchema: ResearchReportArtifactSchema,
271+
execute: async (input, context) => {
272+
const parsedInput = DeepResearchFanoutInputSchema.parse(input);
273+
const report = await runResearchWorkflow({
263274
context,
264-
inputData: DeepResearchFanoutInputSchema.parse(input),
275+
inputData: parsedInput,
265276
workflowName: "deepResearchFanout",
266-
}),
277+
});
278+
return createResearchReportArtifact(report, parsedInput.goal, context);
279+
},
267280
});
268281

282+
async function createResearchReportArtifact(
283+
report: ResearchReport,
284+
topic: string,
285+
context: ToolExecutionContext,
286+
) {
287+
context.abortSignal?.throwIfAborted();
288+
const artifact = await executeGeneratePdf(
289+
buildResearchReportDocument(report, topic),
290+
await workspaceRuntimeFromContext(context),
291+
);
292+
return ResearchReportArtifactSchema.parse({ ...report, artifact });
293+
}
294+
269295
async function executeExaTool(input: unknown, context: ToolExecutionContext) {
270296
const output = await executeExaSearch(
271297
ExaSearchInputSchema.parse(input),

packages/types/src/capabilities.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,18 @@ export const TOOL_CAPABILITIES = [
6262
tool("research", "search_extract", "Extract structured data with Firecrawl.", REMOTE_TOOL),
6363
tool("research", "search_scrape", "Scrape a known URL with Firecrawl.", REMOTE_TOOL),
6464
tool("research", "search_web_content", "Search and scrape with Firecrawl.", REMOTE_TOOL),
65-
tool("research", "research_deep", "Run the deep research workflow.", REMOTE_TOOL),
66-
tool("research", "research_fanout", "Run the deep research fan-out workflow.", REMOTE_TOOL),
65+
tool(
66+
"research",
67+
"research_deep",
68+
"Run deep research and generate a cited PDF report.",
69+
ARTIFACT_TOOL,
70+
),
71+
tool(
72+
"research",
73+
"research_fanout",
74+
"Run deep research fan-out and generate a cited PDF report.",
75+
ARTIFACT_TOOL,
76+
),
6777
tool("research", "search_company", "Search company intel with Exa.", REMOTE_TOOL),
6878
tool("research", "search_web", "Search the web with Exa.", REMOTE_TOOL),
6979
tool("research", "search_web_advanced", "Search the web with Exa filters.", REMOTE_TOOL),

skills/deep-research/SKILL.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,21 @@ compatibility: Requires Exa and Firecrawl research tools; fan-out mode requires
99

1010
# Deep Research
1111

12-
Answer complex questions with sourced synthesis. The output should read like an analyst brief: clear thesis, cited evidence, disagreement handling, and confidence notes. When the question spans many entities or angles, fan out parallel probes first (see Fan-out Mode), then synthesize.
12+
Answer complex questions with sourced synthesis. The output should read like an analyst brief: clear thesis, cited evidence, disagreement handling, and confidence notes. Every run produces a complete PDF deliverable in the user's project in addition to a concise chat summary. When the question spans many entities or angles, use Fan-out Mode.
1313

1414
## Quick Start
1515

16-
1. Rewrite the user's ask into 3-6 research questions.
17-
2. Create a source matrix with question, claim, evidence, URL, date, and confidence columns.
18-
3. Search with `search_web_advanced`; use date/domain filters when appropriate.
19-
4. Scrape authoritative sources with `search_scrape`.
20-
5. Synthesize with inline citations and explicit uncertainty.
16+
1. Scope the user's ask, timeframe, geography, and decision use.
17+
2. Run the request through `research_deep`; it performs the parallel research, validates citations, and creates the PDF deliverable.
18+
3. Return the key conclusion and important caveats in chat, with a short source list.
19+
4. Refer to the PDF naturally as ready below. Do not call a separate document tool or recreate the report.
2120

2221
## Fan-out Mode
2322

24-
Use breadth first when the ask covers many independent entities (companies, tools, policies, markets): survey a population, compare many companies, or scan a market across 10-25 angles. Run it through the `research_fanout` workflow tool.
23+
Use breadth first when the ask covers many independent entities (companies, tools, policies, markets): survey a population, compare many companies, or scan a market across many angles. Run it through the `research_fanout` workflow tool; it creates the cited comparison PDF automatically.
2524

2625
1. Identify the population to cover and the comparison criteria.
27-
2. Define up to 25 independent probe slots with clear per-probe questions and source expectations.
26+
2. Define up to 12 independent probe slots with clear per-probe questions and source expectations.
2827
3. Fan out the probes; keep them independent so slow or failed branches do not block the answer.
2928
4. Use the same fields for every entity so the comparison is fair; track source URLs per cell.
3029
5. Deduplicate facts, aliases, and repeated articles before synthesis.
@@ -55,8 +54,9 @@ Use breadth first when the ask covers many independent entities (companies, tool
5554

5655
## Deliverables
5756

58-
- Markdown report
59-
- Source matrix
57+
- PDF research report saved in the project and shown in Deliverables
58+
- Concise chat summary with the main conclusion and caveats
59+
- Claim-to-source evidence map and source list inside the PDF
6060
- Confidence and gaps
6161

6262
## References

0 commit comments

Comments
 (0)