Merge remote-tracking branch 'origin/master' into worktree/semantic-session-checkpoints

# Conflicts:
#	docs/architecture.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-22 10:29:09 +08:00
419 changed files with 14937 additions and 7239 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, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
* @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>()
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)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
}
return latest
}
declare module 'cordis' {
interface Context {
sessions: SessionStore
@@ -137,6 +158,7 @@ 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 pre-provider request headers and assistant messages at the seed/load boundary. */
@@ -154,6 +176,22 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
}
}
/** 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
@@ -210,6 +248,7 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -384,7 +423,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing) entry.detach()
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
}
}
}
@@ -668,6 +707,7 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -680,7 +720,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending) {
if (entry.announcing || entry.appending || entry.outOfBand) {
entry.detachRequested = true
return
}
@@ -734,7 +774,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending) entry.detach()
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
}
}
@@ -778,6 +818,87 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)

View File

@@ -101,7 +101,8 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
*/
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
/**
* 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
@@ -263,9 +264,23 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these