Spaces:
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 — Low-level design: state machine, channels, routing, LLM layer, API protocols, and frontend architecture (SSE, streaming UX, knowledge flow).
Table of Contents
- Architecture Overview
- Technology Stack
- Project Structure
- State Management
- Graph Nodes
- Routing and Conditional Edges
- LLM Integration
- API Layer
- Frontend: Real-Time Workspace
- Running the Project
- Configuration
- FAQ: Architecture & design choices
- 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) - 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
{
"@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.
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:
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.
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.
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:
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:
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:
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:
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:
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
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
function routeAfterHitl(state: ResearchState): "tool_executor" | typeof END {
if (!state.humanApproved) {
return END; // Fail-safe if approval missing
}
return "tool_executor";
}
Post-Tool Routing
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.
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
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:
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;datais{ node, state }.event: hitl_waiting— Emitted when the graph reaches the HITL gate and pauses.datais{ node: "__interrupt__", state: { threadId, interrupt } }, which the client uses to show the approval banner and remember whichthreadIdto resume.event: error— On exception;dataincludesnode: "_error"andstate.errorMessage.
// 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 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.
// 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
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:
npm run build:worker
This writes dist/llm/worker-entry.js and dist/llm/pipeline.js. The production build runs this step automatically.
Development
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
npm run build
This runs build:worker then builds the Next.js app with webpack. The app is served with:
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. Render builds the image in the cloud (no local Docker required). See 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. Set in .env.local:
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: fromLANGCHAIN_PROJECT(defaults todeeptrust)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
- State Machines for Agents: Using LangGraph to model complex, cyclic agent workflows
- Type-Safe State: Zod schemas with TypeScript inference for runtime validation
- Local LLM Inference: Running models in a worker thread (not on the HTTP event loop) without cloud LLM APIs
- Streaming Responses: Server-Sent Events and ReadableStream for real-time updates
- Human-in-the-Loop: Checkpoint interrupts for manual approval gates
- 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.tsis the narrow public seam: graph helpers, Zod types,chatComplete/ model helpers, and utilities. Everything else underlib/agent/is internal to the agent package.- Nodes (
thinker,auditor,synthesizer) only depend onchatComplete, 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,
fetchfor web search) lives in nodes and tools, not inapp/, 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) 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 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:
Dedicated worker bundle —
tsconfig.worker.jsoncompiles onlylib/agent/llm/pipeline.tsandworker-entry.tstodist/llm/. The heavy Transformers pipeline is not bundled into the Next server chunk; you runnpm run build:worker(or fullnpm run build) before first use. The main thread loadsdist/llm/worker-entry.jsvianew Worker(workerPath).Singleton worker + async RPC —
lib/agent/llm/index.tsspawns oneWorker, lazily on first request. The main thread sends{ id, type, payload }messages (getStatus,load,chat); the worker replies withresolve/rejector interleavedprogressduring model download/load. ApendingMapcorrelatesidto Promise resolvers so concurrent API calls (e.g. model status + a chat from different requests) do not trample each other.Stable surface for the agent — Nodes call
chatComplete(system, user)andloadModelwithout knowing about threads. Swapping the implementation later (remote API, different runtime) means changingllm/index.tsand the worker contract, not every node.Operational visibility — the worker is created with
stdout/stderrpiped 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. 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 requirewindow;db.tsrejects server-sideopenDB(). - 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