Skip to content

Commit d45fca9

Browse files
arjunjaggi34claude
andauthored
Search: SearXNG as a second provider (#260)
SearXNG joins Tavily behind zorp_search::SearchProvider, so web_search can run against a self-hosted instance with no account and no key. ZORP_SEARCH_PROVIDER picks between them, read in web_search_tool() and nowhere else. Unset or blank is tavily, so nothing that worked stops working, and any other value is an error that leaves the tool unregistered rather than falling back to a provider nobody named. The provider is one GET to {base}/search?q=...&format=json against ZORP_SEARXNG_BASE_URL, http://localhost:8888 by default, with its own ureq agent and timeouts. No key means no MissingApiKey and no new error variant, and a comment says why. An unreachable instance is Transport, never an empty Vec, and so is an empty result list beside engines that did not answer. A 403 says how to turn JSON output on. It is not a loopback capability and nothing says it is. The availability answer now names the provider and still says every search leaves the machine, and a test pins that for SearXNG. Closes #258. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3bc3359 commit d45fca9

12 files changed

Lines changed: 874 additions & 54 deletions

File tree

‎AGENTS.md‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,33 @@ resulting artifact, deliver it in the right form.
223223
inherited harness code as they're added. Use new crates or clearly
224224
named modules, not the inherited ones.
225225
- `zorp-search/` is zorp's own web search capability: a
226-
`SearchProvider` trait with Tavily as the first provider. It depends on
227-
no other workspace member and knows nothing about agents or tools.
228-
`zorp-agent` exposes it as the `web_search` built-in behind the
229-
non-default `search` feature, which is the only built-in that sends
230-
anything over the network. `research` deliberately does not enable it;
231-
run `--features research,search` when you want it. The API key comes
232-
from `ZORP_TAVILY_API_KEY` and never from a flavor manifest. `zorp-web`
233-
has its own opt-in `search` feature that turns the same built-in on for
234-
the browser, off by default for the same reason, and it reports whether
235-
the tool is really there at `GET /api/capabilities`. That answer is
236-
observed rather than re-derived: `zorp_agent::web_search_availability`
237-
shares one function with the registration site, and a test pins it to
238-
`tool_names()`. See `docs/DECISIONS.md` (2026-08-21) before changing
239-
either.
226+
`SearchProvider` trait with two providers, Tavily and a self-hosted
227+
SearXNG. It depends on no other workspace member and knows nothing
228+
about agents or tools. `zorp-agent` exposes it as the `web_search`
229+
built-in behind the non-default `search` feature, which is the only
230+
built-in that sends anything over the network. `research` deliberately
231+
does not enable it; run `--features research,search` when you want it.
232+
`ZORP_SEARCH_PROVIDER` picks the provider, read in `web_search_tool()`
233+
and nowhere else: unset or blank means `tavily`, `searxng` means
234+
SearXNG, and anything else is an error that leaves the tool
235+
unregistered, never a fallback to the other provider. Tavily's key
236+
comes from `ZORP_TAVILY_API_KEY`; SearXNG takes no key and reads its
237+
instance from `ZORP_SEARXNG_BASE_URL`, defaulting to
238+
`http://localhost:8888`. None of the three ever comes from a flavor
239+
manifest, because a workspace file the model can write must not move
240+
where queries go. A local SearXNG is not a loopback capability and must
241+
not be described as one: it forwards every query to the engines it
242+
aggregates, so a search still leaves the machine and the availability
243+
answer still says so. `zorp-web` has its own opt-in `search` feature
244+
that turns the same built-in on for the browser, off by default for the
245+
same reason, and it reports whether the tool is really there, and which
246+
provider it uses, at `GET /api/capabilities`. That answer is observed
247+
rather than re-derived: `zorp_agent::web_search_availability` shares
248+
one function with the registration site for whichever provider is
249+
selected, and a test pins it to `tool_names()`. Run `cargo test -p
250+
zorp-search`, `cargo test -p zorp-agent --features search` and `cargo
251+
test -p zorp-web --features search` whenever any of it changes. See
252+
`docs/DECISIONS.md` (2026-08-21, 2026-09-18) before changing either.
240253
- `zorp-skill/` is zorp's own skill capability: discovery and parsing
241254
for Claude Code compatible skills (`SKILL.md` in a directory, YAML
242255
frontmatter plus a markdown body). Like `zorp-search` it depends on no

‎CLAUDE.md‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,33 @@ resulting artifact, deliver it in the right form.
223223
inherited harness code as they're added. Use new crates or clearly
224224
named modules, not the inherited ones.
225225
- `zorp-search/` is zorp's own web search capability: a
226-
`SearchProvider` trait with Tavily as the first provider. It depends on
227-
no other workspace member and knows nothing about agents or tools.
228-
`zorp-agent` exposes it as the `web_search` built-in behind the
229-
non-default `search` feature, which is the only built-in that sends
230-
anything over the network. `research` deliberately does not enable it;
231-
run `--features research,search` when you want it. The API key comes
232-
from `ZORP_TAVILY_API_KEY` and never from a flavor manifest. `zorp-web`
233-
has its own opt-in `search` feature that turns the same built-in on for
234-
the browser, off by default for the same reason, and it reports whether
235-
the tool is really there at `GET /api/capabilities`. That answer is
236-
observed rather than re-derived: `zorp_agent::web_search_availability`
237-
shares one function with the registration site, and a test pins it to
238-
`tool_names()`. See `docs/DECISIONS.md` (2026-08-21) before changing
239-
either.
226+
`SearchProvider` trait with two providers, Tavily and a self-hosted
227+
SearXNG. It depends on no other workspace member and knows nothing
228+
about agents or tools. `zorp-agent` exposes it as the `web_search`
229+
built-in behind the non-default `search` feature, which is the only
230+
built-in that sends anything over the network. `research` deliberately
231+
does not enable it; run `--features research,search` when you want it.
232+
`ZORP_SEARCH_PROVIDER` picks the provider, read in `web_search_tool()`
233+
and nowhere else: unset or blank means `tavily`, `searxng` means
234+
SearXNG, and anything else is an error that leaves the tool
235+
unregistered, never a fallback to the other provider. Tavily's key
236+
comes from `ZORP_TAVILY_API_KEY`; SearXNG takes no key and reads its
237+
instance from `ZORP_SEARXNG_BASE_URL`, defaulting to
238+
`http://localhost:8888`. None of the three ever comes from a flavor
239+
manifest, because a workspace file the model can write must not move
240+
where queries go. A local SearXNG is not a loopback capability and must
241+
not be described as one: it forwards every query to the engines it
242+
aggregates, so a search still leaves the machine and the availability
243+
answer still says so. `zorp-web` has its own opt-in `search` feature
244+
that turns the same built-in on for the browser, off by default for the
245+
same reason, and it reports whether the tool is really there, and which
246+
provider it uses, at `GET /api/capabilities`. That answer is observed
247+
rather than re-derived: `zorp_agent::web_search_availability` shares
248+
one function with the registration site for whichever provider is
249+
selected, and a test pins it to `tool_names()`. Run `cargo test -p
250+
zorp-search`, `cargo test -p zorp-agent --features search` and `cargo
251+
test -p zorp-web --features search` whenever any of it changes. See
252+
`docs/DECISIONS.md` (2026-08-21, 2026-09-18) before changing either.
240253
- `zorp-skill/` is zorp's own skill capability: discovery and parsing
241254
for Claude Code compatible skills (`SKILL.md` in a directory, YAML
242255
frontmatter plus a markdown body). Like `zorp-search` it depends on no

‎docs/DECISIONS.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,73 @@ was believed at the time and not only what survived.
1212

1313
---
1414

15+
## 2026-09-18: a search provider is picked by one environment variable, and a wrong name is an error
16+
17+
**Decision:** SearXNG is the second `zorp_search::SearchProvider`
18+
(`zorp-search/src/searxng.rs`), and `ZORP_SEARCH_PROVIDER` picks which one
19+
`web_search` uses. It is read in `web_search_tool()` in
20+
`zorp-agent/src/agent.rs` and nowhere else. Unset or blank means `tavily`,
21+
so nothing that worked before stops working. `searxng` means SearXNG, at
22+
`ZORP_SEARXNG_BASE_URL` or `http://localhost:8888` when that is unset. Any
23+
other value is an error: the tool is not registered, stderr says why, and
24+
`web_search_availability` reports the same sentence, naming the variable
25+
and the values it takes. Matching is exact after trimming, one spelling
26+
per provider, the lowercase identifier `SearchProvider::name` returns. From
27+
issue #258.
28+
29+
**Why an unknown value is an error and never the default.** Somebody who
30+
wrote `searxgn` meant to keep their queries on their own instance. Quietly
31+
sending them to Tavily instead is the one outcome worse than no search at
32+
all, because it looks like it worked. The tests set a Tavily key before
33+
naming a provider that does not exist, so a fallback would register the
34+
tool, and assert that it does not.
35+
36+
**Why the environment and not a flavor manifest.** For the reason the
37+
Tavily key is not in one. A workspace flavor is a file the model can write,
38+
and a file the model can write must not move where queries go. The same
39+
holds for `ZORP_SEARXNG_BASE_URL`.
40+
41+
**`web_search_availability` still answers for one provider, whichever is
42+
selected.** It calls the same `web_search_tool()` that registration calls, so
43+
the answer and the registry cannot disagree about which provider that is,
44+
and `web_search_availability_agrees_with_the_gates_it_reports_on` still
45+
pins it to `tool_names()`. The detail now names the provider, since with
46+
two of them "registered" alone does not say where a query goes.
47+
48+
**Not a loopback capability.** `zorp-recall` and `zorp-voice` guarantee
49+
text reaches a loopback address or nowhere. This does not, and nothing
50+
says it does. A local SearXNG happens to listen on loopback, but it
51+
forwards every query to the engines its operator enabled, and the same
52+
variable can name a public instance. So there is no `LoopbackUrl` guard,
53+
the base URL may be plain HTTP and may resolve to a private address (an
54+
instance on the operator's own network is the point), and the availability
55+
sentence keeps saying every search leaves this machine for SearXNG as it
56+
does for Tavily. A test asserts that sentence survives selecting SearXNG.
57+
58+
**No key, and no new error variant.** SearXNG takes no key and has a
59+
default base URL, so there is nothing missing to report and its
60+
constructor cannot fail. An instance that is not running is found out by
61+
asking it, which is `SearchError::Transport`, never an empty `Vec`. A
62+
comment in `searxng.rs` says this so the variant does not get added back.
63+
64+
**Two SearXNG specific rules.** An empty `results` beside a non-empty
65+
`unresponsive_engines` is a `Transport` error rather than "nothing
66+
matched", because engines that might have had the answer failed and an
67+
empty list would let a caller read an outage as a novel idea. And a 403
68+
says to add `json` to `search.formats` in the instance's settings.yml,
69+
because a stock install serves HTML only and its 403 reads like an
70+
authentication failure. SearXNG has no result count parameter, so
71+
`max_results` is applied after parsing. The provider builds its own
72+
`ureq` agent with its own timeouts, 10 seconds to connect and 30 to read,
73+
for the reason the 2026-08-22 HTTP agent entry gives.
74+
75+
**Ruled out:** selecting from the flavor manifest or the settings file,
76+
a fallback chain across providers, merging or ranking results across
77+
providers, and a browser control for picking one. Two providers and an
78+
environment variable first.
79+
80+
---
81+
1582
## 2026-09-12: native Mac app (`zorp-desktop`) runs `zorp-web` in-process via Tauri v2
1683

1784
**Decision:** A native macOS desktop application (`Zorp.app`) shipped as a double-clickable universal `.dmg` on GitHub releases. It wraps Apple's system `WKWebView` using Tauri v2 and runs `zorp-web::serve` in-process on a background Tokio runtime, binding loopback port 7777 (falling back to ephemeral port 0 if occupied). `zorp-desktop/` is its own Cargo crate, excluded from the workspace (`exclude = ["zorp-desktop"]` in root `Cargo.toml`) with its own `Cargo.lock`. See `docs/superpowers/specs/2026-09-12-native-mac-app-design.md`.

0 commit comments

Comments
 (0)