Packages doubleo7 as doubleo7-git, sourcing from this repo's own git
history so a later `makepkg -si` picks up new commits and reinstalls
in place. Works around a Manjaro CFLAGS/LDFLAGS hardening flag that
corrupts aws-lc-sys's vendored C build at link time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XCcBkpdbUxSEeofhgAueS
The diagram still showed the original four-agent pipeline
(researcher/reviewer/writer/summarizer) with no mention of the
document embedding/retrieval step added in #10. Added a retriever
box feeding excerpts into the researcher, plus a bullet describing
it, matching the retrieval pipeline already documented in prose
further down the README.
CI runs the test suite as root inside an unmodified Docker base image
(data.forgejo.org/oci/node:20-bookworm), where chmod 0o000 doesn't
actually block reads — root bypasses Unix permission bits entirely.
The two new permission-based tests from the previous commit passed
locally (non-root) but failed in CI for exactly that reason. Skip them
under root via a raw geteuid() FFI check instead of asserting behavior
the OS isn't enforcing.
Verified with cargo build/test (34 passed)/clippy -D warnings/fmt --check.
- collect_documents now partitions --doc paths into files vs.
directories up front and handles each with its own loop, instead of
a single branching if/else per path; the directory branch resolves
entries via a fallible iterator chain (? inside a Result-returning
map, collected with Result::transpose) rather than a nested for loop
- extracted chunk_document (path, splitter) -> Vec<Document> as a pure
function, and Document::from_chunk(path, index, chunk, total) to own
the chunk's source-string formatting, both previously inlined in a
function that mutated a shared Vec in place
- renamed push_document -> chunk_document to match what it now does
(reads and chunks a file into owned Documents) instead of what it
used to do (push one Document into a caller-supplied Vec)
- added unit tests for the reworked logic: multiple files/directories
in one call, a mix of both in one call, an unreadable file inside a
scanned directory (skipped, siblings kept) vs. an unreadable
directory itself (fails the whole call, unlike a file), and direct
tests of chunk_document and Document::from_chunk in isolation
Verified with cargo build/test (34 passed)/clippy -D warnings/fmt --check.
It didn't actually guard what it claimed to: std::fs::read_to_string
loads the whole file into memory before truncation ever ran, so it
never prevented the OOM risk it was documented as protecting against
— it only capped how much of the already-fully-read text got chunked
afterward. Directory scanning is non-recursive and --doc is an
explicit opt-in, so an oversized file is on the caller; chunking
already handles arbitrarily long documents correctly. Can add a
pre-read fs::metadata size check back later if real usage shows a
need for it.
- retrieval::retrieve_relevant now shows a spinner around the query-
embedding call and gets its own tracing span, matching every other
model-calling phase in the pipeline (it previously ran invisibly and
untraced)
- add progress::report() to dedupe the show_progress-gated eprintln!
pattern shared by research.rs and retrieval.rs
- documents::push_document builds its TextSplitter once per collect_documents
call instead of once per file, skips the char-truncation walk entirely
when a file is already under the limit, and pulls the source-string
branch out of the map closure
- researcher::gather_findings drops a doc_context emptiness check that
build_doc_context already guarantees
- documents.rs tests use tempfile::tempdir() instead of a hand-rolled
TempDir type
Verified with cargo build/test/clippy -D warnings/fmt --check.
The "Update rig to 0.42" commit (cd580f3) accidentally reverted this
paragraph's `doubleo7` branding back to the old `deep_research` name and
degraded the prose along with it -- unrelated to the rig version bump.
Restore the rebranded wording from 5bb4ed1.
Ollama doesn't support tool_choice, so rig's tool-forced extractor
(client.extractor::<Review>()) never actually compelled the reviewer's
small local model to call the submit tool -- it just answered in prose,
and extraction exhausted its retries with "No data extracted" every time.
Switch to rig's typed-prompt API (agent.prompt_typed::<Review>()), which
uses Native output mode: Ollama's own `format` JSON-schema constraint on
the completion request, honored regardless of tool-calling ability.
Verified against a live local Ollama instance.
Resolves the Cargo.lock conflict by regenerating it, and picks up
master's rename (deep_research -> doubleo7 crate/binary name, Cli ->
Doubleo7 struct) cleanly through everything this branch added
(--doc/documents.rs/retrieval.rs). Also renamed a leftover
"deep_research-test-" temp-dir prefix in documents.rs's tests for
consistency with the rebrand.
Manually tested chunking against a 13.7KB/12-chunk document (previous
verification only used a 424-char single-chunk file, which never
exercised multi-chunk retrieval). Chunking itself held up. Retrieval
didn't: built a document with 6 near-identical distractor sections and
only one true answer, and the fixed top-5 slots filled entirely with
distractors, excluding the chunk that actually answered the query.
Documented as a known limitation rather than fixed now — it takes a
document engineered to trigger it (several chunks that all read as
similar to the query), not a typical upload.
A flat 20K-char cutoff (copied from the fetched-web-page limit) silently
dropped everything past the first ~20KB of a larger file, and even
within the cutoff, embedding a whole multi-page document as one vector
made retrieval coarse — the vector just averages out whatever topics
the document covers.
Split each file into ~1500-char chunks via text-splitter (recursive
semantic-boundary splitting: paragraph > sentence > word, never mid-word)
and embed each chunk as its own document, tagged with its source and
part number. This removes the practical size ceiling — a large file
chunks the same way a short one does — and sharpens retrieval by letting
it surface the specific passage relevant to a query. It also incidentally
caps the worst-case retrieval payload: 5 chunks now tops out around
7500 chars versus the old worst case of 5 full 20K-char documents.
Verified against a live Ollama nomic-embed-text pull: a short document
still embeds as a single chunk, unchanged from before.
Adds --doc (repeatable, file or directory) so the researcher can draw on
user-supplied documents alongside the web: documents.rs resolves paths
into embeddable text, retrieval.rs embeds them with a dedicated
embedding model (nomic-embed-text, separate from the chat models used
elsewhere) into an in-memory vector index and retrieves the excerpts
most relevant to the topic once up front, and researcher.rs folds those
excerpts into the researcher's task under the same footnote-citation
scheme already used for web sources. The embedding and retrieval phases
show progress the same way every other phase does — a spinner while
embedding, a summary line once excerpts are retrieved, tracing spans
for -l mode.
Verified against a live Ollama nomic-embed-text pull and a real
research round: a planted fact sheet was correctly ranked as the most
relevant of several embedded documents and appeared in the researcher's
task before its first turn.
push and pull_request both fired for commits on a branch with an open
PR. Scope push to master only, matching sporah's workflow, so branch
commits trigger just the pull_request run.
The docker-labeled runner's image is node:20-bookworm, needed for the
checkout/cache actions (both Node-based). Overriding it with
container: rust:1-bookworm dropped Node from the image entirely, so
checkout failed with "node: executable file not found in $PATH". The
rust-ci label (see sporah's workflow) points at a custom image with
both Rust and Node preinstalled, avoiding the conflict.
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.
- .forgejo/workflows/deep_research-ci.yml: build, test, clippy (-D
warnings), and fmt --check on push/PR, scoped to deep_research (not
workspace-wide — swear_cleanup has an unrelated pre-existing clippy
warning that would otherwise break CI on an unrelated project)
- README.md: what the project does, the four-agent architecture, why
it's local-first (Ollama + self-hosted SearXNG, no cloud API key, no
query leaves the host), project layout, and how to run/test it
- docs/case-study.md: narrative walkthrough of the max-turns recovery
path, the DuckDuckGo-rate-limiting root cause and SearXNG fix, and the
separation-of-concerns refactor — each step verified against a live
run of the actual failing case, not just unit tests. Uses a neutral
"AI customer-support chatbot trends" research run as the illustrative
clean-pipeline example rather than the personal topic used during
actual debugging.
core.rs had grown into a 612-line grab-bag mixing six unrelated concerns:
CLI arg parsing, logging setup, top-level orchestration, the researcher
agent phase, chat-history reconstruction utilities, the summarizer agent
phase, and the writer agent phase — while review.rs, tools.rs, stream.rs,
and progress.rs already correctly isolated their own concerns. This
splits core.rs to match that existing pattern instead of being the one
file that doesn't follow it:
- cli.rs — Cli struct + DEFAULT_TOPIC
- observability.rs — initialize_observability
- models.rs — RESEARCHER_MODEL / WRITER_MODEL (previously duplicated
across call sites, now a single source of truth)
- history.rs — pure chat-history parsing/reconstruction helpers
(partial_findings_from_history, annotated_transcript_from_history, and
their private helpers), plus their unit tests. Also dedupes
MAX_TOOL_RESULT_CHARS, which was previously defined twice.
- researcher.rs — gather_findings + GatheredFindings (the tool-calling
research phase)
- summarizer.rs — summarize_partial_history (the max-turns recovery
agent)
- writer.rs — write_report
- research.rs — the top-level research() orchestration loop
main.rs now only does argument parsing, logging setup, and the top-level
call — no orchestration logic of its own. Unit tests stay co-located
with the code they test per Rust convention (not pulled into separate
files) rather than under "prefer new files" — that applies to
production code organization here.
No behavior changes; cargo test/clippy/fmt all clean.
DuckDuckGo's HTML endpoint rate-limits after enough requests, and a
rate-limited response is indistinguishable from a genuine empty result —
which is exactly what burned a full 12-turn research run on 13 consecutive
"No results found" responses. Swapping to a local SearXNG instance's JSON
API (no HTML scraping needed) fixes both problems: SearXNG spreads queries
across multiple upstream engines instead of hammering one, and this
machine already runs an instance.
This tool is explicitly local-only and never released, so the base URL is
a plain default (localhost:8080) overridable via SEARXNG_URL, not a
general-purpose config surface. Evaluated the two third-party SearXNG
crates on crates.io first (searxng, searxng-client) — both are
single-maintainer v0.1.0 packages with no adoption signal and no official
alternative exists, so a hand-rolled reqwest + serde call was the better
bet for something this small.
Drops the DuckDuckGo-specific HTML parsing (parse_search_results,
resolve_ddg_redirect, the .result/.result__a/.result__snippet scraper
selectors) entirely — fetch_page's extract_readable_text still needs
scraper for arbitrary fetched pages, so that dependency stays.
Adds an #[ignore]'d live smoke test (search_web_returns_real_results_from_local_searxng)
for manually verifying against a running instance; not run by default
since there's no CI environment with SearXNG available.
Add summarize_partial_history: a one-shot writer-model pass that turns an
annotated transcript (tool calls with their args, so a fetch's URL or a
search's query stays attached to its result, plus results and interim
notes) into a proper footnote-style findings dump, instead of the flat
concatenation partial_findings_from_history produces on its own.
It's wired in as the MaxTurnsError recovery path in gather_findings, but
partial_findings_from_history stays as the fallback for an empty transcript
or if the summarizer call itself fails — the one guaranteed recovery path
shouldn't have a second turn-budget/model failure as a single point of
failure.
Observability: instrumented with the same #[tracing::instrument(fields(
gen_ai.agent.name = ...))] + spinner pattern as the researcher/reviewer/
writer phases, with info!/warn! events on success, empty-transcript
skip, and summarizer failure.
Also: cargo fmt across the crate (unrelated formatting drift had
accumulated), and adds unit tests for the new transcript_lines /
annotated_transcript_from_history helpers.
Covers truncate, assistant_text, tool_result_text, and
partial_findings_from_history in isolation (fallback text, ordering,
truncation, and filtering out non-text content). gather_findings/
write_report/research still need a live ollama client and aren't covered
here.
Split into two filter_map-based helpers (assistant_text, tool_result_text)
and a shared truncate() so the extraction reads as a flat iterator chain
instead of four levels of nesting.
When the researcher agent exhausts its turn budget mid-investigation, rig
now surfaces PromptError::MaxTurnsError with the chat history intact rather
than nothing at all. Catch it, reconstruct a findings dump from whatever
assistant text and tool results the run produced, and still write a report
from that — clearly flagged as incomplete — instead of propagating the
error and losing all the work.
Between sending the report prompt and the writer actually starting to
generate, the spinner was already dropped, so the terminal went blank
for however long that gap was. Thread the spinner into
write_text_stream instead and stop it right as the first chunk
arrives, covering the wait with the same "Writing report..." line
rather than clearing it early.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tag each phase (research, web search, page fetch, review, rejection,
report writing) with a distinct emoji so the spinner line shows what's
happening at a glance. Search/fetch tool calls now update the active
spinner's message directly via a small shared handle, since they run
as plain tool functions without one threaded down to them otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
write_text_stream() in the new stream.rs doesn't touch anything specific
to write_report (topic, findings, the agent) — it just drains a
MultiTurnStreamItem stream, writes each text chunk to a caller-provided
writer, and returns the accumulated string. Pulling it out lets it be
covered by unit tests against a mocked stream and an in-memory writer,
independent of a live model.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No destructuring needed since only .text is used; matching the whole
Text struct and drops the now-unused rig::message::Text import.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
print!/println! each acquire stdout's lock internally; doing that per
streamed chunk in a tight loop adds needless contention. Lock once up
front and write!/writeln! through the held handle instead — which also
means the trailing newline must go through that same handle rather than
println!, since re-locking from the same thread would deadlock.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
print! already locks stdout per call, so the manual lock()/write!() was
extra ceremony over what the flush actually needed. Matches rig's own
cli_chatbot streaming example more closely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires up starter.rs -> core.rs (CLI parsing and observability init moved
into core, main.rs left as a thin entry point) and switches the report
phase from Agent::prompt to rig's stream_prompt, printing each text
delta to stdout as it arrives instead of waiting for the full response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deep_research and swear_cleanup were sharing one Cargo.toml, so every
build compiled clap/indicatif/scraper/chrono (only needed by
deep_research) even when just building swear_cleanup for its own
course work, and vice versa. Moves each into its own workspace member
crate (deep_research/, swear_cleanup/) with an independent Cargo.toml
declaring only the deps it actually uses; common deps/versions are
pinned once via [workspace.dependencies] so the two don't drift.
Verified `cargo build -p swear_cleanup` alone no longer pulls in
clap/indicatif/scraper/chrono (schemars still compiles for it, but
that's a direct transitive dependency of rig itself, not something
this split can avoid). Also verified the relocated deep_research
binary still runs end-to-end against live Ollama with correct
footnote citations and sources.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a clap-derive Cli (topic argument, --log-level flag) so the
research loop is a proper command-line tool instead of a raw
env::args().nth(1) read. Logging is now opt-in: tracing only
initializes a subscriber when --log-level is passed, so the terminal
stays clean by default. When logging is off, each research phase
(researcher/reviewer/writer) shows an indicatif spinner instead, so
the user isn't staring at a blank terminal during the 1-3 minute
Gemma tool-calling turns.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Interpolates today's date into the researcher's preamble so it can judge
source freshness instead of relying on training-cutoff knowledge, and
asks it to cite facts with bracketed footnote numbers backed by a
Sources list, which the writer agent is now instructed to preserve
through to the final report.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>