Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/sandbox-acp-agent/cordis.yml # examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/session/README.md # packages/core/session/src/types.ts # packages/core/session/tests/derived-cache.spec.ts # packages/llm/llm-deepseek/src/index.ts # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/convert.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/call-config.ts # packages/llm/llm/src/index.ts # packages/ui/acp-agent/src/index.ts # packages/ui/acp/tests/harness.ts # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/src/server.ts # packages/ui/stdio-agent/README.md # packages/ui/stdio-agent/src/index.ts # python/sdk/README.i18n.yaml
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# dsh-invariants
|
||||
|
||||
Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior.
|
||||
Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior.
|
||||
|
||||
**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-core`](../../core/agent-core/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
|
||||
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
|
||||
|
||||
@@ -39,14 +39,23 @@ Agent status (per agent):
|
||||
|
||||
Model requests (on `llm/stream`):
|
||||
|
||||
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the fold of the log's `request/header*` events (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
|
||||
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` is rebuilt through a fresh `Session` from the prefix before its in-flight `step/start`; later content belongs to the next request, and hand-built unfrozen one-shots are excluded. Frozen messages must match that derivation, while every other field matches folded `request/header*` events. The prepended check runs before ordinary short-circuiting stream listeners, but correctness comes from the sequence boundary rather than listener timing. See the [reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
|
||||
|
||||
## Why runtime assertions remain useful
|
||||
|
||||
Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly<SessionEvent>` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships in development while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly<SessionEvent>` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships wherever it is mounted while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
|
||||
## Seeded sessions
|
||||
|
||||
A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped.
|
||||
- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-invariants",
|
||||
"description": "Dev-mode event-contract assertions for the DeepSeek Harness",
|
||||
"description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
/**
|
||||
* Dev-mode invariants: a pure-listener plugin that asserts relationships in
|
||||
* the harness event contract at runtime.
|
||||
*
|
||||
* Everything is a plugin — this is just listeners on `session/created`,
|
||||
* `session/event`, `agent/status`, and the scoped dispatch and request seams.
|
||||
* It is **off in production**: enable it in tests and demos, where a contract
|
||||
* violation should be a loud failure rather than a subtle one. It doubles as
|
||||
* executable documentation of the event taxonomy: the assertions below are
|
||||
* the contract.
|
||||
*
|
||||
* Session owns immutable log storage: it snapshots and deep-freezes every
|
||||
* accepted event at the source. This plugin checks relationships that one
|
||||
* event's types and immutability cannot express, including turn/step nesting,
|
||||
* scoped dispatch, status transitions, and request reconstructability.
|
||||
*
|
||||
* Runtime listeners that fail loudly when cross-event contracts are broken:
|
||||
* turn and step nesting, scoped dispatch, status transitions, and request
|
||||
* reconstruction. The plugin has no environment guard and is active wherever
|
||||
* mounted, including the default `dsh-agent-core` bundle; custom compositions
|
||||
* may omit it. Sessions still own event snapshots and freezing.
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
@@ -328,23 +318,19 @@ function replayEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
applyTransition(trace, validateEvent(trace, event))
|
||||
}
|
||||
|
||||
/** Legal agent status transitions (the only state machine the loop guarantees). */
|
||||
/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */
|
||||
function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
|
||||
// First observation: any status is a valid starting point.
|
||||
if (from === undefined) return
|
||||
// A no-op transition is illegal — setStatus dedups, so we never see it.
|
||||
if (from === to) {
|
||||
throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
|
||||
}
|
||||
// Leaving `disposed` is illegal — disposal is terminal.
|
||||
if (from === 'disposed') {
|
||||
throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
|
||||
}
|
||||
// idle↔running and (idle|running)→disposed are all legal; nothing else exists.
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dev-mode invariants. Contributions are effect-scoped, so
|
||||
* Register the runtime invariants. Contributions are effect-scoped, so
|
||||
* disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply
|
||||
* the trace state is rebuilt by replaying each existing session's log, so a
|
||||
* hot reload mid-turn does not falsely reject the next event.
|
||||
|
||||
@@ -325,19 +325,17 @@ describe('HMR state rebuild', () => {
|
||||
it('rebuilds trace state for a session that exists at (re-)apply time', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// First registration, mid-turn: a turn is open when the plugin reloads.
|
||||
const first = await ctx.plugin(Invariants)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await first.dispose()
|
||||
|
||||
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
|
||||
// step is known — the next chunk must NOT be a false positive.
|
||||
// Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log.
|
||||
await ctx.plugin(Invariants)
|
||||
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
|
||||
.not.toThrow()
|
||||
// And a genuine violation is still caught after the rebuild.
|
||||
// Rebuild must not disable later violations.
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
@@ -541,25 +539,17 @@ describe('surface invariants', () => {
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => {
|
||||
// The unknown-seq check fires when a ref passes the "earlier" test but is
|
||||
// not in knownSeqs — only possible with a gap in seqs. We create a gap by
|
||||
// directly manipulating the private log array to skip a seq.
|
||||
// Create an impossible-through-public-API gap so seq 2 is earlier but unknown.
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
// Push a fake event at seq 3 into the internal log, creating a gap at seq 2.
|
||||
// The invariants plugin replays session.events on every append, so it sees
|
||||
// this gap during trace reconstruction.
|
||||
;(session as unknown as { log: unknown[] }).log.push({
|
||||
type: 'assistant/chunk',
|
||||
seq: 3,
|
||||
time: Date.now(),
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
|
||||
})
|
||||
// Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes
|
||||
// is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not
|
||||
// in knownSeqs ({0, 1, 3} — gap at 2).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
||||
}).toThrow(/unknown seq 2/)
|
||||
@@ -803,12 +793,8 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
|
||||
|
||||
describe('request cross-check ordering (prepend)', () => {
|
||||
it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => {
|
||||
// The replay adapter returns its chunks WITHOUT calling next(), which
|
||||
// would silence a later-registered check — snapshot compositions load
|
||||
// replay before the app bundle that loads invariants. The check prepends,
|
||||
// so it fires ahead of append-registered listeners regardless of load
|
||||
// order. (Prepend orders it against APPENDED listeners only; correctness
|
||||
// rests on the seq-bounded rebuild, not on listener timing.)
|
||||
// Replay short-circuits without next(), so the check prepends ahead of ordinary listeners;
|
||||
// correctness still comes from its sequence-bounded rebuild, not listener timing.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
|
||||
|
||||
Reference in New Issue
Block a user