Merge remote-tracking branch 'origin/master' into pr-265

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/schema.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/stdio-agent/README.md
This commit is contained in:
Dudu-0223
2026-07-14 20:49:54 +08:00
715 changed files with 21094 additions and 14133 deletions

View File

@@ -35,3 +35,53 @@ Both tools declare `isConcurrencySafe: () => true` — they are read-only (fetch
Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
## Model Experience
### System prompt
**What the model sees**: Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections.
**Token effect**: Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema.
#### Web search guidance
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
```
#### Web fetch guidance
```markdown
Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content.
```
### Tool schemas
**What the model sees**: The model sees the generated [`web_search` and `web_fetch` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-web). Result-count and timeout budgets are deployment settings, not model arguments.
**Token effect**: Fixed schema cost per request; config disablement removes both schema and guidance, while a scoped restriction removes only the schema.
### Search result
**What the model sees**: The optional provider-owned answer is followed by `Sources:` and data-dependent lines shaped exactly `- [<title-or-url>](<url>)`, optionally suffixed ` — <snippet> (<publishedAt>)`. With neither answer nor sources the result says `No results found.` A capped list adds `(Showing the first <count> sources. Refine the query for more.)`; every result ends `Cite the relevant URLs above as markdown links in your answer.`
**Token effect**: Data-dependent results are resent until compaction and sources are capped by `searchMaxResults`.
### Fetch result
**What the model sees**: A successful fetch is exactly `Fetched <finalUrl> (HTTP <statusCode>)`, a blank line, and the provider-owned decoded body. Truncation adds a blank line and `(Content truncated. Fetch a more specific URL or section for the full text.)`; failures become `Error: <message>`. Queries and URLs remain in call history.
**Token effect**: Provider caps bound body size; retained call arguments and results are resent until compaction, and timeout policy can replace a late result with a short error.
### Argument errors
**What the model sees**: Blank inputs become exactly `Error: query must be a non-empty string` or `Error: url must be a non-empty string`.
**Token effect**: Only the failing call adds these retained tokens.
## Known Limitations and Deferred Work
- **`htmlToMarkdown` is a minimal regex converter, not an HTML parser** — it strips script/style/noscript, keeps headings/bullets/links, and decodes about a dozen named entities; tables, images, and nested formatting are lost.
- **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md).
- **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants.

View File

@@ -1,15 +1,8 @@
/**
* The model-facing `web_fetch` tool: retrieve the content of a specific URL.
* Execution goes through `ctx.web` — this module owns the model-facing schema,
* argument validation, and PRESENTATION (HTML→markdown, truncation formatting),
* while the fetch provider owns safe retrieval (transport, redirects, caps).
*
* The model-facing schema exposes NO timeout knob: the tool-call budget is
* deployment policy DECLARED via this package's `fetchTimeoutMs` config (attached
* as `ToolDefinition.timeoutMs`) and ENFORCED by `@deepseek-ai/dsh-timeout-policy`
* (a `tools/execute` wrapper), matching the reference-agent `WebFetch` shape. This
* tool just forwards the (possibly deadline-derived) `exec.signal` to `ctx.web`;
* the provider keeps its own timeout only as a resource backstop for direct callers.
* The model-facing `web_fetch` tool. This module owns its schema, validation, and presentation;
* `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes
* `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting
* signal. A provider timeout remains a backstop for direct seam callers.
*/
import type { Context } from 'cordis'

View File

@@ -1,11 +1,8 @@
/**
* Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch`
* presentation. This is intentionally NOT a full HTML parser: it strips
* script/style/noscript, drops tags, decodes the common named/numeric entities,
* and collapses whitespace into a readable plain-text approximation with a few
* markdown affordances (headings, list bullets, links). A heavier converter can
* replace this without touching the seam or the tool schema.
*
* Minimal dependency-free HTML-to-readable-text conversion for `web_fetch`, not a full parser. It
* removes non-content elements and tags, decodes common entities, collapses whitespace, and keeps
* basic headings, lists, and links. A richer converter can replace it without changing the seam or
* tool schema.
* @module @deepseek-ai/dsh-tool-web/html
*/

View File

@@ -1,19 +1,8 @@
/**
* The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web`
* seam. This root plugin registers the tools the product has ENABLED, composing
* the per-tool registration helpers (`applyWebSearchTool`, `applyWebFetchTool`).
*
* The package owns model-facing concerns only — tool names, JSON schemas,
* argument validation, prompt sections, result-cap constants, result formatting,
* HTML→markdown presentation. All web access goes through `ctx.web`; this
* package never imports a concrete provider package.
*
* Tool registration follows product/app ENABLEMENT, not backend availability: a
* tool stays visible even when its selected provider is missing/misconfigured,
* and execution fails with a structured `WebError` (resolved by the seam at call
* time). That keeps the model schema stable without making plugin load order,
* credential state, or HMR timing part of the model-facing contract.
*
* Model-facing `web_search` and `web_fetch` tools over `ctx.web`. This package owns schemas,
* validation, prompt guidance, limits, and presentation, never concrete providers. Enablement
* controls tool registration; an enabled tool remains visible when its provider is unavailable
* and fails with a structured error at execution time.
* @module @deepseek-ai/dsh-tool-web
*/

View File

@@ -1,11 +1,9 @@
/**
* Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search
* provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool
* (`dsh-tool-web`) + the tool-call timeout policy (`dsh-timeout-policy`),
* exercised through `ctx.tools.execute()` — nothing bypasses the tool registry.
* Fetch hits a real loopback HTTP server (verifying the WORLD); search runs the
* real Exa provider over a stubbed global `fetch` (the network is the one
* boundary we mock).
* Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search provider
* (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the
* tool-call timeout policy (`dsh-timeout-policy`), exercised through `ctx.tools.execute()` —
* nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP; search
* uses the real Exa provider with only its network boundary stubbed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -159,10 +157,9 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
})
it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
// A direct seam caller does not go through tools/execute, so the tool-call
// policy never applies; the provider's OWN timeout is the only budget. A
// short per-request hint proves the provider backstop is intact and classifies
// as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT.
// A direct seam caller does not go through tools/execute, so the tool-call policy never
// applies; the provider's own timeout is the only budget. A short request hint must therefore
// produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`.
const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
() => undefined,
(e: unknown) => e as { code?: string },

View File

@@ -1,16 +1,8 @@
/**
* Real-load-path guard for @deepseek-ai/dsh-tool-web. `tool-web` is a NAMESPACE
* plugin with `inject` — so a stray `export default apply` would make the cordis
* Loader's `unwrapExports` (`exports.default ?? exports`) collapse the module to
* the bare `apply` function, DROPPING `inject`. The plugin would then read
* `ctx.web` without having injected it and throw `cannot get property … without
* inject` the moment it loads (postmortem 0001).
*
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
* `Loader.prototype.unwrapExports` and mounts the result over `ctx.web`,
* exercising the exact path the Loader uses. Prove the guard bites: add
* `export default apply` to `src/index.ts`, watch this go red, revert.
* Real Loader-path guard for an injected namespace plugin. A default export would make
* `unwrapExports` collapse the namespace and drop `inject`, causing access to `ctx.web` to fail.
* Hand-built mounting bypasses that path, so this test unwraps through the real Loader first; see
* postmortem 0001.
*/
import { describe, expect, it } from 'vitest'
@@ -41,7 +33,7 @@ describe('dsh-tool-web real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolWeb) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
// Mounting the collapsed shape would throw for missing injection here.
const fiber = await ctx.plugin(unwrapped)
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['web_search', 'web_fetch']))
await fiber.dispose()

