Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-08-06 11:25:28 +08:00
3992 changed files with 241706 additions and 57716 deletions

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -23,30 +23,30 @@ export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* Find the latest closed turn that entered at least one model step, ignoring
* balanced no-step turns produced by rejection, empty input, or cancellation.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const messageTurns = new Set<number>()
const steppedTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'turn/start') {
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
if (event.type === 'step/start') {
steppedTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
}
return latest
}
@@ -93,8 +93,7 @@ declare module 'cordis' {
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* 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
* caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @dshScopeScan unsupported
@@ -104,17 +103,12 @@ declare module 'cordis' {
}
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
/** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('session header is not a plain JSON record')
}
const record = snapshot as Record<string, unknown>
const record = input as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
@@ -139,6 +133,9 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
if (record.origin !== undefined && record.origin !== 'subagent') {
throw new Error('session header origin must be "subagent"')
}
if (record.delegationDepth !== undefined
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
throw new Error('session header delegationDepth must be a non-negative safe integer')
@@ -146,31 +143,51 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
return validateSessionHeader(id, snapshot)
}
/**
* Validate an exclusively owned event and deeply freeze its identified message
* without copying the event. The caller transfers an object graph that no
* producer retains and that shares no mutable children with another event.
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
* @param event - exclusively owned event imported across a trusted boundary.
* @returns the same event object with a validated, deeply frozen message.
*/
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
assertMessageEventShape(
event,
`session event at seq ${event.seq}`,
)
switch (event.type) {
case 'user/message':
deepFreeze(event.data)
break
case 'assistant/message':
case 'tool/result':
deepFreeze(event.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return event
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
return adoptSessionEvent(structuredClone(event))
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
@@ -190,7 +207,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
assertCurrentLlmShape(event, index)
assertCurrentTurnEndShape(event, index)
}
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
@@ -201,25 +217,50 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
: undefined
if (event['type'] === 'request/header') {
const header = record?.['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
? header as Record<string, unknown>
: undefined
const config = headerRecord?.['config']
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
const configRecord = config as Record<string, unknown>
const reasoningEffort = configRecord['reasoningEffort']
if (reasoningEffort !== undefined
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index)
}
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
/** Validate adapter-default provenance imported from a durable request header. */
function assertAdapterDefaults(
value: unknown,
config: Record<string, unknown>,
index: number,
): void {
if (value === undefined) return
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
const defaults = value as Record<string, unknown>
const allowed = new Set(['reasoningEffort', 'maxTokens'])
if (Object.keys(defaults).some(key => !allowed.has(key))
|| Object.values(defaults).some(marker => marker !== true)
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
}
/** Validate only the event-specific invariants needed to safely replay a message. */
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
const data = event['data']
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
@@ -269,22 +310,6 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string
}
}
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
if (event['type'] !== 'turn/end') return
const data = event['data']
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
if (typeof data !== 'object' || data === null) return
const reason = (data as Record<string, unknown>)['reason']
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
const record = reason as Record<string, unknown>
if (record['kind'] === 'aborted'
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
}
}
/** Whether an unknown value carries the current provider/model pair. */
function hasProviderModel(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false
@@ -351,8 +376,10 @@ const attachments = new WeakMap<Session, SessionEntry>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
* Plain class (not a Service) — create live instances via
* `ctx.sessions.create()` and detached instances via {@link create}.
* Seeding with an existing event log replays/forks a session.
* @typert object
*/
export class Session {
private log: SessionEvent[] = []
@@ -367,7 +394,7 @@ export class Session {
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
@@ -381,19 +408,42 @@ export class Session {
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
* in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
*/
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
* @param id - session identity.
* @param seed - optional borrowed replay or fork events.
* @param header - optional borrowed storage metadata.
* @returns a detached session.
*/
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
return new Session(id, seed, header)
}
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
@@ -426,6 +476,13 @@ export class Session {
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
/** Cached immutable public snapshot of the private append-only log. */
@@ -461,7 +518,8 @@ export class Session {
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
@@ -559,6 +617,25 @@ export class Session {
return this.headerFold
}
/** Cached fold of `request/context` events. */
private contextFold: RequestContext | undefined
private contextFoldSeq = 0
/**
* Return the latest resolved route metadata, or `undefined` before the first
* `request/context` event. Each event is folded once.
* @returns the latest immutable route metadata.
*/
requestContext(): RequestContext | undefined {
if (this.contextFoldSeq < this.log.length) {
for (const event of this.log.slice(this.contextFoldSeq)) {
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
}
this.contextFoldSeq = this.log.length
}
return this.contextFold
}
/** The derived-message cache: frozen projections, extended per unseen node. */
private derived: Message[] = []
/** Surface position (nodes projected) the cache has reached. */
@@ -596,7 +673,7 @@ export class Session {
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
@@ -626,10 +703,9 @@ export class Session {
// trace/replay data.
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. Steering's `turn` is log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// Ordinary prompts and injected context project in user role: the
// event's model-facing content stays verbatim. Do NOT
// re-add per-type framing (e.g. `<context>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
@@ -638,9 +714,6 @@ export class Session {
case 'user/message': {
return event.data
}
case 'steering/message': {
return event.data.message
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
@@ -708,7 +781,7 @@ export class SessionStore extends Service {
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
* loop's final events are published before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see
* `dsh-agent-loop`'s creation transaction).
@@ -740,7 +813,7 @@ export class SessionStore extends Service {
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would remove the publication hooks
* before the loop's closing `session/flush`, dropping the closing events.
* before the driver's closing events commit, dropping them.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
@@ -767,9 +840,10 @@ export class SessionStore extends Service {
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.origin === undefined ? {} : { origin: meta.origin },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
}
return new Session(sessionId, seed, header)
return Session.create(sessionId, seed, header)
}
/**
@@ -893,15 +967,17 @@ export class SessionStore extends Service {
/**
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
* with the carrier captured at {@link enter}. THE flush entry point: the
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
* injection, teardown drains) must come through here rather than dispatch a
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* store owns the carrier, so callers (the checkpoint policy's per-request
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
* that flush themselves before reading storage) must come through here
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
* one spelling, and the scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns resolves when every flush listener has settled; after all settle,
* rejects with the first registered listener failure if any listener failed.
* @returns whether at least one durability listener participated, after every
* listener has settled successfully.
* @throws the first registered listener failure after every listener settles.
*/
async flush(session: Session): Promise<void> {
async flush(session: Session): Promise<boolean> {
const { carrier } = this.liveEntryFor(session)
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
@@ -911,12 +987,13 @@ export class SessionStore extends Service {
} catch (error: unknown) {
// Preserve the listener's exact rejection value; flush is a caller-owned
// failure boundary, and Cordis listeners may throw arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
return Promise.reject(error)
}
}))
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
return callbacks.length > 0
}
/** Return the exact live entry; detached/prepared objects reject. */

View File

@@ -144,9 +144,12 @@ function validateEvent(
}
case 'user/message':
break
case 'steering/message':
case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'todo/write':
case 'request/header': {
case 'request/header':
case 'request/context': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}

View File

@@ -1,7 +1,10 @@
/**
* 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.
* needed to resume with a provider-valid transcript, plus the activity-time
* read that must skip the end-seed boundary — which this module does
* not write (`Session`'s constructor does) but whose synthetic closers can
* inherit that boundary's timestamp, the one real coupling between the two.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -9,6 +12,22 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
* `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
* unbalanced log directly — `load()` balances first.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'

View File

@@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts'
* @returns the canonical header.
*/
export function canonicalHeader(header: EpochHeader): EpochHeader {
const adapterDefaults = header.adapterDefaults
return {
config: header.config,
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
? { adapterDefaults }
: {},
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
}
@@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
* @returns whether config, system, and tools all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
if (
!callConfigEquals(a.config, b.config)
|| a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
|| a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
|| a.system !== b.system
) return false
const at = a.tools ?? []
const bt = b.tools ?? []
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))

View File

@@ -15,13 +15,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
'steering/message',
])
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
* @returns true for one of the four message-producing event types.
* @returns true for one of the three message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -37,6 +36,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
}
/**
* Narrow an event to an append-origin surface event: one that entered the
* surface at its own log position and was never itself a replacement copy.
*
* The model-visible surface deliberately shadows replaced ranges, so it is the
* wrong source for a human transcript — a landed replacement would erase
* conversation the user already saw. Append-origin events are that transcript's
* durable source material; replacement copies stay model-only.
* @param event - event to test.
* @returns true when the event appended to the surface tail.
*/
export function isAppendSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: 'append' } {
return isSurfaceEvent(event) && event.surfaceOp === 'append'
}
/**
* Narrow an event to a surface replacement: a node that shadowed an existing
* surface range instead of appending to the tail. The counterpart of
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
* @param event - event to test.
* @returns true when the event replaced a surface range.
*/
export function isReplacementSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
}
/** One replacement operation observed while folding a session surface. */
export interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */
@@ -212,13 +241,14 @@ function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
baseSeq: number,
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
const original = events[originalSeq - baseSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
@@ -246,6 +276,7 @@ function planSurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -258,7 +289,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
return {
kind: 'replace',
seq: event.seq,
@@ -274,8 +305,9 @@ function applySurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events)
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -301,7 +333,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index, events)
const replacement = applySurfaceEvent(state, event, index, events, 0)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -311,38 +343,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
export class SurfaceManager implements SessionSurface {
/** Shared transition state; replacement history is not retained. */
private _state = createFoldState()
/** Last processed seq; -1 folds a seeded log on first access. */
private _lastProcessedSeq = -1
/** Last processed absolute seq. */
private _lastProcessedSeq: number
constructor(private log: readonly SessionEvent[]) {}
/**
* @param log - Contiguous complete log or loaded event window.
* @param baseSeq - Absolute sequence of the window's first event.
*/
constructor(
private log: readonly SessionEvent[],
private readonly baseSeq = 0,
) {
this._lastProcessedSeq = baseSeq - 1
}
/**
* Validate the next candidate without mutating the committed surface.
* @param event - candidate event that has not entered the log yet.
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length, this.log)
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
planSurfaceEvent(
this._state,
event,
this.baseSeq + this.log.length,
this.log,
this.baseSeq,
)
}
/** Monotonic count of folded positional replacements. */
get replaceGeneration(): number {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.replaceGeneration
}
/** Surface event sequences in model-visible order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.nodes
}
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
const tailSeq = this.baseSeq + this.log.length - 1
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
const index = seq - this.baseSeq
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
this._lastProcessedSeq = seq
}
}
}

View File

@@ -3,8 +3,8 @@ import type {
AssistantMessage,
CallId,
LlmCallConfig,
LlmCallConfigAdapterDefaults,
LlmFailure,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
@@ -58,6 +58,11 @@ export interface SessionHeader {
* boundary lets resume and replay distinguish parent history from child work.
*/
readonly seedLength?: number
/**
* Coarse product classification for a session created as a subagent child.
* This is presentation metadata, not proof that the child is continuable.
*/
readonly origin?: 'subagent'
/**
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
* for a subagent child. Persisted so a recursion budget survives restart and
@@ -83,28 +88,20 @@ export interface CreateSessionOptions {
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
}
}
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}
/** Why an active agent driver was cancelled. */
export type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
| { readonly kind: 'hook'; readonly reason: string }
| { readonly kind: 'disposed' }
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
/**
* Why a turn ended. Merge-extensible sum type.
@@ -112,20 +109,15 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
blocked: { kind: 'blocked' }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
* The turn failed. `error` is always a structured failure: the `LlmError`
* facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
* flattened from any other error.
*/
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
disposed: { kind: 'disposed' }
error: { kind: 'error'; error: LlmFailure }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
@@ -163,12 +155,24 @@ export interface TodoItem {
export interface EpochHeader {
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
adapterDefaults?: LlmCallConfigAdapterDefaults
/** Rendered system prompt text; absent for a system-less request. */
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
}
/** Registration-bound metadata for one resolved model route. */
export interface RequestContext {
/** Registered provider route the metadata belongs to. */
provider: string
/** Provider-owned model id the metadata belongs to. */
model: string
/** Maximum combined request and response context in tokens, when advertised. */
contextWindow?: number
}
/**
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
@@ -185,14 +189,19 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started the model loop.
* Opens turn `turn` before the loop claims queued input or runs pre-step.
* Rejection, empty input, cancellation, or failure may close it with no
* step; otherwise the following identified `user/message` event or batch
* records the messages entering the step.
*/
'turn/start': { turn: number; trigger: TurnTrigger }
'turn/start': { turn: number }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn
* with no entered step has no `step/start` or `step/end`. The loop does not await a
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
* per-request durability checkpoint, and consumers that read storage after
* `whenIdle()` flush themselves. Success commits the turn; rejection is
* reported live and does not prevent later work.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
@@ -203,9 +212,8 @@ export interface SessionEventMap {
* A user-role message on the model-visible surface: a direct human prompt
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
* notifications, …), or an entered goal continuation round. All three
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** Raw stream chunk — token-level replay fidelity. */
@@ -241,8 +249,6 @@ export interface SessionEventMap {
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; message: UserMessage }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**
@@ -250,6 +256,34 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
*/
'request/context': RequestContext
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
* would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/end-seed': Record<string, never>
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
@@ -264,7 +298,6 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the ordered surface — its
@@ -281,7 +314,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* - `'append'`: added to the tail — normal path for user/assistant/tool
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -316,7 +349,7 @@ export interface SurfaceIntent {
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`, `steering/message`).
* `assistant/message`, `tool/result`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.