Merge remote-tracking branch 'origin/master' into worktree/fix-1463-rich-content-bridge
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 docs/architecture.md
|
||||
architecture.md: 77000ce9d4608d440e1d903eb80a42f2ed6435ef
|
||||
architecture.zh.md: f2f5310f665b86b86587307e7ce31c5841b96317
|
||||
architecture.md: a1507fa5e54f6703e89f09a5d387e6c9afc81ade
|
||||
architecture.zh.md: 4642a1e7691bccf4d52d9a84c92c8237c3c6658b
|
||||
|
||||
@@ -126,4 +126,4 @@ New behavior attaches to a documented extension point. Changing the loop itself
|
||||
| Fork a live session | `ctx.sessions.fork(source, boundary?, childSessionId?)` |
|
||||
| Scope a registration to one agent | use that agent's `agent.ctx` |
|
||||
|
||||
The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [Chat nodes](cookbook/adding-a-conversation-node.md).
|
||||
The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [settings cards](cookbook/adding-a-settings-card.md).
|
||||
|
||||
@@ -130,4 +130,4 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统
|
||||
| fork 活跃会话 | `ctx.sessions.fork(source, boundary?, childSessionId?)` |
|
||||
| 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` |
|
||||
|
||||
[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.md)和 [Chat 节点](cookbook/adding-a-conversation-node.md)的分步指南。
|
||||
[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和[设置卡片](cookbook/adding-a-settings-card.md)的分步指南。
|
||||
|
||||
6
docs/cookbook/adding-a-settings-card.i18n.yaml
Normal file
6
docs/cookbook/adding-a-settings-card.i18n.yaml
Normal file
@@ -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 docs/cookbook/adding-a-settings-card.md
|
||||
adding-a-settings-card.md: 56ec3be578bbc489bbb979a50bcaed063a35ace5
|
||||
adding-a-settings-card.zh.md: 4643303bfd76ba77676b7e508424f46082627b62
|
||||
100
docs/cookbook/adding-a-settings-card.md
Normal file
100
docs/cookbook/adding-a-settings-card.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Cookbook: adding a settings card
|
||||
|
||||
English | [中文](adding-a-settings-card.zh.md)
|
||||
|
||||
How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically.
|
||||
|
||||
The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins).
|
||||
|
||||
## 1. Register the namespace (Host half)
|
||||
|
||||
The namespace is the join key, so pick it once and spell it in both halves. A consumer that already has a `cordis.yml` entry should register through `installSettingsSection`, which layers the entry under the user document and keeps working when no settings provider is mounted:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
declare function assertReachable(endpoint: string | undefined): void
|
||||
declare function rebuildFromSettings(config: Config): void
|
||||
|
||||
export const MY_PLUGIN_NS = settingsNamespace('my-plugin')
|
||||
|
||||
export interface Config {
|
||||
endpoint?: string
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
endpoint: z.string(),
|
||||
retries: z.number().step(1).min(0).default(3),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
let source = () => config
|
||||
installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, {
|
||||
// Constraints the schema cannot express refuse the write, not the next use.
|
||||
validate: value => void assertReachable(value.endpoint),
|
||||
setSource: (current) => { source = current },
|
||||
onChange: () => { rebuildFromSettings(source()) },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start.
|
||||
|
||||
## 2. Register the card (browser half)
|
||||
|
||||
The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read:
|
||||
|
||||
```ts ignore-check
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes
|
||||
// through cordis services; a value import fails the client bundle-purity gate.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' }))
|
||||
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: 'my-plugin',
|
||||
locale: 'settings.myPlugin',
|
||||
inject: () => card.inject(),
|
||||
}, MyPluginCard),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer.
|
||||
|
||||
## 3. What the tab does with it
|
||||
|
||||
The **Plugin configuration** tab reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this tab.
|
||||
|
||||
Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own.
|
||||
|
||||
## Packaging
|
||||
|
||||
The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } }
|
||||
}
|
||||
```
|
||||
|
||||
The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset:
|
||||
|
||||
```ts ignore-check
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
```
|
||||
|
||||
That preset is not published today, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work).
|
||||
100
docs/cookbook/adding-a-settings-card.zh.md
Normal file
100
docs/cookbook/adding-a-settings-card.zh.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Cookbook: 新增设置卡片
|
||||
|
||||
[English](adding-a-settings-card.md) | 中文
|
||||
|
||||
插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库:Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。
|
||||
|
||||
两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins)。
|
||||
|
||||
## 1. 注册命名空间(Host 半侧)
|
||||
|
||||
命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `installSettingsSection` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
declare function assertReachable(endpoint: string | undefined): void
|
||||
declare function rebuildFromSettings(config: Config): void
|
||||
|
||||
export const MY_PLUGIN_NS = settingsNamespace('my-plugin')
|
||||
|
||||
export interface Config {
|
||||
endpoint?: string
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
endpoint: z.string(),
|
||||
retries: z.number().step(1).min(0).default(3),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
let source = () => config
|
||||
installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, {
|
||||
// Constraints the schema cannot express refuse the write, not the next use.
|
||||
validate: value => void assertReachable(value.endpoint),
|
||||
setSource: (current) => { source = current },
|
||||
onChange: () => { rebuildFromSettings(source()) },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
字段上的 `role('secret')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。
|
||||
|
||||
## 2. 注册卡片(浏览器半侧)
|
||||
|
||||
卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅:
|
||||
|
||||
```ts ignore-check
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes
|
||||
// through cordis services; a value import fails the client bundle-purity gate.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' }))
|
||||
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: 'my-plugin',
|
||||
locale: 'settings.myPlugin',
|
||||
inject: () => card.inject(),
|
||||
}, MyPluginCard),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。
|
||||
|
||||
## 3. 标签页拿它做什么
|
||||
|
||||
**插件配置**标签页读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本标签页之外的。
|
||||
|
||||
卡片按其注册进该 slot 的顺序出现;keyed entry 不声明自己的 `order`。
|
||||
|
||||
## 打包
|
||||
|
||||
浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } }
|
||||
}
|
||||
```
|
||||
|
||||
bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行:
|
||||
|
||||
```ts ignore-check
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
```
|
||||
|
||||
该预设目前未发布,因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work)里。
|
||||
@@ -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 docs/subsystems/llm-streaming.md
|
||||
llm-streaming.md: 0d3a0d53c875c9d943146ba44b775d81fc9cae01
|
||||
llm-streaming.zh.md: fbaa47d14d57e7377be4db6ecaa04f11997572a6
|
||||
llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086
|
||||
llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339
|
||||
|
||||
@@ -157,6 +157,29 @@ type ContextFormed =
|
||||
|
||||
A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Adapter-private lossless-JSON state for replaying a successful response,
|
||||
* carried by a terminal `finish` chunk and stored on the assembled assistant
|
||||
* message's model source. Both halves stay opaque to the harness; only the
|
||||
* split is shared vocabulary, so assembly can keep stored metadata aligned
|
||||
* with stored content without reading either half.
|
||||
*/
|
||||
interface ReplayEnvelope {
|
||||
/** Response-level adapter-private metadata (ids, native stop reason). */
|
||||
response: unknown
|
||||
/**
|
||||
* Per-block adapter-private metadata, one entry per emitted block in
|
||||
* first-seen stream order. When assembly drops a block it drops the entry at
|
||||
* the same position; entries whose length does not match the emitted block
|
||||
* count discard the whole envelope. An adapter whose metadata is independent
|
||||
* of block structure omits this field and the envelope passes through
|
||||
* assembly unchanged.
|
||||
*/
|
||||
blocks?: readonly unknown[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
@@ -176,8 +199,8 @@ type StreamChunk =
|
||||
| {
|
||||
type: 'finish'
|
||||
reason: FinishReason
|
||||
/** Adapter-private lossless-JSON state for replaying a successful response. */
|
||||
replayState?: unknown
|
||||
/** Replay metadata for a successful response; see {@link ReplayEnvelope}. */
|
||||
replayState?: ReplayEnvelope
|
||||
}
|
||||
```
|
||||
|
||||
@@ -213,7 +236,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
|
||||
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
|
||||
- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md).
|
||||
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test.
|
||||
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmRuntime` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content plus provider/model fields without the private state.
|
||||
- **Replay state is adapter-owned; its split is shared.** A successful `finish` may carry a `ReplayEnvelope`: opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. The alignment is the harness's vocabulary — when assembly drops a block it drops the entry at the same position, so stored metadata always describes stored content. The loop stores the pruned envelope with the assembled assistant message. On a later request, `LlmRuntime` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content plus provider/model fields without the private state. Durable content stays authoritative: a stored state the reading adapter cannot use degrades that one message to provider-neutral conversion with a diagnostic instead of failing the request.
|
||||
|
||||
## `ResolvedRetryPolicy`
|
||||
|
||||
@@ -267,6 +290,8 @@ interface TokenUsage {
|
||||
|
||||
`BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with the provider and model that produced it. A consumer that needs the assembled result without re-implementing the fold uses this.
|
||||
|
||||
One keep/drop decision covers content and metadata together: a `max-tokens` finish drops every tool call because a truncated call is unsafe to execute, and the same decision prunes the replay envelope's per-block entry at each dropped position. `blocks()` and `replayState` therefore cannot disagree, whatever assembly removes.
|
||||
|
||||
```ts public-api
|
||||
/**
|
||||
* Incrementally assembles raw {@link StreamChunk}s into complete
|
||||
@@ -296,8 +321,12 @@ declare class BlockAssembler {
|
||||
get usage(): TokenUsage | undefined;
|
||||
/** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
|
||||
get finish(): FinishReason;
|
||||
/** Adapter-private replay state from the terminal finish chunk, if any. */
|
||||
get replayState(): unknown;
|
||||
/**
|
||||
* Replay metadata from the terminal finish chunk, if any, with per-block
|
||||
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
||||
* entries do not align with the emitted blocks.
|
||||
*/
|
||||
get replayState(): ReplayEnvelope | undefined;
|
||||
/**
|
||||
* The assembled assistant message.
|
||||
* @param source - producer attribution for the assembled message.
|
||||
|
||||
@@ -157,6 +157,29 @@ type ContextFormed =
|
||||
|
||||
一个流式响应交错包含多种类型的块(文本、推理(reasoning)、多个工具调用)。`index` 将每个 delta 关联到其所属块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Adapter-private lossless-JSON state for replaying a successful response,
|
||||
* carried by a terminal `finish` chunk and stored on the assembled assistant
|
||||
* message's model source. Both halves stay opaque to the harness; only the
|
||||
* split is shared vocabulary, so assembly can keep stored metadata aligned
|
||||
* with stored content without reading either half.
|
||||
*/
|
||||
interface ReplayEnvelope {
|
||||
/** Response-level adapter-private metadata (ids, native stop reason). */
|
||||
response: unknown
|
||||
/**
|
||||
* Per-block adapter-private metadata, one entry per emitted block in
|
||||
* first-seen stream order. When assembly drops a block it drops the entry at
|
||||
* the same position; entries whose length does not match the emitted block
|
||||
* count discard the whole envelope. An adapter whose metadata is independent
|
||||
* of block structure omits this field and the envelope passes through
|
||||
* assembly unchanged.
|
||||
*/
|
||||
blocks?: readonly unknown[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
@@ -176,8 +199,8 @@ type StreamChunk =
|
||||
| {
|
||||
type: 'finish'
|
||||
reason: FinishReason
|
||||
/** Adapter-private lossless-JSON state for replaying a successful response. */
|
||||
replayState?: unknown
|
||||
/** Replay metadata for a successful response; see {@link ReplayEnvelope}. */
|
||||
replayState?: ReplayEnvelope
|
||||
}
|
||||
```
|
||||
|
||||
@@ -215,7 +238,7 @@ interface LlmFailure {
|
||||
- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
|
||||
- **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。
|
||||
- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明。
|
||||
- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmRuntime` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容以及提供方/模型字段,不会收到私有状态。
|
||||
- **回放状态归适配器所有;其切分是共享词汇。** 成功的 `finish` 可以携带一个 `ReplayEnvelope`:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。对齐关系是 harness 的词汇——组装丢弃某个块时,同一位置的条目一并丢弃,因此存储的元数据始终描述存储的内容。循环把裁剪后的数据与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmRuntime` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容以及提供方/模型字段,不会收到私有状态。持久化内容保持权威:读取适配器无法使用的已存状态只会把这一条消息降级为提供方无关转换并带出诊断,而不是让请求失败。
|
||||
|
||||
## `ResolvedRetryPolicy`
|
||||
|
||||
@@ -273,6 +296,8 @@ interface TokenUsage {
|
||||
|
||||
`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责把 `StreamChunk` 流折叠回 `ContentBlock`、usage、结束原因与回放状态。循环在记录原始分片的同时,把同一批分片送入 assembler,再将组装后的 assistant 内容连同生成它的提供方和模型一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。
|
||||
|
||||
内容与元数据共用同一次保留/丢弃决定:`max-tokens` 结束会丢弃每个工具调用,因为被截断的调用不能安全执行,而同一决定会在每个被丢弃的位置裁剪回放数据的逐块条目。无论组装移除什么,`blocks()` 与 `replayState` 都不可能不一致。
|
||||
|
||||
```ts public-api
|
||||
/**
|
||||
* Incrementally assembles raw {@link StreamChunk}s into complete
|
||||
@@ -302,8 +327,12 @@ declare class BlockAssembler {
|
||||
get usage(): TokenUsage | undefined;
|
||||
/** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
|
||||
get finish(): FinishReason;
|
||||
/** Adapter-private replay state from the terminal finish chunk, if any. */
|
||||
get replayState(): unknown;
|
||||
/**
|
||||
* Replay metadata from the terminal finish chunk, if any, with per-block
|
||||
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
||||
* entries do not align with the emitted blocks.
|
||||
*/
|
||||
get replayState(): ReplayEnvelope | undefined;
|
||||
/**
|
||||
* The assembled assistant message.
|
||||
* @param source - producer attribution for the assembled message.
|
||||
|
||||
Reference in New Issue
Block a user