dsh-ops-cron/lib/tools.js
oliver 819feed310 Make origin session mirror opt-in per job.
Default mirrorToSession to false so cron summaries stay in run history unless the sidebar checkbox or mirror_to_session is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:00:20 +08:00

510 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Model-facing tools for scheduled jobs.
* Plain ToolDefinition objects — do not import @deepseek-ai/dsh-tools.
*/
import { formatInZone, resolveTodayAt } from './scheduler.js'
import {
deliveryLine,
jobVisibleToPeer,
resolveCallerPeer,
resolveCreateDelivery,
resolveCreateOrigin,
} from './delivery.js'
import { resolveCreateAgentPreset } from './preset.js'
import {
assertCanAccessJob,
filterJobsForIdentity,
jobVisibleToIdentity,
UNASSIGNED_OWNER,
} from './ownership.js'
const JOB_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: {
id: { type: 'string' },
name: { type: 'string' },
prompt: { type: 'string' },
enabled: { type: 'boolean' },
cwd: { type: 'string' },
timeoutMinutes: { type: 'number' },
provider: { type: 'string' },
model: { type: 'string' },
reasoningEffort: { type: 'string' },
agentPreset: { type: 'string' },
delivery: { type: 'object', additionalProperties: true },
origin: { type: 'object', additionalProperties: true },
mirrorToSession: { type: 'boolean' },
schedule: { type: 'object', additionalProperties: true },
createdAt: { type: 'number' },
updatedAt: { type: 'number' },
lastRunAt: {},
lastStatus: {},
nextRunAt: {},
},
}
function text(value) {
return [{ type: 'text', text: String(value || '') }]
}
function aborted(exec) {
if (exec?.signal?.aborted) {
const error = new Error('aborted')
error.code = 'ABORTED'
throw error
}
}
function presentNumber(value) {
if (value === undefined || value === null || value === '') return false
return Number.isFinite(Number(value))
}
/**
* Models often send overlapping schedule fields (at + hour, expr + hour).
* Prefer the most specific: after_minutes, then hour+minute, then at, then cron.
*/
export function scheduleFromArgs(args = {}, nowMs = Date.now()) {
const timezone = typeof args.timezone === 'string' && args.timezone.trim()
? args.timezone.trim()
: (typeof args.time_zone === 'string' && args.time_zone.trim() ? args.time_zone.trim() : 'Asia/Shanghai')
const expr = typeof args.expr === 'string' && args.expr.trim()
? args.expr.trim()
: (typeof args.cron === 'string' && args.cron.trim() ? args.cron.trim() : '')
const at = typeof args.at === 'string' && args.at.trim() ? args.at.trim() : ''
const after = presentNumber(args.after_minutes)
? Number(args.after_minutes)
: (presentNumber(args.in_minutes) ? Number(args.in_minutes) : null)
const hasHour = presentNumber(args.hour)
if (after != null) {
if (after < 0) {
const error = new Error('after_minutes must be >= 0')
error.code = 'INVALID_SCHEDULE'
throw error
}
return { kind: 'at', at: new Date(nowMs + after * 60_000).toISOString(), timezone }
}
if (hasHour) {
const instant = resolveTodayAt(
Number(args.hour),
presentNumber(args.minute) ? Number(args.minute) : 0,
timezone,
nowMs,
)
return { kind: 'at', at: new Date(instant).toISOString(), timezone }
}
if (at) return { kind: 'at', at, timezone }
if (expr) return { kind: 'cron', expr, timezone }
const error = new Error('provide after_minutes, hour+minute, at (ISO or HH:mm), or expr')
error.code = 'INVALID_SCHEDULE'
throw error
}
function jobLine(job) {
if (!job) return ''
const tz = job.schedule?.timezone || 'Asia/Shanghai'
const when = job.nextRunAt ? formatInZone(job.nextRunAt, tz) : 'n/a'
const state = job.enabled === false ? 'paused' : 'enabled'
const sched = job.schedule?.kind === 'at'
? `at ${job.schedule.at}`
: (job.schedule?.expr || 'cron')
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
const delivery = deliveryLine(job.delivery)
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery} next=${when} id=${job.id}`
}
export function callerWorkingDirectory(exec) {
const session = exec?.agent?.session
return String(session?.header?.cwd || session?.cwd || exec?.agent?.cwd || '').trim()
}
export function resolveCreateCwd(args, exec, { peer = null } = {}) {
const sessionCwd = callerWorkingDirectory(exec)
const passed = typeof args?.cwd === 'string' ? args.cwd.trim() : ''
// IM peers cannot point scheduled Agents at arbitrary host paths.
if (peer?.botId) return sessionCwd
if (passed) return passed
return sessionCwd
}
function callerSessionId(exec) {
return String(
exec?.agent?.session?.id
|| exec?.agent?.session?.header?.id
|| exec?.sessionId
|| exec?.agent?.sessionId
|| '',
).trim()
}
function empNoFromUserWorkspacePath(cwd) {
const norm = String(cwd || '').replace(/\\/g, '/')
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
return match ? decodeURIComponent(match[1]) : null
}
/**
* Resolve web caller identity for ownership. IM peers keep conversation scoping.
*/
export function resolveToolIdentity(exec, service) {
const uds = typeof service?.getUdsAuth === 'function' ? service.getUdsAuth() : null
const sessionId = callerSessionId(exec)
let empNo = null
if (sessionId && uds?.getSessionOwner) {
empNo = uds.getSessionOwner(sessionId)
}
if (!empNo) {
empNo = empNoFromUserWorkspacePath(callerWorkingDirectory(exec))
}
if (!empNo) return null
return {
empNo: String(empNo),
displayName: String(empNo),
role: 'user',
permissions: { canViewAllSessions: false },
}
}
function forbidForeignJob(job, peer, identity = null) {
if (peer?.botId) {
if (jobVisibleToPeer(job, peer)) return job
const error = new Error('job not found or not owned by this chat')
error.code = 'NOT_FOUND'
throw error
}
if (identity?.empNo) {
assertCanAccessJob(job, identity)
return job
}
// No peer and no web identity: do not expose foreign jobs.
if (job?.ownerEmpNo && job.ownerEmpNo !== UNASSIGNED_OWNER) {
const error = new Error('job not found or not owned by this session')
error.code = 'NOT_FOUND'
throw error
}
return job
}
async function requireOwnedJob(service, id, peer, identity = null) {
const job = await service.getJob?.(id)
if (job) return forbidForeignJob(job, peer, identity)
const jobs = await service.listJobs()
const hit = (jobs || []).find((row) => row.id === id)
if (!hit) {
const error = new Error('job not found')
error.code = 'NOT_FOUND'
throw error
}
return forbidForeignJob(hit, peer, identity)
}
export function callerModelSelection(exec) {
const opts = exec?.agent?.options || {}
const provider = String(opts.provider || '').trim()
const model = String(opts.model || '').trim()
if (!provider || !model) return { provider: '', model: '', reasoningEffort: '' }
const reasoningEffort = String(opts.reasoningEffort || '').trim()
return { provider, model, reasoningEffort }
}
export function resolveCreateModel(args, exec) {
const provider = typeof args?.provider === 'string' ? args.provider.trim() : ''
const model = typeof args?.model === 'string' ? args.model.trim() : ''
const reasoningEffort = typeof args?.reasoning_effort === 'string'
? args.reasoning_effort.trim()
: (typeof args?.reasoningEffort === 'string' ? args.reasoningEffort.trim() : '')
if (provider && model) return { provider, model, reasoningEffort }
const fromSession = callerModelSelection(exec)
return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort }
}
function requireId(args) {
const id = typeof args?.id === 'string' ? args.id.trim() : ''
if (!id) {
const error = new Error('id is required')
error.code = 'INVALID_JOB'
throw error
}
return id
}
export function cronToolDefinitions(service, deps = {}) {
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
const getAgentPresets = typeof deps.getAgentPresets === 'function' ? deps.getAgentPresets : () => undefined
return [
{
name: 'cron_create',
description: 'Create a DSH 定时任务 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Agent preset: omit to inherit (WhatsApp chat preset → session → Host default); or pass agent_preset. Delivery: omit for auto — on WhatsApp/IM creates/reuses a 投递目标 for the current chat (group→group, DM→DM) and sets delivery=im; on Web/DSH defaults to dsh. Or pass delivery=dsh|im with im_bot_id+im_target_id. Session mirror is OFF by default; pass mirror_to_session=true only when the user wants the run summary injected into the origin WhatsApp/Web chat.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', description: 'Short display name in the 定时任务 list.' },
prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' },
expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' },
cron: { type: 'string', description: 'Alias of expr.' },
at: { type: 'string', description: 'One-shot ISO or HH:mm. Prefer after_minutes or hour+minute instead. Ignored if after_minutes or hour is set.' },
after_minutes: { type: 'number', description: 'One-shot in N minutes from now. Use for "一分钟后" / "in 5 minutes". Do not also pass at, hour, or expr.' },
in_minutes: { type: 'number', description: 'Alias of after_minutes.' },
hour: { type: 'integer', description: 'One-shot today/tonight clock hour 0-23. Midnight is 0. Ignored if after_minutes is set.' },
minute: { type: 'integer', description: '0-59, used with hour.' },
timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' },
time_zone: { type: 'string', description: 'Alias of timezone.' },
cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. WhatsApp/IM creates always use the current session cwd.' },
provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' },
model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session. On WhatsApp/IM, delivery is always bound to the current chat — free im_bot_id/im_target_id are ignored.' },
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im (Web/sidebar only; IM chats cannot retarget).' },
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im (Web/sidebar only; IM chats cannot retarget).' },
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
mirror_to_session: { type: 'boolean', description: 'If true, after each fire append the run summary into the origin WhatsApp/Web session as a system-reminder (no new model turn). Default false — results stay in run history (and IM delivery when configured).' },
},
required: ['name', 'prompt'],
},
output: {
schema: {
type: 'object',
additionalProperties: true,
properties: { job: JOB_SCHEMA },
},
render: (_args, value) => {
const job = value.job
const tz = job?.schedule?.timezone || 'Asia/Shanghai'
const when = job?.nextRunAt ? formatInZone(job.nextRunAt, tz) : 'none'
const sched = job?.schedule?.kind === 'at'
? `one-shot ${job.schedule.at}`
: `cron ${job?.schedule?.expr}`
const cwd = job?.cwd ? ` cwd=${job.cwd}` : ' cwd=(recent workspace)'
const model = job?.provider && job?.model
? ` model=${job.provider}/${job.model}`
: ' model=(new-session default at fire time)'
const delivery = ` ${deliveryLine(job?.delivery)}`
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
},
},
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
async execute(args, exec) {
aborted(exec)
const timeout = Number(args.timeout_minutes)
const dshIm = getDshIm()
try {
const peer = await resolveCallerPeer(exec, dshIm)
const identity = resolveToolIdentity(exec, service)
const delivery = await resolveCreateDelivery(args, exec, { dshIm })
const origin = await resolveCreateOrigin(args, exec, { dshIm })
const agentPreset = await resolveCreateAgentPreset(args, exec, {
dshIm,
agentPresets: getAgentPresets(),
})
const job = await service.createJob({
name: args.name,
prompt: args.prompt,
schedule: scheduleFromArgs(args, Date.now()),
cwd: resolveCreateCwd(args, exec, { peer }),
...resolveCreateModel(args, exec),
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
enabled: args.enabled !== false,
delivery,
mirrorToSession: args.mirror_to_session === true,
...origin ? { origin } : {},
agentPreset,
}, identity)
return { job }
} catch (error) {
const tz = args.timezone || args.time_zone || 'Asia/Shanghai'
const nowText = formatInZone(Date.now(), tz)
const message = error instanceof Error ? error.message : String(error)
const code = error && error.code
if (code === 'INVALID_AT' || /already in the past/.test(message)) {
throw new Error(`${message}. Current local time is ${nowText}. For "in N minutes" pass only after_minutes. For "today/tonight at HH:MM" pass only hour and minute (hour=0 is midnight).`)
}
throw error instanceof Error ? error : new Error(message)
}
},
},
{
name: 'cron_list',
description: 'List DSH 定时任务 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
enabled_only: { type: 'boolean', description: 'If true, omit paused jobs.' },
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
jobs: { type: 'array', items: JOB_SCHEMA },
count: { type: 'integer' },
},
required: ['jobs', 'count'],
},
render: (_args, value) => {
const rows = (value.jobs || []).map(jobLine)
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
},
},
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
let jobs = await service.listJobs(identity)
if (peer?.botId) jobs = jobs.filter((job) => jobVisibleToPeer(job, peer))
if (args?.enabled_only === true) jobs = jobs.filter((job) => job.enabled !== false)
return { jobs, count: jobs.length }
},
},
{
name: 'cron_pause',
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.',
parameters: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'string', description: 'Job id.' } },
required: ['id'],
},
output: {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const id = requireId(args)
await requireOwnedJob(service, id, peer, identity)
const job = await service.pauseJob(id, false, identity)
return { job }
},
},
{
name: 'cron_resume',
description: 'Resume a paused scheduled task by id.',
parameters: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'string', description: 'Job id.' } },
required: ['id'],
},
output: {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const id = requireId(args)
await requireOwnedJob(service, id, peer, identity)
const job = await service.pauseJob(id, true, identity)
return { job }
},
},
{
name: 'cron_delete',
description: 'Permanently delete a scheduled task by id. History rows for that job remain until pruned.',
parameters: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'string', description: 'Job id.' } },
required: ['id'],
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
deleted: { type: 'boolean' },
},
required: ['id', 'deleted'],
},
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
},
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const id = requireId(args)
try {
await requireOwnedJob(service, id, peer, identity)
await service.deleteJob(id)
return { id, deleted: true }
} catch (error) {
if (error && error.code === 'NOT_FOUND') return { id, deleted: false }
throw error
}
},
},
]
}
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
const now = formatInZone(nowMs, timeZone)
return [
'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks / dsh-ops-cron).',
'Jobs live in that list and fire a fresh session (and optionally deliver a summary to WhatsApp/IM). They are NOT OS crontab and NOT in-chat schedule_create reminders.',
`Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`,
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.',
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.',
'Session mirror: by default the run summary is NOT injected into the origin WhatsApp/Web chat. Pass mirror_to_session=true only when the user wants follow-up context in that chat. Full tool traces always stay in run history; IM delivery (when configured) is independent.',
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
'2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
'3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
].join('\n')
}
export const CRON_GUIDANCE = cronGuidanceText()
export function makeCronSkill() {
return {
name: 'scheduled-tasks',
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause a job, or mentions cron in DeepSeek Harness.',
source: 'runtime',
provider: 'runtime',
content: `${cronGuidanceText()}
Tools:
- cron_list — list jobs
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. Session mirror is off by default; pass mirror_to_session=true only if the user wants the summary injected into the origin chat.
- cron_pause / cron_resume / cron_delete — by id from cron_list
`,
}
}
export const CRON_SKILL = makeCronSkill()
export function registerCronTools(ctx, service, deps = {}) {
const tools = ctx?.tools
if (!tools || typeof tools.register !== 'function') return () => {}
const offs = cronToolDefinitions(service, deps).map((definition) => tools.register(definition))
return () => {
for (const off of offs) {
if (typeof off === 'function') off()
}
}
}
export function registerCronGuidance(ctx) {
if (typeof ctx?.skills?.register !== 'function') return () => {}
const off = ctx.skills.register(makeCronSkill())
return typeof off === 'function' ? off : () => {}
}