Commit graph

39 commits

Author SHA1 Message Date
85d0902e9f Merge pull request 'Add CI/CD, README, and a case study for deep_research' (#9) from worktree-deep-research-max-turns-report into master
All checks were successful
CI / test (push) Successful in 11m27s
Reviewed-on: #9
2026-08-18 12:12:32 +00:00
04aa2f12c7 Merge branch 'master' into worktree-deep-research-max-turns-report
All checks were successful
CI / test (pull_request) Successful in 1m52s
2026-08-18 12:10:02 +00:00
Austin Schaefer
296ab860c7 Fix CI running twice per PR commit
All checks were successful
CI / test (pull_request) Successful in 14m6s
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.
2026-08-18 13:55:01 +02:00
Austin Schaefer
26e9624c23 Fix CI: use the rust-ci runner instead of overriding the docker-labeled runner's container
Some checks failed
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled
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.
2026-08-18 13:54:09 +02:00
Austin Schaefer
f2c10783db Extract swear_cleanup to its own repo, flatten deep_research to root
Some checks failed
CI / test (push) Failing after 6s
CI / test (pull_request) Failing after 7s
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 13:43:26 +02:00
Austin Schaefer
9fa91b3da7 Add CI/CD, README, and a case study documenting this session's work
Some checks failed
deep_research CI / test (pull_request) Failing after 2m23s
deep_research CI / test (push) Failing after 2m27s
- .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.
2026-08-18 13:28:29 +02:00
477a8da010 Merge pull request 'Write a partial report instead of erroring out when research hits max turns' (#8) from worktree-deep-research-max-turns-report into master
Reviewed-on: #8
2026-08-18 11:03:02 +00:00
Austin Schaefer
6cd2d65158 Split core.rs into one file per concern
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.
2026-08-18 12:59:03 +02:00
Austin Schaefer
c150c67f1e Switch search_web from DuckDuckGo HTML scraping to local SearXNG
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.
2026-08-18 12:25:29 +02:00
Austin Schaefer
21030462b1 Prototype an agentic summarizer for max-turns recovery
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.
2026-08-18 11:54:23 +02:00
Austin Schaefer
e446046744 Add unit tests for the decomposed max-turns recovery 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.
2026-08-18 11:43:14 +02:00
Austin Schaefer
3ddc48fe18 Flatten partial_findings_from_history's nested for/match/if-let pyramid
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.
2026-08-18 11:30:45 +02:00
Austin Schaefer
187d327144 Write a partial report instead of erroring out when research hits max turns
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.
2026-08-18 11:26:53 +02:00
351cc79e57 Merge pull request 'Cover the pre-stream gap with the writing-report spinner' (#7) from worktree-progress-emoji into master
Reviewed-on: #7
2026-08-17 11:25:58 +00:00
Austin Schaefer
e1d6a20b3a Keep the writing-report spinner up until the stream's first chunk
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>
2026-08-17 13:21:23 +02:00
34c699df0f Merge pull request 'Embellish progress spinner with per-activity emojis' (#6) from worktree-progress-emoji into master
Reviewed-on: #6
2026-08-17 10:58:13 +00:00
Austin Schaefer
5c50f75a8a Add emoji indicators for each research phase on the progress spinner
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>
2026-08-17 12:52:56 +02:00
0ce51a5f4e Merge pull request 'Stream the report to the terminal as the writer generates it' (#5) from worktree-deep-research-streaming into master
Reviewed-on: #5
Reviewed-by: Austin Schaefer <austin.schaefer@mailo.eu>
2026-08-17 10:48:52 +00:00
Austin Schaefer
f53fcbd937 refactor: extract the streamed-text draining loop into its own module
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>
2026-08-17 12:37:11 +02:00
Austin Schaefer
b12f156a1b simplify: match the streamed Text item without destructuring its fields
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>
2026-08-17 12:30:21 +02:00
Austin Schaefer
4f32dd83d6 perf: lock stdout once for the whole report stream, not per chunk
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>
2026-08-17 12:20:35 +02:00
Austin Schaefer
469b7cf8c6 simplify: use print! + flush for streamed chunks, not an explicit stdout lock
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>
2026-08-17 12:19:08 +02:00
Austin Schaefer
55f4f7eeb3 feat: stream the report to the terminal as the writer generates it
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>
2026-08-17 12:17:26 +02:00
9193f77112 Merge pull request 'Split into a Cargo workspace with per-project dependency sets' (#4) from worktree-deep-research-cli into master 2026-08-14 17:46:16 +00:00
Austin Schaefer
0bb5870f66 refactor: split into a Cargo workspace with per-project dependency sets
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>
2026-08-14 19:43:13 +02:00
e4292edeb9 Merge pull request 'Formalize deep research tool as a clap CLI with gated logging and a spinner' (#3) from worktree-deep-research-cli into master
Reviewed-on: #3
2026-08-14 17:32:43 +00:00
Austin Schaefer
3499e0ac6b feat: formalize deep research tool as a clap CLI with gated logging and a progress spinner
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>
2026-08-14 19:32:10 +02:00
91e04ea36d Merge pull request 'Deep research agentic loop with rig AgentRunner + Gemma models' (#2) from worktree-deep-research-agent into master
Reviewed-on: #2
2026-08-14 17:07:59 +00:00
Austin Schaefer
34b93eae3d feat: add current-date context and footnote-style citations to research loop
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>
2026-08-14 19:06:15 +02:00
Austin Schaefer
3fd18e6a7c feat: add a reviewer agent that gates and redirects the research loop
Adds a reviewer step (gemma4-e4b, fresh context) between gathering and
writing: it uses rig's typed Extractor to judge whether the findings'
conclusions actually follow from their cited sources, rather than
relying on free-text parsing. research() is now a plain bounded loop —
"the least agentic design that solves the problem", per rig's own
workflow guidance — that reruns the researcher with the reviewer's
solid_findings/gaps feedback folded into the next round's task until
it approves or MAX_RESEARCH_ROUNDS runs out.
2026-08-14 12:58:39 +02:00
Austin Schaefer
2f24dc1b50 feat: name agents and add custom tracing spans for the research/writing phases
Agents were showing up as "Unnamed Agent" in rig's built-in gen_ai.*
spans. Naming them via .name(...) fixes that, and splitting the two
phases into #[tracing::instrument]-annotated functions wraps rig's
per-turn chat/execute_tool spans in a parent span per phase, making
the trace tree legible instead of a flat stream of chat calls.
2026-08-14 12:46:01 +02:00
Austin Schaefer
9212914282 feat: deep research agentic loop with rig AgentRunner + Gemma models
Pins rig to the newest published crates.io release (0.41.0) instead of
the git main branch, and adapts swear_cleanup's revise.rs to that
release's API (OneOrMany::first() returns T directly, raw_completion
folded into CompletionResponse::raw_response).

The research agent (gemma4:26b) drives rig's AgentRunner tool-calling
loop with two lean #[rig::tool_macro] tools — a DuckDuckGo HTML search
and a page-text fetcher — to gather and cross-check findings. A second
agent (gemma4-e4b) turns those findings into a structured report; the
smaller/faster model suffices there since it's reformatting already-
digested notes rather than doing multi-step research reasoning.
2026-08-14 12:40:09 +02:00
Austin Schaefer
84bdbc6784 feat: self-review retry for unusable gemma output
Gemma now checks its own output (a fresh, stateless completion call,
not conversation history) before it's accepted as a seed or revision,
retrying up to 5 times if it's a refusal, meta-commentary describing
what it's about to write, or a list of multiple options instead of
one direct answer. Originally tried a separate CPU-only judge model
(critic-cpu) to avoid VRAM contention, but it was both far slower
(13-16s per judgment vs Gemma's own sub-second calls) and unreliable
on the exact failure patterns it was meant to catch — Gemma reviewing
itself turned out faster and more accurate, so critic-cpu is dropped
entirely.

Also required two rounds of prompt tuning, driven by live failures:
first adding concrete negative examples after the judge approved
outputs it should have rejected, then explicitly scoping the check to
format only after Gemma started rejecting its own genuinely hostile
(but well-formed) output — conflating "should I have generated this"
with the format question actually asked. Added integration tests
covering both directions (rejecting bad formats, accepting hostile-
but-well-formed content) as a fast regression check against an
expensive full generation loop.
2026-08-05 15:50:41 +02:00
Austin Schaefer
57ca9555b4 feat: structured tracing observability
Enable Rig's built-in tracing spans (model, token usage, cache hits,
latency) via tracing-subscriber, filterable through RUST_LOG and
defaulting to info level. Logs write to stderr so stdout stays
reserved for program output. Standardizes the remaining ad-hoc
println! diagnostics (server startup, per-iteration revision progress,
non-convergence) into structured tracing events at appropriate levels.
2026-08-05 15:23:30 +02:00
Austin Schaefer
5843a49cc2 feat: iterative generate/score/revise loop, findings log
Gemma now seeds deliberately hostile text, Shieldstral scores it, and
Gemma revises its own output based on the score until it drops below a
safety threshold (or a max-iteration cap is hit, returning the best
attempt seen). Extracted into a new revise module: score() now borrows
instead of consuming its args so it can run repeatedly, and the
gemma-call/extract-text logic is shared between seed generation and
every revision instead of being duplicated.

Also adds FINDINGS.md logging what actually turned out to be real
obstacles vs. overblown vs. irrelevant while building this out.
2026-08-05 15:07:42 +02:00
Austin Schaefer
62a92a88bd feat: auto-start llama-server, extract server module
Checks whether llama-server is already healthy on startup and spawns it
from configured binary/model paths if not, polling until ready. Server
infra config (binary, model path, host, port, context size) split out
of prompts.toml into its own server.toml, and all of it lives in a new
server module rather than inline in main.rs, alongside a single reused
HTTP client and a shared health-check helper. Gemma client setup now
runs concurrently with the server health-check/spawn since they're
independent.
2026-08-05 14:45:44 +02:00
Austin Schaefer
507b25d370 feat: Introduce two agent flow with profanity verification. 2026-08-05 14:21:38 +02:00
Austin Schaefer
48274d1698 Add rig-core and tokio dependencies, async main 2026-07-31 10:21:31 +02:00
Austin Schaefer
9bad1dfc2d cargo new scaffold 2026-07-31 10:20:46 +02:00