feat: add reusable session preparation
This commit is contained in:
@@ -20,7 +20,7 @@ import type {
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -104,6 +104,30 @@ async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
/** Start an abortable operation and release a value that arrives after cancellation. */
|
||||
async function raceAbortCall<T>(
|
||||
operation: () => PromiseLike<T> | T,
|
||||
signal: AbortSignal,
|
||||
id: SessionId,
|
||||
releaseAbandoned?: (value: T) => void,
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
}
|
||||
const pending = Promise.resolve().then(operation)
|
||||
try {
|
||||
return await raceAbort(pending, signal, id)
|
||||
} catch (error: unknown) {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
|
||||
if (signal.aborted && releaseAbandoned !== undefined) {
|
||||
void pending.then(releaseAbandoned, () => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
||||
function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
|
||||
@@ -524,8 +548,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
||||
const session = this.runtime.ctx.sessions.prepare(id, { meta })
|
||||
const prepared = this.prepare(this.ctx, id, options, session)
|
||||
using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
|
||||
const prepared = this.prepare(this.ctx, id, options, preparation.session)
|
||||
try {
|
||||
return prepared.publish('startup').agent
|
||||
} catch (error: unknown) {
|
||||
@@ -541,14 +565,14 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published handle.
|
||||
*/
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
}))
|
||||
const published = this.setupAndPublish(
|
||||
ownerCtx,
|
||||
options.sessionId,
|
||||
session,
|
||||
preparation,
|
||||
options.agentOptions ?? {},
|
||||
options.setup,
|
||||
options.signal,
|
||||
@@ -562,12 +586,14 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
private async setupAndPublish(
|
||||
ownerCtx: Context,
|
||||
id: SessionId,
|
||||
session: Session,
|
||||
preparation: SessionPreparation,
|
||||
agentOptions: AgentOptions,
|
||||
setup: AgentSetup | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
source: SessionStartSource,
|
||||
): Promise<AgentHandle> {
|
||||
using ownedPreparation = preparation
|
||||
const session = ownedPreparation.session
|
||||
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
|
||||
try {
|
||||
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
@@ -613,26 +639,31 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
ownerAbort.signal,
|
||||
this.ownership.signal,
|
||||
])
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
|
||||
let preparation: SessionPreparation | undefined
|
||||
try {
|
||||
loaded = await raceAbort(persistence.load(id), fused, id)
|
||||
try {
|
||||
preparation = await raceAbortCall(
|
||||
() => persistence.prepare(id, fused),
|
||||
fused,
|
||||
id,
|
||||
(abandoned) => { abandoned[Symbol.dispose]() },
|
||||
)
|
||||
} finally {
|
||||
await unfollowOwner()
|
||||
}
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
return await this.setupAndPublish(
|
||||
ownerCtx,
|
||||
id,
|
||||
preparation,
|
||||
options.agentOptions ?? {},
|
||||
options.setup,
|
||||
options.signal,
|
||||
'resume',
|
||||
)
|
||||
} finally {
|
||||
await unfollowOwner()
|
||||
}
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
const session = this.runtime.ctx.sessions.prepare(id, {
|
||||
seed: loaded.events,
|
||||
meta: loaded.meta,
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
||||
try {
|
||||
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
preparation?.[Symbol.dispose]()
|
||||
}
|
||||
})()
|
||||
this.ownership.trackWrapper(published)
|
||||
|
||||
@@ -187,8 +187,8 @@ export interface AgentFactory {
|
||||
*/
|
||||
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* Prepare a persisted session and resume an agent on it. Async because it awaits
|
||||
* both `ctx.sessionPersistence.prepare` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication follows the same setup-commit and
|
||||
* ordered boundary as {@link createAgent}.
|
||||
|
||||
@@ -13,13 +13,15 @@ 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, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, 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'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { SessionPreparation } from './preparation.ts'
|
||||
export type { SessionPreparationOptions } from './preparation.ts'
|
||||
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
@@ -143,6 +145,17 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate and freeze one exclusively owned persistence header in place. */
|
||||
function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const prototype = Reflect.getPrototypeOf(input)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
}
|
||||
return validateSessionHeader(id, input)
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -442,7 +455,28 @@ export class Session {
|
||||
return new Session(id, seed, header)
|
||||
}
|
||||
|
||||
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
/**
|
||||
* Restore a detached session by taking ownership of fresh persistence values.
|
||||
* Storage shape, event envelopes, sequence continuity, surface transitions,
|
||||
* and header fields are validated before the graphs are frozen in place.
|
||||
* @param id - restored session identity.
|
||||
* @param seed - fresh detached events whose ownership is transferred.
|
||||
* @param header - fresh detached metadata whose ownership is transferred.
|
||||
* @returns a restored detached session.
|
||||
*/
|
||||
static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
|
||||
return new Session(id, seed, header, 'restore')
|
||||
}
|
||||
|
||||
private constructor(
|
||||
id: SessionId,
|
||||
seed?: readonly SessionEvent[],
|
||||
header?: SessionHeader,
|
||||
mode: 'snapshot' | 'restore' = 'snapshot',
|
||||
) {
|
||||
const restoredHeader = mode === 'restore'
|
||||
? validateRestoredSessionHeader(id, header)
|
||||
: undefined
|
||||
if (seed !== undefined) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
@@ -454,7 +488,7 @@ export class Session {
|
||||
for (const [index, source] of seed.entries()) {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
|
||||
}
|
||||
@@ -475,7 +509,7 @@ export class Session {
|
||||
}
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
this.header = restoredHeader ?? 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
|
||||
@@ -822,7 +856,7 @@ export class SessionStore extends Service {
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
|
||||
let sessionId: SessionId
|
||||
if (id === undefined) {
|
||||
do sessionId = SessionId(`session-${++this.counter}`)
|
||||
@@ -831,6 +865,9 @@ export class SessionStore extends Service {
|
||||
sessionId = SessionId(id)
|
||||
}
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
if (options?.seedSource === 'persistence') {
|
||||
return Session.fromRestore(sessionId, options.seed, options.meta)
|
||||
}
|
||||
const seed = options?.seed
|
||||
const meta = options?.meta
|
||||
const header: SessionHeader = {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Ownership of one unpublished Session before registry publication.
|
||||
* @module @deepseek-ai/dsh-session/preparation
|
||||
*/
|
||||
|
||||
import type { Session } from './index.ts'
|
||||
|
||||
/** Options for a preparation whose provider retains unpublished state. */
|
||||
export interface SessionPreparationOptions {
|
||||
/** Release provider-owned state when the Session was not published. */
|
||||
readonly release?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact unpublished Session and the provider state that keeps it usable.
|
||||
* Disposal is synchronous and idempotent. Providers decide whether release
|
||||
* returns the Session to a cache or discards it; publication may consume that
|
||||
* state before disposal, making the callback a no-op.
|
||||
*/
|
||||
export class SessionPreparation implements Disposable {
|
||||
private released = false
|
||||
|
||||
/** The exact Session to use for setup and publication. */
|
||||
readonly session: Session
|
||||
|
||||
private constructor(
|
||||
session: Session,
|
||||
private readonly options: SessionPreparationOptions,
|
||||
) {
|
||||
this.session = session
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an unpublished Session in one preparation lifetime.
|
||||
* @param session - exact unpublished Session.
|
||||
* @param options - optional provider release behavior.
|
||||
* @returns a preparation disposed after publication or rollback.
|
||||
*/
|
||||
static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
|
||||
return new SessionPreparation(session, options ?? {})
|
||||
}
|
||||
|
||||
/** Release provider state once when this preparation leaves its caller. */
|
||||
[Symbol.dispose](): void {
|
||||
if (this.released) return
|
||||
this.released = true
|
||||
this.options.release?.()
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,24 @@ export interface CreateSessionOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh storage values transferred to {@link SessionStore.prepare} without a
|
||||
* second serialization copy. Callers retain no mutable aliases.
|
||||
*/
|
||||
export interface RestoredSessionOptions {
|
||||
/** Fresh detached storage events to validate and freeze in place. */
|
||||
readonly seed: SessionEvent[]
|
||||
/** Fresh detached storage metadata to validate and freeze in place. */
|
||||
readonly meta: SessionHeader
|
||||
/** Select the persistence ownership-transfer path. */
|
||||
readonly seedSource: 'persistence'
|
||||
}
|
||||
|
||||
/** Inputs accepted while constructing an unpublished Session. */
|
||||
export type PrepareSessionOptions =
|
||||
| (CreateSessionOptions & { readonly seedSource?: undefined })
|
||||
| RestoredSessionOptions
|
||||
|
||||
/** Why an active agent driver was cancelled. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
|
||||
Reference in New Issue
Block a user