~/tech-with-ugur

Deep Research on Your Own Laptop

2026-08-18 ai

Run the companion lab

“Deep research” is the feature every AI vendor shipped this year: you hand over a question, an agent plans, searches the web, takes notes, and comes back with a cited report. It is genuinely useful — and it normally runs in someone else’s cloud, on someone else’s model, with your questions and half-formed thoughts as the payload.

This lab builds the miniature, private version: a TypeScript CLI where search <question> runs the whole plan → search → report loop against qwen3:4b served by Ollama on your own machine, and analyze answers follow-up questions grounded only in what you have already researched. The single thing that leaves your laptop is the web-search API call itself (Tavily, free tier). No cloud LLM anywhere.

The honest sales pitch, though, is not the privacy — it is the education. A 4B model is small enough that nothing works by accident. Every convenience a frontier model quietly absorbs — parallel tool calls, vague instructions, content-block messages, its own sense of what year it is — becomes a visible, debuggable failure on a small one. By the end of this lab you don’t just have a research assistant; you know exactly which parts of a deep agent are load-bearing.

Every code block below is copied verbatim from the lab; you can clone it and reproduce everything.

The Deep Agents harness in five minutes

LangChain’s deepagents package (now with an official TypeScript port) is a “batteries included” harness on top of LangGraph: you give it a model and your custom tools, and it adds the agent loop, a filesystem the agent can read and write as its working memory, and a subagent mechanism. The core idea is that a filesystem beats a giant context window: instead of holding everything it has learned in the conversation, the agent writes notes and drafts to files and reads back only what it needs.

Building the research agent is one call:

labs/lab-local-deep-research/src/agents/research-agent.ts:

  return createDeepAgent({
    model,
    tools: [searchTool],
    // virtualMode: the system prompt has the agent address files as absolute
    // paths (/report.md, /notes/*.md). Without this, FilesystemBackend passes
    // absolute paths through as real filesystem paths, escaping topicDir entirely.
    backend: new FilesystemBackend({ rootDir: topicDir, virtualMode: true }),
    systemPrompt: buildResearchSystemPrompt({ today }),
    middleware: [
      createStepLoggingMiddleware({ logger }),
      createStringifyToolContentMiddleware(),
      createToolAllowlistMiddleware({ allowedTools: RESEARCH_AGENT_TOOLS }),
    ],
  });

