Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -33,10 +33,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
## Limitations (MVP, documented deliberately)
- `tool_choice` is not mapped (not part of the core vocabulary).
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
@@ -44,3 +40,23 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LI
## Testing
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
## Model Experience
### DeepSeek request
**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
### DeepSeek response
**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
## Known Limitations and Deferred Work
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.

View File

@@ -52,14 +52,8 @@ export class DeepSeekAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
// TODO(http): deliberately raw `fetch` for the hand-rolled SSE body.
// `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout
// uniformity AND can stream (`responseType: 'stream'` yields the same
// ReadableStream<Uint8Array> parseSse consumes), but adopting it today
// costs a hard `undici` dependency (it does `require('undici')` with no
// globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file`
// import (pulling file-type + mime-types) for a file:// path we never hit.
// Revisit when a second adapter wants shared proxy/intercept config.
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
// outweighs its additional runtime dependencies.
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers: {
@@ -79,15 +73,8 @@ export class DeepSeekAdapter extends LlmAdapter {
const parsed = await response.json() as WireError
if (parsed.error?.message) message = parsed.error.message
} catch {
// Paranoid by design: `code` and the HTTP status are ALREADY captured
// above (and passed to LlmError below), so the only thing this `try`
// can add is a richer provider-supplied message. A malformed, empty,
// or non-JSON error body is a normal thing for gateways/proxies to
// return on a 5xx/429 — swallowing the parse failure keeps the usable
// status-line message instead of letting a JSON.parse throw mask the
// real HTTP error. Nothing else reaches this catch: response.json()
// is the sole statement, and any non-parse failure (e.g. body already
// consumed) is equally non-actionable here.
// Only swallow error-body parsing: status and code are already captured,
// so malformed gateway JSON must not mask the actionable HTTP failure.
}
throw new LlmError(message, code, response.status)
}

View File

@@ -1,19 +1,7 @@
/**
* DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the
* `deepseek` provider route on `ctx.llm`.
*
* Config is cordis-native (schemastery). Secrets flow per the repo policy:
* `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`)
* or from the environment directly; never from ad-hoc files.
*
* ```yaml
* - id: llm-deepseek
* name: '@deepseek-ai/dsh-llm-deepseek'
* config:
* apiKey: !!js process.env.DEEPSEEK_API_KEY
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
* ```
*
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses
* Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`,
* as shown in the package README, rather than reading ad hoc files.
* @module @deepseek-ai/dsh-llm-deepseek
*/

View File

@@ -1,17 +1,8 @@
/**
* Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the
* DeepSeek chat-completions request body.
*
* Block-type mapping (core types handled explicitly; merge-extensible unions
* mean plugin-added block types exist — they are skipped, never errors):
*
* - user `text` → string content (joined)
* - assistant `text` → `content`; `reasoning` → `reasoning_content`, but
* ONLY on assistant messages that carry tool calls (the official passback
* rule for thinking mode — required there, ignored elsewhere, so we save
* the tokens elsewhere); `tool-call` → `tool_calls[]`
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
*
* Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text
* becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages.
* Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by
* thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected.
* @module dsh-llm-deepseek/serialize
*/

View File

@@ -1,15 +1,9 @@
/**
* Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines;
* CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal
* `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}.
*
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
*
* Yields each event's `data:` payload as a string, ending with the literal
* `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream
* that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`.
*
* Handles the wire realities: payloads split across network reads at
* arbitrary byte positions (including mid-UTF-8), CRLF line endings,
* multi-`data:` events (joined with newlines per the SSE spec), comment
* lines, and non-data fields (ignored).
*
* @module dsh-llm-deepseek/sse
*/

View File

@@ -1,16 +1,10 @@
/**
* Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool
* call index. An empty initial reasoning delta does not open a block. Finish reason and the latest
* usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes
* while ensuring no chunk follows `finish`.
*
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
*
* A small state machine over the SSE payload stream:
* - `delta.content` / `delta.reasoning_content` / `delta.tool_calls[i]` each
* own one harness block (index allocated on first sight). The first
* thinking-mode chunk carries `reasoning_content: ""` — that must NOT open
* a reasoning block.
* - `finish_reason` and `usage` are DEFERRED: emitted only at the `[DONE]`
* sentinel, so the wire's two usage shapes (attached to the finish chunk,
* or a trailing usage-only chunk) both work and nothing ever follows
* `finish`. Last usage wins.
*
* @module dsh-llm-deepseek/translate
*/

