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
+20 -4
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)`.
+4 -17
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)
}
+3 -15
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
*/
+4 -13
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
*/
+4 -10
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
*/
+5 -11
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
*/
@@ -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).