feat(schedule): add durable after package

This commit is contained in:
pku-xht
2026-08-05 17:10:52 +08:00
committed by Tianyi Cui
parent 3499c59ba8
commit a229b42e24
18 changed files with 2797 additions and 0 deletions
@@ -0,0 +1,350 @@
/**
* Strict Schedule decoding, replay, time validation, and framing.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type {
AfterScheduleRecord,
ScheduleChange,
ScheduleId as ScheduleIdType,
ScheduleReminderPresentation,
ScheduleView,
} from './types.ts'
/** Durable Schedule protocol version implemented by this package. */
export const SCHEDULE_CHANGE_VERSION = 1 as const
/** Key used by the generic Host/client event-presentation slot. */
export const SCHEDULE_REMINDER_PRESENTATION_KEY = 'schedule/reminder'
const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z')
const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/
/** Error from malformed or transition-invalid durable Schedule data. */
export class ScheduleLogError extends Error {
/** Stable machine-readable error code. */
readonly code = 'corrupt_schedule_log' as const
/**
* Construct a durable-log failure.
* @param message - Package-specific violated invariant.
*/
constructor(message: string) {
super(message)
this.name = 'ScheduleLogError'
}
}
/** Error from a model-supplied after rule that cannot become a record. */
export class ScheduleInputError extends Error {
/** Stable public Schedule input code. */
readonly code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range'
/**
* Construct a stable input failure.
* @param code - Public Schedule error discriminator.
* @param message - Stable public diagnostic.
*/
constructor(
code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range',
message: string,
) {
super(message)
this.name = 'ScheduleInputError'
this.code = code
}
}
/** Pure replay result, retaining active create order and every used id. */
export interface FoldedSchedules {
/** Active records in their original create order. */
readonly active: readonly AfterScheduleRecord[]
/** Every id ever created in this session-local suffix. */
readonly seenIds: readonly ScheduleIdType[]
}
/**
* Brand a raw session-local id without changing its runtime value.
* @param value - Raw session-local id.
* @returns The same string with the Schedule brand.
*/
export function ScheduleId(value: string): ScheduleIdType {
return value as ScheduleIdType
}
/** Whether an unknown value is a non-array object. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require exactly the named durable object keys. */
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(value).sort()
const wanted = [...expected].sort()
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index])
}
/** Validate one stable session-local id at the durable boundary. */
function decodeId(value: unknown): ScheduleIdType {
if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {
throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace')
}
return ScheduleId(value)
}
/** Validate one canonical four-digit-year UTC instant. */
function decodeInstant(value: unknown): string {
if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {
throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant')
}
const epoch = Date.parse(value)
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant')
}
return value
}
/** Decode the exact v1 after record shape. */
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
throw new ScheduleLogError('after schedule must contain exactly id, kind, prompt, afterSeconds, and scheduledAt')
}
if (value['kind'] !== 'after') throw new ScheduleLogError('v1 schedule kind must be "after"')
const prompt = value['prompt']
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
throw new ScheduleLogError('after prompt must be non-empty and already trimmed')
}
const afterSeconds = value['afterSeconds']
if (!Number.isSafeInteger(afterSeconds) || (afterSeconds as number) <= 0) {
throw new ScheduleLogError('afterSeconds must be a positive safe integer')
}
return Object.freeze({
id: decodeId(value['id']),
kind: 'after',
prompt,
afterSeconds: afterSeconds as number,
scheduledAt: decodeInstant(value['scheduledAt']),
})
}
/**
* Decode one strict version-1 `schedule/change` payload.
* @param value - Untrusted durable JSON value.
* @returns Detached, frozen Schedule change.
*/
export function decodeScheduleChange(value: unknown): ScheduleChange {
if (!isRecord(value)) throw new ScheduleLogError('schedule/change payload must be an object')
if (value['version'] !== SCHEDULE_CHANGE_VERSION) {
throw new ScheduleLogError('schedule/change version must be 1')
}
switch (value['operation']) {
case 'create':
if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {
throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule')
}
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: 'create',
schedule: decodeAfterRecord(value['schedule']),
})
case 'delete':
case 'dispatch': {
if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
throw new ScheduleLogError(`schedule ${value['operation']} must contain exactly version, operation, and id`)
}
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: value['operation'],
id: decodeId(value['id']),
})
}
default:
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')
}
}
/**
* Fold the package-owned stream after the durable fork seed boundary.
* @param events - Complete ordered session log or candidate-extended log.
* @param seedLength - Inherited prefix length excluded from child ownership.
* @returns Active records and all previously used ids.
*/
export function foldScheduleEvents(
events: readonly SessionEvent[],
seedLength = 0,
): FoldedSchedules {
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
}
const active = new Map<ScheduleIdType, AfterScheduleRecord>()
const seen = new Set<ScheduleIdType>()
for (const event of events.slice(seedLength)) {
if (event.type !== 'schedule/change') continue
const change = decodeScheduleChange(event.data)
switch (change.operation) {
case 'create':
if (seen.has(change.schedule.id)) {
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)
}
seen.add(change.schedule.id)
active.set(change.schedule.id, change.schedule)
break
case 'delete':
case 'dispatch':
if (!active.delete(change.id)) {
throw new ScheduleLogError(`schedule ${change.operation} targets inactive id ${JSON.stringify(change.id)}`)
}
break
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
default: {
const unreachable: never = change
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
}
}
}
return Object.freeze({
active: Object.freeze([...active.values()]),
seenIds: Object.freeze([...seen]),
})
}
/**
* Allocate the next readable id without reusing any prior session-local id.
* @param folded - Fold containing every previously created id.
* @returns A fresh `schedule-N` identity.
*/
export function allocateScheduleId(folded: FoldedSchedules): ScheduleIdType {
const seen = new Set(folded.seenIds)
let sequence = seen.size + 1
let candidate = ScheduleId(`schedule-${sequence}`)
while (seen.has(candidate)) {
sequence += 1
candidate = ScheduleId(`schedule-${sequence}`)
}
return candidate
}
/**
* Validate a model after rule and compute its durable target.
* @param id - Already allocated session-local id.
* @param prompt - User-authored reminder content.
* @param afterSeconds - Requested positive delay.
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
* @returns Frozen durable after record.
*/
export function createAfterScheduleRecord(
id: ScheduleIdType,
prompt: string,
afterSeconds: number,
now: number,
): AfterScheduleRecord {
const normalizedPrompt = prompt.trim()
if (normalizedPrompt.length === 0) {
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')
}
if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {
throw new ScheduleInputError('invalid_rule', 'after_seconds must be a positive safe integer.')
}
const delay = afterSeconds * 1_000
const target = now + delay
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(delay)
|| !Number.isSafeInteger(target) || target <= now || target > MAX_FOUR_DIGIT_YEAR_MS) {
throw new ScheduleInputError(
'time_out_of_range',
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
)
}
const scheduledAt = new Date(target).toISOString()
/* v8 ignore next -- a safe target within the four-digit Date range always formats canonically. */
if (!UTC_INSTANT.test(scheduledAt)) {
throw new ScheduleInputError(
'time_out_of_range',
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
)
}
return Object.freeze({
id,
kind: 'after',
prompt: normalizedPrompt,
afterSeconds,
scheduledAt,
})
}
/**
* Derive one execution-local management view.
* @param record - Active durable record.
* @param now - Wall-clock sample used for its timing state.
* @returns Complete session-local view.
*/
export function scheduleView(record: AfterScheduleRecord, now: number): ScheduleView {
return Object.freeze({
id: record.id,
kind: record.kind,
prompt: record.prompt,
afterSeconds: record.afterSeconds,
scheduledAt: record.scheduledAt,
state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
deliveryMode: 'session-local',
})
}
/**
* Derive the Web receipt for one dispatch from its owning stream segment.
* A dispatch inside an inherited fork prefix folds that original prefix; a
* child-owned dispatch folds only the child suffix, preserving the same
* `seedLength` ownership rule as the live runtime while still allowing a
* persisted parent receipt to render in child history.
* @param events - Complete contiguous Session log.
* @param dispatchSeq - Exact event seq to present.
* @param seedLength - Inherited fork prefix length.
* @returns The immutable receipt, or `undefined` when the selected event is not a dispatch.
*/
export function scheduleReminderPresentation(
events: readonly SessionEvent[],
dispatchSeq: number,
seedLength = 0,
): ScheduleReminderPresentation | undefined {
if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) {
throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer')
}
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
}
const event = events[dispatchSeq]
if (event === undefined || event.seq !== dispatchSeq) {
throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event')
}
if (event.type !== 'schedule/change') return undefined
const dispatch = decodeScheduleChange(event.data)
if (dispatch.operation !== 'dispatch') return undefined
const segmentStart = dispatchSeq < seedLength ? 0 : seedLength
const before = foldScheduleEvents(events.slice(segmentStart, dispatchSeq))
const record = before.active.find(candidate => candidate.id === dispatch.id)
if (record === undefined) {
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
}
return Object.freeze({
scheduleId: record.id,
prompt: record.prompt,
occurrenceAt: record.scheduledAt,
deliveryMode: 'session-local',
})
}
/**
* Render the fixed injection-resistant model framing for a due reminder.
* @param record - Due active record.
* @returns Stable model-visible text with JSON-escaped dynamic fields.
*/
export function renderReminderFraming(record: AfterScheduleRecord): string {
return [
'[SCHEDULE REMINDER]',
'Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.',
`schedule_id_json: ${JSON.stringify(record.id)}`,
`occurrence_at: ${record.scheduledAt}`,
`reminder_prompt_json: ${JSON.stringify(record.prompt)}`,
].join('\n')
}
@@ -0,0 +1,72 @@
/**
* Agent-scoped durable after reminders over the session event log.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { ScheduleOwner } from './runtime.ts'
import { registerScheduleTools } from './tools.ts'
export type * from './types.ts'
export {
SCHEDULE_CHANGE_VERSION,
SCHEDULE_REMINDER_PRESENTATION_KEY,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
allocateScheduleId,
createAfterScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
scheduleReminderPresentation,
scheduleView,
} from './domain.ts'
export { registerScheduleTools } from './tools.ts'
/** Cordis function-plugin name. */
export const name = 'tool-schedule'
/** Services required before future root agents can receive Schedule. */
export const inject = ['agents', 'sessions', 'tools', 'sessionPersistence']
type OwnerCleanup = () => void | Promise<void>
/** Install Schedule only for root agents published after this plugin loads. */
export function apply(ctx: Context): void {
const owners = new Map<Agent, OwnerCleanup>()
let stopping = false
ctx.effect(() => {
const stopCreated = ctx.on('agent/created', (agent) => {
if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return
const owner = new ScheduleOwner(ctx, agent)
const cleanup: OwnerCleanup = agent.ctx.effect(() => {
const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() })
const stopStatus = agent.ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') owner.requestDrive()
})
owner.start()
return async () => {
stopStatus()
disposeTools()
try {
await owner.dispose()
} finally {
if (owners.get(agent) === cleanup) owners.delete(agent)
}
}
}, 'tool-schedule.owner()')
owners.set(agent, cleanup)
})
return async () => {
stopping = true
stopCreated()
const cleanups = [...owners.values()]
owners.clear()
await Promise.allSettled(cleanups.map(cleanup => Promise.resolve(cleanup())))
}
}, 'tool-schedule.lifecycle()')
}
@@ -0,0 +1,50 @@
/**
* Package-owned strict Schedule stream invariant.
* @module @deepseek-ai/dsh-tool-schedule/invariant
*/
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { foldScheduleEvents, ScheduleLogError } from './domain.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
/** Cordis invariant-companion plugin name. */
export const name = 'tool-schedule-invariant'
/** Service required before reserving this package's invariant ownership. */
export const inject = ['invariants']
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
try {
foldScheduleEvents(events, seedLength)
} catch (error: unknown) {
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
if (!(error instanceof ScheduleLogError)) throw error
fail(error.message)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install replay and pre-append validation for the owned event stream. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
validate(session.events, session.header.seedLength ?? 0, fail)
}
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'schedule/change') return
validate([...session.events, event], session.header.seedLength ?? 0, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the package-owned invariant companion.
* @param ctx - Cordis context carrying the invariant registry.
* @returns Exact registration disposer after child setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,31 @@
/** Schedule-owned use of the shared session durability barrier. */
import type { Context } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
/** Failure to prove that the current live prefix reached a persistence listener. */
export class SchedulePersistenceError extends Error {
/**
* Construct a contained persistence failure.
* @param cause - Rejection returned by the shared barrier, when present.
*/
constructor(cause?: unknown) {
super('Schedule persistence did not complete.', cause === undefined ? undefined : { cause })
this.name = 'SchedulePersistenceError'
}
}
/**
* Require one successful shared persistence checkpoint.
* @param ctx - Context carrying the live session store.
* @param session - Exact live session to checkpoint.
* @returns After at least one listener explicitly acknowledges completed durability work.
*/
export async function flushSchedulePersistence(ctx: Context, session: Session): Promise<void> {
try {
if (!await ctx.sessions.flush(session)) throw new SchedulePersistenceError()
} catch (error: unknown) {
if (error instanceof SchedulePersistenceError) throw error
throw new SchedulePersistenceError(error)
}
}
@@ -0,0 +1,247 @@
/**
* Disposable live timer projection for one exact root agent.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { AfterScheduleRecord } from './types.ts'
import { foldScheduleEvents, renderReminderFraming, ScheduleLogError } from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
/** Select the earliest target while preserving create order for ties. */
function earliest(records: readonly AfterScheduleRecord[]): AfterScheduleRecord | undefined {
let selected: AfterScheduleRecord | undefined
let selectedAt = Number.POSITIVE_INFINITY
for (const record of records) {
const target = Date.parse(record.scheduledAt)
if (target < selectedAt) {
selected = record
selectedAt = target
}
}
return selected
}
/** Render an unknown value for process-local diagnostics only. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}
/** One process-local, disposable projection of an exact agent's durable schedules. */
export class ScheduleOwner {
private timer: ReturnType<typeof setTimeout> | undefined
private idleWait: Promise<void> | undefined
private run: Promise<void> | undefined
private requested = false
private stopping = false
private faulted = false
private disposal: Promise<void> | undefined
/**
* Construct an inactive owner; {@link start} begins the first preflight.
* @param ctx - Global service context.
* @param agent - Exact live root agent.
*/
constructor(
private readonly ctx: Context,
private readonly agent: Agent,
) {}
/** Begin the initial durability preflight and timer derivation. */
start(): void {
this.requestDrive()
}
/** Recompute the live projection after a committed mutation or idle transition. */
requestDrive(): void {
if (this.stopping || this.faulted) return
this.clearTimer()
this.requested = true
if (this.run !== undefined) return
let run: Promise<void>
try {
run = this.ctx.agents.withoutInitiator(() => this.runRequested())
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: could not start owner for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
this.run = run
void run.then(
() => { this.retire(run) },
(error: unknown) => {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: owner failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
this.faulted = true
this.retire(run)
},
)
}
/** Stop future work, cancel timers, and await every outstanding owner promise. */
dispose(): Promise<void> {
return (this.disposal ??= (async () => {
this.stopping = true
this.requested = false
this.clearTimer()
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
await Promise.allSettled(pending)
})())
}
/** Drain coalesced triggers serially. */
private async runRequested(): Promise<void> {
while (this.requested && !this.stopping && !this.faulted) {
this.requested = false
await this.driveOnce()
}
}
/** Retire one exact run and honor a trigger that landed during its final microtask. */
private retire(run: Promise<void>): void {
/* v8 ignore next -- only the exact stored run installs this callback. */
if (this.run !== run) return
this.run = undefined
/* v8 ignore next -- covers a trigger in the promise-settlement microtask gap. */
if (this.requested && !this.stopping && !this.faulted) this.requestDrive()
}
/** Whether this exact root lifecycle remains authoritative. */
private isLive(): boolean {
return this.ctx.agents.get(this.agent.id) === this.agent
&& this.ctx.agents.roots().includes(this.agent)
}
/** Cancel the currently armed timer, if any. */
private clearTimer(): void {
if (this.timer === undefined) return
clearTimeout(this.timer)
this.timer = undefined
}
/** Arm one bounded timer segment; every wake rechecks the wall clock. */
private arm(target: number, now: number): void {
const delay = Math.min(target - now, MAX_TIMER_DELAY_MS)
this.timer = setTimeout(() => {
this.timer = undefined
this.requestDrive()
}, delay)
}
/** Await one public idle boundary without holding admission or creating a retry timer. */
private waitForIdle(): void {
if (this.idleWait !== undefined) return
const wait = this.agent.whenIdle()
this.idleWait = wait
void wait.then(
() => {
this.idleWait = undefined
this.requestDrive()
},
(error: unknown) => {
this.idleWait = undefined
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: idle wait failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
},
)
}
/** Preflight, fold, arm, or dispatch the next active one-shot reminder. */
private async driveOnce(): Promise<void> {
this.clearTimer()
if (this.stopping || !this.isLive()) return
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: preflight failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
if (this.stopping || !this.isLive()) return
let record: AfterScheduleRecord | undefined
try {
const folded = foldScheduleEvents(
this.agent.session.events,
this.agent.session.header.seedLength ?? 0,
)
record = earliest(folded.active)
} catch (error: unknown) {
this.faulted = true
const detail = error instanceof ScheduleLogError ? error.message : renderThrown(error)
this.ctx.logger.warn(`tool-schedule: corrupt schedule log for agent "${this.agent.id}": ${detail}`)
return
}
if (record === undefined) return
const target = Date.parse(record.scheduledAt)
const wakeNow = Date.now()
if (wakeNow < target) {
this.arm(target, wakeNow)
return
}
const release = this.agent.reserveTurnAdmission()
if (release === undefined) {
this.waitForIdle()
return
}
try {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- reservation can invalidate the owner.
if (this.stopping || !this.isLive()) return
const decisionNow = Date.now()
if (decisionNow < target) {
this.arm(target, decisionNow)
return
}
const message = createUserMessage({
content: [{ type: 'text', text: renderReminderFraming(record) }],
source: { kind: 'plugin', plugin: 'tool-schedule' },
})
try {
this.agent.followup(message)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: followup failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
try {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
})
} catch (error: unknown) {
this.faulted = true
this.clearTimer()
this.ctx.logger.warn(`tool-schedule: dispatch append failed for agent "${this.agent.id}": ${renderThrown(error)}`)
return
}
} finally {
release()
}
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: dispatch barrier failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
if (!this.stopping && this.isLive()) this.requestDrive()
}
}
@@ -0,0 +1,346 @@
/**
* Agent-scoped Schedule management tools over the durable session fold.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
allocateScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
scheduleView,
} from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
import type {
AfterScheduleRecord,
PersistenceUncertainError,
ScheduleCreateValue,
ScheduleDeleteValue,
ScheduleId as ScheduleIdType,
ScheduleListValue,
SchedulePersistenceOperation,
ScheduleToolError,
} from './types.ts'
const VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
kind: { type: 'string', required: true, const: 'after' },
prompt: { type: 'string', required: true },
afterSeconds: { type: 'integer', required: true },
scheduledAt: { type: 'string', required: true },
state: { type: 'string', required: true, enum: ['scheduled', 'overdue'] },
deliveryMode: { type: 'string', required: true, const: 'session-local' },
},
} as const
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
return {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: code },
message: { type: 'string', required: true },
},
} as const
}
const BASIC_ERROR_SCHEMAS = [
basicErrorSchema('invalid_prompt'),
basicErrorSchema('invalid_selector'),
basicErrorSchema('invalid_rule'),
basicErrorSchema('time_out_of_range'),
basicErrorSchema('corrupt_schedule_log'),
basicErrorSchema('internal_error'),
] as const
const PERSISTENCE_ERROR_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: 'persistence_uncertain' },
message: { type: 'string', required: true },
operation: { type: 'string', required: true, enum: ['create', 'list', 'delete', 'dispatch'] },
id: { type: 'string' },
},
} as const
const ERROR_SCHEMAS = [...BASIC_ERROR_SCHEMAS, PERSISTENCE_ERROR_SCHEMA] as const
const CREATE_OUTPUT_SCHEMA = { oneOf: [VIEW_SCHEMA, ...ERROR_SCHEMAS] } as const
const LIST_OUTPUT_SCHEMA = {
oneOf: [
{ type: 'array', items: VIEW_SCHEMA },
...ERROR_SCHEMAS,
],
} as const
const DELETE_OUTPUT_SCHEMA = {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
deleted: { type: 'boolean', required: true, const: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
deleted: { type: 'boolean', required: true, const: false },
code: { type: 'string', required: true, const: 'schedule_not_found' },
},
},
...ERROR_SCHEMAS,
],
} as const
const CREATE_DESCRIPTION =
'Create one reminder in the current session. v1 accepts only a non-empty prompt and a positive '
+ 'safe-integer after_seconds delay. Delivery is session-local: the reminder runs on time only '
+ 'while this session is live and otherwise becomes overdue until the session is resumed.'
const LIST_DESCRIPTION =
'List every active reminder in the current session in creation order, including its exact id, '
+ 'UTC target, scheduled or overdue state, and session-local delivery mode.'
const DELETE_DESCRIPTION =
'Delete one active reminder in the current session by the exact id returned by schedule_create '
+ 'or schedule_list. Unknown or already-finished ids return deleted false.'
/** Deterministic model content for every canonical Schedule value. */
function renderValue(_args: unknown, value: unknown): ContentBlock[] {
// The ToolRegistry has already validated the value against the lossless-JSON output schema.
const text = JSON.stringify(value)
return [{ type: 'text', text }]
}
/** Pure generic pending card. */
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
}
/** Stable error for failures not safe to expose. */
function internalError(): ScheduleToolError {
return { code: 'internal_error', message: 'The schedule operation failed.' }
}
/** Stable durable-log failure. */
function corruptLogError(): ScheduleToolError {
return { code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.' }
}
/** Stable persistence uncertainty with the known operation identity. */
function persistenceError(
operation: SchedulePersistenceOperation,
id?: ScheduleIdType,
): PersistenceUncertainError {
return {
code: 'persistence_uncertain',
message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
operation,
...id === undefined ? {} : { id },
}
}
/** Translate a contained input failure to the closed tool union. */
function inputError(error: ScheduleInputError): ScheduleToolError {
return { code: error.code, message: error.message }
}
/** Fold only after a successful preflight, mapping corruption to a stable value. */
function foldForTool(agent: Agent): ReturnType<typeof foldScheduleEvents> | ScheduleToolError {
try {
return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0)
} catch (error: unknown) {
return error instanceof ScheduleLogError ? corruptLogError() : internalError()
}
}
/** Whether a fold attempt produced an error rather than replay state. */
function isToolError(
value: ReturnType<typeof foldScheduleEvents> | ScheduleToolError,
): value is ScheduleToolError {
return 'code' in value
}
/** Require one persistence checkpoint without leaking the backend failure. */
async function preflight(
rootCtx: Context,
agent: Agent,
operation: SchedulePersistenceOperation,
id?: ScheduleIdType,
): Promise<PersistenceUncertainError | undefined> {
try {
await flushSchedulePersistence(rootCtx, agent.session)
return undefined
} catch {
return persistenceError(operation, id)
}
}
/** Validate the v1 selector constraints that the open parameter root cannot express. */
function validateCreateArgs(args: { prompt: string; after_seconds: number }): ScheduleToolError | undefined {
const keys = Object.keys(args as unknown as Record<string, unknown>)
if (keys.some(key => key !== 'prompt' && key !== 'after_seconds')) {
return {
code: 'invalid_selector',
message: 'schedule_create accepts exactly the after_seconds selector in this version.',
}
}
if (args.prompt.trim().length === 0) {
return { code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' }
}
if (!Number.isSafeInteger(args.after_seconds) || args.after_seconds <= 0) {
return { code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' }
}
return undefined
}
/**
* Register all three Schedule tools in one exact agent scope.
* @param rootCtx - Global service context owning sessions and durability.
* @param toolCtx - Exact agent-scoped context receiving the definitions.
* @param agent - Exact live owner whose session the tools mutate.
* @param onDurableChange - Called after a create or actual delete barrier succeeds.
* @returns Idempotent aggregate disposer for the three registrations.
*/
export function registerScheduleTools(
rootCtx: Context,
toolCtx: Context,
agent: Agent,
onDurableChange: () => void,
): () => void {
const disposers: Array<() => void> = []
/** A projection observer cannot reverse a completed durability barrier. */
const notifyDurableChange = (): void => {
try {
onDurableChange()
} catch (error: unknown) {
rootCtx.logger.warn(`tool-schedule: durable-change observer failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
try {
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_create',
description: CREATE_DESCRIPTION,
parameters: {
prompt: {
type: 'string',
required: true,
description: 'Reminder content to present when the target becomes due.',
},
after_seconds: {
type: 'number',
required: true,
description: 'Positive safe-integer delay in seconds.',
},
},
output: { schema: CREATE_OUTPUT_SCHEMA, render: renderValue },
async execute(args, exec): Promise<ScheduleCreateValue> {
if (exec.agent !== agent) return internalError()
const invalid = validateCreateArgs(args)
if (invalid !== undefined) return invalid
const uncertain = await preflight(rootCtx, agent, 'create')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const id = allocateScheduleId(folded)
let record: AfterScheduleRecord
try {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
try {
agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: record,
})
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now())
},
presentCall: args => present('Create reminder', 'other', args.prompt),
})))
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_list',
description: LIST_DESCRIPTION,
parameters: {},
output: { schema: LIST_OUTPUT_SCHEMA, render: renderValue },
async execute(_args, exec): Promise<ScheduleListValue> {
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'list')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now))
},
presentCall: () => present('List reminders', 'read'),
})))
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_delete',
description: DELETE_DESCRIPTION,
parameters: {
id: { type: 'string', required: true, description: 'Exact session-local schedule id.' },
},
output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue },
async execute(args, exec): Promise<ScheduleDeleteValue> {
const id = ScheduleId(args.id)
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'delete', id)
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if (!folded.active.some(record => record.id === id)) {
return { id, deleted: false, code: 'schedule_not_found' }
}
try {
agent.session.append('schedule/change', { version: 1, operation: 'delete', id })
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'delete', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return { id, deleted: true }
},
presentCall: args => present('Delete reminder', 'other', args.id),
})))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
let active = true
return () => {
if (!active) return
active = false
for (const dispose of disposers.reverse()) dispose()
}
}
@@ -0,0 +1,158 @@
/**
* Durable and model-facing Schedule value types.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {} from '@deepseek-ai/dsh-session'
/** Stable reminder identity that is unique and never reused within one session. */
export type ScheduleId = Branded<'ScheduleId'>
/** Durable one-shot reminder created from a positive delay. */
export interface AfterScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
/** Rule discriminator; v1 supports only delayed one-shot reminders. */
readonly kind: 'after'
/** Trimmed user-authored reminder content. */
readonly prompt: string
/** Positive safe-integer delay accepted at creation. */
readonly afterSeconds: number
/** Four-digit-year RFC 3339 UTC target. */
readonly scheduledAt: string
}
/** The v1 durable reminder record union. */
export type ScheduleRecord = AfterScheduleRecord
/** Creates one durable reminder record. */
export interface ScheduleCreateChange {
readonly version: 1
readonly operation: 'create'
readonly schedule: ScheduleRecord
}
/** Deletes one currently active reminder. */
export interface ScheduleDeleteChange {
readonly version: 1
readonly operation: 'delete'
readonly id: ScheduleId
}
/** Records that one active one-shot reminder entered the durable dispatch history. */
export interface ScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
}
/** Strict version-1 durable Schedule mutation union. */
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
/** Current delivery timing derived from the durable record and wall clock. */
export type ScheduleState = 'scheduled' | 'overdue'
/** Fixed v1 delivery boundary: the original session must be live. */
export type ScheduleDeliveryMode = 'session-local'
/** Complete model-facing view of one active after reminder. */
export interface ScheduleView extends AfterScheduleRecord {
/** Whether the target remains in the future. */
readonly state: ScheduleState
/** Reminder delivery never leaves the owning session. */
readonly deliveryMode: ScheduleDeliveryMode
}
/** JSON-compatible Web receipt derived from one durable dispatch. */
export interface ScheduleReminderPresentation {
/** Session-local reminder identity. */
readonly scheduleId: ScheduleId
/** Original user-authored reminder content. */
readonly prompt: string
/** Scheduled one-shot occurrence represented by the dispatch. */
readonly occurrenceAt: string
/** Fixed delivery boundary rendered by the client plugin. */
readonly deliveryMode: ScheduleDeliveryMode
}
/** Operations whose persistence barrier may be uncertain. */
export type SchedulePersistenceOperation = 'create' | 'list' | 'delete' | 'dispatch'
/** Stable error returned for an empty reminder prompt. */
export interface InvalidPromptError {
readonly code: 'invalid_prompt'
readonly message: string
}
/** Stable error returned for a missing, conflicting, or unsupported rule selector. */
export interface InvalidSelectorError {
readonly code: 'invalid_selector'
readonly message: string
}
/** Stable error returned for an invalid after delay. */
export interface InvalidRuleError {
readonly code: 'invalid_rule'
readonly message: string
}
/** Stable error returned when the computed instant cannot use a four-digit UTC year. */
export interface TimeOutOfRangeError {
readonly code: 'time_out_of_range'
readonly message: string
}
/** Stable error returned when the durable Schedule stream is malformed. */
export interface CorruptScheduleLogError {
readonly code: 'corrupt_schedule_log'
readonly message: string
}
/** Stable error returned when a required persistence checkpoint did not complete. */
export interface PersistenceUncertainError {
readonly code: 'persistence_uncertain'
readonly message: string
readonly operation: SchedulePersistenceOperation
readonly id?: ScheduleId
}
/** Stable fallback that does not disclose an internal exception. */
export interface InternalScheduleError {
readonly code: 'internal_error'
readonly message: string
}
/** Closed v1 Schedule management error union. */
export type ScheduleToolError =
| InvalidPromptError
| InvalidSelectorError
| InvalidRuleError
| TimeOutOfRangeError
| CorruptScheduleLogError
| PersistenceUncertainError
| InternalScheduleError
/** Canonical `schedule_create` value. */
export type ScheduleCreateValue = ScheduleView | ScheduleToolError
/** Canonical `schedule_list` value. */
export type ScheduleListValue = ScheduleView[] | ScheduleToolError
/** Successful `schedule_delete` value, including the non-mutating not-found result. */
export type ScheduleDeleteResult =
| { readonly id: ScheduleId; readonly deleted: true }
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
/** Canonical `schedule_delete` value. */
export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Versioned Schedule mutation. The owning package validates the complete
* session-local transition stream before accepting a candidate event.
*/
'schedule/change': ScheduleChange
}
}