docs: rebalance prose cleanup and add trimming skill

This commit is contained in:
Tianyi Cui
2026-07-13 23:27:00 +08:00
parent fcdc318dda
commit 148046b9c8
392 changed files with 2801 additions and 1754 deletions

View File

@@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?, options?)` validates and detaches durable seed/header data, publishes the session, and binds it to the calling fiber.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. It rejects unpublished, detached, or stale objects.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -18,9 +18,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
Use the split lifecycle only when teardown must be ordered with another resource:
- `prepare(id?, options?)` constructs without publication.
- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach.
- `announce(session)` emits the single creation edge. Detach during that dispatch is deferred and later emits the paired disposal edge.
- `prepare(id?, options?)` validates and constructs without publication.
- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement.
- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge.
`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md).
@@ -32,10 +32,10 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data, commits synchronously, then notifies observers with failure containment. Reentrant attached-session appends reject.
- `session.deriveMessages()` incrementally projects the derived surface and returns a fresh array over frozen messages.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.surface` lazily folds new `surfaceOp` markers; `replaceGeneration` changes on rewrites.
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite or invalidation.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.

View File

@@ -34,8 +34,10 @@ declare module 'cordis' {
interface Events {
/**
* Emitted after session publication. A synchronous throw vetoes and rolls
* Creation announcement during session publication. A synchronous throw vetoes and rolls
* back with a paired disposal; detach requested during dispatch is deferred.
* A returned-promise rejection is logged but cannot retroactively veto this
* synchronous boundary.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only sessions entered through that agent's context.
* @param session - the session just entered and announced.
@@ -44,14 +46,17 @@ declare module 'cordis' {
'session/created'(this: Scoped<Session>, session: Session): void
/**
* Emitted once when an announced session leaves the store, including
* publication rollback. Listener failures are contained.
* publication rollback, but never for an entry whose creation announcement
* did not begin. Listener failures are logged and contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
* @param session - the session that is no longer live in the store.
* @mode emit
*/
'session/disposed'(this: Scoped<Session>, session: Session): void
/**
* Post-commit append feed. Observer failures are logged and contained.
* Post-commit, fire-and-forget append feed. The listener snapshot resolves
* before the log push, but callbacks run after it; observer failures are
* logged and contained without making the committed append fail.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only events from sessions entered through that agent's context.
* @param session - the session whose log grew.
@@ -60,7 +65,8 @@ declare module 'cordis' {
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited parallel durability checkpoint; dispatch through
* Awaited parallel durability checkpoint: every listener runs and the
* caller awaits all of them, with no waterfall veto. Dispatch through
* {@link SessionStore.flush}. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
@@ -70,7 +76,11 @@ declare module 'cordis' {
}
}
/** Render injected context as a tagged synthetic user-role message. */
/**
* Render injected context as tagged synthetic user-role content, keeping the
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
* belong in the adapter.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
const close = `</${tag}>`

View File

@@ -12,9 +12,10 @@
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Validate and detach lossless JSON in one read per property. Accepts ordinary
* arrays, plain or null-prototype objects, and JSON scalars; rejects sparse,
* cyclic, exotic, negative-zero, and non-finite values. Getter throws propagate.
* Validate and detach lossless JSON in one read per property, so a stateful
* getter cannot change between validation and copying. Accepts ordinary arrays,
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
* exotic, negative-zero, and non-finite values. Getter throws propagate.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not

View File

@@ -1,5 +1,7 @@
/**
* Crash-recovery repair for an interrupted session log.
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -7,8 +9,10 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* Return deterministic synthetic events that close an open tail turn or step.
* Sequences continue the log and timestamps reuse the last real event.
* Return deterministic synthetic events that close an open tail turn. Unmatched
* calls receive error results first, followed by an open `step/end` and an
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
* last real event. A balanced or empty log returns no events.
*
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
@@ -16,8 +20,8 @@ import type { SessionEvent } from './types.ts'
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Track tool calls vs. their results WITHIN the currently-open turn only: a call is "pending"
// until its matching tool/result arrives.
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
// Assistant blocks register calls; later tool/call events add provenance seqs.
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
for (const event of events) {
switch (event.type) {
@@ -46,8 +50,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
break
case 'tool/call':
// Capture the tool/call event seq for surface provenance on the synthesized
// tool/result.
// Add the tool/call seq used as provenance on a synthetic result.
{
const entry = pendingCalls.get(event.data.callId)
if (entry) {
@@ -76,9 +79,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
const time = last.time
const closers: SessionEvent[] = []
// Synthesize an error tool/result for each tool-call left unanswered by the crash, so
// deriveMessages() yields a valid provider transcript on resume (a dangling assistant
// tool-call is rejected by every provider).
// Close calls before their step: providers reject dangling assistant calls,
// and Map insertion order preserves their transcript order.
for (const [callId, { step, callSeq }] of pendingCalls) {
closers.push({
type: 'tool/result',

View File

@@ -1,6 +1,7 @@
/**
* Request-header reconstruction utilities: the pure fold/diff/apply trio over the
* `request/header` / `request/header-delta` session events.
* Request-header reconstruction utilities over `request/header` snapshots and
* `request/header-delta` events. Writers round-trip each proposed delta and use
* a full snapshot when the encoding cannot represent the change.
* @module dsh-session/request-header
*/
@@ -128,8 +129,11 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] |
}
/**
* Compute the `request/header-delta` payload between two canonical headers, or undefined when
* they are equal.
* Compute the `request/header-delta` payload between two canonical headers, or
* `undefined` when they are equal. The encoding cannot represent every change,
* including pure tool reordering, so callers must apply and compare the result
* before logging it and fall back to a full snapshot on mismatch. The session
* prefix is replaced whole; an empty array removes it.
*
* @param prev - the folded header the log currently implies.
* @param next - the header the next request will actually use.

View File

@@ -23,8 +23,9 @@ const SURFACE_EVENT_TYPES = new Set<string>([
])
/**
* Check only whether a type may enter the message surface. Use
* {@link isSurfaceEvent} when the mandatory `surfaceOp` must also be present.
* Check only whether a type may enter the message surface; it does not require `surfaceOp`. This
* detects eligible seed/load events missing their mandatory marker. Use {@link isSurfaceEvent} to
* narrow a fully formed event whose marker is present.
* @param type - the event type string to test.
* @returns true when the type is one of the five message-producing types.
*/

View File

@@ -1,6 +1,7 @@
/**
* Tool-pairing balance over a session's surface: is a given cut point in the surface a safe
* edge for a collapsed region (e.g. compaction)?
* Tool-pairing balance over a session surface. Compaction changes surface
* positions, so safe cuts are derived from tool-call/result content on the
* surface rather than step markers in the append-only log.
* @module @deepseek-ai/dsh-session/tool-pairing
*/
@@ -27,10 +28,12 @@ function nodeDelta(event: SessionEvent): number {
}
/**
* Check that a surface cut does not split a tool call from its result.
* Check that a surface cut does not split a tool call from its result. A region
* is safe to collapse only when the cuts before its first node and after its
* last node both return `true`.
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param beforeSeq - node immediately after the cut; absent from the surface means after-tail.
* @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail.
* @returns whether every call before the cut has its result before the cut.
* @throws if a result appears without a preceding open call.
*/

View File

@@ -39,8 +39,8 @@ export interface SessionHeader {
/** The session this one was forked from (seed lineage), if any. */
readonly parentSession?: SessionId
/**
* How many leading events were INHERITED via a seed rather than produced by this session —
* the seed boundary.
* How many leading events were inherited through a seed. Persisting this
* boundary lets resume and replay distinguish parent history from child work.
*/
readonly seedLength?: number
}
@@ -99,6 +99,7 @@ export interface TurnEndReasonMap {
*/
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
* Policy blocked every prompt before the first step. The zero-step turn still
@@ -106,8 +107,8 @@ export interface TurnEndReasonMap {
*/
rejected: { kind: 'rejected'; reason: string }
/**
* The turn never ended on its own: the process crashed mid-turn and a persistence backend
* later closed the orphaned (open) turn on reload so the log stays balanced.
* A persistence backend closed a crash-orphaned turn on reload. The loop never
* emits this marker, and the events recorded before the crash remain intact.
*/
interrupted: { kind: 'interrupted' }
}
@@ -198,10 +199,10 @@ export interface ToolsDelta {
}
/**
* The session event vocabulary — the append-only source of truth for an agent's whole
* interaction history. The LLM message history is *derived* from this log; nothing else is
* authoritative. Replay = re-derive from the same events; trace/telemetry = subscribe to the
* log.
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
* sequence numbers stay contiguous, including raw chunks, so persistence can
* store the canonical log verbatim.
*/
export interface SessionEventMap {
/**
@@ -224,8 +225,8 @@ export interface SessionEventMap {
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked
* prompt and why.
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, including in a mixed batch.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
@@ -262,24 +263,19 @@ export interface SessionEventMap {
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/**
* The agent's whole todo list, carried as a full snapshot and replaced wholesale on each
* write — the current list is the most recent `todo/write` (last-write-wins on replay, no
* fold). Appended by an owning agent via `session.append('todo/write', { todos })`.
* Whole-list snapshot; the latest write wins on replay. It is log-only UI
* state and never enters derived model history.
*/
'todo/write': { todos: TodoItem[] }
/**
* Full snapshot of the {@link EpochHeader} the NEXT request is built under, with the {@link
* RequestHeaderReason} it was recorded whole.
* Full {@link EpochHeader} for the next request, appended inside its step
* before dispatch. It is log-only and anchors subsequent deltas.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Amendment to the folded {@link EpochHeader}: at least one of a {@link SystemDelta}, a
* {@link ToolsDelta}, a whole replacement {@link LlmCallConfig} (four scalars — not worth
* diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical
* form's absent field — the loop never produces one in practice: the prefix is composed once
* per instance and anchored by that instance's snapshot, so this arm exists for codec
* totality).
* Log-only amendment to the folded {@link EpochHeader}. System and tools use
* their delta codecs; config and prefix replace whole, with an empty prefix
* encoding removal. Writers verify round-trip equality or log a fallback snapshot.
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}

View File

@@ -1,4 +1,8 @@
/** Derived-message cache behavior against a from-scratch replay oracle. */
/**
* Derived-message cache contract against a scratch oracle: project new nodes
* once, rebuild on surface generation changes, return fresh arrays over shared
* frozen messages, and remain value-equal to replay at every step.
*/
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'

View File

@@ -13,8 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
// An appendable event: its type/data plus, for surface-eligible types, the explicit surface
// intent the generator declares (mirroring how a real caller passes it).
// Each arbitrary supplies its own surface intent; `build` must not synthesize
// one or the property would fail to exercise malformed fixture choices.
type Appendable = {
[T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
}[SessionEventType]

View File

@@ -155,9 +155,8 @@ describe('interruptedTurnClosers', () => {
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
// A tool/call event exists in the log but no assistant/message registered the callId in
// pendingCalls (e.g., a plugin appended it directly, or the assistant/message from a prior
// step didn't have this call).
// A raw tool/call with no assistant-registered pending call has nothing to
// answer; repair still closes the step and turn without synthesizing a result.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },

View File

@@ -129,8 +129,8 @@ describe('Session', () => {
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// The typed overload makes surfaceOp mandatory only when the type argument is a SPECIFIC
// SurfaceEventType literal.
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
.toThrow(/surface-eligible and requires a surfaceOp marker/)
@@ -657,8 +657,8 @@ describe('SessionStore', () => {
})
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
// prepare()/enter() are public cross-package primitives that a caller may separate with
// arbitrary work.
// A stale prepared object must not replace the live same-id entry; its later
// detach would otherwise remove the wrong session.
const ctx = new Context()
await ctx.plugin(SessionStore)
const stale = ctx.sessions.prepare(SessionId('racy'))

View File

@@ -70,14 +70,11 @@ describe('SurfaceManager', () => {
it('rebuild with replace operation splices out shadowed nodes', () => {
const s = surfaceSession()
// seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
// Surface nodes: seq 1 (user), seq 2 (assistant).
// Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
// Replace surface seqs 1 (user) and 2 (assistant) with the summary.
s.append('assistant/message',
{ turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
)
// Now the surface should have just the compaction node.
expect(s.surface.nodes.length).toBe(1)
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
expect(s.surface.nodes[0]!.prev).toBeNull()

View File

@@ -4,7 +4,9 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check.
* Unit coverage for compaction-cut safety: a cut is balanced only when it
* separates no assistant tool call from its result. Non-step nodes are neutral,
* and replace operations prove surface order—not raw log order—is authoritative.
*/
const SURFACE = { surfaceOp: 'append' as const }
@@ -165,8 +167,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message
})
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
// A background task-done inject() lands a context/message inside an open step, between the
// assistant (with a tool-call) and its tool/result.
// The injected context is pairing-neutral, but both adjacent cuts remain
// unbalanced because the tool call is still open across them.
function midStepInjection(): Session {
const s = new Session(SessionId('mid-inject'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -217,7 +219,8 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => {
})
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
// The case the log-position scan got wrong.
// A replacement checkpoint has a high log seq but sits at the surface head;
// its cuts are balanced regardless of later raw-log neighbors.
function checkpointHeadedSession(): Session {
const s = new Session(SessionId('checkpoint'))
// A closed turn with a tool step → surface [u1, asst(call), result].