Fix cron_* Host registration: additionalProperties must be boolean.

labels schemas used additionalProperties:{type:string}, which assertSupportedJsonSchema rejects and aborted the whole tools.register batch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 15:36:20 +08:00
parent 2e13432b0a
commit 6ed7187754
2 changed files with 14 additions and 8 deletions

View file

@ -38,7 +38,7 @@ const JOB_SCHEMA = {
delivery: { type: 'object', additionalProperties: true }, delivery: { type: 'object', additionalProperties: true },
origin: { type: 'object', additionalProperties: true }, origin: { type: 'object', additionalProperties: true },
mirrorToSession: { type: 'boolean' }, mirrorToSession: { type: 'boolean' },
labels: { type: 'object', additionalProperties: { type: 'string' } }, labels: { type: 'object', additionalProperties: true },
watch: { type: 'object', additionalProperties: true }, watch: { type: 'object', additionalProperties: true },
progress: { type: 'object', additionalProperties: true }, progress: { type: 'object', additionalProperties: true },
report: { type: 'object', additionalProperties: true }, report: { type: 'object', additionalProperties: true },
@ -388,7 +388,7 @@ export function cronToolDefinitions(service, deps = {}) {
im_target_id: { type: 'string', description: 'Opaque targetId 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.' }, 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).' }, 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).' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'String labels for discovery/monitoring, e.g. {"role":"worker","task":"theory","owner":"alice"}.' }, labels: { type: 'object', additionalProperties: true, description: 'String labels for discovery/monitoring, e.g. {"role":"worker","task":"theory","owner":"alice"}.' },
watch: { type: 'object', additionalProperties: true, description: 'Listener binding: {"taskId":"..."} and/or {"labels":{...},"match":"any|all","timeout_min":30}.' }, watch: { type: 'object', additionalProperties: true, description: 'Listener binding: {"taskId":"..."} and/or {"labels":{...},"match":"any|all","timeout_min":30}.' },
progress: { type: 'object', additionalProperties: true, description: 'Progress declaration: {"channel":{"file":"progress.json"},"metrics":[{"key":"done","label":"已完成"}]}.' }, progress: { type: 'object', additionalProperties: true, description: 'Progress declaration: {"channel":{"file":"progress.json"},"metrics":[{"key":"done","label":"已完成"}]}.' },
report: { type: 'object', additionalProperties: true, description: 'Watcher report: {"mode":"on_change|on_complete|periodic","delivery":"dsh|im|none","interval_min":15}.' }, report: { type: 'object', additionalProperties: true, description: 'Watcher report: {"mode":"on_change|on_complete|periodic","delivery":"dsh|im|none","interval_min":15}.' },
@ -472,7 +472,7 @@ export function cronToolDefinitions(service, deps = {}) {
enabled_only: { type: 'boolean', description: 'If true, omit paused jobs.' }, enabled_only: { type: 'boolean', description: 'If true, omit paused jobs.' },
state: { type: 'string', description: 'Lifecycle filter: idle|pending|running|succeeded|failed|paused.' }, state: { type: 'string', description: 'Lifecycle filter: idle|pending|running|succeeded|failed|paused.' },
task_id: { type: 'string', description: 'Exact job id.' }, task_id: { type: 'string', description: 'Exact job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label filter (all keys must match unless used with cron_query match=any).' }, labels: { type: 'object', additionalProperties: true, description: 'Label filter (all keys must match unless used with cron_query match=any).' },
}, },
}, },
output: { output: {
@ -514,7 +514,7 @@ export function cronToolDefinitions(service, deps = {}) {
additionalProperties: false, additionalProperties: false,
properties: { properties: {
task_id: { type: 'string', description: 'Watch a single job id.' }, task_id: { type: 'string', description: 'Watch a single job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label selector.' }, labels: { type: 'object', additionalProperties: true, description: 'Label selector.' },
match: { type: 'string', description: 'Label match mode: all (default) or any.' }, match: { type: 'string', description: 'Label match mode: all (default) or any.' },
state: { type: 'string', description: 'Filter projected lifecycle state.' }, state: { type: 'string', description: 'Filter projected lifecycle state.' },
include_runs: { type: 'boolean', description: 'Include run instances per job.' }, include_runs: { type: 'boolean', description: 'Include run instances per job.' },
@ -619,7 +619,7 @@ export function cronToolDefinitions(service, deps = {}) {
additionalProperties: false, additionalProperties: false,
properties: { properties: {
task_id: { type: 'string', description: 'Job id.' }, task_id: { type: 'string', description: 'Job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label selector (by_watch).' }, labels: { type: 'object', additionalProperties: true, description: 'Label selector (by_watch).' },
match: { type: 'string', description: 'any|all for labels.' }, match: { type: 'string', description: 'any|all for labels.' },
}, },
}, },
@ -763,8 +763,8 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai')
'Labels/monitor: pass labels={"role":"worker","task":"theory"} on workers; listeners pass watch={"taskId":"..."} or watch={"labels":{...},"match":"all"}. Use cron_query / cron_progress / cron_runs to poll state and progress.', 'Labels/monitor: pass labels={"role":"worker","task":"theory"} on workers; listeners pass watch={"taskId":"..."} or watch={"labels":{...},"match":"all"}. Use cron_query / cron_progress / cron_runs to poll state and progress.',
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:', '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 / cron_query / cron_runs / cron_progress are in your tool list, call them.', '1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete / cron_query / cron_runs / cron_progress are in your tool list, call them.',
'2. If they are not listed, load skill "scheduled-tasks" via the skill tool (exact name), then retry the cron_* tools. Those tools are registered by the dsh-ops-cron plugin — they are not unlocked by skill_search/dev_tool_search (those names are obsolete).', '2. If they are not listed, load skill "scheduled-tasks" for usage guidance, then look again. skill_load does NOT inject tools — cron_* are registered by the dsh-ops-cron Host plugin at boot.',
'3. If cron_* still return unknown tool after loading the skill, the plugin failed to register (check Host logs for unsupported JSON schema). Tell the user; do not invent crontab workarounds.', '3. If cron_* are still missing after a Host restart with the latest dsh-ops-cron, the plugin failed to register (check Host logs for unsupported JSON schema / tools.register). Tell the user; do not invent crontab workarounds.',
'4. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.', '4. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
].join('\n') ].join('\n')
} }

