Skip to content

Commit 84ea2d2

Browse files
authored
Fix AgentGrade content negotiation (#1946)
* Fix AgentGrade content negotiation * Prioritize markdown cues in docs proxy * Refine root content negotiation
1 parent 4101073 commit 84ea2d2

5 files changed

Lines changed: 253 additions & 10 deletions

File tree

docs-site/agent-discovery.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const AGENT_DISCOVERY_LINKS = [
66

77
export const AGENT_DISCOVERY_HEADERS = [
88
{ key: "Link", value: AGENT_DISCOVERY_LINKS },
9+
{ key: "Vary", value: "Accept" },
910
{ key: "X-Llms-Txt", value: "/llms.txt" },
1011
{ key: "X-Llms-Full-Txt", value: "/llms-full.txt" },
1112
{ key: "X-Agent-Skill", value: "/skill.md" },
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { agentDiscoveryHeaderRecord } from "@/server/agent-discovery";
3+
import { negotiateContentType } from "@/server/content-negotiation";
4+
import { readDoc } from "@/server/docs";
5+
6+
const description =
7+
"Verity is a Lean-native language and formally verified compiler for Ethereum smart contracts.";
8+
9+
const fallbackMarkdown = `# Verity
10+
11+
> ${description}
12+
13+
## Endpoints
14+
15+
- [Documentation](https://veritylang.com/) - Verity overview and guides.
16+
- [Getting Started](https://veritylang.com/getting-started) - Local setup and first verification run.
17+
- [API Docs](https://veritylang.com/api/docs/_index) - Machine-readable documentation index.
18+
- [llms.txt](https://veritylang.com/llms.txt) - Agent operating manual.
19+
20+
## Authentication
21+
22+
No authentication is required for public documentation.
23+
`;
24+
25+
const fallbackText = `${description}
26+
27+
Documentation: https://veritylang.com/
28+
Getting started: https://veritylang.com/getting-started
29+
Machine-readable docs index: https://veritylang.com/api/docs/_index
30+
`;
31+
32+
const html = `<!doctype html>
33+
<html lang="en">
34+
<head>
35+
<meta charset="utf-8">
36+
<title>Verity</title>
37+
<meta name="description" content="${description}">
38+
<link rel="alternate" type="text/plain" title="llms.txt" href="/llms.txt">
39+
</head>
40+
<body>
41+
<main>
42+
<h1>Verity</h1>
43+
<p>${description}</p>
44+
<p><a href="/llms.txt">llms.txt</a></p>
45+
</main>
46+
</body>
47+
</html>
48+
`;
49+
50+
export async function GET(request: NextRequest) {
51+
const negotiatedType =
52+
request.nextUrl.searchParams.get("type") ??
53+
negotiateContentType(request.headers.get("accept"));
54+
const doc = await readDoc("index");
55+
const markdown = doc?.markdown ?? fallbackMarkdown;
56+
const headers = {
57+
...agentDiscoveryHeaderRecord(),
58+
"Vary": "Accept",
59+
"X-Content-Type-Options": "nosniff",
60+
};
61+
62+
if (negotiatedType === "application/json") {
63+
return NextResponse.json(
64+
{
65+
name: "Verity",
66+
url: "https://veritylang.com/",
67+
description,
68+
repository: "https://github.com/lfglabs-dev/verity",
69+
docs: {
70+
index: "https://veritylang.com/api/docs/_index",
71+
llms: "https://veritylang.com/llms.txt",
72+
full: "https://veritylang.com/llms-full.txt",
73+
},
74+
},
75+
{ headers }
76+
);
77+
}
78+
79+
if (negotiatedType === "text/plain") {
80+
return new NextResponse(fallbackText, {
81+
headers: {
82+
...headers,
83+
"Content-Type": "text/plain; charset=utf-8",
84+
},
85+
});
86+
}
87+
88+
if (negotiatedType === "text/html") {
89+
return new NextResponse(html, {
90+
headers: {
91+
...headers,
92+
"Content-Type": "text/html; charset=utf-8",
93+
},
94+
});
95+
}
96+
97+
return new NextResponse(markdown, {
98+
headers: {
99+
...headers,
100+
"Content-Type": "text/markdown; charset=utf-8",
101+
},
102+
});
103+
}

docs-site/app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ const navbar = (
127127
export default async function RootLayout({ children }: { children: React.ReactNode }) {
128128
return (
129129
<html lang="en" dir="ltr" suppressHydrationWarning>
130-
<Head />
130+
<head>
131+
<Head />
132+
<link rel="alternate" type="text/plain" title="llms.txt" href="/llms.txt" />
133+
</head>
131134
<body>
132135
<Layout
133136
navbar={navbar}

docs-site/proxy.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { applyAgentDiscoveryHeaders } from "@/server/agent-discovery";
3+
import { negotiateContentType } from "@/server/content-negotiation";
34

45
/**
56
* Known LLM/AI user agent patterns
@@ -28,13 +29,18 @@ function isLLMUserAgent(userAgent: string | null): boolean {
2829
);
2930
}
3031

32+
function withVaryAccept(response: NextResponse): NextResponse {
33+
response.headers.set("Vary", "Accept");
34+
return response;
35+
}
36+
3137
/**
3238
* Proxy to serve raw markdown for AI agents
3339
*
3440
* Routes to raw markdown API when:
3541
* 1. URL ends with .md extension (e.g., /setup.md)
36-
* 2. Accept header includes text/markdown
37-
* 3. Accept header includes text/plain
42+
* 2. Non-root Accept negotiation selects text/markdown
43+
* 3. Non-root Accept negotiation selects text/plain
3844
* 4. Query param ?format=md is present
3945
* 5. User-Agent is a known LLM (ChatGPT, GPTBot, PerplexityBot, etc.)
4046
*
@@ -57,15 +63,19 @@ export function proxy(request: NextRequest) {
5763
}
5864

5965
const userAgent = request.headers.get("User-Agent");
60-
61-
// Check if this is a docs page request that wants markdown
62-
const wantsMarkdown =
66+
const acceptHeader = request.headers.get("Accept");
67+
const negotiatedType = negotiateContentType(acceptHeader);
68+
const hasExplicitMarkdownCue =
6369
pathname.endsWith(".md") ||
64-
request.headers.get("Accept")?.includes("text/markdown") ||
65-
request.headers.get("Accept")?.includes("text/plain") ||
6670
request.nextUrl.searchParams.get("format") === "md" ||
6771
isLLMUserAgent(userAgent);
6872

73+
// Check if this is a docs page request that wants markdown
74+
const wantsMarkdown =
75+
hasExplicitMarkdownCue ||
76+
(pathname !== "/" &&
77+
(negotiatedType === "text/markdown" || negotiatedType === "text/plain"));
78+
6979
if (wantsMarkdown) {
7080
// Normalize the path (remove .md extension if present)
7181
let docPath = pathname.replace(/\.md$/, "");
@@ -81,10 +91,31 @@ export function proxy(request: NextRequest) {
8191
url.searchParams.delete("format");
8292

8393
// Rewrite to the API route (internal redirect, URL doesn't change for client)
84-
return applyAgentDiscoveryHeaders(NextResponse.rewrite(url));
94+
return withVaryAccept(applyAgentDiscoveryHeaders(NextResponse.rewrite(url)));
95+
}
96+
97+
if (pathname === "/" && negotiatedType !== "text/html") {
98+
const url = new URL(request.url);
99+
url.pathname = "/api/agentgrade/root";
100+
url.searchParams.set("type", negotiatedType);
101+
return withVaryAccept(applyAgentDiscoveryHeaders(NextResponse.rewrite(url)));
102+
}
103+
104+
if (pathname !== "/" && negotiatedType === "application/json") {
105+
return withVaryAccept(
106+
applyAgentDiscoveryHeaders(
107+
NextResponse.json(
108+
{
109+
error: "not_found",
110+
path: pathname,
111+
},
112+
{ status: 404 }
113+
)
114+
)
115+
);
85116
}
86117

87-
return applyAgentDiscoveryHeaders(NextResponse.next());
118+
return withVaryAccept(applyAgentDiscoveryHeaders(NextResponse.next()));
88119
}
89120

90121
// Only run proxy on relevant paths
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const DEFAULT_SUPPORTED_TYPES = [
2+
"text/html",
3+
"text/markdown",
4+
"text/plain",
5+
"application/json",
6+
] as const;
7+
8+
type SupportedType = (typeof DEFAULT_SUPPORTED_TYPES)[number];
9+
10+
type AcceptedType = {
11+
type: string;
12+
q: number;
13+
index: number;
14+
};
15+
16+
function parseAccept(acceptHeader: string | null): AcceptedType[] {
17+
if (!acceptHeader) {
18+
return [];
19+
}
20+
21+
return acceptHeader
22+
.split(",")
23+
.map((part, index) => {
24+
const [rawType, ...params] = part.trim().split(";");
25+
const type = rawType.toLowerCase();
26+
let q = 1;
27+
28+
for (const param of params) {
29+
const [key, value] = param.trim().split("=");
30+
if (key === "q") {
31+
const parsed = Number.parseFloat(value);
32+
q = Number.isFinite(parsed) ? parsed : 0;
33+
}
34+
}
35+
36+
return { type, q, index };
37+
})
38+
.filter(({ type, q }) => Boolean(type) && q > 0);
39+
}
40+
41+
function matchSpecificity(accepted: string, supported: string): number {
42+
if (accepted === supported) {
43+
return 2;
44+
}
45+
46+
const [acceptedType, acceptedSubtype] = accepted.split("/");
47+
const [supportedType] = supported.split("/");
48+
49+
if (accepted === "*/*") {
50+
return 0;
51+
}
52+
53+
if (acceptedSubtype === "*" && acceptedType === supportedType) {
54+
return 1;
55+
}
56+
57+
return -1;
58+
}
59+
60+
export function negotiateContentType(
61+
acceptHeader: string | null,
62+
supportedTypes: readonly SupportedType[] = DEFAULT_SUPPORTED_TYPES
63+
): SupportedType {
64+
const acceptedTypes = parseAccept(acceptHeader);
65+
66+
if (acceptedTypes.length === 0) {
67+
return "text/html";
68+
}
69+
70+
let best: {
71+
type: SupportedType;
72+
q: number;
73+
specificity: number;
74+
index: number;
75+
} | null = null;
76+
77+
for (const accepted of acceptedTypes) {
78+
for (const supported of supportedTypes) {
79+
const specificity = matchSpecificity(accepted.type, supported);
80+
if (specificity < 0) {
81+
continue;
82+
}
83+
84+
const candidate = {
85+
type: supported,
86+
q: accepted.q,
87+
specificity,
88+
index: accepted.index,
89+
};
90+
91+
if (
92+
!best ||
93+
candidate.q > best.q ||
94+
(candidate.q === best.q && candidate.specificity > best.specificity) ||
95+
(candidate.q === best.q &&
96+
candidate.specificity === best.specificity &&
97+
candidate.index < best.index)
98+
) {
99+
best = candidate;
100+
}
101+
}
102+
}
103+
104+
return best?.type ?? "text/html";
105+
}

0 commit comments

Comments
 (0)