Skip to content

Commit be85331

Browse files
authored
fix: reject incomplete research reports before publication (#177)
## Summary - Rejects length-truncated or structurally invalid research Markdown before any PDF is published. - Restricts every citation to provider-collected HTTP(S) evidence and requires the final Sources list to match inline citations exactly. - Doubles synthesis output headroom so reasoning-capable models can finish a focused report without cutting off the references. ## Architecture The synthesis model still produces the canonical Markdown displayed in chat and rendered into the PDF. A deterministic validation boundary now runs before `ResearchReportSchema` succeeds and before the artifact renderer or generated-output store is invoked. Invalid output uses the existing bounded in-memory retry; a second invalid result fails safely instead of publishing a broken report. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Failure handling | Reject and regenerate | Repair malformed Markdown after generation | Repair would invent or alter source attribution and make chat/PDF diverge from the canonical report. | | Citation trust | Exact canonical URL membership | Accept any syntactically valid link | Research reports must cite only evidence returned by Exa or Firecrawl. | | Sources contract | Exact set parity with inline citations | Allow partial or extra references | Prevents missing, unused, duplicate, and truncated source entries. | | Output budget | 8,192 tokens with a 20,000-character report bound | Keep the 4,096-token ceiling | Provides headroom for reasoning and long URLs while retaining the existing byte bound and 1,200-word prompt. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Provider stops at output limit | Reject by finish reason and retry. | | Markdown link is cut off | Reject the unparsed link token. | | Model cites an uncollected URL | Reject before artifact generation. | | Sources list omits or adds a citation | Reject unless source and body URL sets are equal. | | Duplicate or non-link source entry | Reject the report. | | Wrong heading/list shape | Require one H1, one final H2 Sources section, and an unordered link-only list. | ## How to Review 1. Start with `research-markdown.ts` for the deterministic publication contract. 2. Review `deep-research-workflow.ts` for finish-reason wiring, output headroom, and synthesis instructions. 3. Confirm `packages/agent-core/README.md` matches the runtime boundary. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - Direct contract exercise: one valid report accepted unchanged; length cutoff, malformed source, uncollected URL, missing source entry, body/source mismatch, and ordered Sources list all rejected. - Production baseline reproduced before this change: natural-language research completed, PDF downloaded as a valid 4-page A4 document, and `/` listed it; visual inspection exposed the truncated final Markdown link this PR prevents.
1 parent 0f80fac commit be85331

3 files changed

Lines changed: 226 additions & 21 deletions

File tree

packages/agent-core/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,12 @@ Each concurrent research pass performs one bounded Exa discovery call and an opt
7878
Firecrawl extraction of its primary result. Provider-owned IDs, URLs, excerpts, and
7979
the durable claim map are assembled deterministically instead of asking a model to
8080
reproduce citation identifiers. The sole tool-free model call writes only the canonical
81-
Markdown report from those byte-bounded evidence packs. It has an operational timeout
82-
and one in-memory retry for transient provider or invalid Markdown failures; request
83-
cancellation always wins and no secret-bearing state is snapshotted. Prose URL scraping
84-
is not an accepted provenance boundary.
81+
Markdown report from those byte-bounded evidence packs. Before publication, the workflow
82+
rejects truncated generations and validates the heading structure, citation URLs, and the
83+
one-to-one relationship between inline citations and the final Sources list. It has an
84+
operational timeout and one in-memory retry for transient provider or invalid Markdown
85+
failures; request cancellation always wins and no secret-bearing state is snapshotted.
86+
Prose URL scraping is not an accepted provenance boundary.
8587
Successful top-level deep-research and fan-out tools render the validated report's
8688
canonical GitHub-flavored Markdown directly into a PDF artifact. The chat response
8789
and PDF therefore preserve the same headings, prose, lists, tables, links, citations,

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

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ResearchRuntimeContextSchema,
88
} from "../../tools/research";
99
import { CONTEXT } from "../context";
10+
import { parseResearchMarkdown } from "./research-markdown";
1011
import {
1112
exaSource,
1213
firecrawlSource,
@@ -30,10 +31,9 @@ const RESEARCH_RESULT_TEXT_CHARACTERS = 1_600;
3031
const RESEARCH_EVIDENCE_CHARACTERS_PER_SOURCE = 3_000;
3132
const RESEARCH_SYNTHESIS_EVIDENCE_CHARACTERS_PER_SOURCE = 1_200;
3233
const RESEARCH_SCRAPE_CHARACTERS = 6_000;
33-
const RESEARCH_SYNTHESIS_MAX_OUTPUT_TOKENS = 4_096;
34+
const RESEARCH_SYNTHESIS_MAX_OUTPUT_TOKENS = 8_192;
3435
const RESEARCH_MODEL_TIMEOUT_MS = 75_000;
3536
const RESEARCH_MODEL_ATTEMPTS = 2;
36-
const ResearchMarkdownSchema = z.string().trim().min(1).max(20_000).startsWith("# ");
3737

3838
interface ResearchEvidenceSource {
3939
content: string;
@@ -160,7 +160,11 @@ function createSynthesisStep(id: string, config: ResearchWorkflowPrompts) {
160160
return ResearchReportSchema.parse({
161161
claims,
162162
findings: inputData,
163-
report: parseResearchMarkdown(response.text),
163+
report: parseResearchMarkdown({
164+
finishReason: response.finishReason,
165+
sources,
166+
value: response.text,
167+
}),
164168
sources,
165169
});
166170
});
@@ -259,7 +263,8 @@ function researchSynthesisPrompt(config: ResearchWorkflowPrompts, evidence: unkn
259263
"Start with one level-one heading. The returned Markdown is displayed unchanged in chat and rendered unchanged into the PDF.",
260264
"Keep the report focused and complete within 1,200 words while retaining actionable findings and citations.",
261265
"Write report as polished GitHub-flavored Markdown for direct display and PDF rendering. Preserve a clear heading hierarchy, lists, and comparison tables where useful.",
262-
"Cite factual claims with descriptive Markdown links to the exact source URLs in the evidence, and finish with a Sources heading containing only sources used in the report.",
266+
"Cite factual claims with descriptive Markdown links to the exact source URLs in the evidence.",
267+
"Finish with exactly one level-two Sources heading. Under it, include one bullet per cited URL, formatted only as a descriptive Markdown link. Every inline citation must appear in that list, and every listed source must be cited inline.",
263268
].join("\n");
264269
}
265270

