mcp-answer-writer.ts177 lines · main
| 1 | import { env } from '../env.js'; |
| 2 | import { 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 | |
| 23 | export interface GroundedAnswer { |
| 24 | howBrivenWorksHere: string; |
| 25 | whatOurToolsGiveYou: string[]; |
| 26 | whatYouBuildInYourProject: string[]; |
| 27 | docs: string; |
| 28 | } |
| 29 | |
| 30 | export type WriteGroundedResult = |
| 31 | | { grounded: true; answer: GroundedAnswer; model: string } |
| 32 | | { grounded: false }; |
| 33 | |
| 34 | export 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 | |
| 36 | Output 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 | |
| 45 | Hard 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 | */ |
| 58 | export 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 | */ |
| 136 | function 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 | */ |
| 160 | export 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 | */ |
| 174 | export function validateStoredAnswer(parsed: unknown): GroundedAnswer | null { |
| 175 | if (typeof parsed !== 'object' || parsed === null) return null; |
| 176 | return normalizeAnswerFields(parsed as Record<string, unknown>); |
| 177 | } |