Bundle
qp-exa-dynamic
Exa web search provider for the DeepSeek Harness ctx.web seam, with Dynamic Highlights on by default, an /exa command to change highlights, search type and result count at runtime, and an exa_search tool that owns its own result cap.
- Source
- Geighlord007
- License
- MIT
- Updated
- Updated 19 hours ago
Readme
# qp-exa-dynamic
English | [简体中文](README.zh.md)
An [Exa](https://exa.ai)-backed `WebSearchProvider` for the
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) `ctx.web` seam, with Exa
**Dynamic Highlights** on by default and an `/exa` command that changes highlights, search type and
result count at runtime.
```sh
dsh plugin --profile web add qp-exa-dynamic
```
## Why this exists
The first-party `@deepseek-ai/dsh-web-search-exa` cannot reach Exa Dynamic Highlights, for two
independent reasons:
1. **Its request body is hardcoded.** It sends `contents.highlights.highlightsPerUrl`, and no config
field it accepts reaches a `dynamic` field.
2. **Dynamic Highlights is a beta API and needs a header.** Every request that sets `dynamic: true`
must also send `Exa-Beta: dynamic-highlights-2026-08-28`. That provider's headers are
`authorization`, `content-type`, `accept` and `user-agent` — no `Exa-Beta`. Without it Exa answers
HTTP 400:
```json
{"error":"'highlights.dynamic' is in beta. Send the 'Exa-Beta: dynamic-highlights-2026-08-28' request header to use it.","tag":"INVALID_REQUEST"}
```
This provider sends both, and drops `highlightsPerUrl` entirely — measured against the live API, Exa
ignores that parameter, returning byte-identical payloads for values 1 and 5. It uses
`maxCharacters` instead, which is the knob that actually works when Dynamic Highlights are off.
## Measured
Real `/search` calls, one query, 8 results:
| Configuration | Highlight characters |
| --- | --- |
| First-party provider's default (no working knob) | 51,152 |
| This provider, `dynamicHighlights: false` + `highlightsMaxCharacters: 1500` | 10,973 |
| This provider, `dynamicHighlights: true` (default) | 12,716 |
Dynamic Highlights is not a uniform truncation: it concatenates the retrieved documents into one
input, runs a single forward pass, and allocates a shared budget across the result set — so useful
pages keep more context and redundant ones get less.
## Install
```sh
dsh plugin --profile web add qp-exa-dynamic
```
The package declares a `dsh.bundle` manifest, so its bundle patch inserts the provider row for you —
no hand-written patch entry is needed.
To select it, override the `web` row in `$DSH_HOME/profiles/web/cordis.patch.yml`:
```yaml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
fetchProvider: http
```
> A patch **replaces** the targeted row's whole `config` rather than merging into it, so
> `fetchProvider: http` must be restated or the fetch provider is dropped.
Then give it a key, either as plugin config:
```yaml
- id: qp-exa-dynamic
name: qp-exa-dynamic
config:
apiKey: 'your-exa-api-key'
```
or through the environment. `apiKey` is declared `role('secret')`, so it never appears in a
`describe()` response — but a plain-text config file is still a plain-text config file; prefer the
environment when you can.
> **On `$DSH_HOME/.env`.** The plugin reads `apiKeyEnv` (default `EXA_API_KEY`) through the harness's
> launch-environment snapshot, which is documented to consult the inherited environment, the invoking
> directory's `.env` and the Harness home's `.env`. That worked in some deployments and not in
> others — on one Windows install the snapshot came back without the variable even though the file
> was correct, and the config `apiKey` above was the fix. If your provider reports
> `registered but unavailable`, the key is not reaching it; set `apiKey` directly.
Restart `dsh web` after changing the environment. `cordis.patch.yml` itself is hot-reloaded, so
config edits apply without a restart.
## Configuration
Every field has a safe default; you normally only supply a key.
| Field | Default | Meaning |
| --- | --- | --- |
| `providerId` | `exa` | Registry id. Change it only to coexist with another Exa provider. |
| `apiKey` | unset | Literal key; falls back to `apiKeyEnv`. |
| `apiKeyEnv` | `EXA_API_KEY` | Environment variable consulted when `apiKey` is unset. |
| `baseURL` | `https://api.exa.ai` | Exa endpoint; `/search` is appended. |
| `searchType` | `auto` | Retrieval type — see below. Runtime-settable with `/exa type`. |
| `numResults` | `8` | Source cap. Runtime-settable with `/exa results`. |
| `dynamicHighlights` | `true` | On by default; adds the required `Exa-Beta` header. Runtime-settable with `/exa`. |
| `highlightsMaxCharacters` | unset | Per-page highlight cap, used **only** when `dynamicHighlights` is false. |
`dynamicHighlights` is never combined with `highlightsMaxCharacters`: Exa sizes and distributes the
shared budget itself when dynamic is on, and its docs warn against combining the two.
## The `/exa` command
Typed in the composer. It runs directly against the interface and creates no model message.
| Command | Effect |
| --- | --- |
| `/exa` | Toggle Dynamic Highlights |
| `/exa on` / `/exa off` | Set them explicitly |
| `/exa type` | List the retrieval types |
| `/exa type deep` | Set the retrieval type |
| `/exa results` | Report the source cap |
| `/exa results 3` | Set the source cap |
| `/exa status` | Report every knob, the available types, and the real ceiling |
Plain words, no punctuation: the command declares **no argument hint**, so the composer inserts no
template to edit around. `/exa status` lists the retrieval types too, so "which types were there
again" never costs a second command, and a mistyped argument replies with copy-pasteable examples.
Writes land in the `qp-exa-dynamic` settings namespace's user layer, so they survive a
restart. Clearing that section returns the plugin to its configured defaults.
Measured on one provider instance, one query: switching Dynamic Highlights off took the same search
from 12,716 to 57,958 highlight characters — a 4.6x difference, applied on the next search.
## The `exa_search` tool
The plugin also registers a second, model-facing tool beside `web_search`:
```
exa_search(query: string, maxResults?: integer) // maxResults 1-50
```
It exists because of a hard structural fact: `ctx.web.search()` caps its result at the **caller's**
`request.maxResults`, and `dsh-tool-web` sends its own `searchMaxResults` on every call — so no
provider can ever exceed that cap, and raising it means forking an agent preset. This tool owns its
own `request.maxResults`, so the result count becomes a **per-call model argument** instead of a
deployment-level ceiling.
That makes the preset fork optional. Two ways past the default 8 sources:
| Want | Use | Needs the preset fork? |
| --- | --- | --- |
| 20 sources for one question | `exa_search(query, maxResults: 20)` — just ask in words | no |
| `/exa results 20` to work | the `/exa` command | yes |
`/exa results` is a fine setting to keep even without the fork, because it is what this tool falls
back to: with `/exa results 20` set, `exa_search(query)` with no `maxResults` returns 20. It is
`web_search` alone that stays clamped to the deployment's ceiling.
The tool goes through `ctx.web` like `web_search` does, so it uses the same selected provider, the
same search type and the same Dynamic Highlights setting; only the count differs. Its ceiling of 50
is its own — dynamic highlights measured ~1.6k characters per source, so 50 is already ~20k tokens
of context.
A prompt section next to `web_search`'s tells the model when to reach for it; ordinary lookups stay
on `web_search`. If the composition has no `tools` registry the provider still mounts and only the
tool is absent.
## Retrieval types
Exa's `type` is the latency/quality dial. All eight were verified against the live API; measured
latency for one query, 8 results:
| Type | Measured | Use |
| --- | --- | --- |
| `keyword` | 464 ms | Keyword only, fastest |
| `neural` | 737 ms | Semantic retrieval |
| `fast` | 798 ms | Speed with minimal quality loss |
| `instant` | 856 ms | Real-time (chat, voice) |
| `auto` | 1,914 ms | **Default** |
| `deep-lite` | 3,116 ms | Lightweight synthesized output |
| `deep` | 5,282 ms | Multi-step reasoning |
| `deep-reasoning` | 18,278 ms | Hardest research tasks |
The first-party provider's schema lists only `auto`, `keyword` and `neural` — that set is stale.
This provider exposes all eight.
### The `deep*` types are discounted by this seam
Measured through this provider's own class, same query, dynamic highlights on:
| Type | Time | Sources returned |
| --- | --- | --- |
| `fast` | 718 ms | 8 |
| `auto` | 215 ms | 8 |
| `deep` | 6,891 ms | 3 |
| `deep-reasoning` | 14,776 ms | 4 |
The raw API returns 8 results for `deep`; the rest carry no non-blank highlight and are dropped,
because the seam has no other field to derive a snippet from and inventing one would make the seam
lie. The `deep*` family's real product is the synthesized `output`, which `WebSearchSource` has no
field for. In practice the useful range here is `keyword`, `neural`, `fast`, `instant` and `auto`.
## Result count belongs to `dsh-tool-web`
Worth stating plainly, because it is easy to misread:
- The model-facing `web_search` tool takes only `queries` — the model cannot ask for a count.
- The ceiling belongs to `dsh-tool-web`: `searchMaxResults`, default 8. Its own comment:
*"The consumer owns the returned-context limit; providers and models do not."*
- The tool sends `maxResults` on **every** call, and the seam truncates the returned sources to it —
so no provider can exceed that ceiling.
That makes this plugin's `numResults` one-directional: it can pull the count down, never up.
| Configured | Tool ceiling | Sent to Exa |
| --- | --- | --- |
| 3 | 8 | 3 |
| 12 | 8 | 8 (clamped) |
| 20 | 8 | 8 (clamped) |
`/exa status` and `/exa results` report the ceiling the provider actually observed, so a clamped
value is explained rather than silently applied.
**Raising the ceiling is not a profile-patch edit in the Web app.** `dsh-web-app` disables the host
`tool-web` row — `- id: tool-web` / `disabled: true` — because only the `web` service and its search
provider are host-side; the model-facing tool is per session, and the one that actually runs comes
from the **agent preset**. The shipped `standard` preset's row carries `fetch` and `searchTimeoutMs`
and no `searchMaxResults`, so it takes the schema default of 8. A profile patch targeting `tool-web`
lands on the disabled host row and does nothing at all.
Changing it therefore means forking the preset: copy `standard` into `$DSH_HOME/.agent-presets/`, add
`searchMaxResults` to its `tool-web` row, and select it as the default. That is a real trade — a copy
does not track upstream updates to the shipped preset, and the default is read when a session is
created, so running sessions keep the preset they assembled with. For most uses leaving it at 8 is
the better deal: `/exa results` still pulls the count *down*, which is the direction that saves
tokens.
## Known limitations
- **Exa's beta surface can move.** `dynamic-highlights-2026-08-28` is a research preview; a change on
Exa's side means updating `DYNAMIC_BETA_VALUE`.
- **Results without highlights are dropped**, matching the seam's rule. With dynamic highlights on,
8 of 8 results carried a highlight in testing, so it rarely fires.
- **No `category`, domain or date filters, and no full text.** Those are Exa features this provider
does not expose yet.
- **One of this and the first-party Exa provider per profile.** Both register the provider id `exa`
by default; running both needs a distinct `providerId` on one of them.
- **Tested against dsh `0.1.5-rc.1` only**, which is what the peer ranges pin.
- **Without a settings service the `/exa` writes are in-memory only.** The provider itself works
either way; without a command registry there is simply no `/exa`.
## Development
```sh
node test/index.test.js # 40 unit tests, no API key needed
EXA_API_KEY=... node test/live.mjs # hits the real API, spends credit
```
Run the test file directly rather than through `node --test`: the test runner spawns a child process
per file with piped stdio, which fails with `spawn EPERM` in a restricted sandbox.
## Uninstall
```sh
dsh plugin --profile web remove qp-exa-dynamic
```
Remove the `web` override from `cordis.patch.yml` to return to the built-in DeepSeek search. That
edit is hot-reloaded, so it takes effect immediately.
## License
MIT
Install
dsh plugin --profile web add github:Geighlord007/qp-exa-dynamic
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install qp-exa-dynamic from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.