Skip to content

Commit 59e6dba

Browse files
feat: native notification hook with three backend options
1 parent af2c7c5 commit 59e6dba

File tree

4 files changed

+285
-0
lines changed

4 files changed

+285
-0
lines changed

dreadnode/agent/hooks/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44
retry_with_feedback,
55
)
66
from dreadnode.agent.hooks.metrics import tool_metrics
7+
from dreadnode.agent.hooks.notification import (
8+
LogNotificationBackend,
9+
NotificationBackend,
10+
TerminalNotificationBackend,
11+
WebhookNotificationBackend,
12+
notify,
13+
)
714
from dreadnode.agent.hooks.summarize import summarize_when_long
815

916
__all__ = [
1017
"Hook",
18+
"LogNotificationBackend",
19+
"NotificationBackend",
20+
"TerminalNotificationBackend",
21+
"WebhookNotificationBackend",
1122
"backoff_on_error",
1223
"backoff_on_ratelimit",
24+
"notify",
1325
"retry_with_feedback",
1426
"summarize_when_long",
1527
"tool_metrics",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import typing as t
2+
from abc import ABC, abstractmethod
3+
4+
from loguru import logger
5+
6+
if t.TYPE_CHECKING:
7+
from dreadnode.agent.events import AgentEvent
8+
from dreadnode.agent.reactions import Reaction
9+
10+
11+
class NotificationBackend(ABC):
12+
@abstractmethod
13+
async def send(self, event: "AgentEvent", message: str) -> None:
14+
"""Send a notification for the given event."""
15+
16+
17+
class LogNotificationBackend(NotificationBackend):
18+
async def send(self, event: "AgentEvent", message: str) -> None:
19+
logger.info(f"[{event.agent.name}] {message}")
20+
21+
22+
class TerminalNotificationBackend(NotificationBackend):
23+
async def send(self, event: "AgentEvent", message: str) -> None:
24+
import sys
25+
26+
print(f"[{event.agent.name}] {message}", file=sys.stderr)
27+
28+
29+
class WebhookNotificationBackend(NotificationBackend):
30+
def __init__(self, url: str, headers: dict[str, str] | None = None):
31+
self.url = url
32+
self.headers = headers or {}
33+
34+
async def send(self, event: "AgentEvent", message: str) -> None:
35+
import httpx
36+
37+
payload = {
38+
"agent": event.agent.name,
39+
"event": event.__class__.__name__,
40+
"message": message,
41+
"timestamp": event.timestamp.isoformat(),
42+
}
43+
44+
async with httpx.AsyncClient() as client:
45+
await client.post(self.url, json=payload, headers=self.headers)
46+
47+
48+
def notify(
49+
event_type: "type[AgentEvent] | t.Callable[[AgentEvent], bool]",
50+
message: str | t.Callable[["AgentEvent"], str],
51+
backend: NotificationBackend | None = None,
52+
) -> t.Callable[["AgentEvent"], t.Awaitable["Reaction | None"]]:
53+
"""
54+
Create a notification hook that sends notifications when events occur.
55+
56+
Unlike other hooks, notification hooks don't affect agent execution - they return
57+
None (no reaction) and run asynchronously to deliver notifications.
58+
59+
Args:
60+
event_type: Event type to trigger on, or predicate function
61+
message: Static message or callable that generates message from event
62+
backend: Notification backend (defaults to terminal output)
63+
64+
Returns:
65+
Hook that sends notifications
66+
67+
Example:
68+
```python
69+
from dreadnode.agent import Agent
70+
from dreadnode.agent.events import ToolStart
71+
from dreadnode.agent.hooks.notification import notify
72+
73+
agent = Agent(
74+
name="analyzer",
75+
hooks=[
76+
notify(
77+
ToolStart,
78+
lambda e: f"Starting tool: {e.tool_name}",
79+
),
80+
],
81+
)
82+
```
83+
"""
84+
notification_backend = backend or TerminalNotificationBackend()
85+
86+
async def notification_hook(event: "AgentEvent") -> "Reaction | None":
87+
should_notify = False
88+
89+
if isinstance(event_type, type):
90+
should_notify = isinstance(event, event_type)
91+
elif callable(event_type):
92+
should_notify = event_type(event)
93+
94+
if not should_notify:
95+
return None
96+
97+
msg = message(event) if callable(message) else message
98+
99+
try:
100+
await notification_backend.send(event, msg)
101+
except Exception: # noqa: BLE001
102+
logger.exception("Notification hook failed")
103+
104+
return None
105+
106+
return notification_hook

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,9 @@ skip-magic-trailing-comma = false
192192
"dreadnode/transforms/language.py" = [
193193
"RUF001", # intentional use of ambiguous unicode characters for airt
194194
]
195+
"dreadnode/agent/tools/interaction.py" = [
196+
"T201", # print required for user interaction
197+
]
198+
"dreadnode/agent/hooks/notification.py" = [
199+
"T201", # print required for terminal notifications
200+
]

tests/test_notification_hook.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import typing as t
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
import pytest
5+
6+
from dreadnode.agent.events import AgentEvent, ToolStart
7+
from dreadnode.agent.hooks.notification import (
8+
LogNotificationBackend,
9+
NotificationBackend,
10+
TerminalNotificationBackend,
11+
WebhookNotificationBackend,
12+
notify,
13+
)
14+
15+
16+
class MockEvent(AgentEvent):
17+
pass
18+
19+
20+
@pytest.fixture
21+
def mock_event() -> AgentEvent:
22+
agent = MagicMock()
23+
agent.name = "test_agent"
24+
thread = MagicMock()
25+
messages: list[t.Any] = []
26+
events: list[AgentEvent] = []
27+
28+
return MockEvent(
29+
session_id=MagicMock(),
30+
agent=agent,
31+
thread=thread,
32+
messages=messages,
33+
events=events,
34+
)
35+
36+
37+
async def test_log_notification_backend(mock_event: AgentEvent) -> None:
38+
from unittest.mock import patch
39+
40+
backend = LogNotificationBackend()
41+
42+
with patch("dreadnode.agent.hooks.notification.logger.info") as mock_logger:
43+
await backend.send(mock_event, "Test notification")
44+
45+
mock_logger.assert_called_once()
46+
call_args = mock_logger.call_args[0][0]
47+
assert "Test notification" in call_args
48+
assert "test_agent" in call_args
49+
50+
51+
async def test_terminal_notification_backend(mock_event: AgentEvent) -> None:
52+
from io import StringIO
53+
from unittest.mock import patch
54+
55+
backend = TerminalNotificationBackend()
56+
57+
stderr_capture = StringIO()
58+
with patch("sys.stderr", stderr_capture):
59+
await backend.send(mock_event, "Test notification")
60+
61+
output = stderr_capture.getvalue()
62+
assert "Test notification" in output
63+
assert "test_agent" in output
64+
65+
66+
async def test_webhook_notification_backend(mock_event: AgentEvent) -> None:
67+
mock_client = AsyncMock()
68+
mock_post = AsyncMock()
69+
mock_client.__aenter__.return_value.post = mock_post
70+
71+
backend = WebhookNotificationBackend("https://example.com/webhook")
72+
73+
from unittest.mock import patch
74+
75+
import httpx
76+
77+
with patch.object(httpx, "AsyncClient", return_value=mock_client):
78+
await backend.send(mock_event, "Test notification")
79+
80+
mock_post.assert_called_once()
81+
call_kwargs = mock_post.call_args.kwargs
82+
assert call_kwargs["json"]["message"] == "Test notification"
83+
assert call_kwargs["json"]["agent"] == "test_agent"
84+
85+
86+
async def test_notify_hook_with_event_type(mock_event: AgentEvent) -> None:
87+
backend = MagicMock(spec=NotificationBackend)
88+
backend.send = AsyncMock()
89+
90+
hook = notify(MockEvent, "Test message", backend=backend)
91+
92+
reaction = await hook(mock_event)
93+
94+
assert reaction is None
95+
backend.send.assert_called_once_with(mock_event, "Test message")
96+
97+
98+
async def test_notify_hook_uses_terminal_by_default(mock_event: AgentEvent) -> None:
99+
from io import StringIO
100+
from unittest.mock import patch
101+
102+
hook = notify(MockEvent, "Default notification")
103+
104+
stderr_capture = StringIO()
105+
with patch("sys.stderr", stderr_capture):
106+
reaction = await hook(mock_event)
107+
108+
assert reaction is None
109+
output = stderr_capture.getvalue()
110+
assert "Default notification" in output
111+
112+
113+
async def test_notify_hook_with_callable_message(mock_event: AgentEvent) -> None:
114+
backend = MagicMock(spec=NotificationBackend)
115+
backend.send = AsyncMock()
116+
117+
hook = notify(MockEvent, lambda e: f"Event from {e.agent.name}", backend=backend)
118+
119+
reaction = await hook(mock_event)
120+
121+
assert reaction is None
122+
backend.send.assert_called_once_with(mock_event, "Event from test_agent")
123+
124+
125+
async def test_notify_hook_with_predicate(mock_event: AgentEvent) -> None:
126+
backend = MagicMock(spec=NotificationBackend)
127+
backend.send = AsyncMock()
128+
129+
hook = notify(lambda e: e.agent.name == "test_agent", "Matched!", backend=backend)
130+
131+
reaction = await hook(mock_event)
132+
133+
assert reaction is None
134+
backend.send.assert_called_once()
135+
136+
137+
async def test_notify_hook_no_match(mock_event: AgentEvent) -> None:
138+
backend = MagicMock(spec=NotificationBackend)
139+
backend.send = AsyncMock()
140+
141+
hook = notify(ToolStart, "Should not send", backend=backend)
142+
143+
reaction = await hook(mock_event)
144+
145+
assert reaction is None
146+
backend.send.assert_not_called()
147+
148+
149+
async def test_notify_hook_handles_backend_failure(mock_event: AgentEvent) -> None:
150+
from unittest.mock import patch
151+
152+
backend = MagicMock(spec=NotificationBackend)
153+
backend.send = AsyncMock(side_effect=Exception("Backend failed"))
154+
155+
hook = notify(MockEvent, "Test message", backend=backend)
156+
157+
with patch("dreadnode.agent.hooks.notification.logger.exception") as mock_logger:
158+
reaction = await hook(mock_event)
159+
160+
assert reaction is None
161+
mock_logger.assert_called_once_with("Notification hook failed")

0 commit comments

Comments
 (0)