That virtualMode: true earned its comment the hard way. The system prompt tells the agent to write /report.md and /notes/*.md — absolute paths inside its sandbox. With the default (virtualMode: false), FilesystemBackend passed those through as real absolute paths, and the first live run cheerfully wrote its research to the root of my actual filesystem instead of the topic folder. The agent didn’t misbehave; the backend did exactly what it was told. If you take one config flag away from this post, take this one.

The three middleware entries are the other half of the story — each one exists because a live run failed without it. We’ll take them in turn.

Scope → research → report, miniaturized

LangChain’s open_deep_research reference architecture splits deep research into phases: scope the question into a brief, research iteratively, then write the report. That structure scales down beautifully — not as separate graph nodes, but as numbered phases in one system prompt that a small model can follow:

labs/lab-local-deep-research/src/agents/prompts.ts:

Do these phases in order:
1. SCOPE: write /brief.md — 3-6 lines stating what the question asks and which distinct facts you need to find.
2. RESEARCH: call internet_search with ONE focused query per turn. You MUST run at least 3 searches before writing the report — one search is never enough. Make each query target a DIFFERENT aspect of the question (definition, timeline, key people, numbers, criticisms, current status). Use at most 5 searches.
3. REPORT: call write_file to create /report.md with this structure:
   - a title line: # <the question>
   - an opening paragraph that directly answers the question
   - 3-6 named sections that go deep: concrete facts, dates, numbers and names from the search results; note where sources disagree
   - a final section: ## Sources — a markdown bullet list of the URLs you actually used.
   Aim for the depth of a well-researched briefing note, not a summary. Use the detail from the search results — do not compress everything into two sentences per section.

One line in there carries a lesson that cost a full 15-minute run to learn: “You MUST run at least 3 searches”. An earlier draft said “3 to 5 searches, stop early if results get repetitive” — reasonable guidance for a person, or for a frontier model. qwen3:4b read it, ran one search, decided that was plenty, and wrote a thin report. Small models don’t do discretion; they do mandatory numbers. Every soft judgment call you leave in the prompt is a place where a 4B model will take the cheapest exit.

The analyze side is the same harness pointed at the accumulated corpus: the filesystem backend is rooted at the whole research/ tree, and the agent is told to ls, grep, and read_file its way to an answer, citing the paths it used. No embeddings, no vector store — the corpus is small markdown and a model that can grep doesn’t need retrieval infrastructure yet.

Making a 4B model behave

Here is the collected toll every deep-agent tutorial forgets to mention, because their model is 100× bigger than ours. Each item below broke a real run before it earned its fix.

Deterministic sampling, thinking off

labs/lab-local-deep-research/src/agents/model.ts:

import { ChatOllama } from "@langchain/ollama";
import type { AppConfig } from "../config.js";

export function buildChatModel(config: AppConfig): ChatOllama {
  return new ChatOllama({
    model: config.ollamaModel,
    baseUrl: config.ollamaBaseUrl,
    // A 4B model calls tools reliably only with deterministic sampling
    // and thinking mode off; both are load-bearing, not preferences.
    temperature: 0,
    think: false,
    numCtx: config.ollamaNumCtx,
    keepAlive: "10m",
  });
}

With temperature above zero or thinking mode on, tool-call JSON degrades from “occasionally malformed” to “reliably malformed” — the Qwen guides all say this, and they are right. These two settings are the difference between an agent and a random-walk generator.

One tool call per turn — as a mandatory rule

qwen3:4b’s favorite failure was emitting several write_todos calls in one turn, which hard-stopped the LangGraph run. The fix is the bluntest possible prompt engineering, right at the top of the system prompt:

labs/lab-local-deep-research/src/agents/prompts.ts:

MANDATORY RULE: call AT MOST ONE tool per turn. Never call the same tool twice in one turn. Never call two different tools in one turn. One tool call, then wait for its result, every time.

Yes, it says the same thing four ways. That redundancy is the feature: a small model needs the rule restated until there is no phrasing of the mistake left uncovered.

Pin today’s date, or “current” means 2024

The subtlest bug in the lab: a local model’s sense of now is frozen at its training cutoff. Ask it for “the latest developments” and it will happily search for the state of the art as of its training year — in the search queries it writes, too. So both system prompts open with the date:

labs/lab-local-deep-research/src/agents/prompts.ts:

// A local model's sense of "now" is frozen at its training cutoff, so every
// prompt pins today's date — otherwise "current" and "latest" quietly mean
// the model's training year.
function todayLine(today: string): string {
  return `Today's date is ${today}. Interpret "current", "latest" and "recent" relative to this date — NOT your training data. Use this date when your search queries need a year.`;
}

Cloud agents get this injected for free by their platform prompt; run the model yourself and you are the platform now.

Fewer tools than the harness wants to give you

The stock harness hands the model its full toolbox, including a task tool for spawning subagents — exactly the delegation machinery a 4B model cannot be trusted to drive. deepagents can disable tools per model profile, but its provider detection only knows Anthropic, OpenAI and Google; a ChatOllama instance falls through. So the lab trims the toolset the general way, with a middleware that filters what the model is ever shown:

labs/lab-local-deep-research/src/agents/tool-allowlist.ts:

import { createMiddleware } from "langchain";

export const RESEARCH_AGENT_TOOLS = [
  "internet_search",
  "ls",
  "read_file",
  "write_file",
] as const;

export const ANALYZE_AGENT_TOOLS = ["ls", "read_file", "glob", "grep"] as const;

export function createToolAllowlistMiddleware({
  allowedTools,
}: {
  allowedTools: readonly string[];
}) {
  const allowed = new Set(allowedTools);
  return createMiddleware({
    name: "ToolAllowlistMiddleware",
    wrapModelCall: (request, handler) =>
      handler({
        ...request,
        tools: request.tools.filter((candidate) =>
          allowed.has((candidate as { name: string }).name),
        ),
      }),
  });
}

A tool the model never sees is a tool it can never fumble. This is also what makes analyze read-only: it isn’t a permissions system, the write tools are simply not in its world. And it’s the natural extension point — running qwen3:8b or better on real hardware? Add task back to the list and the harness’s subagents light up again.

Flatten tool output, because the Ollama adapter says so

An integration bug you will hit within your first minute: deepagents’ own read_file tool returns its result as content blocks ([{ type: "text", text }]), and @langchain/ollama’s message converter throws on any ToolMessage whose content isn’t a plain string. The harness crashes on its own built-in tool. One more middleware:

labs/lab-local-deep-research/src/agents/stringify-tool-content-middleware.ts:

import { isToolMessage, ToolMessage } from "@langchain/core/messages";
import { createMiddleware } from "langchain";

// deepagents' read_file tool returns text files as content blocks
// (`[{ type: "text", text }]`) rather than a plain string, but
// @langchain/ollama's message converter only accepts string content on
// ToolMessage and throws otherwise. Flatten any non-string tool content
// to text before it reaches the model.
export function createStringifyToolContentMiddleware() {
  return createMiddleware({
    name: "StringifyToolContentMiddleware",
    wrapModelCall: (request, handler) =>
      handler({
        ...request,
        messages: request.messages.map((message) => {
          if (!isToolMessage(message) || typeof message.content === "string") {
            return message;
          }
          return new ToolMessage({ ...message, content: message.text });
        }),
      }),
  });
}

Strip the leaked monologue

Even with think: false, qwen3:4b sometimes leaks its internal reasoning into a final message — as a full <think>...</think> block, or as a stray closing tag when the opener got truncated. A shared helper scrubs both before anything downstream sees the text:

labs/lab-local-deep-research/src/agents/strip-think-blocks.ts:

// qwen3:4b sometimes leaks its internal reasoning into a final message as
// <think>...</think>, or — when the opening tag is truncated away — as
// reasoning text ending in a stray </think> with no opener. Strip both so
// downstream consumers never see the model's internal monologue.
export function stripThinkBlocks(text: string): string {
  const withoutClosedBlocks = text.replace(THINK_BLOCK_PATTERN, "");
  const lastCloseIndex = withoutClosedBlocks.lastIndexOf(THINK_CLOSE_TAG);
  const withoutLeadingReasoning =
    lastCloseIndex === -1
      ? withoutClosedBlocks
      : withoutClosedBlocks.slice(lastCloseIndex + THINK_CLOSE_TAG.length);
  return withoutLeadingReasoning.trim();
}

When the model answers instead of acting

The most expensive failure we hit came late, and it is worth telling in full. A run would do everything right — brief, three focused searches — and then, on the turn where it should call write_file with the report, the model would leak a reasoning block and compose the entire, well-formed report as its reply. A reply with no tool call ends the agent loop; report.md never exists; a 15-minute run dies with nothing to show, even though the report was right there in the transcript.

The fix has two layers, in the same spirit as everything above. The prompt now states the failure mode explicitly — the REPORT phase says “call write_file to create /report.md”, and the rules gained: “The report only exists once you call write_file with file_path /report.md. NEVER put the report text in your reply.” And because a prompt is a request, not a guarantee, the CLI keeps a deterministic backstop:

labs/lab-local-deep-research/src/commands/search.ts:

// qwen3:4b occasionally composes the finished report in its reply instead of
// calling write_file — the loop then ends with no /report.md and a 15-minute
// run would die. If the (think-stripped) reply IS the report, recover it.
export function extractInlineReport(reply: string): string | undefined {
  const stripped = stripThinkBlocks(reply);
  return stripped.startsWith("# ") ? stripped : undefined;
}

When the loop ends without report.md but the think-stripped reply is a markdown report, the CLI writes it to disk itself (with a warning in the logs) instead of failing the run. The pattern is the same one the search tool established: the model’s job is the research; making its output durable is the harness’s job.

Spend model turns on research, not bookkeeping

On a laptop, every model turn costs about a minute. That budget reframes tool design: anything the tool can do in code is a turn the model gets back for actual research. The search tool is where that principle pays off most. It’s a DynamicStructuredTool with a rich schema and a description that tells the model when not to use it:

labs/lab-local-deep-research/src/search/tavily-tool.ts:

  return new DynamicStructuredTool({
    name: "internet_search",
    description:
      "Search the web for up-to-date information. Use it for facts you do not " +
      "already have: definitions, dates, numbers, people, announcements. Each " +
      "call archives its full results (with URLs) to /notes/ automatically — " +
      "do NOT save search notes yourself. Do NOT use it to read local files. " +
      "Returns JSON with a short synthesized answer and a list of results " +
      "(title, url, content snippet).",
    schema: z.object({
      query: z
        .string()
        .min(3)
        .describe(
          "A focused web search query targeting ONE aspect of the research " +
            "question — not the whole question repeated verbatim.",
        ),
    }),

Note what the description promises: the tool archives its own results. Every search’s full, untruncated output is written to notes/ in code — zero model turns spent on note-taking — while the model itself sees a trimmed 1,800-char snippet per result to protect its context window. The earlier design had the agent maintain a todo list and write its own notes; dropping all of that took the run from ~18 model turns to 6, cut the wall time by a third, and — because the turns that remained were all research — the report got longer and better sourced, not worse.

The error path matters just as much on a slow machine:

labs/lab-local-deep-research/src/search/tavily-tool.ts:

      } catch (err) {
        // Returned as content instead of thrown: a transient search failure
        // should cost the agent one turn, not abort the whole research run.
        logger.error({ err, query }, "Searching the web failed.");
        const message = err instanceof Error ? err.message : String(err);
        return JSON.stringify({
          error: `Search failed: ${message}. Try ONE different query, or continue with what you already have.`,
        });
      }

A thrown error would abort a run that’s already ten minutes deep. Returned as structured content, a flaky Tavily call costs exactly one turn: the model reads the error, tries a different query, and moves on — the recovery instruction is right there in the payload.

The last middleware from earlier, createStepLoggingMiddleware, closes the loop on the human side: it wraps every model call and every tool call — the harness’s built-in file tools included — and logs start, success or failure, duration, and which tools the model just asked for. A multi-minute local run streams its progress as JSON lines instead of being a black box you stare at, wondering whether it’s thinking or dead.

A run, end to end

From a fresh clone (Ollama running natively — the model wants Metal/GPU acceleration that containers don’t get on macOS):

labs/lab-local-deep-research/README.md:

ollama pull qwen3:4b
cp .env.example .env        # put your Tavily key in TAVILY_API_KEY
npm ci
npm run search -- "What is the Model Context Protocol and who created it?"

On Air-class hardware a search takes 10–15 minutes: about 6–9 sequential model turns — brief, three to five searches, report — each visible in the logs as it happens. Here is the actual opening of a report the lab produced for “How does the European Union’s AI Act classify high-risk AI systems?”, written by a 4B model from its own searches:

labs/lab-local-deep-research/research/how-does-the-european-union-s-ai-act-classify-high-risk-ai-s/report.md:

# How the European Union's AI Act Classifies High-Risk AI Systems

The European Union's AI Act classifies high-risk AI systems as those that pose significant risks to health, safety, or fundamental rights but have major socio-economic benefits, requiring stringent compliance measures such as third-party conformity assessments, EU database registration, and human oversight mechanisms.

## Definition and Criteria for High-Risk AI Systems

Under Article 6(1) of the AI Act, high-risk AI systems are defined as those that pose significant risks to health, safety, or fundamental rights but have major socio-economic benefits (Recital 46). Classification is based on specific use cases listed in Annexes I and III, including:
- Biometric identification systems
- Critical infrastructure management
- Education and vocational training
- Employment and worker management
- Essential public services (e.g., healthcare, housing)
- Law enforcement and migration systems

Alongside it: a brief.md from the scoping phase, a notes/ folder holding every search’s full results, and a closing ## Sources section listing only URLs the searches actually returned — the prompt forbids invented citations, and the e2e test asserts real http links are present.

Then the corpus becomes queryable:

labs/lab-local-deep-research/README.md:

npm run analyze -- "Who created the Model Context Protocol?"
npm run analyze              # interactive session

analyze answers a one-shot question in well under a minute — it’s just reading files — and cites the paths it used. If the corpus doesn’t contain the answer, it is instructed to say so rather than fall back on its training data: your research assistant, grounded in your research.

The whole flow is verified by a scripted e2e smoke test (npm run e2e): a real search against live Ollama and Tavily, structural assertions on the report (exists, non-trivial, cites real links — model output is nondeterministic, so the assertions are structural), then a real analyze question about it.

Honest limits, and where to take it

Be honest with your expectations: this is a rig for learning how deep agents work, not a Google replacement. On a laptop CPU/iGPU each model turn costs about a minute, so a research run is a coffee break; the free Tavily tier’s 1,000 credits/month is a real budget, and advanced-depth searches draw it down faster than basic ones; and a 16K context window is why the tool trims what the model sees. A machine with a real GPU — or qwen3:8b, one .env variable away — is where it starts feeling fast.

But the architecture is the part that transfers. Planning phases in the prompt, filesystem-as-memory instead of a swollen context, tools that do their own bookkeeping and return errors as content, an allowlist that shows the model only what it can handle, and logs on every step: none of that is specific to small models. It’s just that a small model refuses to work until you get it right — which is exactly what makes it the best teacher. The natural next steps are all one-line invitations the lab leaves open: re-enable subagents on a bigger model, raise the step budget, point analyze at a corpus you’ve built up over weeks. The lab has everything to start from.