fix(web): narrow default search review scope

This commit is contained in:
kingwl
2026-07-31 12:46:18 +08:00
parent 2b2f109dcb
commit 5759c287a2
23 changed files with 340 additions and 59 deletions

View File

@@ -7,7 +7,9 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -27,7 +29,7 @@ export {
DEEPSEEK_DEFAULT_MODEL,
DEEPSEEK_PROVIDER_ID,
} from './provider.ts'
export type { DeepSeekSearchProviderOptions } from './provider.ts'
export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-deepseek'
@@ -87,5 +89,11 @@ export function apply(ctx: Context, config: Config): void {
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,
maxUses,
recordRequest: (request) => {
ctx.get('agents')?.currentInitiator()?.session.append(
'web/deepseek-search-llm-request',
request,
)
},
}))
}

View File

@@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
* No runtime invariant: the package emits a pre-dispatch log event but owns no
* later authoritative dispatch event to relate it to. Exact envelope equality
* is pinned at the provider boundary instead.
*/
const install: InvariantInstaller = () => {}

View File

@@ -14,6 +14,7 @@ import type {
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {
AnthropicError,
AnthropicResponse,
@@ -48,6 +49,41 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/**
* Exact secret-free DeepSeek Messages request recorded immediately before one
* auxiliary search dispatch.
*/
export interface DeepSeekSearchLlmRequest {
/** Fully resolved Messages endpoint. */
readonly endpoint: string
/** `anthropic-version` header value. */
readonly apiVersion: string
/** Exact JSON body sent to the provider. */
readonly body: {
readonly model: string
readonly max_tokens: number
readonly messages: readonly [{
readonly role: 'user'
readonly content: readonly [{
readonly type: 'text'
readonly text: string
}]
}]
readonly tools: readonly [{
readonly type: 'web_search_20250305'
readonly name: 'web_search'
readonly max_uses: number
}]
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
}
}
/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */
export interface DeepSeekSearchProviderOptions {
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
@@ -66,6 +102,11 @@ export interface DeepSeekSearchProviderOptions {
maxTokens: number
/** Maximum `web_search` server-tool uses per request. */
maxUses: number
/**
* Record the exact secret-free request immediately before dispatch. A throw
* prevents dispatch so model-visible auxiliary input cannot escape logging.
*/
recordRequest?: (request: DeepSeekSearchLlmRequest) => void
}
/**
@@ -146,10 +187,27 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const apiKey = await this.apiKey()
const apiKey = await this.apiKey(signal)
throwIfSearchAborted(signal)
const endpoint = `${this.options.baseURL}/messages`
const body: DeepSeekSearchLlmRequest['body'] = {
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}
this.options.recordRequest?.({
endpoint,
apiVersion: this.options.apiVersion,
body,
})
throwIfSearchAborted(signal)
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
response = await fetch(endpoint, {
method: 'POST',
redirect: 'error',
headers: {
@@ -162,19 +220,11 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
'accept': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify({
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}),
body: JSON.stringify(body),
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
@@ -189,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
@@ -201,19 +251,21 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
const payload = await response.json() as AnthropicResponse
return mapAnthropicResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
if (error instanceof WebError) throw error
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
/** Resolve one operation's credential without retaining it on the provider. */
private async apiKey(): Promise<string> {
private async apiKey(signal?: AbortSignal): Promise<string> {
throwIfSearchAborted(signal)
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
let resolved: string | undefined
try {
resolved = await this.options.resolveApiKey?.()
resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
} catch (error: unknown) {
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(
`DeepSeek search credential resolution failed: ${String(error)}`,
'WEB_PROVIDER_ERROR',
@@ -231,6 +283,42 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
}
}
/**
* Race a same-process asynchronous preflight against caller cancellation. The
* attached settlement handlers keep observing an uncooperative operation after
* abort so a later rejection cannot become unhandled.
*/
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return operation
if (signal.aborted) return Promise.reject(searchAborted(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(searchAborted(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error }))
},
)
})
}
/** Throw the provider's stable cancellation error when the caller already aborted. */
function throwIfSearchAborted(signal?: AbortSignal): void {
if (signal?.aborted === true) throw searchAborted(signal)
}
/** Build the provider's stable cancellation error while retaining the caller's reason. */
function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError {
return new WebError('DeepSeek search aborted', 'WEB_ABORTED', {
cause: signal?.aborted === true ? signal.reason : fallback,
})
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'