deeptrust-v2 / README.md
zimejin's picture
docs(ui): privacy-first quick actions and starter prompts
3e06516
|
Raw
History Blame Contribute Delete
35.4 kB
metadata
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

  1. Architecture Overview
  2. Technology Stack
  3. Project Structure
  4. State Management
  5. Graph Nodes
  6. Routing and Conditional Edges
  7. LLM Integration
  8. API Layer
  9. Frontend: Real-Time Workspace
  10. Running the Project
  11. Configuration
  12. FAQ: Architecture & design choices
  13. 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; 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.
// 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: 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) 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:

  1. Dedicated worker bundletsconfig.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 RPClib/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. 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