- Rust 98.5%
- Dockerfile 0.8%
- Shell 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo | ||
| .githooks | ||
| crates/claimcheck-mcp | ||
| docs | ||
| searxng | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| clippy.toml | ||
| Makefile.toml | ||
| README.md | ||
doubleo7
A local-first, multi-agent deep-research CLI: give it a topic, it searches the web, cross-checks what it finds, and writes up a cited report — entirely on infrastructure you control, with no cloud LLM API key and no query ever leaving your machine.
$ doubleo7 "trends in AI customer-support chatbots"
🔎 Researching...
🧐 Reviewing findings...
✍️ Writing report...
# AI Customer-Support Chatbots: 2025–2026 Trends
...
Why this exists
This started as a "does deep research actually work end-to-end" exercise and turned into a small case study in building an agentic system that survives contact with reality: models that hit their turn budget mid-task, search providers that rate-limit, and reviewers that reject good-faith work. The case study walks through what broke and how each failure was fixed, not just papered over.
Architecture
Four small agents, each with one job, coordinated by plain Rust control
flow — not a framework's agent graph, not an LLM deciding when to stop —
plus an optional retrieval step when --doc documents are supplied:
┌────────────┐ excerpts ┌─────────────┐ approve/reject ┌──────────┐
│ retriever │ ───────────► │ researcher │ ───────────────► │ reviewer │
│(--doc only)│ │ (tool-using)│ ◄─────────────── │ │
└────────────┘ └──────┬──────┘ gaps/feedback └────┬─────┘
│ turn budget exhausted │ approved,
│ mid-investigation │ or out of rounds
▼ ▼
┌──────────────┐ ┌──────────────┐
│ summarizer │──findings───►│ writer │──► report
│ (recovery) │ │ │
└──────────────┘ └──────────────┘
- retriever (only when
--docpaths are given) — chunks and embeds user-supplied documents with a dedicated embedding model, then retrieves the excerpts most relevant to the topic once up front and folds them into the researcher's task, cited with the same footnote scheme as web sources. - researcher — a tool-calling agent (
search_web,fetch_page) that gathers and cross-checks evidence, capped at a fixed model-call budget so a confused model can't loop forever. When theclaimcheck-mcpserver is available it also getscheck_claimover MCP, and is told to verify its key facts against the URLs it means to cite before finishing. - reviewer — a separate, fresh-context agent that checks the researcher's conclusions actually follow from its cited sources, and either approves the findings or hands back concrete gaps for another pass.
- writer — turns approved (or partial) findings into a structured, footnoted report, streamed to the terminal as it's generated.
- summarizer (recovery path) — only runs when the researcher exhausts its turn budget before concluding on its own. It reconstructs a proper findings dump from the raw tool-call transcript rather than the run simply failing; see the case study for why this exists and how it degrades gracefully if the summarizer call itself fails.
Everything runs against local models via Ollama and a self-hosted SearXNG instance for search — no OpenAI/ Anthropic/Google API key, no third-party search API, nothing about the research topic leaves the host it runs on. That's a deliberate constraint, not a limitation: it's the same shape a privacy-sensitive customer deployment would need.
Running it
Prerequisites:
- Ollama running locally with a tool-calling-capable
model pulled (the researcher and reviewer/writer models are configured in
src/models.rs) - A local SearXNG instance with its JSON API
enabled (defaults to
http://localhost:8080, overridable viaSEARXNG_URL)
cargo build --workspace # also builds claimcheck-mcp, which enables claim checking
cargo run -- "your research topic"
# or, with tracing spans on stderr instead of the progress spinner:
cargo run -- -l info "your research topic"
# give the researcher your own documents to draw on, alongside the web —
# repeatable, and a directory contributes every file directly inside it
# (one level deep, not recursive):
cargo run -- --doc ./notes.txt --doc ./research-docs/ "your research topic"
doubleo7 launches claimcheck-mcp as a child process and talks MCP to it
over stdio. It looks for the binary next to its own (target/<profile>/),
or at CLAIMCHECK_MCP if set. If the binary can't be started, the run
prints a warning and continues without claim checking.
Uploaded documents are chunked (see documents.rs), embedded with a
dedicated embedding model (see EMBEDDING_MODEL in
src/models.rs) into an in-memory vector index, then the
excerpts most relevant to the topic are retrieved and folded into the
researcher's task alongside anything it finds on the web — the same
footnote-citation scheme applies to both.
Known limitation: retrieval returns a fixed top-N chunks
(retrieval::TOP_N_EXCERPTS). A document with several chunks that all read
as similar to the query — several incident reports, several revisions of
the same section — can crowd out the one chunk that actually answers it,
since only the top N by similarity are ever returned regardless of how many
plausible candidates exist. Reproduced deliberately (a 13.7 KB / 12-chunk
document with 6 near-identical "incident report" sections, only one of
which had the real answer, was built specifically to stress this — the top
5 slots filled entirely with distractors and the answer chunk was
excluded), so it's a real edge case, not a hypothetical. Not fixed for now
since it takes a document engineered to trigger it, but worth knowing if a
report seems to be missing something you know is in an uploaded document.
Project layout
The repo is a Cargo workspace. doubleo7 itself is the root package (so
cargo run runs it), and supporting crates live under crates/:
| Crate | Purpose |
|---|---|
crates/claimcheck-mcp |
MCP server that checks whether a cited source actually supports a claim; the researcher's check_claim tool |
Within doubleo7, split one concern per file rather than one large module:
| File | Responsibility |
|---|---|
main.rs |
Argument parsing, logging setup, and the single top-level call — no orchestration logic |
research.rs |
The research/review round loop |
researcher.rs |
The tool-calling research phase |
review.rs |
The reviewer agent |
summarizer.rs |
Max-turns recovery: reconstructs findings via a model call |
writer.rs |
Turns findings into the final streamed report |
history.rs |
Pure, unit-tested helpers for parsing a rig chat history into usable text |
documents.rs |
Resolves --doc paths into embeddable documents |
retrieval.rs |
Embeds documents into an in-memory vector index and retrieves relevant excerpts |
tools.rs |
search_web (SearXNG) and fetch_page tool implementations |
claimcheck.rs |
Launches claimcheck-mcp and exposes its tools to the researcher over MCP |
stream.rs |
Drains a streaming prompt response to the terminal |
progress.rs |
The terminal spinner and per-phase emoji |
models.rs, cli.rs, observability.rs |
Small shared config: model names, CLI args, tracing setup |
Testing
cargo test --workspace # unit tests — pure functions, no network
cargo test --workspace -- --ignored # + live tests against local SearXNG and Ollama
cargo make # full CI gate locally: fmt check, clippy, tests + coverage, release check, audit
cargo make install-hooks # one-time: enable the tracked pre-commit hook (auto cargo fmt)
Plain cargo test / cargo clippy at the repo root only cover the root
doubleo7 package; pass --workspace (as cargo make and CI do) to include
crates/.
Lint levels live in the root Cargo.toml's [workspace.lints.clippy] (all
of clippy::all plus cognitive_complexity), inherited by every crate via
[lints] workspace = true, with thresholds in the root clippy.toml.
Warnings are denied only at the gate (cargo make lint / CI), not in
everyday builds.
CI (.forgejo/workflows/ci.yml) runs on every push and PR as one job: a
format check, clippy with -D warnings, the test suite under cargo llvm-cov
(which fails on a failing test and reports coverage), and a release-profile
check, then cargo audit. CI builds without debuginfo and caches compiled
objects through sccache, keyed on Cargo.lock.