"""结构化 cron 生命周期控制工具。 cron 任务不是普通用户对话,它经常需要在运行完成后主动告诉调度器: - 这个任务已经可以删掉; - 今天这一轮先结束,下一天再继续; - 下次应该改成新的时间表。 这个工具就是让模型把这些决策显式写成结构化数据,而不是只留在自然语言里。 """ from __future__ import annotations from typing import Any from nanobot.agent.tools.base import Tool from nanobot.cron.types import CronAction class CronActionTool(Tool): """捕获模型输出的机器可读 cron 控制决策。""" def __init__(self, job_id: str): # `job_id` 仅用于回显和审计,不参与决策本身。 self.job_id = job_id # `_decision` 在本轮 agent 执行期间最多被写一次,外部在结束后读取。 self._decision: CronAction | None = None @property def name(self) -> str: return "cron_action" @property def description(self) -> str: return "Record a structured lifecycle action for the currently running cron job." @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { "action": { "type": "string", "enum": ["none", "remove", "disable", "complete_today", "reschedule"], "description": "Lifecycle action for the current cron job", }, "reason": { "type": "string", "description": "Short reason for audit logs", }, "every_seconds": { "type": "integer", "description": "Required when action=reschedule and using fixed interval", }, "cron_expr": { "type": "string", "description": "Required when action=reschedule and using cron expression", }, "tz": { "type": "string", "description": "Optional timezone for cron_expr reschedules", }, "at": { "type": "string", "description": "Required when action=reschedule and using one-time ISO datetime", }, }, "required": ["action"], } @property def decision(self) -> CronAction | None: # 暴露最终结构化决策给 cron runtime,便于后处理调度状态。 return self._decision async def execute( self, action: str, reason: str | None = None, every_seconds: int | None = None, cron_expr: str | None = None, tz: str | None = None, at: str | None = None, **_kwargs: Any, ) -> str: # 统一做小写规范化,避免模型传入 `Remove` / `REMOVE` 之类大小写变体。 normalized = (action or "").strip().lower() allowed_actions = {"none", "remove", "disable", "complete_today", "reschedule"} if normalized not in allowed_actions: return f"Error: unsupported cron action '{action}'" # 非重排任务不允许额外携带调度字段,避免出现“说 remove 但又传 cron_expr”的脏数据。 if normalized != "reschedule" and any(value is not None for value in (every_seconds, cron_expr, tz, at)): return "Error: schedule fields can only be used when action='reschedule'" if normalized == "reschedule": # 重新排期必须在三种时间表达方式里三选一,不能都不传,也不能混传。 options = int(every_seconds is not None) + int(bool(cron_expr)) + int(bool(at)) if options != 1: return "Error: reschedule requires exactly one of every_seconds, cron_expr, or at" # 时区只有 cron 表达式才有意义。 if tz and not cron_expr: return "Error: tz can only be used with cron_expr" # 校验通过后,把本轮决策固化为 dataclass,交给 runtime 在执行后统一消费。 self._decision = CronAction( action=normalized or "none", reason=(reason or "").strip() or None, every_seconds=every_seconds, cron_expr=cron_expr, tz=tz, at=at, ) # 返回给模型/日志的是一条可读确认文本,方便工具调用结果出现在上下文里。 detail = f" for job {self.job_id}" if self._decision.reason: detail += f" ({self._decision.reason})" return f"Recorded cron_action={self._decision.action}{detail}"