diff --git a/agent-side-effect-tools.mjs b/agent-side-effect-tools.mjs index 7c5e257..fd137a8 100644 --- a/agent-side-effect-tools.mjs +++ b/agent-side-effect-tools.mjs @@ -33,6 +33,20 @@ export async function executeScheduledTaskCreateTool(args, { return workerWarning ? { ...task, workerWarning } : task; } +export async function executeScheduledTaskUpdateSpecTool(args, { + userId, + scheduledTaskService, +} = {}) { + if (!scheduledTaskService) throw new Error('scheduledTaskService 不可用'); + return scheduledTaskService.updateTaskSpec({ + userId, + taskId: args.taskId, + taskSpec: args.taskSpec ?? null, + title: args.title ?? null, + mergeTaskSpec: Boolean(args.mergeTaskSpec), + }); +} + export async function executeScheduleCreateItemTool(args, { userId, scheduleService, @@ -134,6 +148,10 @@ export async function commitAgentSideEffectTool(toolName, toolArgs, services) { const task = await executeScheduledTaskCreateTool(toolArgs, services); return { kind: 'scheduled_task', task }; } + if (name === 'scheduled_task_update_spec') { + const task = await executeScheduledTaskUpdateSpecTool(toolArgs, services); + return { kind: 'scheduled_task', task, updated: true }; + } if (name === 'schedule_create_item') { const result = await executeScheduleCreateItemTool(toolArgs, services); return { kind: 'schedule_item', ...result }; diff --git a/agent-side-effect-tools.test.mjs b/agent-side-effect-tools.test.mjs new file mode 100644 index 0000000..bf03d3f --- /dev/null +++ b/agent-side-effect-tools.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + commitAgentSideEffectTool, + executeScheduledTaskUpdateSpecTool, +} from './agent-side-effect-tools.mjs'; + +test('executeScheduledTaskUpdateSpecTool delegates to scheduledTaskService.updateTaskSpec', async () => { + const calls = []; + const task = await executeScheduledTaskUpdateSpecTool({ + taskId: 'task-1', + taskSpec: '新规范', + mergeTaskSpec: true, + }, { + userId: 'user-1', + scheduledTaskService: { + async updateTaskSpec(payload) { + calls.push(payload); + return { id: 'task-1', taskSpec: 'merged spec', title: '新闻' }; + }, + }, + }); + assert.equal(calls[0].taskId, 'task-1'); + assert.equal(calls[0].mergeTaskSpec, true); + assert.equal(task.title, '新闻'); +}); + +test('commitAgentSideEffectTool supports scheduled_task_update_spec', async () => { + const committed = await commitAgentSideEffectTool('scheduled_task_update_spec', { + taskId: 'task-1', + taskSpec: '标准格式说明', + mergeTaskSpec: true, + }, { + userId: 'user-1', + scheduledTaskService: { + async updateTaskSpec() { + return { id: 'task-1', taskSpec: '标准格式说明', status: 'active' }; + }, + }, + }); + assert.equal(committed.kind, 'scheduled_task'); + assert.equal(committed.updated, true); +}); diff --git a/capabilities.mjs b/capabilities.mjs index 115a8aa..1f33fc2 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -469,6 +469,7 @@ export function sandboxMcpTools(capabilities) { 'scheduled_task_create', 'scheduled_task_list', 'scheduled_task_cancel', + 'scheduled_task_update_spec', ); } return tools; diff --git a/capabilities.test.mjs b/capabilities.test.mjs index c8a4584..a0faab1 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -398,6 +398,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => { 'scheduled_task_create', 'scheduled_task_list', 'scheduled_task_cancel', + 'scheduled_task_update_spec', ]); const withBrowse = { ...base, code_browse: true }; @@ -424,6 +425,7 @@ test('private_data_space alone exposes private data tools through sandbox MCP', 'scheduled_task_create', 'scheduled_task_list', 'scheduled_task_cancel', + 'scheduled_task_update_spec', ]); const policy = buildAgentExtensionPolicy(caps, { @@ -458,6 +460,7 @@ test('private_data_space alone exposes private data tools through sandbox MCP', 'scheduled_task_create', 'scheduled_task_list', 'scheduled_task_cancel', + 'scheduled_task_update_spec', ]); }); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 583f5bf..6ba6550 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -21,6 +21,7 @@ import { executeScheduleCreateItemTool, executeScheduleCreateReminderTool, executeScheduledTaskCreateTool, + executeScheduledTaskUpdateSpecTool, } from './agent-side-effect-tools.mjs'; import { createIntentDraftService } from './intent-draft-service.mjs'; import { requireUserConfirmForSideEffectTool } from './intent-tool-confirm-gate.mjs'; @@ -681,15 +682,42 @@ if (isScheduleConfigured()) { }, { name: 'scheduled_task_cancel', - description: '取消当前用户的一个定时自动任务。需提供 taskId 或 titleMatch。', + description: + '取消当前用户的一个定时自动任务。优先传 taskId(先调用 scheduled_task_list 拿到 id 后再取消)。' + + '只有在列表里已明确对应条目、且 taskId 已知时才能取消;不要猜测 titleMatch。', inputSchema: { type: 'object', properties: { - taskId: { type: 'string', description: '任务 ID,优先使用' }, - titleMatch: { type: 'string', description: '按标题模糊匹配最近一条 active 任务' }, + taskId: { type: 'string', description: '任务 ID(来自 scheduled_task_list,优先使用)' }, + titleMatch: { + type: 'string', + description: 'Deprecated fallback:仅当 taskId 不可用且标题关键词来自 list 结果时使用', + }, }, }, }, + { + name: 'scheduled_task_update_spec', + description: + '更新已有定时自动任务的 taskSpec(执行说明)或标题。' + + '用于用户补充格式标准、参考页面、长期执行约束等;先 scheduled_task_list 拿 taskId。', + inputSchema: { + type: 'object', + properties: { + taskId: { type: 'string', description: '任务 ID(来自 scheduled_task_list,必填)' }, + taskSpec: { + type: 'string', + description: '新的完整执行说明;与 mergeTaskSpec 二选一', + }, + title: { type: 'string', description: '可选:更新任务标题' }, + mergeTaskSpec: { + type: 'boolean', + description: '为 true 时将 taskSpec 追加到现有说明末尾(适合补充格式/规范)', + }, + }, + required: ['taskId'], + }, + }, ); } @@ -1234,6 +1262,13 @@ async function callTool(name, args) { }); return [{ type: 'text', text: JSON.stringify(task, null, 2) }]; } + case 'scheduled_task_update_spec': { + const task = await executeScheduledTaskUpdateSpecTool(args, { + userId: PRIVATE_DATA_USER_ID, + scheduledTaskService: getScheduledTaskService(), + }); + return [{ type: 'text', text: JSON.stringify(task, null, 2) }]; + } default: throw new Error(`未知工具:${name}`); } diff --git a/scheduled-task-service.mjs b/scheduled-task-service.mjs index 25a741d..52b14d7 100644 --- a/scheduled-task-service.mjs +++ b/scheduled-task-service.mjs @@ -218,6 +218,51 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM return rows.map(rowToTask); }; + const updateTaskSpec = async ({ + userId, + taskId, + taskSpec = null, + title = null, + mergeTaskSpec = false, + } = {}) => { + if (!userId) throw new Error('缺少用户'); + const safeTaskId = String(taskId ?? '').trim(); + if (!safeTaskId) throw new Error('缺少 taskId'); + const safeTaskSpec = taskSpec == null ? null : String(taskSpec).trim(); + const safeTitle = title == null ? null : String(title).trim(); + if (!safeTaskSpec && !safeTitle) throw new Error('缺少 taskSpec 或 title'); + + const [rows] = await pool.query( + `SELECT * + FROM h5_scheduled_tasks + WHERE id = ? AND user_id = ? AND status IN ('active', 'locked', 'failed') + LIMIT 1`, + [safeTaskId, userId], + ); + const existing = rows[0]; + if (!existing) throw new Error('未找到可更新的定时任务'); + + let nextSpec = existing.task_spec; + if (safeTaskSpec) { + nextSpec = mergeTaskSpec + ? [String(existing.task_spec ?? '').trim(), safeTaskSpec].filter(Boolean).join('\n\n') + : safeTaskSpec; + } + const nextTitle = safeTitle || existing.title; + const ts = clock.now(); + await pool.query( + `UPDATE h5_scheduled_tasks + SET task_spec = ?, title = ?, updated_at = ? + WHERE id = ?`, + [nextSpec, nextTitle, ts, safeTaskId], + ); + const [updated] = await pool.query( + `SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`, + [safeTaskId], + ); + return rowToTask(updated[0]); + }; + const cancelTask = async ({ userId, taskId = null, @@ -385,6 +430,7 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM return { createTask, listTasks, + updateTaskSpec, cancelTask, listDueTasks, lockTask, diff --git a/scheduled-task-service.test.mjs b/scheduled-task-service.test.mjs index ddb04d9..1efd95e 100644 --- a/scheduled-task-service.test.mjs +++ b/scheduled-task-service.test.mjs @@ -275,3 +275,67 @@ test('markTaskSucceeded schedules next run for daily task', async () => { assert.ok(result.nextRunAt > Date.parse('2026-07-31T02:00:00+08:00')); assert.equal(updates.length, 1); }); + +test('updateTaskSpec replaces or merges taskSpec', async () => { + const activeTask = { + id: 'task-news', + user_id: 'user-1', + title: '每日新闻早报页面(5:30)', + task_spec: '按旧格式生成新闻页', + recurrence: 'daily', + hour: 5, + minute: 30, + weekday: null, + timezone: 'Asia/Shanghai', + next_run_at: 1780000000000, + last_run_at: null, + notify_channel: 'both', + status: 'active', + attempts: 0, + last_error: null, + last_result_json: null, + source_channel: 'agent', + source_session_id: null, + source_message_id: null, + source_text: null, + created_at: 1780000000000, + updated_at: 1780000000000, + }; + let stored = { ...activeTask }; + const service = createScheduledTaskService({ + async query(sql, params) { + if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status IN')) { + return [[stored]]; + } + if (sql.includes('UPDATE h5_scheduled_tasks') && sql.includes('task_spec')) { + stored = { + ...stored, + task_spec: params[0], + title: params[1], + updated_at: params[2], + }; + return [{ affectedRows: 1 }]; + } + if (sql.includes('SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1')) { + return [[stored]]; + } + return [[]]; + }, + }); + + const merged = await service.updateTaskSpec({ + userId: 'user-1', + taskId: 'task-news', + taskSpec: '参照 daily-news-0829.html 作为标准格式', + mergeTaskSpec: true, + }); + assert.match(merged.taskSpec, /旧格式/u); + assert.match(merged.taskSpec, /daily-news-0829/u); + + const replaced = await service.updateTaskSpec({ + userId: 'user-1', + taskId: 'task-news', + taskSpec: '全新执行说明', + }); + assert.equal(replaced.taskSpec, '全新执行说明'); +}); diff --git a/skills/scheduled-task-automation/SKILL.md b/skills/scheduled-task-automation/SKILL.md index d11086b..9edeedb 100644 --- a/skills/scheduled-task-automation/SKILL.md +++ b/skills/scheduled-task-automation/SKILL.md @@ -10,21 +10,23 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行 ## 适用范围 - 创建一次性或循环的定时自动任务(Scheduled Task / Recurring Automation) -- 查询、取消当前用户的定时任务 +- 查询、取消、**更新执行说明(taskSpec)** 当前用户的定时任务 - 澄清缺失的执行时间或任务内容 ## 边界 1. **不**处理纯提醒、待办记录、日程查询 —— 那些交给 `schedule-assistant`。 -2. **不**在当前对话里立即执行长任务;本 skill 只负责**写入定时订阅**。 +2. **不**在当前对话里立即执行长任务;本 skill 只负责**写入/更新定时订阅**。 3. 只能操作当前用户的数据;工具成功返回前,不能说「已经设置好了」。 4. 信息不完整时先追问,禁止臆造时间或任务内容。 +5. 用户是在**补充已有任务的长期规范/格式/参考页**(如「以后新闻按 8/29 格式」)时,用 `scheduled_task_update_spec`,**不要**去生成 HTML 页面。 ## 可用工具 -- `scheduled_task_create` +- `scheduled_task_create`(创建,需用户确认卡片) - `scheduled_task_list` - `scheduled_task_cancel` +- `scheduled_task_update_spec`(更新 taskSpec / 标题,直接写入) ## 必填信息(创建前必须齐全) @@ -43,15 +45,12 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行 ## 工作规则 -1. 先判断用户是要**创建**、**查询**还是**取消**定时自动任务。 +1. 先判断用户是要**创建**、**查询**、**取消**还是**更新规范**。 2. 缺时间或缺任务内容时,一次只追问最小必要信息。 -3. 创建成功后,用用户能理解的话说明: - - 何时执行 - - 执行什么 - - 是一次性还是循环 - - 结果会通过哪个通道推送(默认微信 + 站内) -4. 取消时优先用 `taskId`;用户只说任务名时可传 `titleMatch`。 -5. 若提示里给出了 `sourceMessageId`,调用 `scheduled_task_create` 时必须原样传入。 +3. **取消 / 更新**前必须先 `scheduled_task_list`,用返回的 `taskId` 操作;由你理解用户指哪一条,不要猜 titleMatch。 +4. **更新规范**:用户补充格式标准、参考页面 URL、排版约束时,对目标任务调用 `scheduled_task_update_spec`,`mergeTaskSpec: true` 追加说明。 +5. 创建成功后,用用户能理解的话说明何时执行、执行什么、recurrence、推送通道。 +6. 若提示里给出了 `sourceMessageId`,调用 `scheduled_task_create` 时必须原样传入。 ## 回复要求 @@ -65,3 +64,8 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行 助手:(信息齐全后调用 `scheduled_task_create`) 已设置循环任务:每天 06:00(北京时间)自动搜索并生成「今日新闻页」,完成后推送链接。 + +用户:以后新闻早报都按 8 月 29 号那版格式执行 + +助手:(`scheduled_task_list` → 对 5:30 新闻任务 `scheduled_task_update_spec` + mergeTaskSpec) +已更新定时任务的执行说明,之后到点会按新格式生成。 diff --git a/wechat/handlers/scheduled-task.mjs b/wechat/handlers/scheduled-task.mjs index 2cab61c..6ee605a 100644 --- a/wechat/handlers/scheduled-task.mjs +++ b/wechat/handlers/scheduled-task.mjs @@ -4,8 +4,6 @@ import { parseScheduledTaskIntent, } from '../../scheduled-task-intent.mjs'; -const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; - export async function handleWechatScheduledTaskIntent({ intent, user, @@ -31,21 +29,10 @@ export async function handleWechatScheduledTaskIntent({ return formatScheduledTaskListReply(tasks); } - if (taskIntent.action === 'cancel_scheduled_task') { - const titleMatch = String(intent.agentText ?? '') - .replace(/(?:取消|停止|关闭|删除|我的|定时|自动|任务)/gu, ' ') - .trim(); - if (!titleMatch) { - return '请告诉我要取消哪一条定时任务,或提供任务名称关键词。'; - } - const task = await scheduledTaskService.cancelTask({ - userId: user.userId, - titleMatch, - }); - return `已取消定时任务:${task.title}`; - } - - if (taskIntent.action === 'create_scheduled_task') { + if ( + taskIntent.action === 'create_scheduled_task' + || taskIntent.action === 'cancel_scheduled_task' + ) { return null; } } catch (err) { diff --git a/wechat/handlers/scheduled-task.test.mjs b/wechat/handlers/scheduled-task.test.mjs index 548486c..1b70a1c 100644 --- a/wechat/handlers/scheduled-task.test.mjs +++ b/wechat/handlers/scheduled-task.test.mjs @@ -36,6 +36,23 @@ test('handleWechatScheduledTaskIntent passes incomplete create requests to goose assert.equal(reply, null); }); +test('handleWechatScheduledTaskIntent passes cancel requests to goose', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '取消定时推送苏州天气任务', + msgId: 'msg-123-1', + }, + user: { userId: 'user-123' }, + scheduledTaskService: { + async cancelTask() { + throw new Error('should not cancel from inbound regex'); + }, + }, + }); + assert.equal(reply, null); +}); + test('handleWechatScheduledTaskIntent ignores non automation text', async () => { const reply = await handleWechatScheduledTaskIntent({ intent: { diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs index e518e86..84329e6 100644 --- a/wechat/prompts/chat-general.mjs +++ b/wechat/prompts/chat-general.mjs @@ -4,7 +4,11 @@ import { isPageDataIntent, } from '../../chat-skills.mjs'; import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs'; -import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs'; +import { + isScheduledTaskIntent, + parseScheduledTaskIntent, + shouldUseScheduledTaskAutomation, +} from '../../scheduled-task-intent.mjs'; import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs'; import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs'; import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs'; @@ -82,12 +86,18 @@ export function buildWechatAgentPrompt(intent, { '', ].filter(Boolean).join('\n') : ''; - const scheduledTaskAutomationHint = shouldUseScheduledTaskAutomation(agentText) + const scheduledTaskIntent = parseScheduledTaskIntent(agentText); + const scheduledTaskAutomationHint = ( + shouldUseScheduledTaskAutomation(agentText) + || isScheduledTaskIntent(scheduledTaskIntent) + ) ? [ - '【定时自动任务要求】这条消息是 Scheduled Automation(到点自动执行并推送结果),不是待办提醒。', + '【定时自动任务要求】这条消息涉及 Scheduled Automation(到点自动执行并推送结果),不是待办提醒。', '开始前先加载 `scheduled-task-automation` skill,并严格按 skill 边界执行。', - '先澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel。', - '禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置。', + '创建:澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create(需用户确认卡片)。', + '取消/更新规范:必须先 scheduled_task_list 拿 taskId;取消用 scheduled_task_cancel,补充格式/长期约束用 scheduled_task_update_spec(可 mergeTaskSpec)。', + '若用户只是在补充已有定时任务的执行规范(不是要做新页面),禁止走 static-page-publish 页面交付链路。', + '禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置/已取消。', '一次性任务用 runAtLocal;循环任务用 hour/minute/weekday,禁止自行估算 Unix 毫秒。', intent?.msgId ? `调用 scheduled_task_create 时必须传入 sourceMessageId: ${intent.msgId}` : '', '', diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index b84bb0c..12021ad 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -66,6 +66,7 @@ export function buildPageGenerateAgentPrompt( return [ '【微信服务号 · 页面生成任务】', '这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。', + '【定时任务规范】若用户是在为已有定时自动任务补充长期格式/参考页/执行约束(不是要你此刻写 HTML),改加载 scheduled-task-automation,用 scheduled_task_list + scheduled_task_update_spec;禁止走本页面交付链路。', '', docxBlock, imageBlock,