mcp-answer-writer.ts177 lines · main
1import { env } from '../env.js';
2import { log } from '../lib/logger.js';
3
4/**
5 * The grounded answer-writer behind `briven_ask`'s self-growing knowledge
6 * base (owner-approved 2026-07-12). When no hand-curated guide matches a
7 * question, this composes a fresh three-part answer — but ONLY from briven's
8 * own docs/guide context passed in as `grounding`. It never invents a
9 * capability and never suggests going off-platform: a genuine gap must be
10 * returned as `grounded: false`, which the desk turns into the honest
11 * "filed for review" reply.
12 *
13 * Dormant-safe: briven's model engine (BRIVEN_OLLAMA_URL) may be switched
14 * off (e.g. the "Ollama offline until N5 Pro" state). With it unset this
15 * returns `{ grounded: false }` immediately, so the desk simply falls back
16 * to today's filed behaviour — the auto-writer lights up on its own the day
17 * the engine returns, with no code change or redeploy.
18 *
19 * Same posture as the ai-explain/schema/function generators: forwards to the
20 * self-hosted model on the DGX VPS; prompts/outputs are not logged.
21 */
22
23export interface GroundedAnswer {
24 howBrivenWorksHere: string;
25 whatOurToolsGiveYou: string[];
26 whatYouBuildInYourProject: string[];
27 docs: string;
28}
29
30export type WriteGroundedResult =
31 | { grounded: true; answer: GroundedAnswer; model: string }
32 | { grounded: false };
33
34export const ANSWER_WRITER_SYSTEM_PROMPT = `You are briven's knowledge-desk writer. An AI agent building on the briven backend platform asked a question that no hand-curated guide matched. Compose ONE answer, using ONLY the briven context provided in the user message. briven is the fixed platform; the agent builds its project WITHIN it using only the tools briven exposes.
35
36Output STRICT JSON (no markdown, no code fences, no prose outside the object) with exactly these fields:
37{
38 "grounded": boolean,
39 "howBrivenWorksHere": string,
40 "whatOurToolsGiveYou": string[],
41 "whatYouBuildInYourProject": string[],
42 "docs": string
43}
44
45Hard rules:
46- Answer ONLY from the provided briven context. If the context does not clearly support an answer, set "grounded": false and leave the other fields as empty string / empty arrays. Never guess, never invent an endpoint, tool, flag, or capability that is not in the context.
47- NEVER suggest standing up a separate/side database or server, a durable raw or superuser SQL login, or asking briven for special credentials or bespoke features. briven mediates every data touch on purpose. If the agent seems to want raw SQL or a side DB, the correct answer is to use functions + the MCP/SDK within the platform.
48- If the capability genuinely does not exist on briven, set "grounded": false — do NOT manufacture a workaround. A true gap is filed for the platform team, not worked around off-platform.
49- "docs" must be one URL copied from the provided context (do not invent a URL). If unsure, use https://docs.briven.tech.
50- Never name any third-party vendor in the prose.
51- Keep each string tight and practical; whatOurToolsGiveYou / whatYouBuildInYourProject are 1-4 bullets each.`;
52
53/**
54 * Compose a grounded answer, or decline. Fail-soft: any upstream/parse error
55 * returns `{ grounded: false }` (never throws) so the calling desk stays
56 * responsive no matter what the model does.
57 */
58export async function writeGroundedAnswer(input: {
59 question: string;
60 /** briven's own docs/guide context — the ONLY knowledge the model may use. */
61 grounding: string;
62 timeoutMs?: number;
63}): Promise<WriteGroundedResult> {
64 if (!env.BRIVEN_OLLAMA_URL) return { grounded: false };
65
66 const model = env.BRIVEN_OLLAMA_MODEL_EXPLAIN ?? env.BRIVEN_OLLAMA_MODEL;
67 if (!model) return { grounded: false };
68
69 const userMessage =
70 `briven context (the ONLY source you may use):\n${input.grounding}\n\n` +
71 `Agent question:\n${input.question}`;
72
73 const url = `${env.BRIVEN_OLLAMA_URL.replace(/\/$/, '')}/api/generate`;
74 const headers: Record<string, string> = { 'content-type': 'application/json' };
75 if (env.BRIVEN_OLLAMA_API_KEY) headers['x-api-key'] = env.BRIVEN_OLLAMA_API_KEY;
76
77 let res: Response;
78 try {
79 res = await fetch(url, {
80 method: 'POST',
81 headers,
82 body: JSON.stringify({
83 model,
84 system: ANSWER_WRITER_SYSTEM_PROMPT,
85 prompt: userMessage,
86 // Low temperature: this is grounded extraction, not creative writing.
87 options: { temperature: 0.1 },
88 // Ollama honours a JSON grammar so we get a parseable object back.
89 format: 'json',
90 stream: false,
91 }),
92 // Kept tight on purpose: this runs inline in a live MCP tool call, so a
93 // slow model must not freeze the agent. If it can't answer in time we
94 // fall through to the honest "filed" reply and the desk stays snappy.
95 signal: AbortSignal.timeout(input.timeoutMs ?? 12_000),
96 });
97 } catch (err) {
98 log.warn('mcp_answer_writer_unreachable', {
99 message: err instanceof Error ? err.message : String(err),
100 });
101 return { grounded: false };
102 }
103
104 if (!res.ok) {
105 log.warn('mcp_answer_writer_upstream_error', { status: res.status });
106 return { grounded: false };
107 }
108
109 let raw: string;
110 try {
111 const data = (await res.json()) as { response?: string };
112 raw = (data.response ?? '').trim();
113 } catch {
114 return { grounded: false };
115 }
116
117 let parsed: unknown;
118 try {
119 parsed = JSON.parse(raw);
120 } catch {
121 return { grounded: false };
122 }
123
124 const answer = coerceGroundedAnswer(parsed);
125 if (!answer) return { grounded: false };
126
127 return { grounded: true, answer, model };
128}
129
130/**
131 * Normalise the answer fields into a GroundedAnswer, or null if the object is
132 * substanceless. Shared by both the writer-output path (coerceGroundedAnswer)
133 * and the cache-read path (validateStoredAnswer) so a blank/malformed answer
134 * can never be served — whether it came from the model or a hand-seeded row.
135 */
136function normalizeAnswerFields(o: Record<string, unknown>): GroundedAnswer | null {
137 const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
138 const list = (v: unknown): string[] =>
139 Array.isArray(v)
140 ? v.map((x) => str(x)).filter((s) => s.length > 0).slice(0, 6)
141 : [];
142
143 const howBrivenWorksHere = str(o.howBrivenWorksHere);
144 const whatOurToolsGiveYou = list(o.whatOurToolsGiveYou);
145 const whatYouBuildInYourProject = list(o.whatYouBuildInYourProject);
146 let docs = str(o.docs);
147 if (!/^https?:\/\//i.test(docs)) docs = 'https://docs.briven.tech';
148
149 // An answer with no substance is treated as a decline, not a cached blank.
150 if (howBrivenWorksHere.length < 10 && whatOurToolsGiveYou.length === 0) return null;
151
152 return { howBrivenWorksHere, whatOurToolsGiveYou, whatYouBuildInYourProject, docs };
153}
154
155/**
156 * Validate + normalise the MODEL's JSON into a GroundedAnswer, or null if it
157 * declined (grounded:false) or produced an unusable shape. Exported for unit
158 * tests that exercise the parsing without a live model.
159 */
160export function coerceGroundedAnswer(parsed: unknown): GroundedAnswer | null {
161 if (typeof parsed !== 'object' || parsed === null) return null;
162 const o = parsed as Record<string, unknown>;
163 if (o.grounded !== true) return null;
164 return normalizeAnswerFields(o);
165}
166
167/**
168 * Validate a STORED (already-coerced) answer read back from the cache — same
169 * substance guard as the writer path, but without requiring the `grounded`
170 * flag (stored rows hold only the answer fields). Returns null for any
171 * blank/drifted/hand-seeded row so the desk falls through instead of serving
172 * garbage.
173 */
174export function validateStoredAnswer(parsed: unknown): GroundedAnswer | null {
175 if (typeof parsed !== 'object' || parsed === null) return null;
176 return normalizeAnswerFields(parsed as Record<string, unknown>);
177}