fix(cron): support one-time relative reminders

This commit is contained in:
Xubin Ren
2026-06-06 17:19:39 +08:00
parent 229dfe9f9c
commit 4c4661da12
5 changed files with 117 additions and 5 deletions
+44 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from contextvars import ContextVar
from datetime import datetime
from typing import Any
@@ -28,7 +29,21 @@ _CRON_PARAMETERS = tool_parameters_schema(
"(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)"),
every_seconds=IntegerSchema(
0,
description=(
"Recurring interval in seconds. Use only when the user explicitly asks to repeat "
"(e.g. 'every 20 minutes'). Do NOT use for one-time 'in/after N minutes' reminders; "
"use delay_seconds or at instead."
),
),
delay_seconds=IntegerSchema(
0,
description=(
"One-time delay in seconds for relative reminders/tasks like 'in 10 minutes' or "
"'after 1 hour'. The job runs once and is then removed."
),
),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
@@ -46,7 +61,8 @@ _CRON_PARAMETERS = tool_parameters_schema(
required=["action"],
description=(
"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. "
"(every_seconds, delay_seconds, cron_expr, or at); remove requires job_id; list only "
"needs action. "
"Per-action requirements are enforced at runtime (see field descriptions) so the "
"top-level schema stays compatible with providers (e.g. OpenAI Codex/Responses) that "
"reject oneOf/anyOf/allOf/enum/not at the root of function parameters."
@@ -119,6 +135,8 @@ class CronTool(Tool, ContextAware):
def description(self) -> str:
return (
"Schedule reminders and recurring tasks. Actions: add, list, remove. "
"Use delay_seconds for one-time relative requests like 'in 10 minutes'; "
"use every_seconds only for explicit repeats. "
f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}."
)
@@ -137,6 +155,7 @@ class CronTool(Tool, ContextAware):
name: str | None = None,
message: str = "",
every_seconds: int | None = None,
delay_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
@@ -147,7 +166,16 @@ class CronTool(Tool, ContextAware):
if action == "add":
if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution"
return self._add_job(name, message, every_seconds, cron_expr, tz, at, deliver)
return self._add_job(
name,
message,
every_seconds,
cron_expr,
tz,
at,
delay_seconds=delay_seconds,
deliver=deliver,
)
elif action == "list":
return self._list_jobs()
elif action == "remove":
@@ -162,6 +190,7 @@ class CronTool(Tool, ContextAware):
cron_expr: str | None,
tz: str | None,
at: str | None,
delay_seconds: int | None = None,
deliver: bool = True,
) -> str:
if not message:
@@ -180,10 +209,22 @@ class CronTool(Tool, ContextAware):
if err := self._validate_timezone(tz):
return err
schedule_params = [
every_seconds is not None and every_seconds > 0,
delay_seconds is not None and delay_seconds > 0,
bool(cron_expr),
bool(at),
]
if sum(schedule_params) > 1:
return "Error: use exactly one of every_seconds, delay_seconds, cron_expr, or at"
# Build schedule
delete_after = False
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif delay_seconds:
schedule = CronSchedule(kind="at", at_ms=int(time.time() * 1000) + delay_seconds * 1000)
delete_after = True
elif cron_expr:
effective_tz = tz or self._default_timezone
if err := self._validate_timezone(effective_tz):
+9 -2
View File
@@ -15,7 +15,7 @@ Use the `cron` tool to schedule reminders or recurring tasks.
## Examples
Fixed reminder:
Recurring reminder:
```
cron(action="add", message="Time to take a break!", every_seconds=1200)
```
@@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
```
One-time relative reminder:
```
cron(action="add", message="Remind me to drink water", delay_seconds=60)
```
Timezone-aware cron:
```
cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
@@ -45,8 +50,10 @@ cron(action="remove", job_id="abc123")
| User says | Parameters |
|-----------|------------|
| in 20 minutes / after 20 minutes | delay_seconds: 1200 |
| one hour later | delay_seconds: 3600 |
| every 20 minutes | every_seconds: 1200 |
| every hour | every_seconds: 3600 |
| every hour / repeat hourly | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
+15
View File
@@ -137,6 +137,21 @@ async def test_run_history_records_errors(tmp_path) -> None:
assert loaded.state.run_history[0].error == "boom"
@pytest.mark.asyncio
async def test_one_shot_job_deletes_after_run(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
job = service.add_job(
name="one-shot",
schedule=CronSchedule(kind="at", at_ms=int(time.time() * 1000) + 60_000),
message="hello",
delete_after_run=True,
)
assert await service.run_job(job.id, force=True) is True
assert service.get_job(job.id) is None
@pytest.mark.asyncio
async def test_run_history_trimmed_to_max(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
+41
View File
@@ -324,6 +324,47 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
assert job.schedule.at_ms == expected
def test_add_delay_job_creates_one_shot_at_schedule(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
before = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
result = tool._add_job(
None,
"Drink water",
None,
None,
None,
None,
delay_seconds=60,
)
after = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
assert job.schedule.kind == "at"
assert job.delete_after_run is True
assert before + 60_000 <= (job.schedule.at_ms or 0) <= after + 60_000
def test_add_job_rejects_multiple_schedule_modes(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
result = tool._add_job(
None,
"Drink water",
60,
None,
None,
None,
delay_seconds=60,
)
assert "exactly one" in result
assert tool._cron.list_jobs() == []
def test_add_job_delivers_by_default(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
@@ -87,6 +87,14 @@ class TestSchemaSelfDescribesRequirements:
desc = tool.parameters["properties"]["message"]["description"]
assert "REQUIRED" in desc and "action='add'" in desc
def test_delay_description_flags_relative_one_time_reminders(self) -> None:
tool = CronTool(_SvcStub())
delay_desc = tool.parameters["properties"]["delay_seconds"]["description"]
every_desc = tool.parameters["properties"]["every_seconds"]["description"]
assert "One-time" in delay_desc
assert "in/after" in every_desc
assert "Do NOT use" in every_desc
def test_job_id_description_flags_remove_requirement(self) -> None:
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["job_id"]["description"]