Add CI/CD, README, and a case study for deep_research #9
3 changed files with 333 additions and 0 deletions
47
.forgejo/workflows/deep_research-ci.yml
Normal file
47
.forgejo/workflows/deep_research-ci.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: deep_research CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'deep_research/**'
|
||||
- '.forgejo/workflows/deep_research-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'deep_research/**'
|
||||
- '.forgejo/workflows/deep_research-ci.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
container: rust:1-bookworm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://code.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: https://code.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Install rustfmt and clippy
|
||||
run: rustup component add rustfmt clippy
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -p deep_research -- --check
|
||||
|
||||
- name: Lint
|
||||
run: cargo clippy -p deep_research --all-targets -- -D warnings
|
||||
|
||||
- name: Build
|
||||
run: cargo build -p deep_research
|
||||
|
||||
- name: Test
|
||||
run: cargo test -p deep_research
|
||||
111
deep_research/README.md
Normal file
111
deep_research/README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# deep_research
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
$ deep_research "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](./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:
|
||||
|
||||
```
|
||||
┌─────────────┐ approve/reject ┌──────────┐
|
||||
│ researcher │ ───────────────► │ reviewer │
|
||||
│ (tool-using)│ ◄─────────────── │ │
|
||||
└──────┬──────┘ gaps/feedback └────┬─────┘
|
||||
│ turn budget exhausted │ approved,
|
||||
│ mid-investigation │ or out of rounds
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ summarizer │──findings───►│ writer │──► report
|
||||
│ (recovery) │ │ │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **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`)
|
||||
|
||||
```
|
||||
cargo run -p deep_research -- "your research topic"
|
||||
|
||||
# or, with tracing spans on stderr instead of the progress spinner:
|
||||
cargo run -p deep_research -- -l info "your research topic"
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| `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
|
||||
|
||||
```
|
||||
cargo test -p deep_research # unit tests — pure functions, no network
|
||||
cargo test -p deep_research -- --ignored # + a live smoke test against SearXNG
|
||||
cargo clippy -p deep_research --all-targets
|
||||
```
|
||||
|
||||
CI (`.forgejo/workflows/deep_research-ci.yml`) runs formatting, lint, build,
|
||||
and the unit test suite on every push and PR.
|
||||
175
deep_research/docs/case-study.md
Normal file
175
deep_research/docs/case-study.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Case study: hardening an agentic pipeline against real failure
|
||||
|
||||
This is a walkthrough of how `deep_research`'s multi-agent pipeline went
|
||||
from "works in the happy path" to actually resilient — driven by two real
|
||||
failures it hit in normal use, not by imagining edge cases in the abstract.
|
||||
The throughline: root-cause failures instead of papering over them, and
|
||||
verify fixes against a live run, not just a green test suite.
|
||||
|
||||
## The starting point
|
||||
|
||||
The pipeline is four agents with one job each — a researcher that
|
||||
tool-calls its way through web search and page fetches, a reviewer that
|
||||
checks the researcher's conclusions actually follow from its sources, a
|
||||
writer that turns approved findings into a report, and (added in the course
|
||||
of this work) a summarizer that only runs as a recovery path. See the
|
||||
[README](../README.md#architecture) for the full shape.
|
||||
|
||||
Like any agent given a tool-calling budget, the researcher can run out of
|
||||
turns before it decides it's done. The original code treated that as fatal:
|
||||
whatever evidence had been gathered — search results, fetched pages, partial
|
||||
reasoning — was simply discarded when the run errored out.
|
||||
|
||||
## Failure #1: turn-budget exhaustion, and why "just increase the limit" isn't the fix
|
||||
|
||||
Raising the turn cap doesn't solve this class of problem — it just moves the
|
||||
threshold. Any fixed budget can be exhausted by a topic that's slow to
|
||||
narrow down, and the failure mode (total loss of partial work) is the actual
|
||||
defect, not the specific number of turns.
|
||||
|
||||
The fix meant reading past the top-level API surface and into the actual
|
||||
error `rig` (the Rust agent framework this is built on) returns when the
|
||||
budget runs out: `PromptError::MaxTurnsError`, which — critically — carries
|
||||
the full chat history at the point of failure, not just an error string.
|
||||
That's the hook the recovery path needed.
|
||||
|
||||
**First pass** was a purely programmatic recovery: catch that specific
|
||||
error variant, walk the returned message history, and pull out whatever
|
||||
assistant commentary and tool-call results it contains into a flat findings
|
||||
dump, instead of raising.
|
||||
|
||||
**Second pass** made it genuinely agentic rather than just defensive: a
|
||||
dedicated *summarizer* agent — a fresh, tool-free model call — takes that
|
||||
same transcript (now annotated with which tool call produced which result,
|
||||
so a fetched page stays attached to its URL) and reconstructs the same
|
||||
footnote-style findings dump the researcher would have written itself, had
|
||||
it finished. This recovers real information that plain string concatenation
|
||||
would leave scattered and unattributed: deduped sources, correct citation
|
||||
numbering, a coherent narrative instead of a raw tool-output dump.
|
||||
|
||||
The summarizer call is itself just another model call that can fail or
|
||||
have nothing to work with — so it falls back to the programmatic extraction
|
||||
on an empty transcript or a failed call, rather than letting a second
|
||||
failure take down the one recovery path meant to be bulletproof. And
|
||||
regardless of what either path produces, a plain, hard-coded disclaimer is
|
||||
written directly to the output — not left to an LLM's discretion to
|
||||
remember to mention that the research is incomplete.
|
||||
|
||||
This was verified against a real run, not a mocked one: a genuinely
|
||||
under-specified research question was run end-to-end against local models
|
||||
until it actually exhausted its turn budget, with full tracing enabled, to
|
||||
confirm the recovery path fired, the summarizer produced a coherent partial
|
||||
report, and the disclaimer showed up exactly where expected.
|
||||
|
||||
## Failure #2: the recovery path was masking a worse problem
|
||||
|
||||
That live verification run surfaced something the recovery path was built
|
||||
to survive, but shouldn't have had to: the researcher burned its *entire*
|
||||
turn budget re-running variations of the same search, and every single one
|
||||
came back "no results found." The web-search tool was scraping
|
||||
DuckDuckGo's HTML search page directly — no API key required, but no
|
||||
protection from rate limiting either. And a rate-limited response looks
|
||||
*identical* to a genuine empty result: the tool has no way to tell the
|
||||
model "you're being throttled" versus "this topic has no coverage," so the
|
||||
model just kept trying.
|
||||
|
||||
The recovery mechanism did exactly its job here — the run finished with an
|
||||
honest report saying "found nothing," rather than crashing — but that's a
|
||||
consolation prize. The actual bug was upstream: the search tool's failure
|
||||
mode was silent and indistinguishable from success.
|
||||
|
||||
Root cause fixed, not the symptom: the search tool now hits a self-hosted
|
||||
[SearXNG](https://docs.searxng.org/) instance's JSON API instead of
|
||||
scraping HTML. That's a straight upgrade on every axis that mattered here —
|
||||
a real API instead of parsing markup, results aggregated across multiple
|
||||
upstream engines instead of hammering one, and full control over request
|
||||
pacing since it's infrastructure already running on this machine. Before
|
||||
reaching for a third-party crate, the two SearXNG client crates on
|
||||
crates.io were checked and rejected: both single-maintainer, both v0.1.0,
|
||||
neither with any adoption signal — a ~20-line `reqwest` + `serde` call
|
||||
using dependencies already in the tree was the better bet for something
|
||||
this small.
|
||||
|
||||
**Verification, again against a live run**: the exact same research
|
||||
question that had previously burned its full turn budget on empty results
|
||||
was re-run, unchanged, against the new search backend. It completed
|
||||
normally on the second research round — approved by the reviewer, no
|
||||
max-turns event, no recovery path needed. The fix wasn't just plausible on
|
||||
paper; it was confirmed to actually change the outcome of the failure it
|
||||
was meant to fix.
|
||||
|
||||
## Hardening: separation of concerns
|
||||
|
||||
With both failure modes fixed, the module that had accumulated all of this
|
||||
logic — CLI parsing, orchestration, the researcher phase, chat-history
|
||||
reconstruction, the summarizer, and the writer — had grown into a single
|
||||
600+-line file mixing six unrelated concerns, while the rest of the
|
||||
codebase (`review.rs`, `tools.rs`, `stream.rs`, `progress.rs`) already kept
|
||||
one file per concern. That file was split to match: each agent phase, the
|
||||
orchestration loop, and the pure history-parsing helpers now live in their
|
||||
own module, with `main.rs` reduced to argument parsing, logging setup, and
|
||||
a single top-level call — see the [project layout](../README.md#project-layout)
|
||||
in the README for the resulting structure. Unit tests moved with the code
|
||||
they test (Rust convention keeps tests co-located, not split into separate
|
||||
files), and the full suite — now covering the history-reconstruction logic
|
||||
in isolation, independent of any model or network call — stayed green
|
||||
throughout.
|
||||
|
||||
## A clean run, for reference
|
||||
|
||||
With both failures fixed, a normal research pass looks like this — the
|
||||
researcher gathered evidence, the reviewer approved it on the first pass,
|
||||
and the writer produced a cited report, no recovery path exercised:
|
||||
|
||||
> **Topic:** trends in AI customer-support chatbots
|
||||
|
||||
```
|
||||
# Trends in AI Customer-Support Chatbots (2026)
|
||||
|
||||
This report synthesizes current research notes detailing the major
|
||||
technological, operational, and regulatory trends shaping the AI
|
||||
customer-support chatbot market as of 2026.
|
||||
|
||||
### The Shift to Agentic AI
|
||||
The industry is undergoing a fundamental transition from simple, scripted
|
||||
chatbots toward "Agentic AI." This new paradigm focuses on autonomous AI
|
||||
agents capable of performing complex tasks and orchestrating complete
|
||||
workflows, moving beyond basic prompt responses [1, 4]...
|
||||
|
||||
### Governance, Security, and Regulation
|
||||
...compliance with regulations such as GDPR and the EU AI Act is a primary
|
||||
concern, demanding robust structures for security, transparency, and
|
||||
governance [4, 6]...
|
||||
|
||||
### Operational and Economic Impact
|
||||
The overall AI-powered customer service market is projected to reach
|
||||
$15.12 billion in 2026 [1]. Adoption is widespread, with approximately 72%
|
||||
of businesses across various industries having deployed AI-driven chatbots
|
||||
for customer interactions [2]...
|
||||
|
||||
### Sources
|
||||
[1] https://chatmaxima.com/blog/ai-customer-support-statistics-2026/
|
||||
[2] https://www.itransition.com/ai/conversational
|
||||
[3] https://salt.security/eu-ai-act-compliance
|
||||
...
|
||||
```
|
||||
|
||||
(Abbreviated here; the tool prints the full report, headings, open
|
||||
questions, and complete source list to the terminal as it streams.)
|
||||
|
||||
## What this demonstrates
|
||||
|
||||
- Designing a multi-agent pipeline as explicit, single-responsibility
|
||||
stages coordinated by plain control flow, not a single sprawling prompt
|
||||
or an opaque framework agent-graph.
|
||||
- Treating an agent's failure modes (turn-budget exhaustion, a tool's
|
||||
silent degradation) as defects to root-cause and fix, not edge cases to
|
||||
shrug off — including building a second, self-limiting agent as the
|
||||
recovery mechanism itself.
|
||||
- Verifying fixes against live runs of the actual failing case, not just
|
||||
unit tests in isolation.
|
||||
- A local-first architecture (Ollama + self-hosted SearXNG) with no
|
||||
cloud LLM API key and no query leaving the host — the same constraint
|
||||
a privacy-sensitive deployment would need.
|
||||
- Full observability via structured tracing on every agent phase, and a
|
||||
codebase organized so each concern has exactly one home.
|
||||
Loading…
Reference in a new issue