dsh-web-search
已验证@ai4gensteam/dsh-web-search · v0.3.2 · MIT · Web 界面
Web search for the DeepSeek Harness ctx.web seam, over interchangeable backends: Serper, Tavily, and Brave
安装
dsh plugin add @ai4gensteam/dsh-web-search 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
发布到 npm 但没有公开仓库。安装前请检查包内容。
标签
说明文档
dsh-web-search
Web search for the DeepSeek Harness web capability seam (ctx.web), over
interchangeable backends: Serper, Tavily, and Brave Search.
The directory is still called dsh-web-search-serper, from when Serper was the
only backend. The package is not: a name promising one provider while routing
to three is a name that lies to whoever reads their dependency list.
Install
Two steps, and the second one is easy to miss.
dsh plugin --profile web add @ai4gensteam/dsh-web-search
That is pnpm add in your profile directory — it installs the plugin and
does not enable it. Nothing happens until it is in the bundle list in
~/.dsh/profiles/web/package.json:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"@ai4gensteam/dsh-web-search"
]
}
}
}
Then start the harness and open Settings → 搜索 / Search to paste a key. Without one it registers, reports itself unconfigured, and every search fails with a message saying which key is missing — which is the honest behaviour, but it is not search.
Any one backend is enough. Serper and Brave both have free tiers; Tavily is included because its answers carry short summaries some models use well.
This is a third-party plugin. It imports nothing from the harness — every
capability it needs is reached through ctx at runtime, which is what the seams
actually require. A plugin resolved from outside the harness checkout cannot
reach the harness's own packages, so an import would either fail outright or
pull in a second copy whose classes the harness does not recognise. The one
dependency is a schema library, because the settings service needs a schema
object it can call — and it duck-types it, so a locally installed copy is fine.
Why it exists
The shipped roster selects deepseek-official, which resolves
DEEPSEEK_API_KEY at each search. A deployment running another model has no
such key, and then web search has no working provider at all — not a degraded
one, none.
One provider, several backends
ctx.web selects exactly ONE search provider, by id, and reads that id once
when the service is constructed. So several search methods cannot be built by
registering several providers: the seam would still use one, and changing which
would mean editing composition config and restarting.
So one provider registers and routes internally. The seam keeps its single-provider model, and switching backends becomes a settings write that takes effect on the next search.
This is also why the settings page shows a choice rather than a row of toggles. The reference's tool page can enable many at once because its model picks a tool per call; showing switches that cannot all be on would misrepresent what this seam does.
Configuring it
Settings → 网页搜索 / Web search. Each backend has a key field, a state pill, and a Test button that runs a real search through the same code path a model's search takes.
It brings its own page because Settings → Plugins renders a hand-picked set of
first-party namespaces — thirteen are registered on this deployment and three
get a card. settings.section is a list slot, so the page is added beside
Models and the rest rather than shadowing any of them.
Keys may also come from the environment: SERPER_API_KEY, TAVILY_API_KEY,
BRAVE_API_KEY. Prefer that — a key in a config file is a key in a backup.
The key field is declared role('secret'), so it is stripped from every wire
descriptor; a GET reports only whether one is stored and where it comes from.
Adding a backend
A backend is a plain object in lib/backends.js: an id, a label, its key's
environment variable, how to build the request, and how to map the answer.
Everything else — key resolution, error classification, the settings page —
is shared, so a new one is a file's worth of code and one array entry.
Mapping
| sources from | content from |
|
|---|---|---|
| Serper | organic[] |
answerBox, else knowledgeGraph.description |
| Tavily | results[] |
answer |
| Brave | web.results[] |
— (returns none) |
An entry with no URL is dropped; an entry with no snippet is kept — these services always carry a title and a URL, and a titled link is still a usable source. Inventing a snippet would lie; omitting the field does not.
publishedAt carries whatever recency the service reports, which for Serper
and Brave is a relative phrase (2 days ago) rather than a timestamp. It is
passed through as the string it is.
Failures
Redirects are refused rather than followed — the target would be chosen by
whoever answered, and the API key travels in the request. HTTP errors, network
failures and unparseable bodies surface with code WEB_PROVIDER_ERROR; an
aborted request surfaces as WEB_ABORTED, including an abort that fires while
the body is being read, because cancellation is not a provider error.
Known limitation
The error carries a code, but it is not the harness's own WebError class
(see above). core/tools attaches {name, code} as structured error metadata
only for its own class, so a failure from here reaches the model as its message
without that metadata. The seam does not catch or re-wrap provider errors, so
nothing else is affected.