返回源码地图

packages/schedule/tool-schedule/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Model-facing `schedule_create`, `schedule_list`, `schedule_update`, and
3 * `schedule_delete` tools over the Host `ctx.schedule` service. Mounting the
4 * plugin registers them in the mounting scope, so a preset decides which
5 * agents receive them; every call acts on the calling Agent's Session.
6 * @module @deepseek-ai/dsh-tool-schedule
7 */
8
9import type { Context } from '@deepseek-ai/cordis'
10import type { Agent } from '@deepseek-ai/dsh-agent'
11import type { ContentBlock } from '@deepseek-ai/dsh-llm'
12import { delegationDepthOf } from '@deepseek-ai/dsh-subagent'
13import { defineTool } from '@deepseek-ai/dsh-tools'
14import type { GenericCallView } from '@deepseek-ai/dsh-tools'
15import {
16 MAX_TITLE_LENGTH, MIN_EVERY_INTERVAL_SECONDS, REQUIRED_TITLE_MESSAGE, ScheduleId, ScheduleInputError, scheduleView,
17} from '@deepseek-ai/dsh-schedule'
18import type {
19 AtInput, CronInput, DailyInput, WeeklyInput, InternalScheduleError, ScheduleCreateValue, ScheduleDeleteValue,
20 ScheduleListValue, ScheduleTimingChange, ScheduleToolError, ScheduleUpdateValue,
21} from '@deepseek-ai/dsh-schedule'
22
23/** Plugin name registered with the Loader. */
24export const name = 'tool-schedule'
25
26/** Host services this plugin consumes. The Schedule service is resolved per
27 * scope, so a preset that declares this plugin stays inert while the shipped
28 * composition keeps that service off. */
29export const inject = ['tools']
30
31const 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 const
39
40const 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 const
49
50const 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 const
58
59const 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 const
68
69const 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 const
79
80const 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 const
91
92const 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 const
102
103const 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 const
108
109/** Build one exact two-field error schema while preserving its literal code. */
110function 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 const
119}
120
121const 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 const
132
133const CREATE_OUTPUT_SCHEMA = { oneOf: [VIEW_SCHEMA, ...ERROR_SCHEMAS] } as const
134const LIST_OUTPUT_SCHEMA = {
135 oneOf: [
136 { type: 'array', items: VIEW_SCHEMA },
137 ...ERROR_SCHEMAS,
138 ],
139} as const
140const 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 const
162
163const 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 const
182
183const 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.'
188
189const LIST_DESCRIPTION = 'List the active reminders in the current session.'
190
191const DELETE_DESCRIPTION =
192 'Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.'
193
194const 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.'
197
198/** Deterministic model content for every canonical Schedule value. */
199function 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}
204
205/** Pure generic pending card. */
206function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
207 return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
208}
209
210/** Stable error for failures not safe to expose. */
211function internalError(): InternalScheduleError {
212 return { code: 'internal_error', message: 'The schedule operation failed.' }
213}
214
215/** Translate invalid input while withholding internal storage failures. */
216function operationError(error: unknown): ScheduleToolError {
217 return error instanceof ScheduleInputError ? { code: error.code, message: error.message } : internalError()
218}
219
220/**
221 * Refuse a caller that is a delegated child. A subagent cannot use reminders at
222 * all, so every tool rejects before dispatch instead of relying on the mounting
223 * 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 */
227function subagentCallerRefusal(agent: Agent | undefined): ScheduleToolError | undefined {
228 if (agent === undefined || delegationDepthOf(agent) === 0) return undefined
229 return { code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }
230}
231
232/** One supplied fixed-rate interval: a safe integer at or above the Host floor, or undefined. */
233function invalidInterval(everySeconds: number | undefined): ScheduleToolError | undefined {
234 if (everySeconds === undefined) return undefined
235 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 undefined
245}
246
247/** Validate selector constraints that the open parameter root cannot express. */
248function validateCreateArgs(args: {
249 prompt: string
250 title: string
251 after_seconds?: number
252 at?: AtInput
253 every_seconds?: number
254 daily?: DailyInput
255 weekly?: WeeklyInput
256 cron?: CronInput
257}): 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 !== undefined
288 && (!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}
293
294/** Validate the in-place update's selector count, id, and any supplied name, instruction, or interval. */
295function validateUpdateArgs(args: {
296 id: string
297 title?: string
298 prompt?: string
299 at?: AtInput
300 every_seconds?: number
301 daily?: DailyInput
302 weekly?: WeeklyInput
303 cron?: CronInput
304}): 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).length
312 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}
346
347/** The one timing replacement the update carries, or undefined when the request keeps the committed target. */
348function timingChangeFrom(args: {
349 at?: AtInput
350 every_seconds?: number
351 daily?: DailyInput
352 weekly?: WeeklyInput
353 cron?: CronInput
354}): 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 undefined
361}
362
363/**
364 * Selector parameters shared by `schedule_create` and `schedule_update`, in the order the
365 * generated tool catalog states them.
366 */
367const 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 const
426
427/**
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 mount
431 * outside an agent scope still registers the tools but every call without a
432 * caller Agent is refused as an internal failure.
433 * @param ctx - Context owning the `tools` registry and the Host `schedule` service.
434 */
435export function apply(ctx: Context): void {
436 ctx.inject(['schedule'], (scheduleCtx) => { registerScheduleTools(scheduleCtx) })
437}
438
439/**
440 * Register the four reminder tools in the scope that resolved the Schedule
441 * service.
442 * @param ctx - Scope whose `schedule` service the tools act through.
443 */
444function 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.agent
468 if (agent === undefined) return internalError()
469 const refusal = subagentCallerRefusal(agent)
470 if (refusal !== undefined) return refusal
471 const invalid = validateCreateArgs(args)
472 if (invalid !== undefined) return invalid
473 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 }))
482
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.agent
490 if (agent === undefined) return internalError()
491 const refusal = subagentCallerRefusal(agent)
492 if (refusal !== undefined) return refusal
493 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 }))
503
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.agent
517 if (agent === undefined) return internalError()
518 const refusal = subagentCallerRefusal(agent)
519 if (refusal !== undefined) return refusal
520 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 }))
529
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.agent
548 if (agent === undefined) return internalError()
549 const refusal = subagentCallerRefusal(agent)
550 if (refusal !== undefined) return refusal
551 const invalid = validateUpdateArgs(args)
552 if (invalid !== undefined) return invalid
553 if (exec.signal.aborted) return internalError()
554 const id = ScheduleId(args.id)
555 try {
556 const sessionId = agent.session.id
557 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-found
561 // 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()) : result
576 } catch (error: unknown) {
577 return operationError(error)
578 }
579 },
580 presentCall: args => present('Update reminder', 'other', args.id),
581 }))
582}