View File

@@ -187,7 +187,7 @@ describe('serializeRequest', () => {
})
})
describe('review fixes: assistant content shapes', () => {
describe('assistant empty and tool-call content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).

View File

@@ -54,3 +54,24 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
## Testing
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`.
## Model Experience
### Provider request through pi-ai
**What the model sees**: The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
**Token effect**: Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state.
### Provider response
**What the model sees**: pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings.
**Token effect**: Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately.
## Known Limitations and Deferred Work
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
- **`LlmError.status` is unavailable for in-stream failures** — pi-ai error events do not expose a stable HTTP status across providers.

View File

@@ -75,8 +75,8 @@ export class PiAiAdapter extends LlmAdapter {
}
const model = resolveModel(profile, options.model)
// pi-ai's event stream has no iterator-return cancellation hook: abort its
// provider stream when our consumer exits early as well as on caller abort.
// Pi-ai has no iterator-return cancellation hook. Chain an internal signal
// and abort it when this generator exits so early consumers stop the HTTP stream.
const controller = new AbortController()
const onCallerAbort = (): void => { controller.abort(options.signal?.reason) }
if (options.signal?.aborted) controller.abort(options.signal.reason)

View File

@@ -35,7 +35,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
### App attribution (`attribution.ts`)
Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution RFC](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
### Classes
@@ -46,4 +46,17 @@ Every product adapter must identify the application on every provider HTTP reque
### Real adapters
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) is a hand-rolled DeepSeek fetch/SSE adapter, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves any configured provider/model in pi-ai's installed catalog. The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
## Model Experience
None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message.
## Known Limitations and Deferred Work
- **No retry/caching/rate-limit layer ships** — `llm/stream` is the intended wrap seam and has no production listener, so provider 429/5xx failures surface immediately.
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release.
- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround.

View File

@@ -74,10 +74,8 @@ export class BlockAssembler {
}
case 'block-end': {
const partial = this.ensure(chunk.index, chunk.block.type)
// First close wins: a second block-end for an already-closed index is
// a straggler (same rule as post-close deltas). Ignoring it keeps the
// streamed prefix and the final blocks() in agreement — otherwise a
// re-close could rewrite a block already flushed downstream.
// First close wins; ignoring re-close stragglers keeps streamed output
// and the final assembled block in agreement.
if (partial.block) return
partial.block = chunk.block
return chunk.block

View File

@@ -1,14 +1,9 @@
/**
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
* adapters from drifting. See
* `docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
*
* App-attribution vocabulary for provider requests.
*
* Every product LLM adapter must identify the application on every provider
* HTTP request (see the adapter contract on {@link ../index.ts LlmAdapter}):
* a static, non-secret product identity, sent as the standard `User-Agent`.
* Adapters obtain the headers from {@link attributionHeaders} instead of
* hand-copying constants, so the identity cannot drift between
* implementations. The policy and its rationale are pinned in
* docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md.
*
* @module @deepseek-ai/dsh-llm/attribution
*/

View File

@@ -1,14 +1,8 @@
/**
* The call configuration of a conversation and its comparison/freeze
* utilities. `LlmCallConfig` is the non-content third of the request header
* (see `EpochHeader` in dsh-session): everything about a request besides its
* message content that can undermine provider KV-cache reuse — `provider` and
* `model` select the adapter and cache namespace outright, and the sampling
* scalars are treated the same way out of caution. It is per-conversation state recorded in the
* session log (the reconstructability RFC), never a silently-drifting
* per-call knob: the `agent/request` waterfall proposes a replacement, and
* the loop logs a real change as a `request/header-delta` event.
*
* Conversation call configuration and freeze utilities. Model and sampling
* values, including provider routing, are request-header state that can affect cache reuse; request
* waterfalls replace them and the loop logs changes instead of allowing
* silent per-call drift.
* @module dsh-llm/call-config
*/
@@ -40,16 +34,9 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
}
/**
* Deep-freeze a value in place so any later mutation throws (ESM code runs in
* strict mode), and return it. The loop freezes every request it builds
* before dispatch — `llm/stream` listeners and adapters read the request,
* never rewrite it, so the wire bytes cannot silently desync from what the
* session log reconstructs. Guards against cycles with a WeakSet: loop-built
* requests hold `structuredClone`d JSON-validated session data, but the
* helper accepts arbitrarily constructed values. One exemption: an
* `AbortSignal` is never entered or frozen — it is the request's live
* cancellation channel, and freezing one breaks `AbortController.abort()`
* outright (Node stores the aborted flag as an own property of the signal).
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
* {@link AbortSignal} objects are deliberately skipped because they are the
* request's live cancellation channel and freezing them breaks abort.
* @param value - the value to freeze in place.
* @returns the same value, frozen.
*/

View File

@@ -1,13 +1,6 @@
/**
* The harness error taxonomy: one base class so failures carry a stable,
* machine-routable `code` and chain their `cause`, instead of flattening to a
* bare message string. Per-package errors extend {@link HarnessError}; the
* tool layer surfaces `{ name, code }` on results and the session `tool/result`
* event so retry/sandbox plugins and replay can distinguish failure classes.
*
* Lives in dsh-llm (the leaf package every other imports) so a single base is
* shared without a new dependency edge. See the error-taxonomy RFC.
*
* Harness error base with a stable machine-routable code and chained cause.
* Package errors extend it so tool results and replay can retain failure class.
* @module @deepseek-ai/dsh-llm/error
*/

View File

@@ -55,23 +55,10 @@ export class LlmError extends HarnessError {
}
/**
* Base class for LLM provider adapters.
*
* An adapter translates between the harness vocabulary (Message/ContentBlock/
* StreamChunk) and one provider's wire format. Adapters register themselves
* via `ctx.llm.registerAdapter(providers, adapter)`.
*
* Real implementations: `@deepseek-ai/dsh-llm-deepseek` (hand-rolled
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
* deliberately different internals over the same contract; see the
* adapter contract documented on `StreamChunk` in `./types.ts`.
*
* App attribution is part of the adapter contract: every HTTP request to a
* provider carries the headers from `attributionHeaders()` (`./attribution.ts`)
* — the standard `User-Agent` baseline everywhere. An adapter proves it with
* a wire-level test (a mock server asserting the received header), or, for a
* library-backed adapter, by asserting the library's header hook delivers the
* same value to the wire.
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
* `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled
* DeepSeek and pi-ai adapters intentionally exercise this contract through different internals.
*/
export abstract class LlmAdapter {
/**

View File

@@ -1,31 +1,14 @@
/**
* Exhaustiveness helper for switches over core unions.
*
* # When to use which pattern
*
* **Closed unions** (every variant is known at compile time in the consuming
* code — e.g. `StreamChunk` inside the assembler, `FiberState`-like enums):
* end the switch with `default: assertNever(value)`. Adding a variant then
* fails compilation at every switch that must handle it — the error appears
* exactly where work is needed.
*
* **Merge-extensible unions** (plugins add variants via declaration merging —
* `SessionEventMap`, `ContentBlockMap`, `MessageSourceMap`, …): do NOT use
* assertNever. From the core's view the union is open; plugin-added variants
* are valid values the core has never heard of. Handle the known cases and
* fall through intentionally, with a comment saying the switch is
* deliberately non-exhaustive (see `Session.deriveMessages`). The lint rule
* `switch-exhaustiveness-check` enforces that the choice is explicit either
* way.
*
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
* new variant fails compilation at every required handler. Do not use it for declaration-merged
* unions such as session events or content blocks: handle known variants and explicitly fall
* through because plugins may add valid unknown cases.
* @module @deepseek-ai/dsh-llm/never
*/
/**
* Marks unreachable code on a closed union. If this is reachable, either a
* variant was added without updating the switch (compile error at the call
* site — the desired outcome) or a value escaped its type (runtime throw
* with diagnostics — the safety net).
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
* a value that escaped its type throws with diagnostics at runtime.
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
* @returns never — it always throws, with the offending value JSON-rendered in the message.

View File

@@ -1,22 +1,7 @@
/**
* Provider-neutral message and streaming vocabulary.
*
* This is the canonical language spoken by the agent loop, session logs, and
* every plugin. Adapters translate it to provider wire formats (DeepSeek V4
* first); nothing outside an adapter should ever see a provider-specific
* shape.
*
* Extensibility: the unions in this file are derived from interfaces
* (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`) so that plugins
* can extend them via declaration merging:
*
* ```ts
* declare module '@deepseek-ai/dsh-llm' {
* interface ContentBlockMap {
* video: { type: 'video'; url: string }
* }
* }
* ```
* Canonical provider-neutral message and streaming vocabulary for the loop,
* session log, and plugins. Adapters alone translate provider wire shapes;
* mapped interfaces make the content, source, and finish unions extensible.
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -53,15 +38,8 @@ export interface ToolResultBlock {
}
/**
* All known content block shapes, keyed by their `type` tag.
* Merge-extensible: plugins add new block types via declaration merging.
*
* The core set is deliberately limited to blocks every shipping path honors.
* Multimodal content (images, audio, …) has no core block type: a feature
* that needs one adds it via declaration merging in the same coordinated
* change that maps it in the adapters, surfaces it in the UI bridges, and
* prices it in compaction — a producer never lands without its consumers
* (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md).
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
*/
export interface ContentBlockMap {
'text': TextBlock
@@ -145,25 +123,10 @@ export interface TokenUsage {
/**
* Raw streaming protocol emitted by adapters.
*
* A streaming response interleaves several typed blocks (text, reasoning,
* multiple tool calls); `index` ties each delta to its block, and `block-end`
* carries the fully-assembled ContentBlock so consumers don't have to
* re-assemble deltas themselves (use {@link BlockAssembler} when they do).
*
* Adapter contract — every adapter MUST obey these, and every consumer may
* rely on them:
* - Emit `usage` BEFORE `finish`, and nothing after `finish` (defer both to
* the provider's end-of-stream marker so trailing usage-only chunks can't
* violate this).
* - Tool-call `arguments` stay RAW JSON strings end-to-end; partial fragments
* stream via `argumentsDelta` (providers that hand back parsed objects
* re-stringify at `block-end`).
* - Failures may either THROW from `stream()` (transport/protocol errors) or
* end the stream with `finish {kind:'error'|'aborted'}` (provider in-band
* errors, for adapters that can't throw mid-stream); consumers must handle
* both. The agent loop translates a finish-error/aborted into a turn error —
* it never logs a normal completed assistant message for a failed step.
* Block indexes correlate interleaved deltas, and `block-end` carries the
* assembled block. Adapters emit usage before the terminal finish and nothing
* afterward; tool arguments remain raw JSON strings. Failures either throw or
* end with `error`/`aborted`, and consumers must handle both paths.
*/
export type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
@@ -219,17 +182,8 @@ export interface GenerateOptions {
stop?: string[]
signal?: AbortSignal
/**
* The id of the session this request belongs to — stamped by the agent loop
* from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener
* route a call by WHICH session issued it (the replay adapter keys its per-call
* cursor by session, so a parent and its in-process subagent — each with its
* own session on one context — replay from their own recorded scripts).
*
* Typed as `Branded<'SessionId'>` rather than importing `SessionId` from
* `dsh-session`: that package imports `Message` from here, so importing its
* `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a
* real session id assigns with no cast. (A future ids package could own the
* brand and dissolve this note.)
* Session identity stamped by the loop for listener routing. Adapters ignore
* it; replay uses it to keep concurrent parent and child cursors independent.
*/
sessionId?: Branded<'SessionId'>
}

View File

@@ -63,10 +63,8 @@ describe('BlockAssembler', () => {
it('throws from assemble() when a partial has an unhandled blockType', () => {
const assembler = new BlockAssembler()
// A partial whose blockType is not text/reasoning/tool-call cannot be
// assembled without its block-end. A plugin-added block type (here
// 'video', via the merge-extensible ContentBlockMap) opened by a
// block-start with no closing block-end exercises that throw.
// Unknown declaration-merged block types cannot be assembled from partial deltas. Opening a
// plugin-added `video` block without its required `block-end` exercises that failure.
assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk)
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"')
})
@@ -135,13 +133,9 @@ describe('assertNever', () => {
})
})
describe('BlockAssembler regressions (property-test findings)', () => {
describe('BlockAssembler duplicate-close contract', () => {
it('first block-end wins: a duplicate block-end for a closed index is ignored', () => {
// Found by fast-check (the property-testing RFC): two block-ends at the same index made the
// streamed prefix (first block) disagree with final blocks() (second
// block). The first close must win — same straggler rule as post-close
// deltas — so the prefix returned incrementally by push() and the final
// blocks() stay identical.
// The first close wins so streamed and final output cannot disagree.
const chunks: StreamChunk[] = [
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'second' } },