From e96e96059e292e37febc4d05e79fc8ffa4120ca6 Mon Sep 17 00:00:00 2001 From: Yixin Luo <18810541851@163.com> Date: Fri, 30 Jan 2026 22:09:51 +0800 Subject: [PATCH] feat: add TimerTool and NotifyTool TimerTool supports delay (one-shot), interval (recurring), and cron modes using asyncio.sleep and croniter. NotifyTool sends email notifications via Resend HTTP API using httpx. Co-Authored-By: Claude Opus 4.5 --- config.py | 4 + docs/configuration.md | 9 +++ main.py | 4 + pyproject.toml | 2 + rfc/005-timer-notify.md | 62 ++++++++++++++++ test/test_notify_integration.py | 32 ++++++++ test/test_notify_tool.py | 127 ++++++++++++++++++++++++++++++++ test/test_timer_tool.py | 100 +++++++++++++++++++++++++ tools/notify.py | 72 ++++++++++++++++++ tools/timer.py | 95 ++++++++++++++++++++++++ 10 files changed, 507 insertions(+) create mode 100644 rfc/005-timer-notify.md create mode 100644 test/test_notify_integration.py create mode 100644 test/test_notify_tool.py create mode 100644 test/test_timer_tool.py create mode 100644 tools/notify.py create mode 100644 tools/timer.py diff --git a/config.py b/config.py index a4999f8..f5b894c 100644 --- a/config.py +++ b/config.py @@ -114,6 +114,10 @@ class Config: TUI_STATUS_BAR = _cfg.get("TUI_STATUS_BAR", "true").lower() == "true" TUI_COMPACT_MODE = _cfg.get("TUI_COMPACT_MODE", "false").lower() == "true" + # Email Notification Configuration (Resend) + RESEND_API_KEY = _cfg.get("RESEND_API_KEY") or "" + NOTIFY_EMAIL_FROM = _cfg.get("NOTIFY_EMAIL_FROM") or "" + @classmethod def get_retry_delay(cls, attempt: int) -> float: """Calculate delay for a given retry attempt using exponential backoff. diff --git a/docs/configuration.md b/docs/configuration.md index 5c3c20f..d24fffe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -82,6 +82,15 @@ MEMORY_SHORT_TERM_SIZE=100 MEMORY_COMPRESSION_RATIO=0.3 ``` +## Email Notification Configuration (Resend) + +Used by the `notify` tool to send emails via [Resend](https://resend.com): + +```bash +RESEND_API_KEY=re_xxxxxxxx +NOTIFY_EMAIL_FROM=AgenticLoop +``` + ## Retry Configuration ```bash diff --git a/main.py b/main.py index 1116a53..ba16306 100644 --- a/main.py +++ b/main.py @@ -13,10 +13,12 @@ from tools.code_navigator import CodeNavigatorTool from tools.explore import ExploreTool from tools.file_ops import FileReadTool, FileSearchTool, FileWriteTool +from tools.notify import NotifyTool from tools.parallel_execute import ParallelExecutionTool from tools.shell import ShellTool from tools.shell_background import BackgroundTaskManager, ShellTaskStatusTool from tools.smart_edit import SmartEditTool +from tools.timer import TimerTool from tools.web_fetch import WebFetchTool from tools.web_search import WebSearchTool from utils import get_log_file_path, setup_logger, terminal_ui @@ -49,6 +51,8 @@ def create_agent(): CodeNavigatorTool(), ShellTool(task_manager=task_manager), ShellTaskStatusTool(task_manager=task_manager), + TimerTool(), + NotifyTool(), ] # Create LLM instance with LiteLLM (retry config is read from Config directly) diff --git a/pyproject.toml b/pyproject.toml index 08695f1..de66f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "tenacity>=8.2.0", "tree-sitter>=0.21.0,<0.22.0", "tree-sitter-languages>=1.8.0,<1.11.0", + "croniter>=1.3.0", ] [project.optional-dependencies] @@ -50,6 +51,7 @@ dev = [ "ruff>=0.6.0", "pre-commit>=3.0.0", "types-aiofiles>=25.1.0", + "types-croniter>=1.3.0", ] [project.urls] diff --git a/rfc/005-timer-notify.md b/rfc/005-timer-notify.md new file mode 100644 index 0000000..f40e427 --- /dev/null +++ b/rfc/005-timer-notify.md @@ -0,0 +1,62 @@ +# RFC 005: Timer Tool & Notify Tool + +**Status**: Draft +**Created**: 2026-01-29 + +## Problem Statement + +AgenticLoop currently has no way for an agent to schedule delayed or periodic tasks, nor to send notifications to users outside the terminal. This limits the agent to purely synchronous, on-demand interactions. + +Two common use cases motivate this RFC: + +1. **Scheduled tasks**: An agent should be able to wait for a specified duration or until a cron-scheduled time before executing a task (e.g., "every morning at 9 AM, fetch news and send a summary"). +2. **Email notifications**: An agent should be able to send email notifications as part of task execution (e.g., sending a daily digest after gathering information). + +## Design Goals + +- **Async-first**: Both tools use `asyncio.sleep` / `aiosmtplib` — no blocking I/O. +- **Simple agent integration**: The agent calls TimerTool, which blocks (awaits) until the trigger time, then returns a message. The agent then acts on it. For recurring tasks, the agent simply calls TimerTool again. +- **Minimal configuration**: NotifyTool reads SMTP settings from `.aloop/config`. TimerTool requires no external configuration. + +## Proposed Approach + +### TimerTool + +The agent calls TimerTool with a mode, value, and task description. The tool awaits internally until the specified time, then returns the task description back to the agent. + +**Modes:** +- `delay`: Sleep for N seconds, trigger once. +- `interval`: Sleep for N seconds (semantically identical to delay for a single invocation; the agent decides whether to loop). +- `cron`: Parse a cron expression, compute seconds until the next trigger, then sleep. + +**Parameters:** +- `mode` (string, required): `"delay"` | `"interval"` | `"cron"` +- `value` (string, required): Seconds (for delay/interval) or a cron expression (for cron mode) +- `task` (string, required): Task description returned when the timer fires + +**Return format:** `"Timer triggered. Task to execute: {task}"` + +Cron parsing uses the `croniter` library to compute the next fire time. + +### NotifyTool + +Sends an email via the [Resend](https://resend.com) HTTP API. Uses `httpx` (already a project dependency), no extra packages needed. + +**Parameters:** +- `recipient` (string, required): Recipient email address +- `subject` (string, required): Email subject +- `body` (string, required): Email body (plain text) + +**Configuration** (`.aloop/config`): +- `RESEND_API_KEY`: Resend API key +- `NOTIFY_EMAIL_FROM`: Sender address (e.g. `AgenticLoop `) + +## Alternatives Considered + +- **Background scheduling with callback**: More complex, requires managing background tasks and callback mechanisms. The synchronous "sleep then return" approach is simpler and fits the ReAct loop naturally. +- **OS-level cron**: Would require external setup and wouldn't integrate with the agent loop. + +## Open Questions + +- Should there be a maximum sleep duration to prevent indefinite hangs? Currently left to the tool timeout configuration. +- Should NotifyTool support HTML email bodies? Starting with plain text only for simplicity. diff --git a/test/test_notify_integration.py b/test/test_notify_integration.py new file mode 100644 index 0000000..a86ef9a --- /dev/null +++ b/test/test_notify_integration.py @@ -0,0 +1,32 @@ +"""Integration test: send a real email via Resend. + +Requires RESEND_API_KEY and NOTIFY_EMAIL_FROM in .aloop/config. +Run with: RUN_INTEGRATION_TESTS=1 python -m pytest test/test_notify_integration.py -v +""" + +import os + +import pytest + +from tools.notify import NotifyTool + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def notify_tool(): + return NotifyTool() + + +@pytest.mark.skipif( + os.environ.get("RUN_INTEGRATION_TESTS") != "1", + reason="Set RUN_INTEGRATION_TESTS=1 to run", +) +async def test_send_real_email(notify_tool): + result = await notify_tool.execute( + recipient="luoyixin6688@gmail.com", + subject="AgenticLoop NotifyTool Test", + body="This is a test email sent from the AgenticLoop NotifyTool integration test.", + ) + print(result) + assert "sent successfully" in result diff --git a/test/test_notify_tool.py b/test/test_notify_tool.py new file mode 100644 index 0000000..3b2910c --- /dev/null +++ b/test/test_notify_tool.py @@ -0,0 +1,127 @@ +"""Tests for the NotifyTool.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tools.notify import NotifyTool + + +@pytest.fixture +def notify_tool(): + return NotifyTool() + + +class TestNotifyToolProperties: + def test_name(self, notify_tool): + assert notify_tool.name == "notify" + + def test_description(self, notify_tool): + assert "email" in notify_tool.description.lower() + + def test_parameters(self, notify_tool): + params = notify_tool.parameters + assert "recipient" in params + assert "subject" in params + assert "body" in params + + def test_schema(self, notify_tool): + schema = notify_tool.to_anthropic_schema() + assert schema["name"] == "notify" + required = schema["input_schema"]["required"] + assert "recipient" in required + assert "subject" in required + assert "body" in required + + +class TestNotifyExecution: + async def test_missing_recipient(self, notify_tool): + result = await notify_tool.execute(recipient="", subject="Hi", body="Test") + assert "Error" in result + assert "recipient" in result.lower() + + async def test_missing_api_key(self, notify_tool): + with patch("tools.notify.Config") as mock_config: + mock_config.RESEND_API_KEY = "" + result = await notify_tool.execute( + recipient="user@example.com", subject="Hi", body="Test" + ) + assert "Error" in result + assert "RESEND_API_KEY" in result + + async def test_missing_from_address(self, notify_tool): + with patch("tools.notify.Config") as mock_config: + mock_config.RESEND_API_KEY = "re_123" + mock_config.NOTIFY_EMAIL_FROM = "" + result = await notify_tool.execute( + recipient="user@example.com", subject="Hi", body="Test" + ) + assert "Error" in result + assert "NOTIFY_EMAIL_FROM" in result + + @patch("tools.notify.httpx.AsyncClient") + @patch("tools.notify.Config") + async def test_send_success(self, mock_config, mock_client_cls, notify_tool): + mock_config.RESEND_API_KEY = "re_123" + mock_config.NOTIFY_EMAIL_FROM = "agent@example.com" + + mock_resp = MagicMock() + mock_resp.status_code = 200 + + mock_client = AsyncMock() + mock_client.post.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await notify_tool.execute( + recipient="user@example.com", subject="Hello", body="World" + ) + + assert "sent successfully" in result + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args + assert call_kwargs[1]["json"]["to"] == ["user@example.com"] + assert call_kwargs[1]["json"]["subject"] == "Hello" + + @patch("tools.notify.httpx.AsyncClient") + @patch("tools.notify.Config") + async def test_send_api_error(self, mock_config, mock_client_cls, notify_tool): + mock_config.RESEND_API_KEY = "re_123" + mock_config.NOTIFY_EMAIL_FROM = "agent@example.com" + + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await notify_tool.execute( + recipient="user@example.com", subject="Hello", body="World" + ) + + assert "Error" in result + assert "403" in result + + @patch("tools.notify.httpx.AsyncClient") + @patch("tools.notify.Config") + async def test_send_network_error(self, mock_config, mock_client_cls, notify_tool): + mock_config.RESEND_API_KEY = "re_123" + mock_config.NOTIFY_EMAIL_FROM = "agent@example.com" + + mock_client = AsyncMock() + mock_client.post.side_effect = Exception("Connection refused") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await notify_tool.execute( + recipient="user@example.com", subject="Hello", body="World" + ) + + assert "Error" in result + assert "Connection refused" in result diff --git a/test/test_timer_tool.py b/test/test_timer_tool.py new file mode 100644 index 0000000..ded098e --- /dev/null +++ b/test/test_timer_tool.py @@ -0,0 +1,100 @@ +"""Tests for the TimerTool.""" + +import time + +import pytest + +from tools.timer import TimerTool + + +@pytest.fixture +def timer_tool(): + return TimerTool() + + +class TestTimerToolProperties: + def test_name(self, timer_tool): + assert timer_tool.name == "timer" + + def test_description(self, timer_tool): + assert "timer" in timer_tool.description.lower() + + def test_parameters(self, timer_tool): + params = timer_tool.parameters + assert "mode" in params + assert "value" in params + assert "task" in params + + def test_schema(self, timer_tool): + schema = timer_tool.to_anthropic_schema() + assert schema["name"] == "timer" + assert "mode" in schema["input_schema"]["properties"] + + +class TestTimerDelay: + async def test_delay_returns_task(self, timer_tool): + result = await timer_tool.execute(mode="delay", value="0", task="do something") + assert "Timer triggered" in result + assert "do something" in result + + async def test_delay_no_recurring_instruction(self, timer_tool): + result = await timer_tool.execute(mode="delay", value="0", task="one-shot") + assert "MUST call the timer tool again" not in result + + async def test_delay_waits(self, timer_tool): + start = time.monotonic() + await timer_tool.execute(mode="delay", value="0.1", task="test") + elapsed = time.monotonic() - start + assert elapsed >= 0.09 + + async def test_delay_invalid_value(self, timer_tool): + result = await timer_tool.execute(mode="delay", value="abc", task="test") + assert "Error" in result + + async def test_delay_negative_value(self, timer_tool): + result = await timer_tool.execute(mode="delay", value="-1", task="test") + assert "Error" in result + + +class TestTimerInterval: + async def test_interval_returns_task(self, timer_tool): + result = await timer_tool.execute(mode="interval", value="0", task="repeat this") + assert "Timer triggered" in result + assert "repeat this" in result + + async def test_interval_includes_recurring_instruction(self, timer_tool): + result = await timer_tool.execute(mode="interval", value="0", task="repeat this") + assert "MUST call the timer tool again" in result + assert 'mode="interval"' in result + + async def test_interval_invalid_value(self, timer_tool): + result = await timer_tool.execute(mode="interval", value="abc", task="test") + assert "Error" in result + + async def test_interval_negative_value(self, timer_tool): + result = await timer_tool.execute(mode="interval", value="-1", task="test") + assert "Error" in result + + +class TestTimerCron: + async def test_cron_invalid_expression(self, timer_tool): + result = await timer_tool.execute(mode="cron", value="not a cron", task="test") + assert "Error" in result + assert "invalid cron" in result + + async def test_cron_valid_expression(self, timer_tool): + result = await timer_tool.execute(mode="cron", value="* * * * *", task="cron task") + assert "Timer triggered" in result + assert "cron task" in result + + async def test_cron_includes_recurring_instruction(self, timer_tool): + result = await timer_tool.execute(mode="cron", value="* * * * *", task="cron task") + assert "MUST call the timer tool again" in result + assert 'mode="cron"' in result + + +class TestTimerUnknownMode: + async def test_unknown_mode(self, timer_tool): + result = await timer_tool.execute(mode="bogus", value="1", task="test") + assert "Error" in result + assert "unknown mode" in result diff --git a/tools/notify.py b/tools/notify.py new file mode 100644 index 0000000..c82f827 --- /dev/null +++ b/tools/notify.py @@ -0,0 +1,72 @@ +"""Email notification tool using Resend API.""" + +from typing import Any, Dict + +import httpx + +from config import Config +from tools.base import BaseTool + +RESEND_API_URL = "https://api.resend.com/emails" + + +class NotifyTool(BaseTool): + """Send email notifications via Resend.""" + + @property + def name(self) -> str: + return "notify" + + @property + def description(self) -> str: + return ( + "Send an email notification via Resend. " + "Requires RESEND_API_KEY and NOTIFY_EMAIL_FROM in .aloop/config." + ) + + @property + def parameters(self) -> Dict[str, Any]: + return { + "recipient": { + "type": "string", + "description": "Recipient email address.", + }, + "subject": { + "type": "string", + "description": "Email subject line.", + }, + "body": { + "type": "string", + "description": "Email body (plain text).", + }, + } + + async def execute(self, recipient: str, subject: str, body: str) -> str: + if not recipient: + return "Error: recipient email address is required." + + api_key = Config.RESEND_API_KEY + if not api_key: + return "Error: RESEND_API_KEY is not configured in .aloop/config." + + from_addr = Config.NOTIFY_EMAIL_FROM + if not from_addr: + return "Error: NOTIFY_EMAIL_FROM is not configured in .aloop/config." + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + RESEND_API_URL, + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "from": from_addr, + "to": [recipient], + "subject": subject, + "text": body, + }, + ) + if resp.status_code == 200: + return f"Email sent successfully to {recipient}." + return f"Error sending email: {resp.status_code} {resp.text}" + except Exception as e: + return f"Error sending email: {e}" diff --git a/tools/timer.py b/tools/timer.py new file mode 100644 index 0000000..612e8ab --- /dev/null +++ b/tools/timer.py @@ -0,0 +1,95 @@ +"""Timer tool for scheduling delayed or periodic agent tasks.""" + +import asyncio +import time +from typing import Any, Dict + +from croniter import croniter + +from tools.base import BaseTool + + +class TimerTool(BaseTool): + """Wait until a specified time or duration, then return the task description.""" + + @property + def name(self) -> str: + return "timer" + + @property + def description(self) -> str: + return ( + "Set a timer to trigger after a delay or at a cron-scheduled time. " + "Modes: 'delay' (wait N seconds), 'interval' (wait N seconds, agent loops), " + "'cron' (wait until next cron match). Returns the task description when triggered." + ) + + @property + def parameters(self) -> Dict[str, Any]: + return { + "mode": { + "type": "string", + "description": ( + "Timer mode: 'delay' (one-shot, fire once after N seconds), " + "'interval' (recurring, fire every N seconds — you must call timer again after each task), " + "'cron' (recurring, fire on cron schedule — you must call timer again after each task)" + ), + "enum": ["delay", "interval", "cron"], + }, + "value": { + "type": "string", + "description": ( + "For delay/interval: number of seconds (e.g. '60'). " + "For cron: a cron expression (e.g. '0 9 * * *' for daily at 9 AM)." + ), + }, + "task": { + "type": "string", + "description": "Task description to return when the timer triggers.", + }, + } + + async def execute(self, mode: str, value: str, task: str) -> str: + if mode == "delay": + try: + seconds = float(value) + except ValueError: + return f"Error: value must be a number for delay mode, got '{value}'" + if seconds < 0: + return f"Error: value must be non-negative, got {seconds}" + await asyncio.sleep(seconds) + return f"Timer triggered. Task to execute: {task}" + + if mode == "interval": + try: + seconds = float(value) + except ValueError: + return f"Error: value must be a number for interval mode, got '{value}'" + if seconds < 0: + return f"Error: value must be non-negative, got {seconds}" + await asyncio.sleep(seconds) + return ( + f"Timer triggered. Task to execute: {task}\n\n" + f"[IMPORTANT: This is a recurring interval timer. " + f"After completing the task above, you MUST call the timer tool again " + f'with the same parameters (mode="interval", value="{value}", ' + f'task="{task}") to continue the cycle.]' + ) + + if mode == "cron": + if not croniter.is_valid(value): + return f"Error: invalid cron expression '{value}'" + now = time.time() + cron = croniter(value, now) + next_fire = cron.get_next(float) + wait_seconds = max(0, next_fire - now) + await asyncio.sleep(wait_seconds) + return ( + f"Timer triggered. Task to execute: {task}\n\n" + f"[IMPORTANT: This is a recurring cron timer. " + f"After completing the task above, you MUST call the timer tool again " + f'with the same parameters (mode="cron", value="{value}", ' + f'task="{task}") to continue the schedule.]' + ) + + return f"Error: unknown mode '{mode}'. Use 'delay', 'interval', or 'cron'."