Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <onboarding@resend.dev>
```

## Retry Configuration

```bash
Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
62 changes: 62 additions & 0 deletions rfc/005-timer-notify.md
Original file line number Diff line number Diff line change
@@ -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 <onboarding@resend.dev>`)

## 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.
32 changes: 32 additions & 0 deletions test/test_notify_integration.py
Original file line number Diff line number Diff line change
@@ -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
127 changes: 127 additions & 0 deletions test/test_notify_tool.py
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions test/test_timer_tool.py
Original file line number Diff line number Diff line change
@@ -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
Loading