@@ -303,19 +308,6 @@ function compactEvidence(value: string): string {
303308
.slice(0, RESEARCH_SYNTHESIS_EVIDENCE_CHARACTERS_PER_SOURCE);
304309
}
305310

306-
function parseResearchMarkdown(value: unknown): string {
307-
const parsed = ResearchMarkdownSchema.safeParse(value);
308-
if (parsed.success) {
309-
return parsed.data;
310-
}
311-
throw new APIError(
312-
502,
313-
"upstream_provider_outage",
314-
"Research synthesis returned invalid Markdown",
315-
{ retriable: true },
316-
);
317-
}
318-
319311
async function generateResearchOutput<T>(
320312
abortSignal: AbortSignal,
321313
generate: (generationSignal: AbortSignal) => Promise<T>,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { APIError } from "@cheatcode/observability";
2+
import { lexer, type Token, type Tokens } from "marked";
3+
import { z } from "zod/v4";
4+
import type { ResearchSource } from "./research-schemas";
5+
6+
const ResearchMarkdownSchema = z.string().trim().min(1).max(20_000).startsWith("# ");
7+
const REJECTED_FINISH_REASONS = new Set(["content-filter", "error", "length", "tool-calls"]);
8+
9+
interface ParseResearchMarkdownOptions {
10+
finishReason: string | undefined;
11+
sources: ResearchSource[];
12+
value: unknown;
13+
}
14+
15+
/** Validates the complete, evidence-bound Markdown contract before artifact publication. */
16+
export function parseResearchMarkdown(options: ParseResearchMarkdownOptions): string {
17+
if (options.finishReason && REJECTED_FINISH_REASONS.has(options.finishReason)) {
18+
throw invalidResearchMarkdown();
19+
}
20+
const parsed = ResearchMarkdownSchema.safeParse(options.value);
21+
if (!parsed.success) {
22+
throw invalidResearchMarkdown();
23+
}
24+
const tokens = lexer(parsed.data, { gfm: true });
25+
validateHeadingStructure(tokens);
26+
validateCitationStructure(tokens, options.sources);
27+
return parsed.data;
28+
}
29+
30+
function validateHeadingStructure(tokens: Token[]): void {
31+
const meaningfulTokens = tokens.filter((token) => token.type !== "space");
32+
const first = meaningfulTokens[0];
33+
const levelOneHeadings = meaningfulTokens.filter(
34+
(token) => isHeadingToken(token) && token.depth === 1,
35+
);
36+
if (!first || !isHeadingToken(first) || first.depth !== 1 || levelOneHeadings.length !== 1) {
37+
throw invalidResearchMarkdown();
38+
}
39+
}
40+
41+
function validateCitationStructure(tokens: Token[], sources: ResearchSource[]): void {
42+
const sourceHeadingIndexes = tokens.flatMap((token, index) =>
43+
isHeadingToken(token) && token.depth === 2 && token.text.trim().toLowerCase() === "sources"
44+
? [index]
45+
: [],
46+
);
47+
if (sourceHeadingIndexes.length !== 1) {
48+
throw invalidResearchMarkdown();
49+
}
50+
const sourceHeadingIndex = sourceHeadingIndexes[0];
51+
if (sourceHeadingIndex === undefined) {
52+
throw invalidResearchMarkdown();
53+
}
54+
const sourceBlocks = tokens
55+
.slice(sourceHeadingIndex + 1)
56+
.filter((token) => token.type !== "space");
57+
const sourceList = sourceBlocks[0];
58+
if (
59+
sourceBlocks.length !== 1 ||
60+
!isListToken(sourceList) ||
61+
sourceList.ordered ||
62+
sourceList.items.length === 0 ||
63+
containsUnparsedMarkdownLink(tokens)
64+
) {
65+
throw invalidResearchMarkdown();
66+
}
67+
68+
const allowedUrls = new Set(sources.map((source) => canonicalHttpUrl(source.url)));
69+
const bodyUrls = validateAllowedLinks(tokens.slice(0, sourceHeadingIndex), allowedUrls);
70+
const listedUrls = validateSourceList(sourceList, allowedUrls);
71+
if (bodyUrls.size === 0 || !setsEqual(bodyUrls, listedUrls)) {
72+
throw invalidResearchMarkdown();
73+
}
74+
}
75+
76+
function validateAllowedLinks(tokens: Token[], allowedUrls: Set<string>): Set<string> {
77+
const urls = new Set<string>();
78+
for (const link of collectLinks(tokens)) {
79+
const url = canonicalHttpUrl(link.href);
80+
if (!allowedUrls.has(url)) {
81+
throw invalidResearchMarkdown();
82+
}
83+
urls.add(url);
84+
}
85+
return urls;
86+
}
87+
88+
function validateSourceList(sourceList: Tokens.List, allowedUrls: Set<string>): Set<string> {
89+
const urls = new Set<string>();
90+
for (const item of sourceList.items) {
91+
const links = collectLinks(item.tokens);
92+
if (links.length !== 1 || textOutsideLinks(item.tokens).trim()) {
93+
throw invalidResearchMarkdown();
94+
}
95+
const url = canonicalHttpUrl(links[0]?.href);
96+
if (!allowedUrls.has(url) || urls.has(url)) {
97+
throw invalidResearchMarkdown();
98+
}
99+
urls.add(url);
100+
}
101+
return urls;
102+
}
103+
104+
function collectLinks(tokens: Token[]): Tokens.Link[] {
105+
const links: Tokens.Link[] = [];
106+
for (const token of tokens) {
107+
if (isLinkToken(token)) {
108+
links.push(token);
109+
continue;
110+
}
111+
const children = childTokens(token);
112+
if (children.length > 0) {
113+
links.push(...collectLinks(children));
114+
}
115+
}
116+
return links;
117+
}
118+
119+
function textOutsideLinks(tokens: Token[]): string {
120+
return tokens
121+
.map((token) => {
122+
if (token.type === "link" || token.type === "space") {
123+
return "";
124+
}
125+
const children = childTokens(token);
126+
if (children.length > 0) {
127+
return textOutsideLinks(children);
128+
}
129+
return "text" in token && typeof token.text === "string" ? token.text : "";
130+
})
131+
.join("");
132+
}
133+
134+
function containsUnparsedMarkdownLink(tokens: Token[]): boolean {
135+
return tokens.some((token) => {
136+
if (isLinkToken(token) || token.type === "code" || token.type === "codespan") {
137+
return false;
138+
}
139+
const children = childTokens(token);
140+
if (children.length > 0) {
141+
return containsUnparsedMarkdownLink(children);
142+
}
143+
return (
144+
"text" in token &&
145+
typeof token.text === "string" &&
146+
/!?\[[^\]\n]+\]\([^)\n]*(?:\n|$)/u.test(token.text)
147+
);
148+
});
149+
}
150+
151+
function childTokens(token: Token): Token[] {
152+
if ("tokens" in token && Array.isArray(token.tokens)) {
153+
return token.tokens;
154+
}
155+
if (isListToken(token)) {
156+
return token.items.flatMap((item) => item.tokens);
157+
}
158+
if (isTableToken(token)) {
159+
return [
160+
...token.header.flatMap((cell) => cell.tokens),
161+
...token.rows.flatMap((row) => row.flatMap((cell) => cell.tokens)),
162+
];
163+
}
164+
return [];
165+
}
166+
167+
function isHeadingToken(token: Token): token is Tokens.Heading {
168+
return token.type === "heading" && "depth" in token && typeof token.depth === "number";
169+
}
170+
171+
function isLinkToken(token: Token): token is Tokens.Link {
172+
return token.type === "link" && "href" in token && typeof token.href === "string";
173+
}
174+
175+
function isListToken(token: Token | undefined): token is Tokens.List {
176+
return Boolean(token && token.type === "list" && "items" in token && Array.isArray(token.items));
177+
}
178+
179+
function isTableToken(token: Token): token is Tokens.Table {
180+
return token.type === "table" && "rows" in token && Array.isArray(token.rows);
181+
}
182+
183+
function canonicalHttpUrl(value: string | undefined): string {
184+
try {
185+
const url = new URL(value ?? "");
186+
if (url.protocol !== "http:" && url.protocol !== "https:") {
187+
throw invalidResearchMarkdown();
188+
}
189+
return url.href;
190+
} catch (error) {
191+
if (error instanceof APIError) {
192+
throw error;
193+
}
194+
throw invalidResearchMarkdown();
195+
}
196+
}
197+
198+
function setsEqual(left: Set<string>, right: Set<string>): boolean {
199+
return left.size === right.size && [...left].every((value) => right.has(value));
200+
}
201+
202+
function invalidResearchMarkdown(): APIError {
203+
return new APIError(
204+
502,
205+
"upstream_provider_outage",
206+
"Research synthesis returned invalid Markdown",
207+
{
208+
retriable: true,
209+
},
210+
);
211+
}

0 commit comments

Comments
 (0)