mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into fix/subagent-depth-budget
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: 45c6edff41a7bc21c76aeeaf14d16af824c601de
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91ba7523705d1500150efe0eac9085ea980e80d6
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: 3be1d5d8fd9dba20cfca34c79cb01d89fad8097a
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a8aa8812934e755fe0175c8f3f20d194e4d24b4a
|
||||
|
||||
@@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid
|
||||
- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).
|
||||
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
|
||||
- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.
|
||||
- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth.
|
||||
- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Status: implemented
|
||||
- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。
|
||||
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
|
||||
- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。
|
||||
- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。
|
||||
- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
---
|
||||
name: dsh-translate-docs
|
||||
description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result
|
||||
description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result
|
||||
---
|
||||
|
||||
# Translating DeepSeek-Harness docs
|
||||
|
||||
## Delegate to a subagent
|
||||
|
||||
When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation.
|
||||
|
||||
## What this skill is
|
||||
|
||||
**This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not.
|
||||
|
||||
## Sources of truth (read, don't re-summarize)
|
||||
|
||||
5
packages/web/AGENTS.md
Normal file
5
packages/web/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# AGENTS.md — Web Packages
|
||||
|
||||
These rules supplement the package conventions in [packages/AGENTS.md](../AGENTS.md).
|
||||
|
||||
- **Reject redirects on credential-bearing provider requests.** Configure the HTTP client to fail before following any redirect response. Regression coverage must prove that the redirect target is not contacted and that every credentialed provider opts into the policy. The configured endpoint necessarily receives the initial request; this prevents automatic forwarding of credentials or request data to another origin, not compromise of the configured endpoint.
|
||||
@@ -37,7 +37,7 @@ DeepSeek returns no provider-generated answer surface this provider trusts as `c
|
||||
|
||||
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`.
|
||||
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu
|
||||
return { sources, truncated: false }
|
||||
}
|
||||
|
||||
/** The DeepSeek-backed search provider. */
|
||||
/** The DeepSeek-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
||||
export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
readonly id = DEEPSEEK_PROVIDER_ID
|
||||
|
||||
@@ -145,6 +145,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/messages`, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
// Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
|
||||
// may expect `Authorization: Bearer` — send both so either resolves.
|
||||
|
||||
@@ -162,6 +162,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
|
||||
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers['x-api-key']).toBe('ds-key')
|
||||
expect(headers['authorization']).toBe('Bearer ds-key')
|
||||
|
||||
123
packages/web/web-search-deepseek/tests/redirect.spec.ts
Normal file
123
packages/web/web-search-deepseek/tests/redirect.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Real HTTP coverage proves whether native `fetch` contacts a cross-origin `Location`; mocked
|
||||
* request-init assertions alone cannot observe that boundary.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek'
|
||||
|
||||
const TEST_API_KEY = 'redirect-test-key'
|
||||
const TEST_QUERY = 'private redirect query'
|
||||
const targetRequests: ReceivedRequest[] = []
|
||||
|
||||
interface ReceivedRequest {
|
||||
readonly body: string
|
||||
readonly headers: IncomingMessage['headers']
|
||||
readonly method?: string
|
||||
}
|
||||
|
||||
let redirectOrigin: string
|
||||
let targetOrigin: string
|
||||
|
||||
const targetServer = createServer((request, response) => {
|
||||
void captureRequest(request).then((received) => {
|
||||
targetRequests.push(received)
|
||||
response.writeHead(204).end()
|
||||
}, (error: unknown) => response.destroy(asError(error)))
|
||||
})
|
||||
|
||||
const redirectServer = createServer((request, response) => {
|
||||
request.resume()
|
||||
const status = Number(new URL(request.url ?? '/', 'http://fixture.test').pathname.split('/')[1])
|
||||
response.writeHead(status, { location: `${targetOrigin}/collect` }).end()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
targetOrigin = await listen(targetServer)
|
||||
redirectOrigin = await listen(redirectServer)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([close(redirectServer), close(targetServer)])
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider redirect policy', () => {
|
||||
it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => {
|
||||
targetRequests.length = 0
|
||||
const provider = new DeepSeekSearchProvider({
|
||||
apiKey: TEST_API_KEY,
|
||||
baseURL: `${redirectOrigin}/${status}`,
|
||||
model: 'deepseek-chat',
|
||||
apiVersion: '2023-06-01',
|
||||
maxTokens: 32,
|
||||
maxUses: 1,
|
||||
})
|
||||
|
||||
await expect(provider.search({ query: TEST_QUERY }))
|
||||
.rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' })
|
||||
expect(targetRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows default 307 following forwards the custom credential and POST body', async () => {
|
||||
targetRequests.length = 0
|
||||
const body = JSON.stringify({ query: TEST_QUERY })
|
||||
await fetch(`${redirectOrigin}/307`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': TEST_API_KEY,
|
||||
'authorization': `Bearer ${TEST_API_KEY}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
expect(targetRequests).toHaveLength(1)
|
||||
expect(targetRequests[0]).toMatchObject({ method: 'POST', body })
|
||||
expect(targetRequests[0]?.headers['x-api-key']).toBe(TEST_API_KEY)
|
||||
})
|
||||
})
|
||||
|
||||
/** Read a complete request received by the redirect target. */
|
||||
function captureRequest(request: IncomingMessage): Promise<ReceivedRequest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = []
|
||||
request.on('data', (chunk: unknown) => {
|
||||
if (typeof chunk === 'string' || chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk))
|
||||
else reject(new TypeError('unexpected HTTP request chunk'))
|
||||
})
|
||||
request.once('error', reject)
|
||||
request.once('end', () => {
|
||||
resolve({
|
||||
...request.method !== undefined ? { method: request.method } : {},
|
||||
headers: request.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Listen on an ephemeral loopback port and return the server origin. */
|
||||
async function listen(server: Server): Promise<string> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address() as AddressInfo
|
||||
return `http://127.0.0.1:${address.port}`
|
||||
}
|
||||
|
||||
/** Close a listening fixture server after every request has settled. */
|
||||
async function close(server: Server): Promise<void> {
|
||||
if (!server.listening) return
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Normalize an unknown fixture failure for `ServerResponse.destroy`. */
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
@@ -23,7 +23,7 @@ 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`.
|
||||
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`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
|
||||
return { sources, truncated: false }
|
||||
}
|
||||
|
||||
/** The Exa-backed search provider. */
|
||||
/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
||||
export class ExaSearchProvider implements WebSearchProvider {
|
||||
readonly id = EXA_PROVIDER_ID
|
||||
|
||||
@@ -100,6 +100,7 @@ export class ExaSearchProvider implements WebSearchProvider {
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/search`, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('ExaSearchProvider request mapping', () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.exa.test/search')
|
||||
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
|
||||
expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
query: 'hello',
|
||||
|
||||
@@ -23,7 +23,7 @@ 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`).
|
||||
`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`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function mapPerplexityResponse(response: PerplexityResponse): WebSearchRe
|
||||
}
|
||||
}
|
||||
|
||||
/** The Perplexity-backed search provider. */
|
||||
/** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
||||
export class PerplexitySearchProvider implements WebSearchProvider {
|
||||
readonly id = PERPLEXITY_PROVIDER_ID
|
||||
|
||||
@@ -103,6 +103,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('PerplexitySearchProvider request mapping', () => {
|
||||
await new PerplexitySearchProvider(options).search({ query: 'hello' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.perplexity.test/chat/completions')
|
||||
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
|
||||
expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer pplx-key')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user