Skip to content

Commit 15991dc

Browse files
feat: native notification hook with three backend options (#304)
* feat: native notification hook with three backend options * chore: redesign notification hooks for better ergonomics pr feedback * chore: cleanup * fix: linting and formatting
1 parent af2c7c5 commit 15991dc

File tree

6 files changed

+462
-0
lines changed

6 files changed

+462
-0
lines changed

dreadnode/agent/agent.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
_total_usage_from_events,
3131
)
3232
from dreadnode.agent.hooks import Hook, retry_with_feedback
33+
from dreadnode.agent.hooks.notification import NotificationBackend, TerminalNotificationBackend
3334
from dreadnode.agent.reactions import (
3435
Continue,
3536
Fail,
@@ -62,6 +63,14 @@
6263
CommitBehavior = t.Literal["always", "on-success"]
6364

6465

66+
async def _safe_send(backend: NotificationBackend, event: AgentEvent, message: str) -> None:
67+
"""Send notification with error handling."""
68+
try:
69+
await backend.send(event, message)
70+
except Exception: # noqa: BLE001
71+
logger.exception(f"Notification failed for {event.__class__.__name__}")
72+
73+
6574
class AgentWarning(UserWarning):
6675
"""Warning raised when an agent is used in a way that may not be safe or intended."""
6776

@@ -111,6 +120,24 @@ class Agent(Model):
111120
assert_scores: list[str] | t.Literal[True] = Field(default_factory=list)
112121
"""Scores to ensure are truthy, otherwise the agent task is marked as failed."""
113122

123+
notifications: t.Annotated[bool | NotificationBackend | None, SkipValidation] = Config(
124+
default=None, repr=False
125+
)
126+
"""
127+
Enable notifications.
128+
- True: Uses TerminalNotificationBackend (stderr output)
129+
- NotificationBackend instance: Uses custom backend
130+
- None/False: Disabled
131+
"""
132+
notification_events: list[type[AgentEvent]] | t.Literal["all"] = Config(
133+
default="all", repr=False
134+
)
135+
"""Which event types to notify on. Defaults to all events."""
136+
notification_formatter: t.Annotated[t.Callable[[AgentEvent], str] | None, SkipValidation] = (
137+
Config(default=None, repr=False)
138+
)
139+
"""Custom formatter for notification messages. If None, uses event's default representation."""
140+
114141
_generator: rg.Generator | None = PrivateAttr(None, init=False)
115142

116143
@field_validator("tools", mode="before")
@@ -129,6 +156,49 @@ def validate_tools(cls, value: t.Any) -> t.Any:
129156

130157
return tools
131158

159+
def model_post_init(self, context: t.Any) -> None:
160+
super().model_post_init(context)
161+
162+
# Auto-inject notification hook if enabled
163+
if self.notifications:
164+
backend = (
165+
self.notifications
166+
if isinstance(self.notifications, NotificationBackend)
167+
else TerminalNotificationBackend()
168+
)
169+
170+
self.hooks.append(
171+
self._create_notification_hook(
172+
backend,
173+
self.notification_events,
174+
self.notification_formatter,
175+
)
176+
)
177+
178+
def _create_notification_hook(
179+
self,
180+
backend: NotificationBackend,
181+
events: list[type[AgentEvent]] | t.Literal["all"],
182+
formatter: t.Callable[[AgentEvent], str] | None,
183+
) -> Hook:
184+
"""Create a notification hook that delegates formatting to events."""
185+
import asyncio
186+
187+
async def notification_hook(event: AgentEvent) -> None:
188+
# Filter events
189+
if events != "all" and not any(isinstance(event, et) for et in events):
190+
return
191+
192+
# Use custom formatter if provided, otherwise delegate to event
193+
message = formatter(event) if formatter else event.format_notification()
194+
195+
# Fire and forget - don't block agent execution
196+
_ = asyncio.create_task(_safe_send(backend, event, message)) # noqa: RUF006
197+
198+
return
199+
200+
return notification_hook
201+
132202
def __repr__(self) -> str:
133203
description = shorten_string(self.description or "", 50)
134204

dreadnode/agent/events.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,22 @@ def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
115115
border_style="dim",
116116
)
117117

