--- title: DeepTrust Research Agent emoji: πŸ”¬ colorFrom: blue colorTo: indigo sdk: docker app_port: 3000 pinned: false short_description: LangGraph research agent workspace. tags: - langgraph - nextjs - transformers - research-agent - llm - typescript --- # DeepTrust Research Agent A TypeScript implementation of an autonomous research agent: LangGraph state machines, local LLM inference (Hugging Face Transformers in a worker thread), and a real-time, AI-centric Next.js workspace. The UI is designed for a Cursor/Gemini-like flowβ€”immediate feedback, Server-Sent Events (SSE) streaming, optimistic updates, a knowledge/context drop zone, and quick-action chipsβ€”so the full application from graph nodes to the browser is understandable in one read. ## Documentation - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** β€” Low-level design: state machine, channels, routing, LLM layer, API protocols, and frontend architecture (SSE, streaming UX, knowledge flow). ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Technology Stack](#technology-stack) 3. [Project Structure](#project-structure) 4. [State Management](#state-management) 5. [Graph Nodes](#graph-nodes) 6. [Routing and Conditional Edges](#routing-and-conditional-edges) 7. [LLM Integration](#llm-integration) 8. [API Layer](#api-layer) 9. [Frontend: Real-Time Workspace](#frontend-real-time-workspace) 10. [Running the Project](#running-the-project) 11. [Configuration](#configuration) 12. [FAQ: Architecture & design choices](#faq-architecture--design-choices) 13. [Client-side knowledge store (browser RAG)](#client-side-knowledge-store-browser-rag) --- ## Architecture Overview DeepTrust implements a cyclic state graph where a research query flows through multiple specialized nodes: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ [START] ──► thinker ──► auditor ──► tool_executor β”‚ β”‚ β–² β”‚ β”‚ β”‚ β”‚ β”‚ reject β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β”‚ β”‚ synthesizer β”‚ β”‚ β”‚ β”‚ β”‚ [END] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Node Responsibilities - **Thinker**: Decomposes a research question into a structured, multi-step plan - **Auditor**: Validates the plan against organizational policy; rejects non-compliant plans - **HITL Gate**: Pauses execution for human approval before tool execution - **Tool Executor**: Executes each plan step sequentially (currently `web_search`; see [FAQ](#faq-architecture--design-choices)) - **Synthesizer**: Aggregates tool outputs into a final research report The graph supports revision loops: if the Auditor rejects a plan, control returns to the Thinker with structured feedback. A configurable ceiling (`maxPlanRevisions`) prevents infinite loops. --- ## Technology Stack | Layer | Technology | Purpose | |-------|------------|---------| | **Frontend** | Next.js 16, React 19, Tailwind CSS 4 | Server-side rendering, streaming UI updates | | **State Machine** | LangGraph.js | Graph construction, checkpointing, conditional routing | | **LLM Inference** | @huggingface/transformers | Local model loading and text generation | | **Schema Validation** | Zod 4 | Runtime type validation for state and API contracts | | **Type System** | TypeScript 5 | Static type safety across the codebase | ### Key Dependencies ```json { "@langchain/langgraph": "^1.1.5", "@huggingface/transformers": "^3.8.1", "zod": "^4.3.6", "next": "16.1.6", "react": "19.2.3" } ``` --- ## Project Structure ``` lib/agent/ β”œβ”€β”€ index.ts # Public API exports β”œβ”€β”€ graph.ts # StateGraph construction and compilation β”œβ”€β”€ state.ts # Zod schemas and TypeScript types β”œβ”€β”€ routing.ts # Conditional edge functions β”œβ”€β”€ llm/ β”‚ β”œβ”€β”€ index.ts # Worker proxy: loadModel, chatComplete, getModelStatus β”‚ β”œβ”€β”€ pipeline.ts # Pipeline config (used by worker) β”‚ └── worker-entry.ts # Worker entry: runs Transformers in a separate thread β”œβ”€β”€ nodes/ β”‚ β”œβ”€β”€ index.ts # Node exports β”‚ β”œβ”€β”€ thinker.ts # Plan generation node β”‚ β”œβ”€β”€ auditor.ts # Policy validation node β”‚ β”œβ”€β”€ hitl-gate.ts # Human approval checkpoint β”‚ β”œβ”€β”€ tool-executor.ts # Tool dispatch node β”‚ └── synthesizer.ts # Report synthesis node └── utils/ β”œβ”€β”€ index.ts # Utility exports β”œβ”€β”€ extract-json.ts # Robust JSON parsing └── policy.ts # Policy file loader dist/llm/ # Built by npm run build:worker β”œβ”€β”€ worker-entry.js # Compiled worker └── pipeline.js # Pipeline bundle app/ β”œβ”€β”€ page.tsx # Workspace UI: chat, context panel, model card, SSE client β”œβ”€β”€ layout.tsx β”œβ”€β”€ globals.css └── api/ β”œβ”€β”€ research/ β”‚ └── route.ts # POST: SSE stream of research events └── model/ └── load/ └── route.ts # GET/POST: model load + SSE progress ``` --- ## State Management ### The ResearchState Schema All state flows through a single Zod-validated schema. This ensures runtime type safety and enables serialization for checkpointing. ```typescript export const ResearchState = z.object({ // Identity threadId: z.string().uuid(), sessionName: z.string().default("Unnamed Session"), // Input userQuery: z.string().min(1), // Planning plan: ResearchPlan.nullable().default(null), rejectionFeedback: z.string().nullable().default(null), planRevisionCount: z.number().int().nonnegative().default(0), maxPlanRevisions: z.number().int().positive().default(5), // Auditing auditResult: AuditResult.nullable().default(null), // Execution currentStepIndex: z.number().int().nonnegative().default(0), humanApproved: z.boolean().default(false), // Output finalReport: z.string().nullable().default(null), // Observability reasoning: z.array(ReasoningEntry).default([]), status: RunStatus.default("idle"), updatedAt: z.string().datetime(), errorMessage: z.string().nullable().default(null), }); ``` ### Channel Configuration LangGraph requires explicit channel definitions for state merging. Most fields use last-write-wins semantics, but the `reasoning` array uses append-only concatenation: ```typescript const graph = new StateGraph({ channels: { threadId: { value: (_, n) => n }, // ... other scalar fields use (_, n) => n // Append-only reasoning log (nodes pass one new entry; cap matches state.ts) reasoning: { value: (existing: ReasoningEntry[], incoming: ReasoningEntry[]) => { const merged = [...(existing ?? []), ...(incoming ?? [])]; const cap = 20; return merged.length > cap ? merged.slice(merged.length - cap) : merged; }, default: () => [], }, }, }); ``` ### Sub-Schemas **ResearchStep**: A single action in the research plan. ```typescript export const ResearchStep = z.object({ id: z.string().uuid(), tool: z.enum(["web_search"]), input: z.string().min(1), rationale: z.string(), output: z.string().optional(), executedAt: z.string().datetime().optional(), }); ``` **AuditResult**: Structured feedback from the Auditor. ```typescript export const AuditResult = z.object({ verdict: z.enum(["approved", "rejected", "needs_revision"]), policyViolations: z.array(z.string()).default([]), suggestions: z.array(z.string()).default([]), auditedAt: z.string().datetime(), }); ``` --- ## Graph Nodes Each node is an async function that receives the current state and returns a partial state update. ### Thinker Node Generates or revises a research plan. Prompts the LLM with structured output requirements: ```typescript async function thinkerNode(state: ResearchState): Promise> { const isRevision = state.planRevisionCount > 0 && state.rejectionFeedback; const system = ` You are the Thinker node of DeepTrust, an autonomous research agent. Return ONLY a valid JSON object matching: { "objective": string, "steps": Array<{ "id": UUID, "tool": string, "input": string, "rationale": string }>, "estimatedTokenBudget": number, "createdAt": ISO8601, "revision": number }`; const userMessage = isRevision ? `Research question: "${state.userQuery}"\n\nPREVIOUS PLAN REJECTED:\n${state.rejectionFeedback}` : `Research question: "${state.userQuery}"`; const rawThought = await chatComplete(system, userMessage); const parsed = extractJSON(rawThought); const plan = ResearchPlan.parse({ ...parsed, revision: state.planRevisionCount }); return { plan, status: "thinking", rejectionFeedback: null, reasoning: appendReasoning(state, { node: "thinker", summary: "..." }), updatedAt: new Date().toISOString(), }; } ``` ### Auditor Node Validates plans against `POLICY.md`. Returns structured violations and suggestions: ```typescript async function auditorNode(state: ResearchState): Promise> { const policy = loadPolicy(); const system = ` You are the Auditor node. Evaluate research plans against policy. Return ONLY: { "verdict": "approved"|"rejected"|"needs_revision", ... }`; const rawThought = await chatComplete(system, `POLICY:\n${policy}\n\nPLAN:\n${JSON.stringify(state.plan)}`); const auditResult = AuditResult.parse(extractJSON(rawThought)); const isRejected = auditResult.verdict !== "approved"; return { auditResult, rejectionFeedback: isRejected ? formatFeedback(auditResult) : null, planRevisionCount: isRejected ? state.planRevisionCount + 1 : state.planRevisionCount, status: isRejected ? "thinking" : "awaiting_approval", }; } ``` ### HITL Gate Node Uses LangGraph's `interrupt()` primitive to pause execution and write a checkpoint: ```typescript async function hitlGateNode(state: ResearchState): Promise> { interrupt({ message: "Plan ready for review. Set humanApproved=true to continue.", plan: state.plan, auditResult: state.auditResult, }); return { updatedAt: new Date().toISOString() }; } ``` ### Tool Executor Node Iterates through plan steps. Each invocation processes one step and increments `currentStepIndex`: ```typescript async function toolExecutorNode(state: ResearchState): Promise> { const step = state.plan.steps[state.currentStepIndex]; const output = await dispatchTool(step.tool, step.input); const updatedSteps = state.plan.steps.map((s, i) => i === state.currentStepIndex ? { ...s, output, executedAt: new Date().toISOString() } : s ); return { plan: { ...state.plan, steps: updatedSteps }, currentStepIndex: state.currentStepIndex + 1, status: "executing", }; } ``` ### Synthesizer Node Aggregates all step outputs into a final report: ```typescript async function synthesizerNode(state: ResearchState): Promise> { const stepsContext = state.plan.steps .map((s, i) => `Step ${i + 1} [${s.tool}]: ${s.output}`) .join("\n\n"); const finalReport = await chatComplete( "Write a comprehensive research report.", `Objective: ${state.plan.objective}\n\nResults:\n${stepsContext}` ); return { finalReport, status: "complete" }; } ``` --- ## Routing and Conditional Edges LangGraph uses routing functions to determine the next node based on current state. ### Post-Audit Routing ```typescript function routeAfterAudit(state: ResearchState): "thinker" | "hitl_gate" | typeof END { if (state.planRevisionCount >= state.maxPlanRevisions) { return END; // Safety ceiling reached } if (state.auditResult?.verdict !== "approved") { return "thinker"; // Loop back for revision } return "hitl_gate"; // Proceed to human approval } ``` ### Post-HITL Routing ```typescript function routeAfterHitl(state: ResearchState): "tool_executor" | typeof END { if (!state.humanApproved) { return END; // Fail-safe if approval missing } return "tool_executor"; } ``` ### Post-Tool Routing ```typescript function routeAfterToolStep(state: ResearchState): "tool_executor" | "synthesizer" { if (state.currentStepIndex < state.plan.steps.length) { return "tool_executor"; // More steps remain } return "synthesizer"; // All steps complete } ``` --- ## LLM Integration ### Hugging Face Transformers The project uses `@huggingface/transformers` for local inference. Models are cached to `.hf-cache/` for persistence across restarts. ```typescript import { pipeline, TextGenerationPipeline, env } from "@huggingface/transformers"; env.cacheDir = process.env.HF_CACHE_DIR || "./.hf-cache"; const MODEL_ID = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-360M-Instruct"; let generatorPromise: Promise | null = null; export function loadModel(onProgress?: ProgressCallback): Promise { if (generatorPromise) return generatorPromise; generatorPromise = pipeline("text-generation", MODEL_ID, { progress_callback: (data) => { onProgress?.({ status: data.status === "progress" ? "downloading" : "loading", progress: Math.round((data.progress || 0) * 100), file: data.file || "", message: `Downloading ${data.file?.split("/").pop()}`, }); }, }); return generatorPromise; } ``` ### Chat Completion Interface ```typescript export async function chatComplete(systemPrompt: string, userMessage: string): Promise { const generator = await loadModel(); const output = await generator( [ { role: "system", content: systemPrompt }, { role: "user", content: userMessage }, ], { max_new_tokens: 4096, do_sample: true, temperature: 0.7 } ); const result = output[0] as { generated_text: Array<{ role: string; content: string }> }; return result.generated_text.find((m) => m.role === "assistant")?.content ?? ""; } ``` ### JSON Extraction Small models often produce malformed JSON. The `extractJSON` utility handles common issues: ```typescript export function extractJSON(text: string): unknown { // Try direct parse try { return JSON.parse(text); } catch {} // Remove markdown fences const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/g, ""); // Extract JSON object const match = cleaned.match(/\{[\s\S]*\}/); if (match) { try { return JSON.parse(match[0]); } catch {} } throw new Error(`Could not extract JSON from: ${text.slice(0, 200)}`); } ``` --- ## API Layer ### Research Endpoint (SSE) `POST /api/research` streams research state updates as **Server-Sent Events** so the client can show progress immediately and parse events by type. Using SSE (instead of raw NDJSON) gives a standard, well-supported streaming protocol and allows future event names without changing the wire format. **Request body:** `{ "query": string, "retrievedContext"?: string, "contextUrls"?: string[] }`. `retrievedContext` and `contextUrls` come from the client-side knowledge store (files/URLs/notes) and are used to condition the plan and synthesis steps. **Response:** `Content-Type: text/event-stream`. Each message is an SSE message: - `event: start` β€” First event; signals that the run has started (enables optimistic UI). - `event: research` β€” One per graph node update; `data` is `{ node, state }`. - `event: hitl_waiting` β€” Emitted when the graph reaches the HITL gate and pauses. `data` is `{ node: "__interrupt__", state: { threadId, interrupt } }`, which the client uses to show the approval banner and remember which `threadId` to resume. - `event: error` β€” On exception; `data` includes `node: "_error"` and `state.errorMessage`. ```typescript // Server: send helper const send = (event: string, payload: { node: string; state: Record }) => { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`)); }; send("start", { node: "_start", state: { status: "started", ... } }); for await (const event of runResearch(query, "Research Session", options)) { if (event.node === "__interrupt__") { send("hitl_waiting", event); return; } send("research", event); } // On catch: send("error", { node: "_error", state: { status: "failed", errorMessage } }); ### HITL Approval Endpoint `POST /api/research/approve` resumes a paused run after human approval. The client sends `{ "threadId": string }` (obtained from the earlier `hitl_waiting` event), and the server: - Streams with LangGraph `Command({ resume: true, update: { humanApproved: true } })` so `interrupt()` in `hitl_gate` receives a resume value and state updates in one step. - Streams the remaining `{ node, state }` events as `event: research` SSE messages until completion or error. ``` ### Model Loading Endpoint `GET /api/model/load?...modelId=...&dtype=...` (or POST with same query) streams download/load progress via SSE. The client parses `data: {...}` lines to drive the progress bar and status pill. See [Frontend: Real-Time Workspace](#frontend-real-time-workspace) for how the UI consumes these streams. --- ## Frontend: Real-Time Workspace The React client (`app/page.tsx`) is a single-page workspace that mirrors a Cursor/Gemini-style flow: immediate, non-blocking feedback, streaming AI responses, and a dedicated context/knowledge area. ### What the UI Provides | Area | Purpose | |------|--------| | **Chat** | User messages and assistant replies. When a run finishes, the final report is streamed **word-by-word** into the last assistant message to mimic a live conversation. | | **Context / Knowledge** | Drag-and-drop zone for PDFs, text files, or URLs; optional fields to add URLs and short notes. Indexed on the client and summarized into `retrievedContext` + `contextUrls` for each research request. | | **Quick-action chips** | A row of buttons below the input (e.g. local knowledge + cited web search, HITL plan approval, on-device vs network) that set or extend the query and trigger a run. | | **Starter cards** | Empty state with example prompts (e.g. local inference and verifiable research) that fill the input and can be run in one click. | | **Model card** | Compact panel for model selection, load/progress, and status (Ready / Loading / Error). | | **Plan & audit panel** | Shows the latest plan objective, step list, and audit verdict (approved / rejected / needs_revision), plus any policy violations. | | **Reasoning trace** | Scrollable list of the latest reasoning entries from the event stream (node + summary + status) for observability. | ### Optimistic UI and Shimmer - On β€œRun Research”, the UI immediately appends the user message and a placeholder assistant message with a shimmer skeleton, then consumes SSE and updates that message when the final report arrives. - Shimmer and loading states use Tailwind (e.g. `animate-pulse`, neutral backgrounds) so the interface feels responsive even when the agent is still planning or executing. ### SSE Consumption (Research) The client uses `EventSource`-style parsing on the `ReadableStream`: split by `\n\n`, then for each line look for `event:` and `data:` and dispatch by event type. Accumulated `research` events update both the reasoning trace and the chat when `finalReport` is present. A special `hitl_waiting` event updates local HITL state and shows the approval banner instead of treating the pause as an error. ```typescript // Conceptual: read stream, split by double newline, parse "event:" and "data:" const chunks = buffer.split("\n\n"); for (const chunk of chunks) { const eventMatch = chunk.match(/event:\s*(\w+)/); const dataMatch = chunk.match(/data:\s*(\{[\s\S]*\})/); if (eventMatch && dataMatch) { const payload = JSON.parse(dataMatch[1]); if (eventMatch[1] === "research") setEvents((prev) => [...prev, payload]); if (eventMatch[1] === "hitl_waiting" && payload.node === "__interrupt__") { setHitlThreadId(payload.state.threadId); setHitlPayload(payload.state.interrupt); } // ... handle start, error; when payload.state.finalReport exists, run word-by-word animation } } ``` ### Word-by-Word Streaming When an event contains `state.finalReport`, the full text is not dumped at once. A small timer (e.g. every 40ms) reveals the report word-by-word in the last assistant message and clears the β€œstreaming” state when done. This keeps the same SSE event payload while making the reply feel live. --- ## Running the Project ### Prerequisites - Node.js 20+ - npm or pnpm ### Installation ```bash npm install ``` ### Build the LLM worker (required for local inference) Inference runs in a Node.js worker thread. Compile the worker once before using the app locally: ```bash npm run build:worker ``` This writes `dist/llm/worker-entry.js` and `dist/llm/pipeline.js`. The production build runs this step automatically. ### Development ```bash npm run dev ``` Open http://localhost:3000. Click "Load Model" to download and initialize the LLM, then run research queries. If you see an error that the worker was not found, run `npm run build:worker` first. ### Production build ```bash npm run build ``` This runs `build:worker` then builds the Next.js app with webpack. The app is served with: ```bash npm run start ``` ### First run The first model load downloads weights to `.hf-cache/` (approximately 400MB for SmolLM2-360M Q4). Subsequent loads are fast. ### Deploy (Render) The repo includes a **Dockerfile** and config for [Render](https://render.com). Render builds the image in the cloud (no local Docker required). See **[DEPLOY.md](DEPLOY.md)** for steps. --- ## Configuration Create `.env.local` from `.env.example` (copy the file and adjust values). ### Web search (research agent) The plan step `web_search` runs in the tool executor. By default it queries **DuckDuckGo** over HTTPS (no API key). Optionally set **Google Custom Search** for programmatic web results: - `GOOGLE_CSE_API_KEY` β€” API key from Google Cloud (Custom Search API enabled) - `GOOGLE_CSE_CX` β€” Programmable Search Engine ID (cx) If both are set, Google is used; otherwise DuckDuckGo. See comments in `.env.example`. ### Observability (LangSmith) To make agent behavior observable and debug failures (e.g. thinker returning invalid JSON), use [LangSmith](https://smith.langchain.com/). Set in `.env.local`: ```bash LANGCHAIN_TRACING_V2=true LANGCHAIN_API_KEY=your-langsmith-api-key LANGCHAIN_PROJECT=deeptrust ``` With tracing enabled, every graph run is recorded. You can inspect prompts, raw LLM outputs, and state transitions in the LangSmith UI, which helps diagnose schema validation errors and long-running or looping runs. This project forwards LangGraph metadata with each run: - `project`: from `LANGCHAIN_PROJECT` (defaults to `deeptrust`) - `run_name`: `"DeepTrust research session"` - `source`: `"deeptrust-ui"` These fields make it easier to filter and group traces in LangSmith. ### Available Models | Model | Size | Speed | Quality | |-------|------|-------|---------| | `HuggingFaceTB/SmolLM2-360M-Instruct` | 400MB | Fast | Basic | | `HuggingFaceTB/SmolLM2-1.7B-Instruct` | 1.7GB | Moderate | Better | --- ## Key Concepts Demonstrated 1. **State Machines for Agents**: Using LangGraph to model complex, cyclic agent workflows 2. **Type-Safe State**: Zod schemas with TypeScript inference for runtime validation 3. **Local LLM Inference**: Running models in a **worker thread** (not on the HTTP event loop) without cloud LLM APIs 4. **Streaming Responses**: Server-Sent Events and ReadableStream for real-time updates 5. **Human-in-the-Loop**: Checkpoint interrupts for manual approval gates 6. **Revision Loops**: Cyclic graph edges for iterative refinement with safety ceilings --- ## FAQ: Architecture & design choices These answers are aimed at engineers reviewing the system end-to-end: how responsibilities are split, where state lives, and why the stack looks the way it does. ### Why Next.js for this project? Next.js gives a **single TypeScript codebase** with a clear split between UI and server logic without introducing a separate BFF service. The **App Router** route handlers (`app/api/...`) are natural places for long-lived **Server-Sent Events (SSE)** streams: the research and model-load endpoints return `ReadableStream` bodies and push typed events to the client. That matches how we want the workspace to feelβ€”incremental updates, no second HTTP framework to deploy. Next also aligns with **Hugging Face Spaces** and container-style hosts (Dockerfile): one Node process serves the UI and APIs, which simplifies ops compared to a static SPA plus a standalone API server. ### How is the β€œagent” separated from the web app? **`lib/agent/`** is the domain layer: LangGraph construction (`graph.ts`), Zod state (`state.ts`), routing functions, node implementations, LLM facades, and utilities. **`app/`** owns HTTP boundaries (`app/api/**/route.ts`), the SSE wire format, and the React workspace (`app/page.tsx`). The dependency rule is one-way: **application code imports the agent; the agent never imports React, Next.js, or route handlers.** That keeps orchestration **portable**β€”the same `runResearch` / `approveAndResume` entry points could be invoked from a script, a different framework, or a job runner without pulling UI code along. Concretely: - **`lib/agent/index.ts`** is the narrow public seam: graph helpers, Zod types, `chatComplete` / model helpers, and utilities. Everything else under `lib/agent/` is internal to the agent package. - **Nodes** (`thinker`, `auditor`, `synthesizer`) only depend on `chatComplete`, state schemas, and helpersβ€”they do not know whether the caller is an API route or a test harness. - **Side-effecting work** (policy file read, `fetch` for web search) lives in nodes and tools, not in `app/`, so policy and tool behavior stay centralized. - **Route handlers** stay thin: validate input, build options, iterate async generators from the graph, and encode events as SSE. No business rules in the route. Net effect: the **graph and prompts are the product’s brain**; Next.js is a **host** for I/O and rendering, not a place where control flow leaks. ### Why keep embeddings and vector storage in the browser (`lib/knowledge`)? See [Client-side knowledge store (browser RAG)](#client-side-knowledge-store-browser-rag) for the full pictureβ€”briefly: **privacy** (documents stay on-device), **no server vector DB** to run or pay for, and **CORS-free** PDF/text handling in the user’s browser. The tradeoff is retrieval runs client-side and only **derived text** (`retrievedContext` + `contextUrls`) is sent to the API. ### Why LangGraph instead of a hand-rolled loop? Research here is inherently **cyclic** (thinker ↔ auditor revisions, tool steps, HITL). LangGraph provides an explicit **StateGraph**, **checkpointing** keyed by `thread_id`, and first-class **interrupt/resume** for human approval. Conditional edges encode policy (β€œapproved β†’ HITL”, β€œrejected β†’ thinker”) in one place instead of scattering `if` chains across services. We still treat LLM outputs as untrusted: Zod validation, JSON extraction helpers, and retry/feedback loops live in nodesβ€”LangGraph carries the **control flow**, not business shortcuts. ### How is core state handled? There is **one canonical state shape** (`ResearchState` in `state.ts`), validated with **Zod** at creation and when parsing LLM-derived structures. LangGraph **channels** define merge semantics: scalars are last-write-wins; `reasoning` is append-only with a **bounded tail** so traces stay useful without unbounded memory growth. Serialized checkpoints must be JSON-safe, which drives field choices (ISO strings, plain objects). See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for channel details and SSE payloads. ### Why run the LLM in a worker thread? Local inference via `@huggingface/transformers` is **CPU/GPU-heavy** and would **block the Node event loop** on the main thread during load and generationβ€”unacceptable for a server that must accept new HTTP connections and **stream SSE** for research and model progress. **What we built:** 1. **Dedicated worker bundle** β€” `tsconfig.worker.json` compiles only `lib/agent/llm/pipeline.ts` and `worker-entry.ts` to **`dist/llm/`**. The heavy Transformers pipeline is not bundled into the Next server chunk; you run **`npm run build:worker`** (or full `npm run build`) before first use. The main thread loads **`dist/llm/worker-entry.js`** via `new Worker(workerPath)`. 2. **Singleton worker + async RPC** β€” `lib/agent/llm/index.ts` spawns **one** `Worker`, lazily on first request. The main thread sends `{ id, type, payload }` messages (`getStatus`, `load`, `chat`); the worker replies with `resolve` / `reject` or **interleaved `progress`** during model download/load. A **`pending` `Map`** correlates `id` to Promise resolvers so concurrent API calls (e.g. model status + a chat from different requests) do not trample each other. 3. **Stable surface for the agent** β€” Nodes call **`chatComplete(system, user)`** and **`loadModel`** without knowing about threads. Swapping the implementation later (remote API, different runtime) means changing **`llm/index.ts`** and the worker contract, not every node. 4. **Operational visibility** β€” the worker is created with `stdout`/`stderr` piped through, which helps when debugging downloads and crashes on **Hugging Face Spaces** or Docker. So the split is not cosmetic: it is **event-loop isolation** plus a **minimal RPC boundary** between β€œweb server” and β€œinference engine,” which is the same architectural move you would make for image processing or any other long-running native work beside Express/Next. ### Why SSE for the workspace instead of WebSockets? The client mostly needs **server β†’ browser** push (research events, model progress, errors). **SSE** over a normal HTTP POST/GET response is simpler than WebSockets for that shape: standard proxies understand it, reconnect semantics are straightforward, and route handlers stay a single request lifecycle. Bidirectional chat beyond β€œapprove this plan” is not required for the core loop; the approve path is a second POST with its own stream. ### How are human-in-the-loop and resume implemented? After an approved audit, **`hitl_gate`** calls LangGraph’s **`interrupt()`**, which checkpoints and pauses until the graph is resumed with a **`Command`** carrying a `resume` value and state updates (e.g. `humanApproved: true`). The UI stores `threadId` from the `hitl_waiting` event; **`POST /api/research/approve`** streams again from that checkpoint. Using `Command` is important: a bare `stream(null)` without a resume value does not satisfy `interrupt()` and the run would stall. ### How do policy, auditor, and tools relate? **Policy** (`POLICY.md` / default text) is the rule set; the **Auditor** node prompts the LLM to emit structured verdicts. Small models often parrot placeholders or noise, so the codebase includes **normalization and guardrails** (e.g. treating obvious nonsense violations as non-blocking) to keep the product usableβ€”this is a pragmatic layer on top of β€œideal” policy enforcement. **Tools** execute only after HITL approval; **`web_search`** runs on the server with optional Google CSE env vars and a DuckDuckGo default, keeping secrets out of the client. ### What is intentionally out of scope or β€œphase 2”? Persistent checkpoints beyond **in-memory** `MemorySaver` (e.g. Postgres), additional tools (`document_fetch`, code execution), and parallel step execution are documented as extensions in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). The current architecture is optimized for **clarity, local inference, and a single deployable** rather than maximum throughput. --- ## Client-side knowledge store (browser RAG) **`lib/knowledge/`** implements a **small vector retrieval layer entirely in the browser**. It is not used by the server-side agent directly; instead the React client **indexes** user documents locally, **retrieves** relevant snippets for the current query, and passes **`retrievedContext`** and **`contextUrls`** in the JSON body of `POST /api/research`. The Thinker and Synthesizer inject that text into prompts on the server (`ResearchState.knowledgeContext` / `contextUrls`). This keeps raw files out of the request body and avoids shipping user PDFs to your backend. ### Components | Piece | Role | |-------|------| | **IndexedDB** (`db.ts`) | Database `deeptrust-knowledge` with object stores **`documents`** (metadata: id, type, label, optional url) and **`chunks`** (text segments + embedding vectors). Chunks are indexed **`byDocument`** so deleting a document removes its chunks in one transaction. | | **Embeddings** (`embeddings.ts`) | **`@xenova/transformers`** runs **`feature-extraction`** with **`Xenova/all-MiniLM-L6-v2`** in the browser (lazy singleton pipeline). Vectors are **L2-normalized**; **cosine similarity** is computed in plain JavaScript for query ↔ chunk scoring. | | **Chunking** (`chunk.ts`) | Fixed windows (~500 chars, ~80 overlap, word-boundary friendly) for PDF and note text so long documents become many retrievable units. | | **PDFs** (`pdf.ts`) | Text extraction in the client before chunk/embed (no server-side PDF parser required for v1). | | **Store** (`store.ts`) | Orchestrates **add** (PDF, note, URL), **list**, **remove**, and **`retrieve`**. Ingestion embeds each chunk and persists to IndexedDB. URLs are stored as **reference-only** rows (embedding of a short `URL: …` string); the app does **not** fetch arbitrary URLs server-side in v1. | ### Retrieval On research submit, the client calls **`retrieve(query)`**: embed the query, score **every stored chunk** against it (cosine similarity), take **top-K** (currently 8), and concatenate chunk text into **`retrievedContext`** with source labels. URL-type documents contribute their hrefs to **`contextUrls`**. This is a **linear scan over chunks**β€”appropriate for local, modest corpora; a future upgrade could add an approximate index or server-side sync without changing the agent contract. ### Boundaries - **Import only from client components** (or dynamic `import()` from the client bundle). IndexedDB and Xenova require **`window`**; `db.ts` rejects server-side `openDB()`. - **Server LLM** (SmolLM in the worker) is separate from **embedding** (MiniLM in the tab): two different models for two roles. - **Privacy**: IndexedDB is per-origin; clearing site data clears the store. Only the **retrieved** snippet string crosses the wire to your API, not the full original files. --- ## License MIT