doubleo7/README.md

168 lines
8.2 KiB
Markdown
Raw Normal View History

2026-08-18 12:26:50 +00:00
# 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.
```
2026-08-18 12:26:50 +00:00
$ doubleo7 "trends in AI customer-support chatbots"
🔎 Researching...
🧐 Reviewing findings...
✍️ Writing report...
# AI Customer-Support Chatbots: 20252026 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](./docs/case-study.md) 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 `--doc` paths 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.
- **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](https://ollama.com) and a
self-hosted [SearXNG](https://searx.space) 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](https://ollama.com) running locally with a tool-calling-capable
model pulled (the researcher and reviewer/writer models are configured in
[`src/models.rs`](./src/models.rs))
- A local [SearXNG](https://docs.searxng.org/) instance with its JSON API
enabled (defaults to `http://localhost:8080`, overridable via
`SEARXNG_URL`)
```
Extract swear_cleanup to its own repo, flatten deep_research to root deep_research is the only project this repo is meant to showcase, so the Cargo workspace wrapping it and an unrelated side project no longer earns its keep: - swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup, not pushed anywhere) via `git subtree split`, with its pre-workspace- split history (when it lived at src/swear_cleanup/ in a single shared crate) spliced onto its post-split history rather than starting from a single flattened snapshot. FINDINGS.md, which was sitting at this repo's root but was actually swear_cleanup's own build log, went with it. - deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root; the [workspace] table collapsed into a plain [package] manifest with dependency versions inlined from the old [workspace.dependencies]. - Cargo.toml keeps an explicit empty [workspace] table (not just omitted) so that checking this repo out as a nested git worktree — this project's own normal workflow — can't accidentally inherit a stale ancestor directory's workspace manifest, which is exactly what broke the build while testing this change from a worktree. - .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now- meaningless -p deep_research scoping and path filters (redundant when it's the only thing in the repo). - README.md and docs/case-study.md updated for the flattened commands (cargo run/test with no -p flag); their relative links to each other and to src/ were already correct since both moved together. Verified: cargo build/test/clippy/fmt all clean from the new repo root.
2026-08-18 11:43:26 +00:00
cargo run -- "your research topic"
# or, with tracing spans on stderr instead of the progress spinner:
Extract swear_cleanup to its own repo, flatten deep_research to root deep_research is the only project this repo is meant to showcase, so the Cargo workspace wrapping it and an unrelated side project no longer earns its keep: - swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup, not pushed anywhere) via `git subtree split`, with its pre-workspace- split history (when it lived at src/swear_cleanup/ in a single shared crate) spliced onto its post-split history rather than starting from a single flattened snapshot. FINDINGS.md, which was sitting at this repo's root but was actually swear_cleanup's own build log, went with it. - deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root; the [workspace] table collapsed into a plain [package] manifest with dependency versions inlined from the old [workspace.dependencies]. - Cargo.toml keeps an explicit empty [workspace] table (not just omitted) so that checking this repo out as a nested git worktree — this project's own normal workflow — can't accidentally inherit a stale ancestor directory's workspace manifest, which is exactly what broke the build while testing this change from a worktree. - .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now- meaningless -p deep_research scoping and path filters (redundant when it's the only thing in the repo). - README.md and docs/case-study.md updated for the flattened commands (cargo run/test with no -p flag); their relative links to each other and to src/ were already correct since both moved together. Verified: cargo build/test/clippy/fmt all clean from the new repo root.
2026-08-18 11:43:26 +00:00
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"
```
Uploaded documents are chunked (see `documents.rs`), embedded with a
dedicated embedding model (see `EMBEDDING_MODEL` in
[`src/models.rs`](./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.
## Installing (Arch/Manjaro)
[`packaging/PKGBUILD`](./packaging/PKGBUILD) builds and installs `doubleo7`
as a proper pacman package (`doubleo7-git`), pulling source straight from
this repo's git history rather than a crates.io release:
```
cd packaging
makepkg -si
```
`-s` resolves `makedepends` (`cargo`, `git`) via pacman first, `-i`
installs the resulting package after building it. To pick up upstream
changes later, just rerun the same command from a checkout with the latest
commits — `pkgver()` derives its version from `git rev-list`/`git
rev-parse`, so `makepkg` detects the new commit, rebuilds, and `pacman -U`
replaces the old install in place. Uninstall with `sudo pacman -R
doubleo7-git`.
Note: Manjaro's default `CFLAGS`/`LDFLAGS` hardening flags corrupt the
vendored C build inside `aws-lc-sys` (a transitive TLS dependency), causing
a symbol-mismatch link error — `PKGBUILD`'s `build()` unsets them before
invoking `cargo build` to work around it.
## Project layout
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 |
| `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
```
Extract swear_cleanup to its own repo, flatten deep_research to root deep_research is the only project this repo is meant to showcase, so the Cargo workspace wrapping it and an unrelated side project no longer earns its keep: - swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup, not pushed anywhere) via `git subtree split`, with its pre-workspace- split history (when it lived at src/swear_cleanup/ in a single shared crate) spliced onto its post-split history rather than starting from a single flattened snapshot. FINDINGS.md, which was sitting at this repo's root but was actually swear_cleanup's own build log, went with it. - deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root; the [workspace] table collapsed into a plain [package] manifest with dependency versions inlined from the old [workspace.dependencies]. - Cargo.toml keeps an explicit empty [workspace] table (not just omitted) so that checking this repo out as a nested git worktree — this project's own normal workflow — can't accidentally inherit a stale ancestor directory's workspace manifest, which is exactly what broke the build while testing this change from a worktree. - .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now- meaningless -p deep_research scoping and path filters (redundant when it's the only thing in the repo). - README.md and docs/case-study.md updated for the flattened commands (cargo run/test with no -p flag); their relative links to each other and to src/ were already correct since both moved together. Verified: cargo build/test/clippy/fmt all clean from the new repo root.
2026-08-18 11:43:26 +00:00
cargo test # unit tests — pure functions, no network
cargo test -- --ignored # + a live smoke test against SearXNG
cargo clippy --all-targets
```
Extract swear_cleanup to its own repo, flatten deep_research to root deep_research is the only project this repo is meant to showcase, so the Cargo workspace wrapping it and an unrelated side project no longer earns its keep: - swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup, not pushed anywhere) via `git subtree split`, with its pre-workspace- split history (when it lived at src/swear_cleanup/ in a single shared crate) spliced onto its post-split history rather than starting from a single flattened snapshot. FINDINGS.md, which was sitting at this repo's root but was actually swear_cleanup's own build log, went with it. - deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root; the [workspace] table collapsed into a plain [package] manifest with dependency versions inlined from the old [workspace.dependencies]. - Cargo.toml keeps an explicit empty [workspace] table (not just omitted) so that checking this repo out as a nested git worktree — this project's own normal workflow — can't accidentally inherit a stale ancestor directory's workspace manifest, which is exactly what broke the build while testing this change from a worktree. - .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now- meaningless -p deep_research scoping and path filters (redundant when it's the only thing in the repo). - README.md and docs/case-study.md updated for the flattened commands (cargo run/test with no -p flag); their relative links to each other and to src/ were already correct since both moved together. Verified: cargo build/test/clippy/fmt all clean from the new repo root.
2026-08-18 11:43:26 +00:00
CI (`.forgejo/workflows/ci.yml`) runs formatting, lint, build, and the unit
test suite on every push and PR.