1
/**2
* Model-facing `schedule_create`, `schedule_list`, `schedule_update`, and3
* `schedule_delete` tools over the Host `ctx.schedule` service. Mounting the4
* plugin registers them in the mounting scope, so a preset decides which5
* agents receive them; every call acts on the calling Agent's Session.6
* @module @deepseek-ai/dsh-tool-schedule7
*/9
import type { Context } from '@deepseek-ai/cordis'10
import type { Agent } from '@deepseek-ai/dsh-agent'11
import type { ContentBlock } from '@deepseek-ai/dsh-llm'12
import { delegationDepthOf } from '@deepseek-ai/dsh-subagent'13
import { defineTool } from '@deepseek-ai/dsh-tools'14
import type { GenericCallView } from '@deepseek-ai/dsh-tools'15
import {16
MAX_TITLE_LENGTH, MIN_EVERY_INTERVAL_SECONDS, REQUIRED_TITLE_MESSAGE, ScheduleId, ScheduleInputError, scheduleView,17
} from '@deepseek-ai/dsh-schedule'18
import type {19
AtInput, CronInput, DailyInput, WeeklyInput, InternalScheduleError, ScheduleCreateValue, ScheduleDeleteValue,20
ScheduleListValue, ScheduleTimingChange, ScheduleToolError, ScheduleUpdateValue,21
} from '@deepseek-ai/dsh-schedule'23
/** Plugin name registered with the Loader. */24
export const name = 'tool-schedule'26
/** Host services this plugin consumes. The Schedule service is resolved per27
* scope, so a preset that declares this plugin stays inert while the shipped28
* composition keeps that service off. */29
export const inject = ['tools']31
const SHARED_VIEW_PROPERTIES = {32
id: { type: 'string', required: true },33
title: { type: 'string', required: true },34
prompt: { type: 'string', required: true },35
scheduledAt: { type: 'string', required: true },36
state: { type: 'string', required: true, enum: ['scheduled', 'overdue'] },37
deliveryMode: { type: 'string', required: true, const: 'host' },38
} as const40
const AFTER_VIEW_SCHEMA = {41
type: 'object',42
additionalProperties: false,43
properties: {44
...SHARED_VIEW_PROPERTIES,45
kind: { type: 'string', required: true, const: 'after' },46
afterSeconds: { type: 'integer', required: true },47
},48
} as const50
const AT_VIEW_SCHEMA = {51
type: 'object',52
additionalProperties: false,53
properties: {54
...SHARED_VIEW_PROPERTIES,55
kind: { type: 'string', required: true, const: 'at' },56
},57
} as const59
const EVERY_VIEW_SCHEMA = {60
type: 'object',61
additionalProperties: false,62
properties: {63
...SHARED_VIEW_PROPERTIES,64
kind: { type: 'string', required: true, const: 'every' },65
everySeconds: { type: 'integer', required: true },66
},67
} as const69
const DAILY_VIEW_SCHEMA = {70
type: 'object',71
additionalProperties: false,72
properties: {73
...SHARED_VIEW_PROPERTIES,74
kind: { type: 'string', required: true, const: 'daily' },75
time: { type: 'string', required: true },76
timeZone: { type: 'string', required: true },77
},78
} as const80
const WEEKLY_VIEW_SCHEMA = {81
type: 'object',82
additionalProperties: false,83
properties: {84
...SHARED_VIEW_PROPERTIES,85
kind: { type: 'string', required: true, const: 'weekly' },86
time: { type: 'string', required: true },87
timeZone: { type: 'string', required: true },88
weekdays: { type: 'array', required: true, items: { type: 'integer' } },89
},90
} as const92
const CRON_VIEW_SCHEMA = {93
type: 'object',94
additionalProperties: false,95
properties: {96
...SHARED_VIEW_PROPERTIES,97
kind: { type: 'string', required: true, const: 'cron' },98
expression: { type: 'string', required: true },99
timeZone: { type: 'string', required: true },100
},101
} as const103
const VIEW_SCHEMA = {104
oneOf: [105
AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA, DAILY_VIEW_SCHEMA, WEEKLY_VIEW_SCHEMA, CRON_VIEW_SCHEMA,106
],107
} as const109
/** Build one exact two-field error schema while preserving its literal code. */110
function basicErrorSchema<const C extends string>(code: C) {111
return {112
type: 'object',113
additionalProperties: false,114
properties: {115
code: { type: 'string', required: true, const: code },116
message: { type: 'string', required: true },117
},118
} as const119
}121
const ERROR_SCHEMAS = [122
basicErrorSchema('invalid_prompt'),123
basicErrorSchema('invalid_selector'),124
basicErrorSchema('invalid_rule'),125
basicErrorSchema('invalid_time_zone'),126
basicErrorSchema('not_future'),127
basicErrorSchema('time_out_of_range'),128
basicErrorSchema('frequency_too_high'),129
basicErrorSchema('subagent_session'),130
basicErrorSchema('internal_error'),131
] as const133
const CREATE_OUTPUT_SCHEMA = { oneOf: [VIEW_SCHEMA, ...ERROR_SCHEMAS] } as const134
const LIST_OUTPUT_SCHEMA = {135
oneOf: [136
{ type: 'array', items: VIEW_SCHEMA },137
...ERROR_SCHEMAS,138
],139
} as const140
const DELETE_OUTPUT_SCHEMA = {141
oneOf: [142
{143
type: 'object',144
additionalProperties: false,145
properties: {146
id: { type: 'string', required: true },147
deleted: { type: 'boolean', required: true, const: true },148
},149
},150
{151
type: 'object',152
additionalProperties: false,153
properties: {154
id: { type: 'string', required: true },155
deleted: { type: 'boolean', required: true, const: false },156
code: { type: 'string', required: true, const: 'schedule_not_found' },157
},158
},159
...ERROR_SCHEMAS,160
],161
} as const163
const UPDATE_OUTPUT_SCHEMA = {164
oneOf: [165
VIEW_SCHEMA,166
{167
type: 'object',168
additionalProperties: false,169
properties: {170
id: { type: 'string', required: true },171
updated: { type: 'boolean', required: true, const: false },172
code: {173
type: 'string',174
required: true,175
enum: ['schedule_not_found', 'schedule_ended', 'schedule_conflict'],176
},177
},178
},179
...ERROR_SCHEMAS,180
],181
} as const183
const CREATE_DESCRIPTION =184
'Create a reminder in the current session that delivers prompt when it becomes due. '185
+ 'Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. '186
+ 'Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. '187
+ 'After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash.'189
const LIST_DESCRIPTION = 'List the active reminders in the current session.'191
const DELETE_DESCRIPTION =192
'Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.'194
const UPDATE_DESCRIPTION =195
'Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; '196
+ 'omitted fields keep their stored values. To change a relative delay, create a new reminder.'198
/** Deterministic model content for every canonical Schedule value. */199
function renderValue(_args: unknown, value: unknown): ContentBlock[] {200
// The ToolRuntime has already validated the value against the lossless-JSON output schema.201
const text = JSON.stringify(value)202
return [{ type: 'text', text }]203
}205
/** Pure generic pending card. */206
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {207
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }208
}210
/** Stable error for failures not safe to expose. */211
function internalError(): InternalScheduleError {212
return { code: 'internal_error', message: 'The schedule operation failed.' }213
}215
/** Translate invalid input while withholding internal storage failures. */216
function operationError(error: unknown): ScheduleToolError {217
return error instanceof ScheduleInputError ? { code: error.code, message: error.message } : internalError()218
}220
/**221
* Refuse a caller that is a delegated child. A subagent cannot use reminders at222
* all, so every tool rejects before dispatch instead of relying on the mounting223
* preset's tool filter alone.224
* @param agent - the calling agent, when the outer call has one.225
* @returns the stable refusal, or undefined for a top-level caller.226
*/227
function subagentCallerRefusal(agent: Agent | undefined): ScheduleToolError | undefined {228
if (agent === undefined || delegationDepthOf(agent) === 0) return undefined229
return { code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }230
}232
/** One supplied fixed-rate interval: a safe integer at or above the Host floor, or undefined. */233
function invalidInterval(everySeconds: number | undefined): ScheduleToolError | undefined {234
if (everySeconds === undefined) return undefined235
if (!Number.isSafeInteger(everySeconds)) {236
return { code: 'invalid_rule', message: 'every_seconds must be a safe integer.' }237
}238
if (everySeconds < MIN_EVERY_INTERVAL_SECONDS) {239
return {240
code: 'frequency_too_high',241
message: `every_seconds must be at least ${MIN_EVERY_INTERVAL_SECONDS}.`,242
}243
}244
return undefined245
}247
/** Validate selector constraints that the open parameter root cannot express. */248
function validateCreateArgs(args: {249
prompt: string250
title: string251
after_seconds?: number252
at?: AtInput253
every_seconds?: number254
daily?: DailyInput255
weekly?: WeeklyInput256
cron?: CronInput257
}): ScheduleToolError | undefined {258
const keys = Object.keys(args)259
if (keys.some(key => key !== 'prompt'260
&& key !== 'title'261
&& key !== 'after_seconds'262
&& key !== 'at'263
&& key !== 'every_seconds'264
&& key !== 'daily'265
&& key !== 'weekly'266
&& key !== 'cron')267
|| Number(args.after_seconds !== undefined)268
+ Number(args.at !== undefined)269
+ Number(args.every_seconds !== undefined)270
+ Number(args.daily !== undefined)271
+ Number(args.weekly !== undefined)272
+ Number(args.cron !== undefined) !== 1) {273
return {274
code: 'invalid_selector',275
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, daily, weekly, or cron.',276
}277
}278
if (args.prompt.trim().length === 0) {279
return { code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' }280
}281
if (args.title.trim().length === 0) {282
return { code: 'invalid_prompt', message: REQUIRED_TITLE_MESSAGE }283
}284
if (args.title.trim().length > MAX_TITLE_LENGTH) {285
return { code: 'invalid_prompt', message: `title must be at most ${MAX_TITLE_LENGTH} characters.` }286
}287
if (args.after_seconds !== undefined288
&& (!Number.isSafeInteger(args.after_seconds) || args.after_seconds <= 0)) {289
return { code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' }290
}291
return invalidInterval(args.every_seconds)292
}294
/** Validate the in-place update's selector count, id, and any supplied name, instruction, or interval. */295
function validateUpdateArgs(args: {296
id: string297
title?: string298
prompt?: string299
at?: AtInput300
every_seconds?: number301
daily?: DailyInput302
weekly?: WeeklyInput303
cron?: CronInput304
}): ScheduleToolError | undefined {305
const selectors = [306
args.at !== undefined,307
args.every_seconds !== undefined,308
args.daily !== undefined,309
args.weekly !== undefined,310
args.cron !== undefined,311
].filter(Boolean).length312
if (Object.keys(args).some(key => key !== 'id'313
&& key !== 'title'314
&& key !== 'prompt'315
&& key !== 'at'316
&& key !== 'every_seconds'317
&& key !== 'daily'318
&& key !== 'weekly'319
&& key !== 'cron')320
|| selectors > 1) {321
return {322
code: 'invalid_selector',323
message: 'schedule_update accepts at most one of at, every_seconds, daily, weekly, or cron.',324
}325
}326
if (args.id.length === 0 || args.id.trim() !== args.id) {327
return { code: 'invalid_rule', message: 'schedule_update id must be non-empty without surrounding whitespace.' }328
}329
if (selectors === 0 && args.title === undefined && args.prompt === undefined) {330
return {331
code: 'invalid_selector',332
message: 'schedule_update needs a new title, prompt, or one of at, every_seconds, daily, weekly, or cron.',333
}334
}335
if (args.title !== undefined && args.title.trim().length === 0) {336
return { code: 'invalid_prompt', message: REQUIRED_TITLE_MESSAGE }337
}338
if (args.title !== undefined && args.title.trim().length > MAX_TITLE_LENGTH) {339
return { code: 'invalid_prompt', message: `title must be at most ${MAX_TITLE_LENGTH} characters.` }340
}341
if (args.prompt !== undefined && args.prompt.trim().length === 0) {342
return { code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' }343
}344
return invalidInterval(args.every_seconds)345
}347
/** The one timing replacement the update carries, or undefined when the request keeps the committed target. */348
function timingChangeFrom(args: {349
at?: AtInput350
every_seconds?: number351
daily?: DailyInput352
weekly?: WeeklyInput353
cron?: CronInput354
}): ScheduleTimingChange | undefined {355
if (args.at !== undefined) return { kind: 'at', at: args.at }356
if (args.every_seconds !== undefined) return { kind: 'every', every_seconds: args.every_seconds }357
if (args.daily !== undefined) return { kind: 'daily', daily: args.daily }358
if (args.weekly !== undefined) return { kind: 'weekly', weekly: args.weekly }359
if (args.cron !== undefined) return { kind: 'cron', cron: args.cron }360
return undefined361
}363
/**364
* Selector parameters shared by `schedule_create` and `schedule_update`, in the order the365
* generated tool catalog states them.366
*/367
const SELECTOR_PARAMETERS = {368
every_seconds: {369
type: 'number',370
description: `Fixed-rate interval in whole seconds, at least ${MIN_EVERY_INTERVAL_SECONDS}, aligned to the creation time; changing it with schedule_update re-aligns it to the save time.`,371
},372
daily: {373
type: 'object',374
additionalProperties: false,375
description: 'Every day at a local time.',376
properties: {377
time: { type: 'string', required: true, description: 'HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00.' },378
time_zone: { type: 'string', required: true, description: 'UTC or IANA Area/Location, for example Asia/Shanghai.' },379
},380
},381
weekly: {382
type: 'object',383
additionalProperties: false,384
description: 'On the given weekdays at a local time.',385
properties: {386
time: { type: 'string', required: true, description: 'HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00.' },387
time_zone: { type: 'string', required: true, description: 'UTC or IANA Area/Location, for example Asia/Shanghai.' },388
weekdays: {389
type: 'array',390
required: true,391
description: 'ISO weekdays, Monday 1 through Sunday 7, without repetitions.',392
items: { type: 'integer' },393
},394
},395
},396
cron: {397
type: 'object',398
additionalProperties: false,399
description: 'Five-field Vixie cron expression in a time zone.',400
properties: {401
expression: {402
type: 'string',403
required: true,404
description: 'minute hour day-of-month month day-of-week, for example "*/15 9-17 * * 1-5". '405
+ 'When both day fields are restricted, a date matches if either one matches.',406
},407
time_zone: { type: 'string', required: true, description: 'UTC or IANA Area/Location, for example Asia/Shanghai.' },408
},409
},410
at: {411
description: 'Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone.',412
oneOf: [413
{ type: 'string' },414
{415
type: 'object',416
additionalProperties: false,417
properties: {418
date: { type: 'string', required: true },419
time: { type: 'string', required: true },420
time_zone: { type: 'string', required: true },421
},422
},423
],424
},425
} as const427
/**428
* Register the four Schedule tools in the mounting scope.429
*430
* Each call mutates the Session of the Agent that dispatched it, so a mount431
* outside an agent scope still registers the tools but every call without a432
* caller Agent is refused as an internal failure.433
* @param ctx - Context owning the `tools` registry and the Host `schedule` service.434
*/435
export function apply(ctx: Context): void {436
ctx.inject(['schedule'], (scheduleCtx) => { registerScheduleTools(scheduleCtx) })437
}439
/**440
* Register the four reminder tools in the scope that resolved the Schedule441
* service.442
* @param ctx - Scope whose `schedule` service the tools act through.443
*/444
function registerScheduleTools(ctx: Context): void {445
ctx.tools.register(defineTool({446
name: 'schedule_create',447
description: CREATE_DESCRIPTION,448
parameters: {449
prompt: {450
type: 'string',451
required: true,452
description: 'Reminder content to present when the target becomes due.',453
},454
title: {455
type: 'string',456
required: true,457
description: `Task name of at most ${MAX_TITLE_LENGTH} characters, shown on the task card and in task lists.`,458
},459
after_seconds: {460
type: 'number',461
description: 'Delay in whole seconds.',462
},463
...SELECTOR_PARAMETERS,464
},465
output: { schema: CREATE_OUTPUT_SCHEMA, render: renderValue },466
async execute(args, exec): Promise<ScheduleCreateValue> {467
const agent = exec.agent468
if (agent === undefined) return internalError()469
const refusal = subagentCallerRefusal(agent)470
if (refusal !== undefined) return refusal471
const invalid = validateCreateArgs(args)472
if (invalid !== undefined) return invalid473
if (exec.signal.aborted) return internalError()474
try {475
return scheduleView(await ctx.schedule.create(agent.session.id, args, exec.signal), Date.now())476
} catch (error: unknown) {477
return operationError(error)478
}479
},480
presentCall: args => present('Create reminder', 'other', args.prompt),481
}))483
ctx.tools.register(defineTool({484
name: 'schedule_list',485
description: LIST_DESCRIPTION,486
parameters: {},487
output: { schema: LIST_OUTPUT_SCHEMA, render: renderValue },488
async execute(_args, exec): Promise<ScheduleListValue> {489
const agent = exec.agent490
if (agent === undefined) return internalError()491
const refusal = subagentCallerRefusal(agent)492
if (refusal !== undefined) return refusal493
if (exec.signal.aborted) return internalError()494
try {495
const records = await ctx.schedule.list({ sessionId: agent.session.id })496
return records.map(record => scheduleView(record, Date.now()))497
} catch (error: unknown) {498
return operationError(error)499
}500
},501
presentCall: () => present('List reminders', 'read'),502
}))504
ctx.tools.register(defineTool({505
name: 'schedule_delete',506
description: DELETE_DESCRIPTION,507
parameters: {508
id: { type: 'string', required: true, description: 'Exact schedule id.' },509
},510
output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue },511
async execute(args, exec): Promise<ScheduleDeleteValue> {512
if (args.id.length === 0 || args.id.trim() !== args.id) {513
return { code: 'invalid_rule', message: 'schedule_delete id must be non-empty without surrounding whitespace.' }514
}515
const id = ScheduleId(args.id)516
const agent = exec.agent517
if (agent === undefined) return internalError()518
const refusal = subagentCallerRefusal(agent)519
if (refusal !== undefined) return refusal520
if (exec.signal.aborted) return internalError()521
try {522
return await ctx.schedule.delete({ sessionId: agent.session.id, id }, exec.signal)523
} catch (error: unknown) {524
return operationError(error)525
}526
},527
presentCall: args => present('Delete reminder', 'other', args.id),528
}))530
ctx.tools.register(defineTool({531
name: 'schedule_update',532
description: UPDATE_DESCRIPTION,533
parameters: {534
id: { type: 'string', required: true, description: 'Schedule id returned by schedule_list.' },535
title: {536
type: 'string',537
description: `New task name of at most ${MAX_TITLE_LENGTH} characters.`,538
},539
prompt: {540
type: 'string',541
description: 'New reminder content.',542
},543
...SELECTOR_PARAMETERS,544
},545
output: { schema: UPDATE_OUTPUT_SCHEMA, render: renderValue },546
async execute(args, exec): Promise<ScheduleUpdateValue> {547
const agent = exec.agent548
if (agent === undefined) return internalError()549
const refusal = subagentCallerRefusal(agent)550
if (refusal !== undefined) return refusal551
const invalid = validateUpdateArgs(args)552
if (invalid !== undefined) return invalid553
if (exec.signal.aborted) return internalError()554
const id = ScheduleId(args.id)555
try {556
const sessionId = agent.session.id557
const expected = (await ctx.schedule.list({ sessionId }))558
.find(record => record.id === id)559
if (expected === undefined) {560
// The catalog also holds inactive reminders, which is the one not-found561
// case the model can act on: it has to create a new reminder instead.562
const ended = (await ctx.schedule.catalog())563
.some(entry => entry.sessionId === sessionId && entry.id === id)564
return { id, updated: false, code: ended ? 'schedule_ended' : 'schedule_not_found' }565
}566
const change = timingChangeFrom(args)567
const result = await ctx.schedule.update({568
sessionId,569
id,570
expected,571
...(change === undefined ? {} : { change }),572
...(args.title === undefined ? {} : { title: args.title }),573
...(args.prompt === undefined ? {} : { prompt: args.prompt }),574
}, exec.signal)575
return 'record' in result ? scheduleView(result.record, Date.now()) : result576
} catch (error: unknown) {577
return operationError(error)578
}579
},580
presentCall: args => present('Update reminder', 'other', args.id),581
}))582
}