fix(docs): align site with bilingual source pairs

This commit is contained in:
Yichen Jiang
2026-07-15 18:08:28 +08:00
parent e0a0b8b06d
commit 6af61c6f4e
43 changed files with 2112 additions and 353 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
index.md: 0261b49b071167f7c2a33f78bbc1959cc6f1879f
index.zh.md: 5819344430fcbde31bf825e9815120983e44e3f6
+158
View File
@@ -0,0 +1,158 @@
# Three-layer capability design
English | [中文](index.zh.md)
When a capability is general enough to need replaceable implementations, such as Bash execution, Harness splits it into three packages: an **interface**, an **implementation**, and a **consumer**. Each layer can evolve or be replaced independently.
## Bash example
The Bash execution capability consists of:
- **Interface** (`dsh-bash`) — defines Bash request and result shapes
- **Implementation** (`dsh-bash-local`) — executes commands on the local machine
- **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│
│ (interface) │ │ (implementation) │ │(consumer/tool)│
└─────────────┘ └──────────────────┘ └──────────────┘
▲ │
└────────────────────────────────────────────┘
inject: ['bash']
```
## Benefits of the split
### Replace implementations
One interface can have multiple implementations selected through `cordis.yml`:
```yaml
# Local execution
- name: '@deepseek-ai/dsh-bash-local'
# Or a future remote sandbox implementation
# - name: '@deepseek-ai/dsh-bash-remote'
# config:
# endpoint: 'https://sandbox.example.com'
```
The interface and tool remain unchanged while the implementation changes.
### Evolve independently
- The interface changes rarely after its contract stabilizes.
- Implementations can improve performance and security independently.
- Consumers can change how they present the capability to the model.
### Decouple dependencies
- The implementation depends on the interface.
- The consumer depends on the interface.
- The implementation and consumer **do not depend on each other**.
## Built-in three-layer capabilities
| Capability | Interface | Implementation | Consumer |
|------|-------------|------|---------------|
| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` |
| Filesystem | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` |
| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` |
| Subagent | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` |
| Compaction | `dsh-compact` | `dsh-compact-basic` | The implementation consumes agent-loop extension events |
## Develop a three-layer capability
### Step 1: define the interface
```ts ignore-check
// packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
myCap: MyCapService
}
}
export abstract class MyCapService extends Service {
constructor(ctx: Context) {
super(ctx, 'myCap')
}
/** Execute the capability. */
abstract execute(request: MyCapRequest): Promise<MyCapResult>
}
export interface MyCapRequest {
input: string
}
export interface MyCapResult {
output: string
}
```
### Step 2: write an implementation
```ts ignore-check
// packages/my-cap/my-cap-local/src/index.ts
import type { Context } from 'cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap'
class MyCapLocal extends MyCapService {
async execute(request: MyCapRequest): Promise<MyCapResult> {
// Concrete implementation.
return { output: request.input.toUpperCase() }
}
}
export const name = 'my-cap-local'
export function apply(ctx: Context) {
ctx.plugin(MyCapLocal)
}
```
### Step 3: write a consumer
```ts ignore-check
// packages/my-cap/tool-my-cap/src/index.ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-my-cap'
export const inject = ['tools', 'myCap']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'my_cap',
description: 'Execute my capability.',
parameters: {
input: { type: 'string', required: true },
},
async execute(args) {
const result = await ctx.myCap.execute({ input: args.input })
return [{ type: 'text', text: result.output }]
},
}))
}
```
### Compose them in cordis.yml
```yaml
- name: '@deepseek-ai/dsh-my-cap-local'
- name: '@deepseek-ai/dsh-tool-my-cap'
```
## Design points
- **Do not split preemptively** — use three packages only when the capability needs replaceable implementations. A simple tool plugin does not.
- **The interface owns Request/Result types** — implementations and consumers depend only on the interface package.
- **Explicit > implicit** — resolve defaults in an explicit `resolve(request): Spec` step rather than hiding `?? default` expressions inside `run()`.
## Next steps
- [LLM adapter](./llm-adapter.md) — implement an LLM backend, a common capability interface extension
+158
View File
@@ -0,0 +1,158 @@
# 能力的三层拆分
[English](index.md) | 中文
当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。
## 以 Bash 为例
考虑 "Bash 执行" 这个能力:
- **接口** (`dsh-bash`) — 定义"bash 执行"长什么样:输入是什么、输出是什么
- **实现** (`dsh-bash-local`) — 真正在本地跑命令的代码
- **消费者** (`dsh-tool-bash`) — 把这个能力包装成模型能调用的 tool
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│
│ (interface) │ │ (implementation) │ │(consumer/tool)│
└─────────────┘ └──────────────────┘ └──────────────┘
▲ │
└────────────────────────────────────────────┘
inject: ['bash']
```
## 拆分的好处
### 具体实现可替换
同一个接口可以有多种实现。用户通过 `cordis.yml` 选择:
```yaml
# Local execution
- name: '@deepseek-ai/dsh-bash-local'
# Or a future remote sandbox implementation
# - name: '@deepseek-ai/dsh-bash-remote'
# config:
# endpoint: 'https://sandbox.example.com'
```
接口不变、tool 不变,只换实现。
### 独立演进
- 接口定义稳定后很少改动
- 实现可以独立优化(性能、安全)
- 消费者(tool)可以调整对模型的呈现方式
### 依赖解耦
- 实现 depend on 接口
- 消费者 depend on 接口
- 实现和消费者**互不依赖**
## Harness 中内置的三件套
| 能力 | 接口 (seam) | 实现 | 消费者 (tool) |
|------|-------------|------|---------------|
| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` |
| 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` |
| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` |
| 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` |
| 压缩 | `dsh-compact` | `dsh-compact-basic` | 由实现插件消费 agent-loop 的扩展事件 |
## 开发你自己的三件套
### 第一步:定义接口
```ts ignore-check
// packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
myCap: MyCapService
}
}
export abstract class MyCapService extends Service {
constructor(ctx: Context) {
super(ctx, 'myCap')
}
/** Execute the capability. */
abstract execute(request: MyCapRequest): Promise<MyCapResult>
}
export interface MyCapRequest {
input: string
}
export interface MyCapResult {
output: string
}
```
### 第二步:编写实现
```ts ignore-check
// packages/my-cap/my-cap-local/src/index.ts
import type { Context } from 'cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap'
class MyCapLocal extends MyCapService {
async execute(request: MyCapRequest): Promise<MyCapResult> {
// Concrete implementation.
return { output: request.input.toUpperCase() }
}
}
export const name = 'my-cap-local'
export function apply(ctx: Context) {
ctx.plugin(MyCapLocal)
}
```
### 第三步:编写消费者 (tool)
```ts ignore-check
// packages/my-cap/tool-my-cap/src/index.ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-my-cap'
export const inject = ['tools', 'myCap']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'my_cap',
description: 'Execute my capability.',
parameters: {
input: { type: 'string', required: true },
},
async execute(args) {
const result = await ctx.myCap.execute({ input: args.input })
return [{ type: 'text', text: result.output }]
},
}))
}
```
### 在 cordis.yml 中组合
```yaml
- name: '@deepseek-ai/dsh-my-cap-local'
- name: '@deepseek-ai/dsh-tool-my-cap'
```
## 设计要点
- **不要预防性拆分** — 只有当你确实需要可替换实现时才拆三件套。一个简单的 tool 插件不需要拆分。
- **接口定义 Request/Result 类型** — 实现和消费者只依赖接口包。
- **Explicit > Implicit** — 实现中的默认值处理应该是显式的 `resolve(request): Spec` 步骤,不是隐藏在 `run()` 中的 `?? default`。
## 下一步
- [LLM 适配器](./llm-adapter.md) — 实现一个 LLM 后端(最常见的 seam 扩展)
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
llm-adapter.md: 18e05ab79f7daf9f86fe1eb27bdd4440fb9107bc
llm-adapter.zh.md: f3c1ac70f7b4c11fb9f6dcb247be342fb358bd39
+185
View File
@@ -0,0 +1,185 @@
# LLM adapters
English | [中文](llm-adapter.zh.md)
This guide connects a new LLM provider to Harness.
## Overview
An LLM adapter extends `LlmAdapter` and implements `stream()`, translating Harness's provider-neutral request into a provider API call and translating the response back into Harness chunks.
## Minimal implementation
```ts
import type { Context } from 'cordis'
import Schema from 'schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. Convert options.messages to the provider format.
// 2. Call the streaming API.
// 3. Convert the response into StreamChunk values.
}
}
export interface Config {
apiKey: string
models: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
models: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.models, adapter)
}
```
## StreamChunk protocol
`stream()` yields chunks using this protocol:
```ts
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. Start each content block with block-start.
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. Stream text through text-delta.
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'text-delta', index: 0, text: ' world' }
// 3. End each content block with block-end and the complete block.
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'Hello world' },
}
// 4. Tool-call block.
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{"command":"ls"}',
}
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{"command":"ls"}',
},
}
// 5. Token usage.
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. Finish reason.
yield { type: 'finish', reason: { kind: 'stop' } }
// Alternatively, { kind: 'tool-calls' } requests tool execution.
}
```
### Key rules
- Every `block-start` has a matching `block-end`.
- `index` increases from 0 and identifies content-block order.
- A `tool-call-delta` carries raw JSON text in `argumentsDelta`, either all at once or over multiple chunks.
- `finish` is the final chunk.
- Emit `usage` before `finish`.
## GenerateOptions
`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it.
## Register an adapter
```ts ignore-check
ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter)
```
The first argument lists the model names handled by the adapter. If `cordis.yml` selects `model: model-name-1`, the service routes that request to this adapter.
## Use it from cordis.yml
```yaml
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
models:
- my-model-v1
- my-model-v2
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-agent'
config:
model: my-model-v1 # References the model registered above.
```
## Reference implementations
The repository contains complete implementations:
- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format
- `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format
- `examples/echo-agent/src/mock-llm.ts` — minimal local teaching adapter
Start with the mock adapter to study a complete chunk sequence without network behavior.
## Error handling
Adapters throw transport and protocol failures as `LlmError` values with stable codes. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`.
```ts
import {
attributionHeaders,
LlmAdapter,
LlmError,
type GenerateOptions,
type StreamChunk,
} from '@deepseek-ai/dsh-llm'
class HttpAdapter extends LlmAdapter {
constructor(private readonly endpoint: string) {
super()
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...attributionHeaders(),
},
body: JSON.stringify({ model: options.model, messages: options.messages }),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status)
}
// A real adapter parses the response and emits the complete chunk sequence.
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
```
@@ -0,0 +1,185 @@
# LLM 适配器
[English](llm-adapter.md) | 中文
本文介绍如何为 Harness 接入一个新的 LLM 提供方。
## 概述
LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。
## 最小实现
```ts
import type { Context } from 'cordis'
import Schema from 'schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. Convert options.messages to the provider format.
// 2. Call the streaming API.
// 3. Convert the response into StreamChunk values.
}
}
export interface Config {
apiKey: string
models: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
models: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.models, adapter)
}
```
## StreamChunk 协议
`stream()` 必须按以下协议 yield chunk
```ts
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. Start each content block with block-start.
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. Stream text through text-delta.
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'text-delta', index: 0, text: ' world' }
// 3. End each content block with block-end and the complete block.
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'Hello world' },
}
// 4. Tool-call block.
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{"command":"ls"}',
}
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{"command":"ls"}',
},
}
// 5. Token usage.
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. Finish reason.
yield { type: 'finish', reason: { kind: 'stop' } }
// Alternatively, { kind: 'tool-calls' } requests tool execution.
}
```
### 关键规则
- 每个 `block-start` 必须有对应的 `block-end`
- `index` 从 0 递增,标识内容块顺序
- `tool-call-delta``argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次)
- `finish` 必须是最后一个 chunk
- `usage``finish` 之前 yield
## GenerateOptions
`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。
## 注册适配器
```ts ignore-check
ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter)
```
第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。
## 在 cordis.yml 中使用
```yaml
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
models:
- my-model-v1
- my-model-v2
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-agent'
config:
model: my-model-v1 # References the model registered above.
```
## 实战参考
仓库中有两个完整实现可供参考:
- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式)
- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式)
- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用)
mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。
## 错误处理
适配器应将传输和协议故障作为带稳定 code 的 `LlmError` 抛出;agent loop 会保留该错误及其 code,供诊断和策略使用。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。
```ts
import {
attributionHeaders,
LlmAdapter,
LlmError,
type GenerateOptions,
type StreamChunk,
} from '@deepseek-ai/dsh-llm'
class HttpAdapter extends LlmAdapter {
constructor(private readonly endpoint: string) {
super()
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...attributionHeaders(),
},
body: JSON.stringify({ model: options.model, messages: options.messages }),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status)
}
// A real adapter parses the response and emits the complete chunk sequence.
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
```