mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'worktree/agent-execution-context-rfc' into worktree/explicit-turn-signal
This commit is contained in:
@@ -81,7 +81,7 @@ Resolved targets must expose at least three concepts:
|
||||
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
|
||||
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
|
||||
|
||||
Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC: Session surface — a linked list over the event log for LLM message derivation
|
||||
# RFC: Session surface — an ordered projection over the event log
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -8,13 +8,13 @@ The event log is authoritative, but history manipulation had no durable shared m
|
||||
|
||||
## Decision
|
||||
|
||||
Add a **surface** — a derived, cached linked list of "surface nodes" (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log.
|
||||
Add a **surface** — a derived, cached order of event sequences (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log.
|
||||
|
||||
### Two new top-level fields on `SessionEvent`
|
||||
|
||||
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`** — seq numbers of events that are provenance sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). Provenance is a core design principle; without it, the replace-range operation cannot be validated on replay.
|
||||
- **`sourceEventSeqs?: number[]`** — seq numbers of events that are provenance sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; omission there means legacy or otherwise unrecorded provenance. Other surface events require a non-empty list when the field is present. Provenance is a core design principle; without it, the replace-range operation cannot be validated on replay.
|
||||
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
|
||||
|
||||
### SurfaceOp: two operations
|
||||
@@ -25,13 +25,13 @@ export type SurfaceOp =
|
||||
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
|
||||
```
|
||||
|
||||
1. **Append** — add a new node to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends, and `sourceEventSeqs` where applicable (e.g., `assistant/message` records its `assistant/chunk` sources; `tool/result` records its `tool/call` source).
|
||||
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source.
|
||||
|
||||
2. **Replace** — remove nodes from `start` through `end` (both inclusive) and insert a new node in their place. Both `start` and `end` must be valid surface node seqs in the current surface; `start === end` replaces a single node. The node's `sourceEventSeqs` must contain every shadowed surface node. The shadowed events remain in the log but are no longer on the surface.
|
||||
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
|
||||
|
||||
### SurfaceManager: delta-based, not full rebuild
|
||||
|
||||
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial delta folded on first access.
|
||||
A `SurfaceManager` class (private to `Session`) maintains one ordered `number[]` of event seqs. It tracks `_lastProcessedSeq` and processes only the new events since the last access rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial suffix folded on first access. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no link objects or seq-to-node map duplicate the order.
|
||||
|
||||
Delta processing is O(1) when no new events and O(new events) when new events arrive.
|
||||
|
||||
@@ -47,23 +47,24 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
|
||||
|
||||
### Invariants
|
||||
|
||||
The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows).
|
||||
The dev-mode invariants plugin validates: `sourceEventSeqs` references (only `assistant/message` may use an empty list; otherwise no duplicates, references earlier events, and references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows).
|
||||
|
||||
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`.
|
||||
- **Half-open `[start, endExclusive)` replace ranges** — rejected: the surface is a doubly-linked list whose ends are naturally named by node seqs, and single-node replacement (`start === end`) reads naturally with inclusive semantics.
|
||||
- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`start === end`) reads naturally with inclusive semantics.
|
||||
- **Linked node objects plus a seq map** — rejected: production did not read predecessor links, the only successor use was the next array position, and replacement already required linear `indexOf` lookup. A single seq array preserves the same asymptotic behavior with one representation to validate.
|
||||
- **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceIntent`), new fields on `SessionEvent`, modified `append()` (third required `SurfaceIntent` param), refactored `deriveMessages()` (walks the surface as the sole derivation path), surface-aware `repair.ts`. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
|
||||
- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array; `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
|
||||
- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance.
|
||||
- **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration).
|
||||
- **`packages/support/invariants`**: Surface-related validation rules.
|
||||
- **`packages/session-persistence/session-persistence-jsonl`**: No changes required.
|
||||
- **`packages/session-persistence/session-persistence`**: Abstract interface unchanged.
|
||||
|
||||
The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes — the new node takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
|
||||
The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
|
||||
|
||||
@@ -12,6 +12,8 @@ Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistenc
|
||||
|
||||
Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The RFC's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all.
|
||||
|
||||
The coordinator retires each live session from its `session/disposed` notification: it waits for that exact Session object's initialization, serializes a final drain, and then removes the owned state, buffer, and init entries. Failed drains retain their buffers for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still the current tail, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters the write-path listeners before awaiting all admitted retirements, remaining buffers, and chains, then closes the backend.
|
||||
|
||||
### The hook interface (`PersistenceBackend<TornMarker>`)
|
||||
|
||||
Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage:
|
||||
@@ -30,7 +32,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t
|
||||
|
||||
## Testing
|
||||
|
||||
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. A new `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, dispose-drain, crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). The per-backend specs shrank to storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
|
||||
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -39,4 +41,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever
|
||||
|
||||
## Consequences
|
||||
|
||||
The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
|
||||
@@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecReq
|
||||
|
||||
Three deliberate choices:
|
||||
|
||||
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly.
|
||||
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them.
|
||||
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins.
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`.
|
||||
|
||||
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Filesystem resolution used one plugin-load cwd while bash used the session proje
|
||||
|
||||
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
|
||||
|
||||
- `FileSystem.resolve` widens to `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. An options object (not a positional `cwd?`) leaves room for future resolution hints without another signature change.
|
||||
- `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth.
|
||||
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
|
||||
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default.
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
### The mechanism
|
||||
|
||||
**Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
|
||||
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
|
||||
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot. `request/header-delta` encodes system changes by common-prefix/suffix line trim, tools by name-keyed additions/removals/changes, and config or prefix by full replacement. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot for unrepresentable changes such as pure tool reordering.
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
|
||||
|
||||
Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written.
|
||||
|
||||
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written.
|
||||
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
||||
|
||||
**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
|
||||
|
||||
@@ -39,15 +39,16 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
- **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records.
|
||||
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
|
||||
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
|
||||
- **Narrative fields on the header events** (a `reason`/`changed` list on deltas): derivable by diffing consecutive events — one home per fact; snapshots carry a reason because an anchor's cause is NOT derivable from the data.
|
||||
- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation.
|
||||
- **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
|
||||
- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern).
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern).
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam.
|
||||
- Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Session logs grow one `request/header` snapshot per conversation (system + tool schemas: the dominant term), plus deltas on real changes — small next to `assistant/chunk` volume; `SESSION_FORMAT_VERSION` stays `0` (pre-release churn is absorbed, backends reject-not-migrate).
|
||||
- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
|
||||
- Snapshot goldens changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
|
||||
- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# RFC: Tool result retention library
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts.
|
||||
|
||||
The shared abstraction the tools need is **retention**, not generic collection. A caller feeds items or text chunks into a bounded object and later receives the retained content plus exact omission metadata. Tool-specific code still owns business semantics: file grouping, line numbering, exit codes, provider error states, spill files, and model-facing prose. The common library owns only the mechanical question "what did we keep, and what did we omit?"
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-retention` lives under `packages/util/` (peer to `dsh-brand` and `dsh-timeout`) and owns bounded model-facing output. It is a library of pure classes and functions, **not** a Cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. Tool packages import it directly when they need bounded output.
|
||||
|
||||
The library has two independent retainers:
|
||||
|
||||
- `ItemRetainer<T>` handles ordered logical units such as paths, grep matches, or search sources. It supports `head` retention only in v1, while keeping the retainer shape open to additional retention strategies later.
|
||||
- `TextRetainer` handles byte-oriented text streams such as bash stdout/stderr or web response bodies. It supports `head`, `tail`, and `headTail` retention while preserving UTF-8 boundaries at `finish()`.
|
||||
|
||||
Both retainers return a small `PushDecision` after each `push()` so callers can tell whether that unit/chunk was fully retained and whether the accumulated result is now truncated. Omission counts are exact because callers keep feeding every observed item/chunk.
|
||||
|
||||
```ts ignore-check
|
||||
/**
|
||||
* How much content the retainer omitted.
|
||||
*
|
||||
* `unknown` is reserved for callers that omit without a count; the retainers
|
||||
* themselves return `none` or `exact`.
|
||||
*/
|
||||
type Omitted =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'exact'; count: number }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
interface PushDecision {
|
||||
kept: boolean
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for ordered logical units.
|
||||
*/
|
||||
interface RetainedItems<T> {
|
||||
items: T[]
|
||||
truncated: boolean
|
||||
seen: number
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for text streams.
|
||||
*
|
||||
* The returned `text` is safe to send to a formatter; the retainer does not add
|
||||
* tool-specific headers, exit markers, XML tags, or recovery instructions.
|
||||
*/
|
||||
interface RetainedText {
|
||||
text: string
|
||||
truncated: boolean
|
||||
omittedBytes: Omitted
|
||||
}
|
||||
```
|
||||
|
||||
### Strategies
|
||||
|
||||
Item retention supports a head window. Text retention supports head, tail, and headTail byte windows.
|
||||
|
||||
```ts ignore-check
|
||||
type ItemRetentionStrategy =
|
||||
| {
|
||||
/** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
|
||||
kind: 'head'
|
||||
maxItems: number
|
||||
}
|
||||
|
||||
type TextRetentionStrategy =
|
||||
| {
|
||||
/** Keep the first `maxBytes` bytes. */
|
||||
kind: 'head'
|
||||
maxBytes: number
|
||||
}
|
||||
| {
|
||||
/** Keep the final `maxBytes` bytes. Requires reading to the end. */
|
||||
kind: 'tail'
|
||||
maxBytes: number
|
||||
}
|
||||
| {
|
||||
/** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
|
||||
kind: 'headTail'
|
||||
headBytes: number
|
||||
tailBytes: number
|
||||
}
|
||||
```
|
||||
|
||||
### Tool mapping
|
||||
|
||||
`read` is intentionally outside the v1 retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`.
|
||||
|
||||
`FsGlobEntry` and `FlatGrepMatch` below are the intended discovery-tool item shapes, not existing retention-library exports. `FsGlobEntry` is one backend-derived path, and `FlatGrepMatch` is one ungrouped grep match before the backend groups retained matches by file.
|
||||
|
||||
`glob` uses `ItemRetainer<FsGlobEntry>` with `{ kind: 'head', maxItems: globMaxResults }` after collecting the full sorted path list. The tool keeps the retained first page inline and may save the full list through the spill seam. Path mapping, skipped candidates, and `incomplete` stay outside the retainer.
|
||||
|
||||
`grep` uses `ItemRetainer<FlatGrepMatch>` with `{ kind: 'head', maxItems: grepMaxMatches }` before grouping. The executor parses ripgrep output, maps paths, applies per-line preview truncation, and pushes flat matches. After `finish()`, the tool groups retained matches by file and can save the full match list through the spill seam when the inline result is capped. Grouping is not part of the retainer because the cap is total matches, not files; per-match preview truncation and `incomplete` are also separate from result-level retention.
|
||||
|
||||
`bash` can use `TextRetainer` with `tail` or `headTail` and reads to process completion. The bash executor still owns spill files, exit status, signal, timeout, and background-task behavior; the retention helper only replaces ad hoc in-memory head/tail accounting where that behavior is desired. Long-running task ownership remains orthogonal to the [generic long-running tool runtime](2026-06-20-generic-long-running-tool-runtime.md).
|
||||
|
||||
`web_fetch` can use `TextRetainer` with `head` or `headTail`, or keep provider-owned body caps when the provider must read and decode internally. Either way, the fetch result's `truncated` remains a provider/tool fact, and the library only supplies retained text and omission metadata.
|
||||
|
||||
`web_search` can use `ItemRetainer<WebSearchSource>` with `head`. Current providers often return an array, so this is post-hoc but still standardizes notices.
|
||||
|
||||
### Notices
|
||||
|
||||
The library exposes a neutral notice shape and a tiny formatter hook, but tools provide the user-facing words. A grep footer says "Narrow the pattern, path, or include"; a web fetch footer says "Fetch a more specific URL or section"; bash may point to a spill file. The retainer cannot know those recovery actions.
|
||||
|
||||
```ts ignore-check
|
||||
interface RetentionNotice {
|
||||
scope: string
|
||||
strategy: 'head' | 'tail' | 'headTail'
|
||||
unit: 'items' | 'bytes' | 'chars' | 'lines'
|
||||
limit: number | { head: number; tail: number }
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
const formatGrepNotice = (notice: RetentionNotice): string =>
|
||||
formatRetentionNotice(
|
||||
notice,
|
||||
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
||||
)
|
||||
```
|
||||
|
||||
The formatter hook is deliberately small: a tool turns a `RetentionNotice` into its own footer text. The helper may standardize omission wording, but it does not own recovery guidance.
|
||||
|
||||
`truncated` means the retainer omitted otherwise-available content because of a budget. It does not mean the upstream was incomplete. Tools keep separate fields for permission failures, skipped binary files, provider partial failures, unreadable candidates, invalid UTF-8, and any other "could not inspect" condition.
|
||||
|
||||
## Consequences
|
||||
|
||||
**What shipped.** `@deepseek-ai/dsh-retention` exports `ItemRetainer`, `TextRetainer`, the result types (`RetainedItems`, `RetainedText`), the strategy types (`ItemRetentionStrategy`, `TextRetentionStrategy`), `Omitted`, `PushDecision`, `RetentionNotice`, and the neutral notice helpers `describeOmitted` / `formatRetentionNotice` — with no dependency on Cordis or any tool package. Unit tests cover item-head retention with exact omission counts, text-head retention, text-tail retention, head-tail byte retention, zero budgets, UTF-8 boundary handling (2-, 3-, and 4-byte codepoints and invalid lead bytes at each cut), and unknown omission wording.
|
||||
|
||||
**What is documented but not yet migrated.** `glob`, `grep`, `bash`, `web_fetch`, and `web_search` have their mappings documented in the [package README](../../../../packages/util/retention/README.md), but not every tool has been migrated onto the library in this change; migration is deliberately separate follow-up work. `read` is documented as intentionally out of scope: its `read-render` line-window contract (`offset`/`limit`, `totalLines`, offset-range errors, per-line preview truncation, a byte cap over the selected window) is not generic retention, and one `Omitted` count cannot represent both sides of a line window.
|
||||
|
||||
**Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording.
|
||||
|
||||
**Tradeoffs accepted.** The v1 surface deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Post-hoc `truncate(text)` only.** Rejected: it matches Codex's history/tool-output truncation use case but loses item counts, grouping boundaries, UTF-8-safe byte windows, and exact omission metadata.
|
||||
|
||||
**One generic `Collector<T>` with pluggable callbacks.** Rejected for v1: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small.
|
||||
|
||||
**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case.
|
||||
|
||||
**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned.
|
||||
|
||||
**Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive.
|
||||
@@ -0,0 +1,189 @@
|
||||
# RFC: Tool output spill policy
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools.
|
||||
|
||||
Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](./2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results.
|
||||
|
||||
The shape matches the timeout policy design: a tool author normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path.
|
||||
|
||||
## Decision
|
||||
|
||||
A thin spill storage seam plus a default spill policy plugin, in a new `packages/spill/` group:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-spill` | Interface: `ctx.spillStore`, vocabulary types, no storage implementation. |
|
||||
| `@deepseek-ai/dsh-spill-local` | Local backend: private, session-scoped file storage on the host filesystem. |
|
||||
| `@deepseek-ai/dsh-spill-policy` | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill locator. |
|
||||
|
||||
There is no dedicated model-facing consumer package. The consumer is the existing `ctx.tools` execution pipeline: `dsh-spill-policy` consumes final tool results through the `tools/post-execute` waterfall, and the model follows the backend-supplied retrieval hint for the returned locator.
|
||||
|
||||
### Spill seam
|
||||
|
||||
The storage seam is minimal: save text and return a locator plus retrieval hint.
|
||||
|
||||
```ts ignore-check
|
||||
interface SpillStore {
|
||||
saveText(input: SaveTextSpill): Promise<SpillRef>
|
||||
}
|
||||
|
||||
interface SpillSource {
|
||||
toolName: string
|
||||
callId: CallId
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SaveTextSpill {
|
||||
owner: { sessionId: SessionId }
|
||||
source: SpillSource
|
||||
suggestedName: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type SpillLocator = Branded<'SpillLocator'>
|
||||
|
||||
interface SpillRef {
|
||||
locator: SpillLocator
|
||||
bytes: number
|
||||
retrievalHint: string
|
||||
}
|
||||
```
|
||||
|
||||
`SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
|
||||
|
||||
`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `<root>/session-<hash>/<random>-<safeName>`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path.
|
||||
|
||||
### Spill policy
|
||||
|
||||
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
|
||||
|
||||
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
|
||||
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
|
||||
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
|
||||
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
|
||||
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
|
||||
|
||||
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
|
||||
|
||||
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
|
||||
|
||||
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
|
||||
|
||||
## Showcase: web_fetch
|
||||
|
||||
`web_fetch` is the first showcase because it returns a naturally large text result and needs no tool-specific spill code. The tool is ordinary:
|
||||
|
||||
```ts ignore-check
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'web_fetch',
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
|
||||
return [{ type: 'text', text: formatFetchOutput(result) }]
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
With `dsh-spill-policy` configured, a large formatted fetch result is automatically retained and spilled. A deployment demonstrates the behavior by setting the provider resource cap higher than the policy cap:
|
||||
|
||||
```yaml
|
||||
- id: web-fetch-local
|
||||
name: '@deepseek-ai/dsh-web-fetch-local'
|
||||
config:
|
||||
maxBodyChars: 500000
|
||||
|
||||
- id: spill-local
|
||||
name: '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
```
|
||||
|
||||
This separation is important. `web-fetch-local` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
|
||||
|
||||
## Relationship to retention and early spill
|
||||
|
||||
Retention is separate from spill storage:
|
||||
|
||||
- `@deepseek-ai/dsh-retention` owns preview mechanics (`TextRetainer`, `ItemRetainer`, and omitted metadata).
|
||||
- `@deepseek-ai/dsh-spill` owns saving final text and returning a locator plus retrieval hint.
|
||||
- `@deepseek-ai/dsh-spill-policy` applies the default final-result policy in the tool pipeline, composing the two.
|
||||
|
||||
The final-result policy cannot replace tool-owned early spill. Some useful content is not present in final `ToolExecutionResult.content`:
|
||||
|
||||
- `bash` final output is already a tail plus a temp spill path; the complete stdout/stderr streams live in executor files.
|
||||
- `subagent` final output is the child final answer, not the child rollout.
|
||||
- Future tools may produce runtime artifacts that are never represented by their final `ToolExecutionResult.content`.
|
||||
|
||||
Those cases can consume `ctx.spillStore` directly in later work. They are not part of the first showcase.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new model-facing `artifact_read` or `artifact_search` tool in v1.
|
||||
- No per-tool retention configuration in v1.
|
||||
- No model-facing timeout/truncation arguments.
|
||||
- No migration of `read` output into spill files.
|
||||
- No replacement for provider/resource caps such as `web-fetch-local.maxBodyChars`.
|
||||
- No bash temp-file normalization or subagent rollout capture in the first cut.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `saveFile()` / `linkOrCopy` for existing executor spill files, needed for bash normalization.
|
||||
- Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL).
|
||||
- Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient.
|
||||
- Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
|
||||
- Cleanup and retention policy for old spill files, likely tied to session cleanup.
|
||||
|
||||
## Testing
|
||||
|
||||
- `dsh-spill` unit tests pin the seam contract: registration as `ctx.spillStore`, one-implementation-per-context, and disposal release.
|
||||
- `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection.
|
||||
- `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`).
|
||||
- `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result.
|
||||
- The `coding-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`).
|
||||
|
||||
## Consequences
|
||||
|
||||
The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work.
|
||||
|
||||
Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators.
|
||||
|
||||
The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader.
|
||||
|
||||
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
|
||||
|
||||
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require each tool to opt in with a retention declaration.** Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
|
||||
|
||||
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
|
||||
|
||||
**Use `ctx.fs.writeText` or the model-facing `write` tool.** Rejected: workspace filesystem writes carry project-file semantics, write/edit policy, observation state, and user-facing side effects. Spill files are runtime artifacts, not model-authored workspace edits. The existing `read` tool may inspect them later, but creation belongs to the runtime spill seam.
|
||||
|
||||
**Let `web-fetch-local` fetch without caps and rely on spill-policy.** Rejected: spill-policy runs after the final tool result exists and cannot protect network, memory, or decoding resources. Provider resource caps stay mandatory.
|
||||
|
||||
**Merge retention into spill.** Rejected: retention and spill have different responsibilities. `TextRetainer`/`ItemRetainer` decide what preview is kept and what was omitted; spill storage only saves the final text the policy asks it to save.
|
||||
@@ -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
|
||||
2026-07-14-provider-routed-llm-adapters.md: 75ef047a7f95621d9a9c018b6dc57439e6f2bb22
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 75ac9adbe5f8e96930a72a977c1969ff3a119ee8
|
||||
@@ -0,0 +1,91 @@
|
||||
# RFC: Provider-routed LLM adapters and a generic pi-ai backend
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-14-provider-routed-llm-adapters.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-llm` registered adapters by exact model name. A plugin supplied a model list at Cordis startup, `LlmService` stored one adapter per listed string, and `GenerateOptions.model` selected the adapter and the provider model at once. This worked while both shipping adapters targeted the same two DeepSeek models, but it conflated two independent decisions: which upstream provider owns a request, and which model that provider should run.
|
||||
|
||||
The conflation prevents a provider gateway from serving an open-ended model catalog. OpenRouter, for example, is one provider with many model ids, while a private OpenAI-compatible endpoint may add models without changing the Harness plugin tree. Every newly selected model currently needs to have been registered during plugin startup. The same model id can also exist at multiple providers, so model-only registration cannot state which provider the caller intended.
|
||||
|
||||
`dsh-llm-pi-ai` exposed none of pi-ai's provider abstraction. It constructed an inline DeepSeek `openai-completions` model, applied DeepSeek-specific payload patches, and stamped every replayed assistant message as DeepSeek. pi-ai itself has a provider/model catalog, selects APIs such as `openai-responses`, `anthropic-messages`, and `google-generative-ai`, and preserves provider-specific response ids and reasoning/tool signatures for later turns. The Harness conversion dropped that provenance, so simply replacing the inline model with a catalog lookup would have made same-model replay and cross-provider handoff incomplete.
|
||||
|
||||
The adapter configuration also assumes one DeepSeek API key and endpoint. A generic backend needs independent credentials and endpoint overrides per provider while leaving AWS, Google ADC, OAuth, and other ambient authentication mechanisms to pi-ai.
|
||||
|
||||
## Decision
|
||||
|
||||
### Provider is the adapter registration key
|
||||
|
||||
`GenerateOptions` and `LlmCallConfig` carry `provider: string` beside `model: string`; `AgentOptions` carries the corresponding optional creation field. A loop request is valid only after both values are non-empty, and both values are part of the logged request header. `agent/request` may return a replacement pair on any step, so a session can switch providers and models without changing the Cordis plugin lifecycle.
|
||||
|
||||
`LlmService` registers and resolves adapters by provider. `registerAdapter(providers, adapter)` checks the entire provider list before mutating the registry, rejects a duplicate with `DUPLICATE_ADAPTER`, and disposes the whole registration as one effect. Model ids are not registration keys; the selected adapter still validates or forwards them. The later [LLM catalog and ACP selection RFC](2026-07-15-llm-model-catalog-and-acp-selection.md) added advisory `listProviders()` / `listModels()` discovery without turning model membership into request validation.
|
||||
|
||||
A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` registers `deepseek`; `dsh-llm-pi-ai` may also register `deepseek`, but loading both owners is a configuration error rather than an ordering rule or fallback. A deployment that wants the hand-rolled DeepSeek implementation excludes `deepseek` from the pi-ai profiles. A deployment that wants pi-ai's DeepSeek implementation does not mount `dsh-llm-deepseek`.
|
||||
|
||||
`dsh-llm-deepseek` removes its model registration list and accepts any model string routed through provider `deepseek`. Its request serialization, `/chat/completions` endpoint, thinking options, SSE parsing, and error behavior remain unchanged; `options.model` is still sent verbatim.
|
||||
|
||||
### Explicit pi-ai provider profiles
|
||||
|
||||
`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, timeouts, and retry settings. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
|
||||
|
||||
The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
|
||||
|
||||
The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix.
|
||||
|
||||
pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer.
|
||||
|
||||
### Durable assistant provenance and replay state
|
||||
|
||||
Assistant messages carry provider-neutral provenance containing the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records this provenance and `deriveMessages()` returns it with the assistant message. User, system, context, and tool-result messages carry no assistant provenance. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
|
||||
|
||||
A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches it to the assistant provenance only when the post-`agent/step-result` content is structurally equal to the assembled provider output. A listener that rewrites content keeps the provider/model provenance but loses the now-stale replay state. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
|
||||
|
||||
The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmService` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content and provenance.
|
||||
|
||||
This state is model-visible replay input and therefore follows the existing [reconstructable-request rule](../../implemented/architecture/2026-07-05-reconstructable-requests.md): it is present in both the terminal `finish` chunk and the assembled `assistant/message` provenance that drives derivation. Resume and fork preserve it verbatim. Compaction that shadows the assistant message also removes its replay state from the active surface; the summary is ordinary provider-neutral content.
|
||||
|
||||
### Propagate the target through every request producer
|
||||
|
||||
Every model-selection surface carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`.
|
||||
|
||||
Compaction configuration gains `summarizationProvider` beside `summarizationModel`. Both are empty to inherit, or both are non-empty to select an explicit target; a half-configured pair fails load. Inheritance uses the last logged request target when one exists and falls back to the agent's creation options. `compact/summary` records both fields with the existing model-call envelope.
|
||||
|
||||
The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter.
|
||||
|
||||
The on-disk session format remains the pre-release pinned version `0`, with no compatibility promise. Seed/load validation rejects request headers lacking provider and assistant messages lacking required provenance instead of accepting an old shape that can no longer reconstruct the request.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep model names as registry keys and add wildcard adapters.** A wildcard introduces fallback ordering between exact registrations and catch-all plugins, makes duplicate ownership dependent on listener order, and still cannot distinguish the same model id at two providers without another convention.
|
||||
|
||||
**Encode provider and model into one string.** Values such as OpenRouter's `openai/gpt-*` already contain provider-like prefixes and slashes. A delimiter convention would leak routing syntax into every model selector and require escaping rules; two explicit fields are unambiguous and independently loggable.
|
||||
|
||||
**Add `backend + provider + model`.** A backend key would allow `dsh-llm-deepseek` and pi-ai's DeepSeek implementation to coexist and switch per request. The accepted deployment rule is instead one adapter owner per provider: implementations of the same upstream are alternatives selected by plugin composition. A third routing dimension would burden every request and configuration for a capability with no current consumer.
|
||||
|
||||
**Let `dsh-llm-pi-ai` automatically register every pi-ai provider.** This would claim ambient credentials and provider names the deployment never intended to expose, and would conflict with native adapters such as `dsh-llm-deepseek`. Explicit profiles make capability and credential scope reviewable.
|
||||
|
||||
**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle surface.
|
||||
|
||||
**Accept arbitrary inline pi-ai model descriptors.** This would support catalog-external private model ids, but it exposes pi-ai's model and compatibility schema as Harness configuration and makes the adapter responsible for validating protocol-specific combinations. The first version supports custom endpoints by overriding `baseURL` on catalog models; custom descriptors require a separate decision after a real catalog-external deployment is identified.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Provider names are deployment-wide route ownership keys: two providers may use the same model string, but mounting two adapters for one provider fails at load instead of creating fallback order.
|
||||
- Model selection no longer changes the Cordis plugin graph. Catalog-backed adapters can accept any installed catalog model selected after startup, while the native DeepSeek adapter forwards arbitrary DeepSeek model ids.
|
||||
- A custom `baseURL` preserves the selected catalog model's protocol and capabilities; it does not make catalog-external model ids valid. Private endpoints must implement that catalog entry's protocol.
|
||||
- pi-ai credentials and transport knobs are scoped per provider profile. An omitted key delegates to pi-ai ambient authentication, while an explicitly empty key is invalid.
|
||||
- `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support.
|
||||
- Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state.
|
||||
- Current pre-release session JSONL requires provider/model request headers and assistant provenance. Older shapes remain version `0` but are rejected rather than migrated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, cancellation, content rewrites, and same-instance versus different-instance replay dispatch.
|
||||
- Keyless loop/session tests and ACP snapshots exercise durable provider/model metadata, resume and fork propagation, workflow/subagent overrides, and unchanged user-visible transcripts; the key-gated DeepSeek e2e retains real provider streaming and tool follow-up coverage.
|
||||
- Public JSDoc, package READMEs, architecture and core-data-structure docs, generated catalogs, examples, session fixtures, and Python SDK pairs use provider/model targets consistently and are checked by the repository documentation and type-equivalence gates.
|
||||
|
||||
## Risks
|
||||
|
||||
This is a repo-wide pre-release API break: model-only request construction, adapter registration, app protocols, fixtures, and persisted version-0 event shapes all change together, with no compatibility aliases. The provider exclusivity rule deliberately prevents two implementations of the same upstream from coexisting in one context. A pi-ai dependency update can change the accepted provider/model catalog, so the lockfile and adapter e2e matrix define the tested set. Custom `baseURL` endpoints inherit the chosen catalog model's protocol assumptions and cannot repair an incompatible proxy. Catalog-external model descriptors and multimodal content remain unsupported. pi-ai replay state may contain opaque encrypted reasoning signatures; it is persisted because the provider requires it for continuity, but it is never rendered or logged outside the existing session record.
|
||||
@@ -0,0 +1,91 @@
|
||||
# RFC: 基于提供方路由的 LLM 适配器与通用 pi-ai 后端
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-14-provider-routed-llm-adapters.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-llm` 按精确模型名称注册适配器。插件在 Cordis 启动时提供模型列表,`LlmService` 为列表中的每个字符串保存一个适配器,`GenerateOptions.model` 同时选择适配器与提供方模型。两个正式适配器都只面向相同的两个 DeepSeek 模型时,这种方式可以工作,但它混淆了两个独立决策:由哪个上游提供方承接请求,以及该提供方应运行哪个模型。
|
||||
|
||||
这种混淆使提供方网关无法提供开放的模型目录。例如,OpenRouter 是一个包含大量模型 ID 的提供方,私有 OpenAI 兼容端点也可能在不修改 Harness 插件树的情况下增加模型。目前,每个新选择的模型都必须在插件启动期间完成注册。同一个模型 ID 还可能存在于多个提供方中,因此仅按模型注册无法表达调用方预期使用的提供方。
|
||||
|
||||
`dsh-llm-pi-ai` 没有暴露 pi-ai 的提供方抽象。它以内联方式构造 DeepSeek `openai-completions` 模型,应用 DeepSeek 专用的 payload 补丁,并将每条回放的助手消息标记为 DeepSeek。pi-ai 自身提供提供方/模型目录,能够选择 `openai-responses`、`anthropic-messages`、`google-generative-ai` 等 API,并保留提供方专用的响应 ID,以及后续轮次所需的推理和工具签名。Harness 转换丢弃了这些来源信息,因此仅将内联模型替换为目录查询,会导致同模型回放与跨提供方移交不完整。
|
||||
|
||||
适配器配置同样假定只存在一个 DeepSeek API 密钥和端点。通用后端需要为各提供方分别配置凭据和端点覆盖,同时继续由 pi-ai 处理 AWS、Google ADC、OAuth 等环境认证机制。
|
||||
|
||||
## 决策
|
||||
|
||||
### 提供方作为适配器注册键
|
||||
|
||||
`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。
|
||||
|
||||
`LlmService` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并将整组注册作为一个 effect 释放。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 RFC](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。
|
||||
|
||||
在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。
|
||||
|
||||
`dsh-llm-deepseek` 移除模型注册列表,接受通过 `deepseek` 提供方路由的任意模型字符串。其请求序列化、`/chat/completions` 端点、thinking 选项、SSE(Server-Sent Events)解析和错误行为保持不变;`options.model` 仍会原样发送。
|
||||
|
||||
### 显式 pi-ai 提供方配置
|
||||
|
||||
`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、超时和重试设置。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
|
||||
|
||||
插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。
|
||||
|
||||
适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
|
||||
|
||||
pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
|
||||
|
||||
### 持久化助手来源信息与回放状态
|
||||
|
||||
助手消息携带提供方无关的来源信息,其中包含请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些来源信息,`deriveMessages()` 返回助手消息时也会包含这些信息。用户、system、context 与工具结果消息不携带助手来源信息。provider/model 字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
|
||||
|
||||
成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。只有当 `agent/step-result` 处理后的内容与提供方组装输出在结构上相等时,agent loop 才会把回放状态附加到助手来源信息。监听器重写内容后,provider/model 来源信息仍会保留,但已经陈旧的回放状态会被移除。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
|
||||
|
||||
pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。
|
||||
|
||||
该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](../../implemented/architecture/2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 来源信息中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。
|
||||
|
||||
### 在所有请求生产方中传播目标
|
||||
|
||||
每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。
|
||||
|
||||
压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用 envelope 记录两个字段。
|
||||
|
||||
JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
|
||||
|
||||
磁盘会话格式仍使用预发布阶段固定的版本 `0`,且不承诺兼容性。seed/load 验证会拒绝缺少 provider 的请求头,以及缺少必需来源信息的助手消息,不会接受已无法重建请求的旧格式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续以模型名称作为注册表键,并增加通配适配器。** 通配机制会在精确注册与兜底插件之间引入回退顺序,使重复所有权取决于监听器顺序;若不再增加其他约定,仍无法区分不同提供方中相同的模型 ID。
|
||||
|
||||
**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择接口,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。
|
||||
|
||||
**增加 `backend + provider + model`。** backend 键可以让 `dsh-llm-deepseek` 与 pi-ai 的 DeepSeek 实现共存,并按请求切换。最终采用的部署规则是一个提供方对应一个适配器所有者:同一上游的不同实现属于由插件组合选定的替代项。第三个路由维度会增加每个请求与配置的负担,却没有当前消费方。
|
||||
|
||||
**让 `dsh-llm-pi-ai` 自动注册所有 pi-ai 提供方。** 这种方式会占用部署无意暴露的环境凭据和提供方名称,并与 `dsh-llm-deepseek` 等原生适配器冲突。显式配置可以审查能力和凭据范围。
|
||||
|
||||
**每个提供方挂载一个 pi-ai 插件实例。** 独立实例可以隔离配置,但会重复插件声明,也无法实现配置注册的原子性。每个请求本就向同一个适配器提供 provider,因此经过验证的配置映射具有更小的生命周期接口。
|
||||
|
||||
**接受任意内联 pi-ai 模型描述符。** 这种方式可支持目录外的私有模型 ID,但会将 pi-ai 的模型与兼容性 schema 暴露为 Harness 配置,并要求适配器验证协议专用组合。当前版本通过覆盖目录模型的 `baseURL` 支持自定义端点;只有实际出现目录外部署需求后,才会另行决策是否支持自定义描述符。
|
||||
|
||||
## 影响
|
||||
|
||||
- 提供方名称是部署范围内的路由所有权键:两个提供方可以使用相同的模型字符串,但为同一个提供方挂载两个适配器会在加载时失败,不会形成回退顺序。
|
||||
- 模型选择不再改变 Cordis 插件图。目录型适配器可以接受启动后选择的任意已安装目录模型,原生 DeepSeek 适配器则会转发任意 DeepSeek 模型 ID。
|
||||
- 自定义 `baseURL` 会保留所选目录模型的协议与能力,但不会让目录外模型 ID 变为有效。私有端点必须实现该目录项对应的协议。
|
||||
- pi-ai 凭据与传输选项按提供方配置隔离。省略密钥时委托 pi-ai 使用环境认证;显式空密钥无效。
|
||||
- pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。
|
||||
- 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。
|
||||
- 当前预发布会话 JSONL 要求请求头包含 provider/model,助手消息包含来源信息。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移。
|
||||
|
||||
## 测试
|
||||
|
||||
- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、取消、内容重写,以及同一实例与不同实例间的回放分发。
|
||||
- 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化 provider/model 元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。
|
||||
- 公共 JSDoc、package README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。
|
||||
|
||||
## 风险
|
||||
|
||||
这是一次覆盖全仓库的预发布 API 破坏性变更:仅模型的请求构造、适配器注册、应用协议、fixture,以及持久化版本 0 事件格式会同时变化,不提供兼容别名。提供方排他规则有意禁止同一上游的两个实现共存于同一上下文。pi-ai 依赖升级可能改变可接受的提供方/模型目录,因此锁文件与适配器 e2e 矩阵定义已验证集合。自定义 `baseURL` 端点会继承所选目录模型的协议假设,无法修复不兼容的代理。目录外模型描述符与多模态内容仍不受支持。pi-ai 回放状态可能包含不透明的加密推理签名;提供方需要该信息维持连续性,因此系统会持久化该状态,但不会在现有会话记录之外渲染或记录它。
|
||||
@@ -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-15-agent-execution-context.md: bbc33bb6a27c0222ec7ef582a5e5c20cdd566cd8
|
||||
2026-07-15-agent-execution-context.zh.md: 64bebc6e70519426bd88e2ea9bb9d40d64f1c58b
|
||||
2026-07-15-agent-execution-context.md: a8d1338a6fc277c3adcd1ed835a54dd0b667ce4b
|
||||
2026-07-15-agent-execution-context.zh.md: 4cf78ce2c6ecba15eb7ae90479f877ce4e07cfe1
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-07-15-agent-execution-context.zh.md)
|
||||
|
||||
The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.
|
||||
|
||||
Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header.
|
||||
Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. This is mandatory control infrastructure rather than optional model-visible context enrichment.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface AgentExecutionService {
|
||||
}
|
||||
```
|
||||
|
||||
`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners.
|
||||
`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners.
|
||||
|
||||
`AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
|
||||
|
||||
@@ -40,11 +40,21 @@ A host-aware transport may derive a deployment-owned header such as `X-Harness-S
|
||||
|
||||
This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning.
|
||||
|
||||
## Reference model
|
||||
|
||||
| Claude Code | Harness design |
|
||||
|---|---|
|
||||
| AppState store | Cordis deployment services and their owned live state |
|
||||
| QueryEngine | Agent driver plus loop-owned runtime state |
|
||||
| ToolUseContext | Explicit Agent, tool, and request parameters at capability seams |
|
||||
| AgentContext ALS | Narrow `AgentExecution` carrier |
|
||||
| Transcript | Event-sourced `Session` and persistence backends |
|
||||
|
||||
## Verification
|
||||
|
||||
Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal.
|
||||
Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. Detached-work coverage prevents context leakage without changing existing explicit cancellation contracts.
|
||||
|
||||
The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive.
|
||||
The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. Documentation checks keep the repository layout in `AGENTS.md`, the package table in `packages/core/README.md`, and the group description in `packages/README.md` aligned with the mandatory provider.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
|
||||
|
||||
进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。
|
||||
进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。它是必载控制基础设施,而不是模型可见的可选上下文增强。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface AgentExecutionService {
|
||||
}
|
||||
```
|
||||
|
||||
`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。
|
||||
`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。一个同类实现曾观察到未清空的隐式值穿过已调度工作泄漏进后续轮次;显式 undefined 边界可以防止这类泄漏。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。
|
||||
|
||||
`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
||||
|
||||
@@ -40,11 +40,21 @@ export interface AgentExecutionService {
|
||||
|
||||
本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。
|
||||
|
||||
## 参考模型
|
||||
|
||||
| Claude Code | Harness 中的设计 |
|
||||
|---|---|
|
||||
| AppState store | Cordis 部署服务及其拥有的实时状态 |
|
||||
| QueryEngine | Agent 驱动及 loop 所拥有的运行时状态 |
|
||||
| ToolUseContext | 能力边界上的显式 Agent、工具和请求参数 |
|
||||
| AgentContext ALS | 窄粒度 `AgentExecution` 载体 |
|
||||
| Transcript | 事件溯源 `Session` 与持久化后端 |
|
||||
|
||||
## 验证
|
||||
|
||||
服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。
|
||||
服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。针对脱离主调用链的异步工作的测试会防止上下文泄漏,同时不改变现有显式取消契约。
|
||||
|
||||
测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。
|
||||
测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。文档检查确保 `AGENTS.md` 中的仓库布局、`packages/core/README.md` 中的包表,以及 `packages/README.md` 中的分组说明都与该必载提供方保持一致。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.md: d84fe9fdb75bd2d28a00269c84d29c4223798253
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 019819c4aa5ab4ad281b5b32daa76a004c9d6466
|
||||
@@ -0,0 +1,66 @@
|
||||
# RFC: Advisory LLM catalogs and per-session ACP model selection
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Provider-routed adapters let every request choose `provider + model`, but `LlmService` exposed only routing and streaming. A UI could not discover which providers were registered or which models an adapter was prepared to recommend. ACP clients therefore received no `model` session config option, so Zed, JetBrains, and VS Code integrations had no model list even though the request seam already supported runtime switching.
|
||||
|
||||
Model discovery cannot become request validation. The hand-written DeepSeek adapter deliberately forwards arbitrary model ids to a public or private endpoint, while pi-ai has a finite installed catalog that is authoritative for its own request resolution. Treating one shared catalog as a whitelist would remove the private-endpoint behavior that provider routing was designed to preserve.
|
||||
|
||||
ACP selection must also preserve the provider dimension. The same model id may appear under multiple routes, and switching a global adapter or agent template would leak one editor session's choice into every other session. Prompt variables and request routing must change together; a selection that lands during asynchronous prompt assembly cannot make `{{model}}` name one model while the request reaches another.
|
||||
|
||||
## Decision
|
||||
|
||||
### Provider-neutral advisory discovery
|
||||
|
||||
`LlmAdapter` gains `providerInfo(provider)` and asynchronous `listModels(provider)` methods. Their provider-neutral results are `LlmProviderInfo { id, name }` and `LlmModelInfo { provider, id, name, description? }`. The defaults preserve existing adapter behavior by naming a provider after its route and advertising no models.
|
||||
|
||||
`LlmService.listProviders()` returns detached metadata in registration order. `LlmService.listModels(provider)` delegates to the route owner, validates non-empty ids and names, rejects a mismatched provider or duplicate model id with `INVALID_CATALOG`, and returns detached values. Unknown providers still fail with `NO_ADAPTER`. Provider metadata is validated atomically during `registerAdapter()` so a malformed display record cannot leave a partial registration.
|
||||
|
||||
Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input.
|
||||
|
||||
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` and `deepseek-v4-pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
|
||||
|
||||
### ACP session config option
|
||||
|
||||
The ACP bridge advertises one select with `id: model` and `category: model` in `session/new` and `session/load` when the session has a complete target whose provider is registered. Each opaque option value encodes the full provider/model pair. Models are grouped by provider when multiple non-empty provider groups exist; a single group is flattened for clients that render simple selects better.
|
||||
|
||||
The session's current target is added to the displayed options when its adapter omits it. This preserves custom DeepSeek and private-endpoint models while keeping the adapter catalog advisory. A target with an unregistered provider is not advertised, and a model-less agent remains available to another `agent/request` supplier.
|
||||
|
||||
`session/set_config_option` accepts only values from the current catalog snapshot and updates a target reference owned by that ACP session. No global `LlmService` or `AgentOptions` state changes, so concurrent sessions may select different providers and models. The existing permission select remains independent, and every response returns the complete refreshed option state.
|
||||
|
||||
### Prompt/request consistency and durability
|
||||
|
||||
Agent setup installs scoped `system-prompt/assemble` and `agent/request` listeners. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
|
||||
|
||||
The request header remains the durable source of truth. When a selected target is actually used, the existing full `request/header` snapshot records it. `session/load` initializes the ACP selection from the folded last request header before falling back to bridge config. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
|
||||
|
||||
ACP's experimental `providers/*` capability is not used. That draft surface configures provider base URLs, protocols, and headers, including secrets; it does not enumerate models and would give the UI authority to rewrite deployment-owned adapter configuration.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return model strings only.** A model-only value loses the provider route and becomes ambiguous as soon as two providers expose the same id.
|
||||
|
||||
**Make catalogs mandatory whitelists.** This conflicts with the hand-written adapter's arbitrary model pass-through and private deployments. The selected adapter already owns authoritative request validation.
|
||||
|
||||
**Store selection in `AgentOptions` or `LlmService`.** Those are creation-wide or deployment-wide objects. Mutating them would couple concurrent ACP sessions and bypass the logged `agent/request` replacement path.
|
||||
|
||||
**Persist a new model-selection session event immediately.** An unused UI selection has not affected a model request. Recording the existing request header when the target is consumed preserves the model-visible-if-and-only-if-logged rule without adding a second source of truth.
|
||||
|
||||
**Use ACP `providers/*`.** That unstable API changes endpoint and authentication configuration rather than selecting a model for one session, and its lifecycle and secret-handling semantics do not match this feature.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any adapter can expose a dynamic model list without leaking provider-library types into the core seam.
|
||||
- Catalog consumers must treat absence as “not advertised,” never “invalid request.”
|
||||
- pi-ai-backed ACP deployments automatically inherit the installed pi-ai provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support.
|
||||
- ACP clients receive a standard stable model config option, with provider-aware values and per-session isolation.
|
||||
- Request headers remain compatible with the provider-routed session shape; no new JSONL event or format version is required.
|
||||
- A catalog read can be asynchronous. ACP reads a detached snapshot before creating or resuming an agent, so discovery failure cannot leave a partially published session.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, ACP provider grouping, custom-current insertion, invalid values, provider/model request routing, prompt-variable alignment, concurrent-session isolation, model-less fallback, and load restoration from the request header. The existing ACP transport suites verify that the additional config option does not change prompt, cancellation, replay, approval, or tool-rendering behavior.
|
||||
@@ -0,0 +1,66 @@
|
||||
# RFC: 建议性 LLM 目录与 ACP 会话级模型选择
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmService` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使请求接缝已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。
|
||||
|
||||
模型发现不能变成请求校验。手写 DeepSeek 适配器会把任意模型 ID 原样转发给公开或私有端点,而 pi-ai 的有限安装目录则是其自身请求解析的权威依据。将共享目录视为白名单,会破坏提供方路由需要保留的私有端点能力。
|
||||
|
||||
ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多个路由下;切换全局适配器或 agent 模板会让一个编辑器会话的选择泄漏到其他会话。Prompt 变量与请求路由必须同时变化;如果选择发生在异步 prompt 组装期间,不能让 `{{model}}` 表示一个模型、实际请求却到达另一个模型。
|
||||
|
||||
## 决策
|
||||
|
||||
### 提供方中立的建议性发现
|
||||
|
||||
`LlmAdapter` 增加 `providerInfo(provider)` 与异步 `listModels(provider)` 方法。其提供方中立结果分别为 `LlmProviderInfo { id, name }` 和 `LlmModelInfo { provider, id, name, description? }`。默认实现以路由名称作为提供方名称,并且不展示模型,从而保持现有适配器行为。
|
||||
|
||||
`LlmService.listProviders()` 按注册顺序返回分离后的元数据。`LlmService.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回分离后的值。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。
|
||||
|
||||
目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。
|
||||
|
||||
`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含 `deepseek-v4-flash` 和 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
|
||||
|
||||
### ACP 会话配置项
|
||||
|
||||
当会话具有完整目标且目标提供方已注册时,ACP bridge 会在 `session/new` 与 `session/load` 中展示一个 `id: model`、`category: model` 的选择项。每个不透明选项值都编码完整的提供方/模型字段组合。存在多个非空提供方分组时按提供方分组;只有一个分组时将其展开,以便对简单选择器支持更好的客户端展示。
|
||||
|
||||
如果适配器目录未包含会话当前目标,该目标仍会加入展示选项。这能保留自定义 DeepSeek 与私有端点模型,同时维持目录的建议性。提供方未注册的目标不会展示;缺少模型的 agent 仍可由其他 `agent/request` 提供者补齐。
|
||||
|
||||
`session/set_config_option` 只接受当前目录快照中的值,并更新该 ACP 会话独占的目标引用。它不会修改全局 `LlmService` 或 `AgentOptions` 状态,因此并发会话可以选择不同的提供方和模型。现有权限选择项保持独立,每次响应都返回完整的刷新后配置项状态。
|
||||
|
||||
### Prompt/请求一致性与持久化
|
||||
|
||||
Agent setup 会安装作用域内的 `system-prompt/assemble` 与 `agent/request` 监听器。Prompt 组装为每个 step 只快照一次选中的字段组合,在下游 prompt 监听器完成后覆盖组装结果中的 `provider` 与 `model` 变量;请求监听器则在下游请求监听器完成后应用同一个快照。因此,异步组装期间发生的选择会从下一个 step 生效,不会导致 prompt 文本与路由分裂。其他调用配置字段保持不变。
|
||||
|
||||
请求头仍是持久化事实来源。当选中目标被实际使用时,现有的完整 `request/header` 快照会记录它。`session/load` 先从折叠后的最后请求头初始化 ACP 选择,再回退到 bridge 配置。一个从未被请求使用的选择只保留在内存中,因为它从未成为模型可见状态。
|
||||
|
||||
本功能不使用 ACP 的实验性 `providers/*` 能力。该草案接口配置提供方 base URL、协议和 headers,其中可能包含密钥;它不枚举模型,并且会赋予 UI 改写部署所有的适配器配置的权力。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只返回模型字符串。** 仅模型值会丢失提供方路由;两个提供方暴露相同 ID 时立刻产生歧义。
|
||||
|
||||
**将目录设为强制白名单。** 这与手写适配器的任意模型透传和私有部署冲突。请求的权威校验本就属于被选中的适配器。
|
||||
|
||||
**将选择存入 `AgentOptions` 或 `LlmService`。** 这些对象分别面向创建过程或整个部署。修改它们会耦合并发 ACP 会话,并绕开带日志归因的 `agent/request` 替换路径。
|
||||
|
||||
**立即写入新的模型选择会话事件。** 尚未使用的 UI 选择没有影响模型请求。目标被消费时记录现有请求头,既满足“模型可见当且仅当已记录”的规则,也不会引入第二个事实来源。
|
||||
|
||||
**使用 ACP `providers/*`。** 该不稳定 API 用于修改端点与认证配置,而不是为单个会话选择模型;其生命周期和密钥处理语义都不适合本功能。
|
||||
|
||||
## 结果
|
||||
|
||||
- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到核心接缝。
|
||||
- 目录消费者必须把缺失理解为“未展示”,而不是“请求无效”。
|
||||
- 基于 pi-ai 的 ACP 部署会自动继承已安装的 pi-ai 提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留任意模型能力。
|
||||
- ACP 客户端会收到稳定标准的模型配置项,其中的值保留提供方信息,并按会话隔离。
|
||||
- 请求头继续使用基于提供方路由的会话结构;不需要增加 JSONL 事件或格式版本。
|
||||
- 目录读取可以是异步的。ACP 在创建或恢复 agent 前读取分离后的快照,因此发现失败不会留下部分发布的会话。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试覆盖目录分离与错误元数据、pi-ai 和 DeepSeek 目录投影、ACP 提供方分组、自定义当前模型补入、无效值、提供方/模型请求路由、prompt 变量一致性、并发会话隔离、无模型回退,以及从请求头恢复选择。现有 ACP 传输测试验证新增配置项不会改变 prompt、取消、回放、审批或工具展示行为。
|
||||
@@ -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
|
||||
2026-07-15-replay-token-meter-service.md: 34df0383d1b8ae8047c4283eef3800de772c3cae
|
||||
2026-07-15-replay-token-meter-service.zh.md: 51f319f3c473fe247791e69133eef4280b768002
|
||||
@@ -0,0 +1,60 @@
|
||||
# RFC: Replay token meter service
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-15-replay-token-meter-service.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting.
|
||||
|
||||
Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can lack chunk provenance, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result.
|
||||
|
||||
## Decision
|
||||
|
||||
### One concrete LLM-family service
|
||||
|
||||
`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly.
|
||||
|
||||
The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies.
|
||||
|
||||
### Per-session replay folds
|
||||
|
||||
Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical full request-header snapshots, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state.
|
||||
|
||||
`measure(session, requestHeader?)` synchronizes the fold once and returns scalar pressure together with positional per-node prices. `totalTokens` remains request-and-response pressure; `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override changes pressure pricing only, while the surface fields always describe the current session. `estimateMessage(message)` applies the fixed heuristic without session state. Each result is one detached, deeply immutable snapshot carrying one `logRevision`. Every measurement clones the current nodes and is therefore O(surface).
|
||||
|
||||
Provider usage is reused only when the measured canonical request envelope equals the latest successful-call anchor. Any provider, model, system, prefix, tool, or call-config change causes complete heuristic repricing. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A later successful request replaces the earlier anchor, including across provider or model switches.
|
||||
|
||||
Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty provenance list means a known empty provider stream; absent legacy provenance conservatively treats the durable assistant output as provider output.
|
||||
|
||||
### Compact-basic consumes, but does not own, measurement
|
||||
|
||||
`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The singleton service consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection.
|
||||
|
||||
Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement.
|
||||
|
||||
Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. `summarizationProvider` and `summarizationModel` must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair.
|
||||
|
||||
The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies provider, model, tools, and other call config. A router-only agent without a complete provider/model pair skips that provisional check because `agent/request` can route later; any routed target can use the singleton estimator.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across provider/model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, unified snapshot detachment and deep immutability, surface-total equality, listener ordering, reload, compact defaults, routing fallback, one-call automatic decisions, retention, convergence, and log-revision rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API.
|
||||
- **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration.
|
||||
- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select.
|
||||
- **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence.
|
||||
- **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Token pressure has one replay-aware owner that compaction and future plugins can share.
|
||||
- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed.
|
||||
- Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer.
|
||||
- Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold.
|
||||
- Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure.
|
||||
- The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware.
|
||||
@@ -0,0 +1,60 @@
|
||||
# RFC: 回放式 token 计量服务
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-15-replay-token-meter-service.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。
|
||||
|
||||
提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。
|
||||
|
||||
## 决策
|
||||
|
||||
### 一个具体的 LLM 家族服务
|
||||
|
||||
`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。
|
||||
|
||||
服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。
|
||||
|
||||
### 逐会话回放折叠
|
||||
|
||||
每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范的完整请求头快照、步骤边界、表层追加与替换、assistant usage,以及 assistant 分片来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。
|
||||
|
||||
`measure(session, requestHeader?)` 只同步一次折叠,并在返回标量压力的同时给出逐位置节点价格。`totalTokens` 仍表示请求与响应压力;`surfaceTokens` 是仅针对表层的启发式总量,并等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只改变压力定价,表层字段始终描述当前会话。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。每个结果都是一个分离且深度不可变的快照,只携带一个 `logRevision`。每次计量都会复制当前节点,因此成本为 O(surface)。
|
||||
|
||||
只有当待计量的规范请求信封等于最近一次成功调用的锚点时,服务才复用提供方 usage。提供方、模型、系统提示词、前缀、工具或调用配置任一变化都会触发完整的启发式重新定价。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。后续成功请求会替换先前锚点,提供方或模型切换时也一样。
|
||||
|
||||
Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早分片 seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。
|
||||
|
||||
### compact-basic 消费计量,但不拥有计量
|
||||
|
||||
`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。单例服务一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。
|
||||
|
||||
自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。
|
||||
|
||||
压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、空的摘要提供方/模型、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。`summarizationProvider` 与 `summarizationModel` 必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。
|
||||
|
||||
pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头给出提供方、模型、工具及其他调用配置。没有完整提供方/模型组合的纯路由 agent(智能体)会跳过该临时检查,因为 `agent/request` 仍可稍后路由;任意已路由目标都可使用这个单例估算器。
|
||||
|
||||
## 测试
|
||||
|
||||
单元覆盖固定服务配置、固定估算、信封失效、提供方/模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、统一快照的分离性与深度不可变性、表层总量相等性、监听器顺序、重载、压缩默认值、路由回退、自动决策单次调用、保留、收敛与日志修订回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。
|
||||
- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。
|
||||
- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。
|
||||
- **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。
|
||||
- **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。
|
||||
|
||||
## 后果
|
||||
|
||||
- Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。
|
||||
- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。
|
||||
- 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。
|
||||
- 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。
|
||||
- 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。
|
||||
- pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。
|
||||
Reference in New Issue
Block a user