Merge PR #3125: fix: harden cron tool contract

fix: harden cron tool contract
This commit is contained in:
Xubin Ren
2026-04-19 04:01:27 +08:00
committed by GitHub
2 changed files with 113 additions and 34 deletions
+72 -33
View File
@@ -5,41 +5,71 @@ from datetime import datetime
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronSchedule
_CRON_PARAMETERS = tool_parameters_schema(
@tool_parameters( action=StringSchema("Action to perform", enum=["add", "list", "remove"]),
tool_parameters_schema( name=StringSchema(
action=StringSchema("Action to perform", enum=["add", "list", "remove"]), "Optional short human-readable label for the job "
name=StringSchema( "(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message."
"Optional short human-readable label for the job " ),
"(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message." message=StringSchema(
), "REQUIRED when action='add'. Instruction for the agent to execute when the job triggers "
message=StringSchema( "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"REQUIRED when action='add'. Instruction for the agent to execute when the job triggers " "Not used for action='list' or action='remove'."
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). " ),
"Not used for action='list' or action='remove'." every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
), cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"), tz=StringSchema(
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"), "Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
tz=StringSchema( "When omitted with cron_expr, the tool's default timezone applies."
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). " ),
"When omitted with cron_expr, the tool's default timezone applies." at=StringSchema(
), "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
at=StringSchema( "Naive values use the tool's default timezone."
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). " ),
"Naive values use the tool's default timezone." deliver=BooleanSchema(
), description="Whether to deliver the execution result to the user channel (default true)",
deliver=BooleanSchema( default=True,
description="Whether to deliver the execution result to the user channel (default true)", ),
default=True, job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
), required=["action"],
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."), description=(
required=["action"], "Action-specific parameters: add requires a non-empty message plus one schedule "
) "(every_seconds, cron_expr, or at); remove requires job_id; list only needs action."
),
) )
_CRON_PARAMETERS["oneOf"] = [
{
"properties": {
"action": {"enum": ["add"]},
"message": {"type": "string", "minLength": 1},
},
"required": ["action", "message"],
},
{
"properties": {
"action": {"enum": ["list"]},
},
"required": ["action"],
},
{
"properties": {
"action": {"enum": ["remove"]},
},
"required": ["action", "job_id"],
},
]
@tool_parameters(_CRON_PARAMETERS)
class CronTool(Tool): class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks.""" """Tool to schedule reminders and recurring tasks."""
@@ -95,6 +125,15 @@ class CronTool(Tool):
f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}." f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}."
) )
def validate_params(self, params: dict[str, Any]) -> list[str]:
errors = super().validate_params(params)
action = params.get("action")
if action == "add" and not str(params.get("message") or "").strip():
errors.append("message is required when action='add'")
if action == "remove" and not str(params.get("job_id") or "").strip():
errors.append("job_id is required when action='remove'")
return errors
async def execute( async def execute(
self, self,
action: str, action: str,
@@ -130,8 +169,8 @@ class CronTool(Tool):
) -> str: ) -> str:
if not message: if not message:
return ( return (
"Error: cron action='add' requires a non-empty 'message' " "Error: cron action='add' requires a non-empty 'message' parameter "
"parameter describing what to do when the job triggers " "describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"." "(e.g. the reminder text). Retry including message=\"...\"."
) )
if not self._channel or not self._chat_id: if not self._channel or not self._chat_id:
+41 -1
View File
@@ -7,7 +7,6 @@ import pytest
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
from tests.test_openai_api import pytest_plugins
def _make_tool(tmp_path) -> CronTool: def _make_tool(tmp_path) -> CronTool:
@@ -346,6 +345,47 @@ def test_add_job_can_disable_delivery(tmp_path) -> None:
assert job.payload.deliver is False assert job.payload.deliver is False
def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None:
tool = _make_tool(tmp_path)
assert tool.parameters["required"] == ["action"]
assert tool.parameters["oneOf"] == [
{
"properties": {
"action": {"enum": ["add"]},
"message": {"type": "string", "minLength": 1},
},
"required": ["action", "message"],
},
{
"properties": {"action": {"enum": ["list"]}},
"required": ["action"],
},
{
"properties": {"action": {"enum": ["remove"]}},
"required": ["action", "job_id"],
},
]
def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
tool = _make_tool(tmp_path)
assert "message is required when action='add'" in tool.validate_params({"action": "add"})
assert tool.validate_params({"action": "list"}) == []
assert "job_id is required when action='remove'" in tool.validate_params({"action": "remove"})
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context("telegram", "chat-1")
result = tool._add_job(None, "", 60, None, None, None)
assert "action='add' requires a non-empty 'message'" in result
assert "Retry including message=" in result
def test_list_excludes_disabled_jobs(tmp_path) -> None: def test_list_excludes_disabled_jobs(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
job = tool._cron.add_job( job = tool._cron.add_job(