View File

@@ -8,7 +8,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed.
The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed.
## Transport hygiene
@@ -33,6 +35,12 @@ The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct
The numeric limits are validated at plugin construction: every cap except `maxRedirects` must be a positive finite number, and `maxRedirects` must be a non-negative integer. An invalid value throws rather than silently constructing a provider with nonsensical limits.
## Security note
## Model Experience
SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate and per-hop re-validation) is **deferred** — see the [web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets.
Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which places this provider's `maxBodyChars`-bounded decoded text or markdown-shaped HTML under its fetch-result wrapper and retains provider failures while redirects, headers, and transport mechanics remain hidden.
## Known Limitations and Deferred Work
- **SSRF / private-network protection is deferred** — no blocking of private, loopback, link-local, multicast, or otherwise non-public destinations, no DNS-resolve-then-validate, no per-hop re-validation (see [the web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md)). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets.
- **Only textual content decodes** — html/xhtml and `text/*`-plus-JSON/XML families; a missing `Content-Type` or any binary type throws `WEB_UNSUPPORTED_CONTENT_TYPE`, and text-extractable PDF decoding is named deferred work.
- **Charset comes only from the `Content-Type` header** (UTF-8 default) — an HTML `<meta charset>` declaration is ignored, and a declared-but-unrecognized charset label throws rather than falling back.

View File

@@ -1,21 +1,10 @@
/**
* `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public
* HTTP(S) URL with platform-native `fetch` at the repo's Node floor and returns a status
* code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL
* validation, redirect policy, timeout, abort, byte caps, charset decoding,
* content-type classification, binary rejection — but NOT presentation
* (HTML→markdown lives in `@deepseek-ai/dsh-tool-web`).
*
* Redirects are followed manually (`redirect: 'manual'`) so the provider can
* enforce a same-origin-only policy: a cross-origin redirect is refused with
* `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (Claude Code's WebFetch
* uses the same model). It does NOT carry browser cookies, editor/git
* credentials, or implicit access to private services.
*
* SSRF / private-network protection is DEFERRED (see the package RFC); until it
* lands this provider is an SSRF primitive and must not be enabled where it can
* reach sensitive internal targets.
* Safe HTTP(S) retrieval for `ctx.web`: validates URLs, follows only same-origin redirects,
* enforces time and size limits, classifies and decodes text, and leaves presentation to
* `@deepseek-ai/dsh-tool-web`. Requests carry no browser cookies or ambient credentials.
*
* Private-network and SSRF protection is not implemented; do not enable this provider where
* it can reach sensitive internal targets.
* @module @deepseek-ai/dsh-web-fetch-local/provider
*/
@@ -60,11 +49,8 @@ export class LocalFetchProvider implements WebFetchProvider {
if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
// One deadline signal fuses the caller's abort with our own timeout, so the
// network request and the streaming read both stop on either. The timeout
// abort carries a TimeoutReason we recover afterward to classify the cause
// (translateAbortOrNetwork), instead of hand-rolling a controller + timer +
// reason-recovery dance.
// One signal stops both the request and body read. The deadline's TimeoutReason later
// distinguishes this provider's timeout from caller or outer-deadline cancellation.
using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
return await this.followAndRead(request.url, d.signal)
}
@@ -78,11 +64,7 @@ export class LocalFetchProvider implements WebFetchProvider {
const response = await this.requestOnce(currentUrl, signal)
if (isRedirectStatus(response.status)) {
// The redirect budget is enforced BEFORE this hop's target is resolved
// or origin-checked, so `maxRedirects: N` follows at most N redirects
// exactly: the (N+1)th redirect is refused as "exceeded" regardless of
// where it points (a same-origin/cross-origin distinction on a hop we
// are not allowed to follow would be the wrong diagnosis).
// Enforce the redirect budget before resolving or validating the next hop.
if (redirectsFollowed >= this.limits.maxRedirects) {
await response.body?.cancel()
throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED')
@@ -95,10 +77,9 @@ export class LocalFetchProvider implements WebFetchProvider {
throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR')
}
const target = resolveRedirect(location, currentUrl)
// Re-validate the target against the same transport hygiene a direct
// request gets: a redirect must not be a back door to a credentialed,
// non-http(s), or over-long URL that validateFetchUrl would reject. A
// rejection here must still cancel the body first (see below).
// Re-validate the target against the same transport hygiene a direct request gets: a
// redirect must not be a back door to a credentialed, non-http(s), or over-long URL
// that validateFetchUrl would reject.
let validatedTarget: URL
try {
validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength)

View File

@@ -33,4 +33,29 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
## Mapping
DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url``url`, `title``title`, `publishedAt``page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks: `url``url`, `title``title`, and `publishedAt``page_age`. Snippets live separately as URL-keyed `cited_text` entries in a text block's `citations[]`; the provider joins them, leaving `snippet` absent when no excerpt exists.
Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`.
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`.
## Model Experience
### Auxiliary DeepSeek search request
**What the model sees**: A separate DeepSeek model receives exactly `Perform a web search for the query: <query>` as its user text and one native `web_search` server-tool definition. This request is not part of the conversation model's context.
**Token effect**: Separate provider input and output tokens are incurred for each search; `maxTokens` caps generated output and `maxUses` caps native search uses.
### Conversation tool result, indirectly
**What the model sees**: Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
**Token effect**: Zero direct conversation tokens from registration. Result tokens scale with returned sources and snippets, then the seam enforces the requested source bound.
## Known Limitations and Deferred Work
- **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint.
- **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation.
- **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL.
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -1,15 +1,7 @@
/**
* `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed
* `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a
* default-export service): it registers INTO the seam's provider registry, like
* `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`.
*
* The provider talks to DeepSeek's Anthropic-compatible Messages API with the
* native `web_search_20250305` server tool. It reuses `$DEEPSEEK_API_KEY` (no
* new secret) but NOT `$DEEPSEEK_BASE_URL` — the search endpoint is the
* Anthropic-compatible base, distinct from the chat-completions base the LLM
* adapter uses.
*
* Register a DeepSeek-backed provider in `ctx.web`. It calls the Anthropic-compatible Messages API
* with native `web_search_20250305`. The provider reuses `DEEPSEEK_API_KEY` but not
* `DEEPSEEK_BASE_URL`, because search and chat-completions use different bases.
* @module @deepseek-ai/dsh-web-search-deepseek
*/

View File

@@ -1,22 +1,8 @@
/**
* `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's
* Anthropic-compatible Messages API with the native `web_search_20250305` server
* tool enabled.
*
* Unlike a dedicated search endpoint (Exa's `POST /search`, Perplexity's
* `/chat/completions`), this issues a FULL Messages model call carrying a server
* tool, so a search costs a complete model turn in latency and tokens. In return
* DeepSeek runs the search server-side and returns STRUCTURED
* `web_search_tool_result` blocks — this provider parses those blocks and never
* scrapes URLs out of model prose. Strict mode: if the response carries no
* `web_search_tool_result` block (native search did not trigger), it throws
* `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping.
*
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
* The Anthropic wire shape is a provider-private detail and does NOT make this
* provider depend on `ctx.llm`.
*
* DeepSeek search through an Anthropic-compatible Messages model call with the native
* `web_search_20250305` server tool. Each search costs a model turn, but returns structured
* result blocks; absence of those blocks is an error rather than a prose-scraping fallback.
* The wire format and native `fetch` client are provider-private and do not use `ctx.llm`.
* @module @deepseek-ai/dsh-web-search-deepseek/provider
*/
@@ -101,19 +87,16 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
}
/**
* Map a DeepSeek Anthropic Messages response to a normalized search result.
* Walks `web_search_tool_result` blocks for citeable `web_search_result` items,
* joins each to its citation excerpt as `snippet`, and dedupes by `url` (a
* `max_uses > 1` request can surface the same URL across searches). The seam
* owns the final `maxResults` truncation, so `truncated` is always `false` here.
*
* Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result`
* block is present — native search did not trigger, and prose-scraping is not a
* fallback.
* Map a DeepSeek Anthropic Messages response to a normalized search result. Walks
* `web_search_tool_result` blocks for citeable `web_search_result` items, joins each to its
* citation excerpt as `snippet`, and dedupes by `url` (a `max_uses > 1` request can surface
* the same URL across searches). The seam owns the final `maxResults` truncation, so
* `truncated` is always `false` here.
*
* @param query - the original request query, echoed on the result.
* @param response - the parsed Messages response body.
* @returns the normalized result with deduped, snippet-joined sources.
* @throws {@link WebError} when native search produced no result block.
*/
export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
const blocks = response.content ?? []

View File

@@ -1,16 +1,7 @@
/**
* Wire types for DeepSeek's Anthropic-compatible Messages API
* (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool
* enabled. Types only — no runtime code.
*
* DeepSeek returns structured content blocks: `web_search_tool_result` blocks
* carry the citeable `web_search_result` items (`url`/`title`/`page_age`), while
* the snippet/excerpt for a URL lives separately in a `text` block's
* `citations[]` (a `cited_text` keyed by `url`). The provider joins the two.
*
* The Anthropic wire shape is a provider-private detail; it does not make this
* provider depend on `ctx.llm`.
*
* Provider-private wire types for DeepSeek's Anthropic-compatible Messages API. Citeable
* result items and citation excerpts arrive in separate blocks; the provider joins them by
* URL. These types do not create a dependency on `ctx.llm`.
* @module @deepseek-ai/dsh-web-search-deepseek/types
*/

View File

@@ -300,14 +300,8 @@ describe('web-search-deepseek plugin registration', () => {
})
it('survives the real Loader unwrapExports path keeping name/inject/Config', () => {
// A stray `export default apply` would make the cordis Loader's
// unwrapExports (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING `inject: ['web']` — the plugin would then
// read ctx.web without injecting it and throw "cannot get property … without
// inject" the moment it loads. A hand-built ctx.plugin(namespace) mount
// bypasses unwrapExports and cannot catch that, so drive the real path.
// Prove it bites: add `export default apply` to src/index.ts, watch this go
// red, revert.
// A default export would make `unwrapExports` collapse the namespace and drop `inject: ['web']`.
// Drive the real Loader path because hand-built namespace mounting cannot expose that failure.
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(deepseekPlugin) as Record<string, unknown>
expect(unwrapped).toBe(deepseekPlugin)

View File

@@ -24,3 +24,13 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
## Mapping
Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url``url`, `title``title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt``publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
## Model Experience
Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which retains this provider's `maxResults`-bounded URLs, titles, first highlights, and publication dates or its exact `Exa search aborted`, `Exa search request failed: <error>`, and `Exa returned an unprocessable response body: <error>` failures under the consumer's error wrapper while generated answers and provider-private fields remain outside context.
## Known Limitations and Deferred Work
- **A result with no non-blank highlight is dropped entirely** — no portable snippet to map, so fewer sources than the requested count can return.
- **Only `searchType`/`numResults`/`highlightsPerResult` are exposed** — Exa's other controls (livecrawl, category, domain/date filters, full-text contents) wait on provider-neutral seam fields ([seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md)).
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -1,14 +1,8 @@
/**
* `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API
* (`POST /search` with highlight contents). Maps Exa's flat `results[]` into the
* seam's normalized `WebSearchResult`. Exa returns no provider-generated answer,
* so `content` is omitted; each result maps to a `WebSearchSource` with `url`,
* `title`, the first highlight as `snippet`, and `publishedDate` as
* `publishedAt`.
*
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
*
* `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with
* highlight contents). It maps the first non-blank highlight to `snippet`, maps
* `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content`
* because Exa returns no generated answer.
* @module @deepseek-ai/dsh-web-search-exa/provider
*/

View File

@@ -24,3 +24,24 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
## Mapping
`content``choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt``date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`).
## Model Experience
### Auxiliary Perplexity request
**What the model sees**: A separate Perplexity model receives `<query>` verbatim as its sole user message through the chat-completions endpoint. This request is not part of the conversation model's context.
**Token effect**: Separate provider tokens are incurred per search; `maxTokens` caps the generated answer.
### Conversation tool result, indirectly
**What the model sees**: Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees the generated answer plus structured result metadata or URL-only citations. This provider's exact failures are `Perplexity search aborted`, `Perplexity search request failed: <error>`, and `Perplexity returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
**Token effect**: Zero direct conversation tokens from registration. Answer and source tokens are data-dependent, source count is seam-bounded, and the retained result or error is resent until compaction.
## Known Limitations and Deferred Work
- **Citation-fallback sources are URL-only** — when Perplexity omits structured `search_results[]`, sources carry no `title`/`snippet`/`publishedAt`, so the tool renders bare hostname labels.
- **Over-returned sources still cost tokens and latency** — with no result-count control on the wire, `maxResults` is enforced only post-hoc by seam truncation.
- **Only `model`/`maxTokens`/`searchRecency` are exposed** — Perplexity's other search controls (domain filters, `web_search_options` context size, images) wait on provider-neutral seam fields ([seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md)).
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -1,15 +1,8 @@
/**
* `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity
* search API (an OpenAI-compatible `POST /chat/completions`). Maps the generated
* answer (`choices[0].message.content`) into `content`, and prefers the
* structured `search_results[]` for `sources[]`, falling back to the URL-only
* `citations[]` when `search_results` is absent.
*
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape
* is a provider-private detail and does NOT make this provider depend on
* Perplexity search over its OpenAI-compatible chat-completions endpoint. The generated answer
* becomes `content`; sources prefer structured `search_results[]` and fall back to URL-only
* `citations[]`. The wire format and native `fetch` client are provider-private and do not use
* `ctx.llm`.
*
* @module @deepseek-ai/dsh-web-search-perplexity/provider
*/
@@ -99,12 +92,16 @@ export class PerplexitySearchProvider implements WebSearchProvider {
constructor(private readonly options: PerplexitySearchProviderOptions) {}
// Availability checks stay beside each provider's distinct config contract;
// a shared base class would obscure which fields make this backend usable.
/* jscpd:ignore-start */
status(): WebProviderStatus {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
return { available: true }
}
/* jscpd:ignore-end */
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
let response: Response
@@ -159,6 +156,9 @@ export class PerplexitySearchProvider implements WebSearchProvider {
}
}
// These two predicates are intentionally local: exporting generic internals
// from the public web seam would cost more API surface than these pure checks.
/* jscpd:ignore-start */
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
@@ -168,3 +168,4 @@ function isAbortError(error: unknown): boolean {
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}
/* jscpd:ignore-end */

View File

@@ -1,13 +1,7 @@
/**
* Wire types for the Perplexity search API
* (`POST https://api.perplexity.ai/chat/completions`, an OpenAI-compatible chat
* shape). Types only — no runtime code. Perplexity returns a generated answer in
* `choices[0].message.content` plus citation surfaces: a structured
* `search_results[]` (preferred) and a URL-only `citations[]` fallback.
*
* The OpenAI-compatible wire shape is a provider-private detail; it does not make
* this provider depend on `ctx.llm`.
*
* Wire types for the Perplexity search API (`POST https://api.perplexity.ai/chat/completions`,
* an OpenAI-compatible chat shape). Results prefer structured `search_results` and fall back to
* URL-only `citations`; the provider-private wire shape does not depend on `ctx.llm`.
* @module @deepseek-ai/dsh-web-search-perplexity/types
*/

View File

@@ -42,3 +42,14 @@ The failure branches throw `WebError`, whose structured code (plus message detai
## Vocabulary
`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
## Model Experience
Indirectly, through `dsh-tool-web`, which retains bounded normalized provider data or the exact configured-provider, unavailable-provider, no-provider, multiple-provider, and `Error: <message>` failures while this registry contributes no prompt or schema itself.
## Known Limitations and Deferred Work
- **No observation surface** — no provider-change event and no capability-status query; availability is observed only by executing `search()`/`fetch()` and routing the thrown `WebError` codes, and the no-provider failure is the generic `WEB_PROVIDER_UNAVAILABLE` with no per-provider reason enumeration ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)).
- **`WebSearchRequest` carries only `query` + `maxResults`** — provider-neutral controls (recency, domain filters, regional hints, search depth) are deferred until Exa and Perplexity can both honor them honestly ([seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md)).
- **`WebFetchBody` has no `pdf` arm** — text-extractable PDF support is named deferred work; the closed union makes adding it a compile-enforced change across the three web packages.
- **Provider-backed page extraction is out of scope of `fetch()`** — a Firecrawl/Tavily-style `web_extract` capability is deferred rather than widening the fetch seam.

View File

@@ -1,17 +1,8 @@
/**
* The web access seam (`ctx.web`): a provider registry plus a provider-selecting
* execution surface for two capabilities — search and fetch. Provider packages
* register concrete backends with `registerSearchProvider` /
* `registerFetchProvider`; the model-facing consumer
* (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and
* routes on the structured {@link WebError} codes selection throws.
*
* The registry half stays close to `LlmService`: a `Map<id, provider>` per
* capability kind, register methods that return disposers, duplicate ids that
* throw, and execution-time resolution that throws when the selected provider is
* absent or unusable — with selection rules that never depend on registration
* order.
*
* The web access seam (`ctx.web`): registries and provider-selecting execution for search and
* fetch. Duplicate ids are rejected. At execution time, a configured provider must exist and
* be usable; without one, exactly one usable provider is required, so selection never depends
* on registration order.
* @module @deepseek-ai/dsh-web
*/

View File

@@ -1,19 +1,7 @@
/**
* Vocabulary for the web capability seam (`ctx.web`): the search/fetch
* request/result shapes providers produce and consumers format, the provider
* status discriminant selection reads, the execution-control context, and the
* typed error taxonomy.
*
* These types are shared by every provider backend
* (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`,
* `@deepseek-ai/dsh-web-fetch-local`, and future backends) and by the
* model-facing consumer (`@deepseek-ai/dsh-tool-web`). Search and fetch share no
* request schema and no business logic, but they are deliberately one seam:
* `ctx.web` is a single web-access middle layer with one provider-selection
* policy, one abort/error vocabulary, and one product-facing configuration
* point. The cost is the parallel `Search`/`Fetch` shapes below; that
* parallelism is intentional.
*
* Vocabulary for the web capability seam (`ctx.web`). Search and fetch deliberately share one
* seam so provider selection, cancellation, errors, and product configuration have one owner,
* while retaining separate request and result shapes.
* @module @deepseek-ai/dsh-web/types
*/
@@ -162,39 +150,11 @@ export interface WebFetchProvider {
}
/**
* Typed web error. Extends {@link HarnessError} so it carries a stable,
* machine-routable `code` (a `string`, like every other seam's error) and
* chains `cause`. `ToolRegistry.execute()` converts a thrown `WebError` into an
* error tool result whose structured metadata exposes the code, so callers
* (hooks, tests, UI) route on it.
*
* The `code` is an open `string`, NOT a closed union: a provider may raise its
* own codes without editing this package, and a consumer must tolerate an
* unknown code (a future provider will introduce ones this file never named).
* The codes split by who owns them — seam-neutral codes any provider may see,
* versus codes specific to a single implementation:
*
* Seam-neutral (raised by `WebService` selection and the shared contract):
* - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable.
* - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered.
* - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its
* `status()` reports unavailable.
* - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers
* exist (selection refuses to pick by registration order).
* - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is
* already registered for that capability kind.
* - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`.
* - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced
* through the seam, including network/transport failure (DNS, connection
* refused, TLS).
*
* Fetch-transport codes (owned by the `dsh-web-fetch-local` implementation; a
* different fetch backend need not raise these and may raise its own):
* - `WEB_INVALID_URL`: the fetch URL is malformed or not http(s).
* - `WEB_BLOCKED_URL`: the fetch URL is rejected by policy (credentials in URL).
* - `WEB_REDIRECT_BLOCKED`: a cross-origin redirect was refused.
* - `WEB_FETCH_TOO_LARGE`: the response exceeded the byte/character cap.
* - `WEB_FETCH_TIMEOUT`: the fetch exceeded its timeout.
* - `WEB_UNSUPPORTED_CONTENT_TYPE`: the response content type cannot be decoded.
* Typed web error with a machine-routable, open-string `code` and chained `cause`.
* Consumers must tolerate provider-specific codes. Shared codes cover unavailable,
* missing, unusable, ambiguous, or duplicate providers, cancellation, and provider failure;
* the local fetch provider additionally distinguishes invalid or blocked URLs, redirects,
* size and timeout limits, and unsupported content types. Tool execution exposes the code in
* structured error metadata.
*/
export class WebError extends HarnessError {}