Spaces:
Sleeping
Sleeping
| 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<ResearchState>({ | |
| 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<Partial<ResearchState>> { | |
| 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<Partial<ResearchState>> { | |
| 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<Partial<ResearchState>> { | |
| 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<Partial<ResearchState>> { | |
| 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<Partial<ResearchState>> { | |
| 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<TextGenerationPipeline> | null = null; | |
| export function loadModel(onProgress?: ProgressCallback): Promise<TextGenerationPipeline> { | |
| 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<string> { | |
| 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<string, unknown> }) => { | |
| 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 | |