View file

@ -460,12 +460,18 @@ test('cron tool output schemas never use type arrays (Host rejects them)', () =>
function walk(node, path) { function walk(node, path) {
if (!node || typeof node !== 'object') return if (!node || typeof node !== 'object') return
if (Array.isArray(node.type)) bad.push(`${path}.type=${JSON.stringify(node.type)}`) if (Array.isArray(node.type)) bad.push(`${path}.type=${JSON.stringify(node.type)}`)
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
bad.push(`${path}.additionalProperties must be boolean (got ${typeof node.additionalProperties})`)
}
if (Array.isArray(node.oneOf)) node.oneOf.forEach((branch, i) => walk(branch, `${path}.oneOf[${i}]`)) if (Array.isArray(node.oneOf)) node.oneOf.forEach((branch, i) => walk(branch, `${path}.oneOf[${i}]`))
if (node.properties && typeof node.properties === 'object') { if (node.properties && typeof node.properties === 'object') {
for (const [key, child] of Object.entries(node.properties)) walk(child, `${path}.properties.${key}`) for (const [key, child] of Object.entries(node.properties)) walk(child, `${path}.properties.${key}`)
} }
if (node.items) walk(node.items, `${path}.items`) if (node.items) walk(node.items, `${path}.items`)
} }
for (const def of defs) walk(def.output?.schema, def.name) for (const def of defs) {
walk(def.output?.schema, `${def.name}.output`)
walk(def.parameters, `${def.name}.parameters`)
}
assert.deepEqual(bad, []) assert.deepEqual(bad, [])
}) })