[codex] Add skill context to Ask Hexclave (#1605)

## Summary

- Fetches the canonical Hexclave skill from `https://skill.hexclave.com`
when the backend AI route is invoked through MCP `ask_hexclave`
- Appends that skill content to the spawned docs agent's system context
before generation
- Adds focused tests for non-Ask-Hexclave no-op behavior, successful
skill embedding, and loud fetch failure

## Why

The public MCP server exposes the skill as a separate resource/prompt,
but the backend docs agent spawned by `ask_hexclave` only saw the user's
question. That meant clients had to correctly load the skill themselves,
and the server-side answer quality could miss the canonical
setup/context.

## Validation

- `pnpm -C apps/backend exec eslint
'src/app/api/latest/ai/query/[mode]/route.ts'
src/lib/ai/mcp-skill-context.ts src/lib/ai/mcp-skill-context.test.ts`
- `pnpm exec vitest run
apps/backend/src/lib/ai/mcp-skill-context.test.ts --config /dev/null
--environment node`
- `pnpm exec tsc --noEmit --target es2022 --module esnext
--moduleResolution bundler --lib es2022,dom --types vitest
apps/backend/src/lib/ai/mcp-skill-context.ts
apps/backend/src/lib/ai/mcp-skill-context.test.ts`

## Notes

- Normal backend Vitest/typecheck are blocked in this fresh worktree
because generated/built `@hexclave/shared/dist/*` files are missing, and
repo instructions say not to run package builds from the agent.
- Full backend lint also reports an unrelated pre-existing error in
`apps/backend/scripts/run-bulldozer-studio.ts`.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds full Hexclave docs to `ask_hexclave` requests by fetching
https://docs.hexclave.com/llms-full.txt and appending them to the docs
agent system prompt. Includes a 5‑minute cache and 5s timeout, and skips
docs tools when `ask_hexclave` is used.

- **New Features**
- Added `getMcpSkillContextPrompt` to fetch and inject docs for
`ask_hexclave`; no‑op otherwise.
- Integrated in `route.ts` to append context before tool selection and
pass `mcpToolName` to `getTools`.
- Reliability: 5‑minute TTL cache, 5s timeout, and error handling; tests
cover success, no‑op, errors, timeouts, null/undefined, and cache hits.

- **Refactors**
  - Switched source to `https://docs.hexclave.com/llms-full.txt`.
  - Removed `docs-mintlify/llms-full.txt` and related generator code.
  - Cache TTL now uses `performance.now()` for accurate expiry.

<sup>Written for commit e0dc388c64.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1605?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* AI queries now dynamically fetch and include documentation context
during operation, with in-memory caching to minimize network requests
and redundant fetches.

* **Tests**
* Added comprehensive test suite validating documentation fetching
behavior, error handling for network failures and timeouts, and caching
mechanisms to ensure reliability.

* **Chores**
  * Removed auto-generated documentation artifact from the codebase.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
This commit is contained in:
Mantra 2026-06-18 11:40:02 -07:00 committed by GitHub
parent 9388b99c2f
commit 75e497f3ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 167 additions and 1390 deletions

View File

@ -3,6 +3,7 @@ import { selectModel } from "@/lib/ai/models";
import { getFullSystemPrompt } from "@/lib/ai/prompts";
import { reviewMcpCall } from "@/lib/ai/qa-reviewer";
import { requestBodySchema } from "@/lib/ai/schema";
import { getMcpSkillContextPrompt } from "@/lib/ai/mcp-skill-context";
import { getTools } from "@/lib/ai/tools";
import { getVerifiedQaContext } from "@/lib/ai/verified-qa";
import { listManagedProjectIds } from "@/lib/projects";
@ -61,7 +62,12 @@ export const POST = createSmartRouteHandler({
if (isDocsOrSearch) {
systemPrompt += await getVerifiedQaContext();
}
const tools = await getTools(toolNames, { auth: fullReq.auth, targetProjectId: projectId });
systemPrompt += await getMcpSkillContextPrompt(body.mcpCallMetadata?.toolName);
const tools = await getTools(toolNames, {
auth: fullReq.auth,
targetProjectId: projectId,
mcpToolName: body.mcpCallMetadata?.toolName,
});
const toolsArg = Object.keys(tools).length > 0 ? tools : undefined;
const isCreateDashboard = systemPromptId === "create-dashboard";
const isBuildAnalyticsQuery = systemPromptId === "build-analytics-query";

View File

@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _clearDocsCache, getMcpSkillContextPrompt } from "./mcp-skill-context";
describe("getMcpSkillContextPrompt", () => {
afterEach(() => {
vi.restoreAllMocks();
_clearDocsCache();
});
it("returns empty string for non-ask_hexclave tool names", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
await expect(getMcpSkillContextPrompt("other_tool")).resolves.toMatchInlineSnapshot(`""`);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns empty string for null toolName", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
await expect(getMcpSkillContextPrompt(null)).resolves.toMatchInlineSnapshot(`""`);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns empty string for undefined toolName", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
await expect(getMcpSkillContextPrompt(undefined)).resolves.toMatchInlineSnapshot(`""`);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("fetches and embeds the full documentation for ask_hexclave requests", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("# Hexclave Docs\n\nUse Hexclave docs."),
);
await expect(getMcpSkillContextPrompt("ask_hexclave")).resolves.toMatchInlineSnapshot(`
"
## MCP-Provided Hexclave Documentation Context
The current request came through the public Hexclave MCP server's ask_hexclave tool.
The backend fetched the full Hexclave documentation from https://docs.hexclave.com/llms-full.txt
immediately before spawning this assistant. Treat this documentation as baseline context
for answering the user's question, while still using documentation tools for specific
facts and citations:
# Hexclave Docs
Use Hexclave docs.
"
`);
expect(fetchSpy).toHaveBeenCalledWith("https://docs.hexclave.com/llms-full.txt", expect.objectContaining({
headers: { Accept: "text/markdown" },
signal: expect.any(AbortSignal),
}));
});
it("fails loudly when the documentation cannot be fetched", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("missing", { status: 503, statusText: "Service Unavailable" }),
);
await expect(getMcpSkillContextPrompt("ask_hexclave")).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Failed to fetch docs from https://docs.hexclave.com/llms-full.txt: 503 Service Unavailable]`);
});
it("throws a descriptive error when the fetch times out", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(() => {
const err = new DOMException("The operation was aborted", "AbortError");
return Promise.reject(err);
});
await expect(getMcpSkillContextPrompt("ask_hexclave")).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Docs fetch from https://docs.hexclave.com/llms-full.txt timed out after 5000ms]`);
});
it("returns cached documentation on subsequent calls within TTL", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("# Cached Docs"),
);
const first = await getMcpSkillContextPrompt("ask_hexclave");
const second = await getMcpSkillContextPrompt("ask_hexclave");
expect(first).toBe(second);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,67 @@
const HEXCLAVE_DOCS_FULL_URL = "https://docs.hexclave.com/llms-full.txt";
const FETCH_TIMEOUT_MS = 5_000;
const CACHE_TTL_MS = 5 * 60 * 1_000; // 5 minutes
let cachedDocs: { text: string, fetchedAt: number } | null = null;
async function fetchDocsText(): Promise<string> {
const now = performance.now();
if (cachedDocs && now - cachedDocs.fetchedAt < CACHE_TTL_MS) {
return cachedDocs.text;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(HEXCLAVE_DOCS_FULL_URL, {
headers: { Accept: "text/markdown" },
signal: controller.signal,
});
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error(`Docs fetch from ${HEXCLAVE_DOCS_FULL_URL} timed out after ${FETCH_TIMEOUT_MS}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(
`Failed to fetch docs from ${HEXCLAVE_DOCS_FULL_URL}: ${response.status} ${response.statusText}`,
);
}
const text = await response.text();
cachedDocs = { text, fetchedAt: now };
return text;
}
export async function getMcpSkillContextPrompt(toolName: string | null | undefined): Promise<string> {
if (toolName !== "ask_hexclave") {
return "";
}
const docsContext = await fetchDocsText();
return `
## MCP-Provided Hexclave Documentation Context
The current request came through the public Hexclave MCP server's ask_hexclave tool.
The backend fetched the full Hexclave documentation from https://docs.hexclave.com/llms-full.txt
immediately before spawning this assistant. Treat this documentation as baseline context
for answering the user's question, while still using documentation tools for specific
facts and citations:
${docsContext}
`;
}
/**
* Exposed for testing only clears the module-level docs cache.
*/
export function _clearDocsCache(): void {
cachedDocs = null;
}

View File

@ -20,6 +20,7 @@ export type ToolName = typeof TOOL_NAMES[number];
export type ToolContext = {
auth: SmartRequestAuth | null,
targetProjectId?: string | null,
mcpToolName?: string | null,
};
export async function getTools(
@ -31,6 +32,9 @@ export async function getTools(
for (const toolName of toolNames) {
switch (toolName) {
case "docs": {
if (context.mcpToolName === "ask_hexclave") {
break;
}
const docsTools = await createDocsTools();
Object.assign(tools, docsTools);
break;

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
import path from "path";
import { readFileSync } from "fs";
import path from "path";
import { llmsTxt } from "../packages/shared/src/ai/llms/llms";
import { remindersPrompt } from "../packages/shared/src/ai/unified-prompts/reminders";
import { aiSetupPrompt, cliSetupPrompt, convexSetupPrompt, getSdkSetupPrompt, pythonBackendSetupPrompt, restApiBackendSetupPrompt, supabaseSetupPrompt } from "../packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt";
import { deindent } from "../packages/shared/src/utils/strings";
import { writeFileSyncIfChanged } from "./utils";
import { remindersPrompt } from "../packages/shared/src/ai/unified-prompts/reminders";
import { buildLlmsFullTxt, llmsTxt } from "../packages/shared/src/ai/llms/llms";
const generatedComment = "This file is auto-generated by scripts/generate-setup-prompt-docs.ts. Do not edit it manually; edit packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts instead.";
type SdkSetupToolCategory = "frontend" | "backend" | "database" | "other";
@ -590,8 +590,3 @@ writeFileSyncIfChanged(
path.join(repoRoot, "docs-mintlify/llms.txt"),
llmsTxt.replace(/[ \t]+$/gm, "") + "\n",
);
writeFileSyncIfChanged(
path.join(repoRoot, "docs-mintlify/llms-full.txt"),
buildLlmsFullTxt(docsJson).replace(/[ \t]+$/gm, "") + "\n",
);