118+
def format_notification(self) -> str:
119+
"""
120+
Format this event as a human-readable notification message.
121+
Override in subclasses for custom formatting.
122+
"""
123+
return f"{self.__class__.__name__}"
124+
118125
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
119126
yield self.format_as_panel()
120127

121128

122129
@dataclass
123130
class AgentStart(AgentEvent):
131+
def format_notification(self) -> str:
132+
return f"Starting agent: {self.agent.name}"
133+
124134
def format_as_panel(self, *, truncate: bool = False) -> Panel:
125135
return Panel(
126136
format_message(self.messages[0], truncate=truncate),
@@ -158,6 +168,10 @@ def __repr__(self) -> str:
158168
message = f"Message(role={self.message.role}, content='{message_content}', tool_calls={tool_call_count})"
159169
return f"GenerationEnd(message={message})"
160170

171+
def format_notification(self) -> str:
172+
tokens = self.usage.total_tokens if self.usage else "unknown"
173+
return f"Generation complete ({tokens} tokens)"
174+
161175
def format_as_panel(self, *, truncate: bool = False) -> Panel:
162176
cost = round(self.estimated_cost, 6) if self.estimated_cost else ""
163177
usage = str(self.usage) or ""
@@ -173,6 +187,9 @@ def format_as_panel(self, *, truncate: bool = False) -> Panel:
173187

174188
@dataclass
175189
class AgentStalled(AgentEventInStep):
190+
def format_notification(self) -> str:
191+
return "Agent stalled: no tool calls and no stop conditions met"
192+
176193
def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
177194
return Panel(
178195
Text(
@@ -189,6 +206,9 @@ def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
189206
class AgentError(AgentEventInStep):
190207
error: BaseException
191208

209+
def format_notification(self) -> str:
210+
return f"Error: {self.error.__class__.__name__}: {self.error!s}"
211+
192212
def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
193213
return Panel(
194214
repr(self),
@@ -205,6 +225,9 @@ class ToolStart(AgentEventInStep):
205225
def __repr__(self) -> str:
206226
return f"ToolStart(tool_call={self.tool_call})"
207227

228+
def format_notification(self) -> str:
229+
return f"Starting tool: {self.tool_call.name}"
230+
208231
def format_as_panel(self, *, truncate: bool = False) -> Panel:
209232
content: RenderableType
210233
try:
@@ -245,6 +268,10 @@ def __repr__(self) -> str:
245268
message = f"Message(role={self.message.role}, content='{message_content}')"
246269
return f"ToolEnd(tool_call={self.tool_call}, message={message}, stop={self.stop})"
247270

271+
def format_notification(self) -> str:
272+
status = " (requesting stop)" if self.stop else ""
273+
return f"Finished tool: {self.tool_call.name}{status}"
274+
248275
def format_as_panel(self, *, truncate: bool = False) -> Panel:
249276
panel = format_message(self.message, truncate=truncate)
250277
subtitle = f"[dim]{self.tool_call.id}[/dim]"
@@ -294,6 +321,10 @@ class AgentEnd(AgentEvent):
294321
stop_reason: "AgentStopReason"
295322
result: "AgentResult"
296323

324+
def format_notification(self) -> str:
325+
status = "Failed" if self.result.failed else "Finished"
326+
return f"{status}: {self.stop_reason} (steps: {self.result.steps}, tokens: {self.result.usage.total_tokens})"
327+
297328
def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
298329
res = self.result
299330
status = "[bold red]Failed[/bold red]" if res.failed else "[bold green]Success[/bold green]"

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: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import typing as t
2+
from abc import ABC, abstractmethod
3+
4+
from loguru import logger
5+
6+
if t.TYPE_CHECKING:
7+
import httpx
8+
9+
from dreadnode.agent.events import AgentEvent
10+
11+
12+
class NotificationBackend(ABC):
13+
@abstractmethod
14+
async def send(self, event: "AgentEvent", message: str) -> None:
15+
"""Send a notification for the given event."""
16+
17+
18+
class LogNotificationBackend(NotificationBackend):
19+
async def send(self, event: "AgentEvent", message: str) -> None:
20+
logger.info(f"[{event.agent.name}] {message}")
21+
22+
23+
class TerminalNotificationBackend(NotificationBackend):
24+
async def send(self, event: "AgentEvent", message: str) -> None:
25+
import sys
26+
27+
print(f"[{event.agent.name}] {message}", file=sys.stderr)
28+
29+
30+
class WebhookNotificationBackend(NotificationBackend):
31+
def __init__(self, url: str, headers: dict[str, str] | None = None, timeout: float = 5.0):
32+
self.url = url
33+
self.headers = headers or {}
34+
self.timeout = timeout
35+
self._client: httpx.AsyncClient | None = None
36+
37+
async def __aenter__(self) -> "WebhookNotificationBackend":
38+
import httpx
39+
40+
self._client = httpx.AsyncClient(timeout=self.timeout)
41+
return self
42+
43+
async def __aexit__(self, *args: object) -> None:
44+
if self._client:
45+
await self._client.aclose()
46+
47+
async def send(self, event: "AgentEvent", message: str) -> None:
48+
import httpx
49+
50+
if not self._client:
51+
self._client = httpx.AsyncClient(timeout=self.timeout)
52+
53+
payload = self._build_payload(event, message)
54+
await self._client.post(self.url, json=payload, headers=self.headers)
55+
56+
def _build_payload(self, event: "AgentEvent", message: str) -> dict[str, str]:
57+
"""Override this to customize webhook payload."""
58+
return {
59+
"agent": event.agent.name,
60+
"event": event.__class__.__name__,
61+
"message": message,
62+
"timestamp": event.timestamp.isoformat(),
63+
}
64+
65+
66+
def notify(
67+
event_type: "type[AgentEvent] | t.Callable[[AgentEvent], bool]",
68+
message: str | t.Callable[["AgentEvent"], str] | None = None,
69+
backend: NotificationBackend | None = None,
70+
) -> t.Callable[["AgentEvent"], t.Awaitable[None]]:
71+
"""
72+
Create a notification hook that sends notifications when events occur.
73+
74+
Unlike other hooks, notification hooks don't affect agent execution - they return
75+
None (no reaction) and run asynchronously to deliver notifications.
76+
77+
Args:
78+
event_type: Event type to trigger on, or predicate function
79+
message: Static message or callable that generates message from event.
80+
If None, uses event.format_notification()
81+
backend: Notification backend (defaults to terminal output)
82+
83+
Returns:
84+
Hook that sends notifications
85+
86+
Example:
87+
```python
88+
from dreadnode.agent import Agent
89+
from dreadnode.agent.events import ToolStart
90+
from dreadnode.agent.hooks.notification import notify
91+
92+
agent = Agent(
93+
name="analyzer",
94+
hooks=[
95+
notify(ToolStart), # Uses default formatting
96+
notify(
97+
ToolStart,
98+
lambda e: f"Starting tool: {e.tool_name}",
99+
),
100+
],
101+
)
102+
```
103+
"""
104+
notification_backend = backend or TerminalNotificationBackend()
105+
106+
async def notification_hook(event: "AgentEvent") -> None:
107+
should_notify = False
108+
109+
if isinstance(event_type, type):
110+
should_notify = isinstance(event, event_type)
111+
elif callable(event_type):
112+
should_notify = event_type(event)
113+
114+
if not should_notify:
115+
return
116+
117+
# Use custom message if provided, otherwise delegate to event
118+
if message is None:
119+
msg = event.format_notification()
120+
else:
121+
msg = message(event) if callable(message) else message
122+
123+
try:
124+
await notification_backend.send(event, msg)
125+
except Exception: # noqa: BLE001
126+
logger.exception("Notification hook failed")
127+
128+
return
129+
130+
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+
]

0 commit comments

Comments
 (0)