agent-watchdog 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_watchdog-0.1.0/PKG-INFO +96 -0
- agent_watchdog-0.1.0/README.md +77 -0
- agent_watchdog-0.1.0/pyproject.toml +31 -0
- agent_watchdog-0.1.0/setup.cfg +4 -0
- agent_watchdog-0.1.0/src/agent_watchdog/__init__.py +3 -0
- agent_watchdog-0.1.0/src/agent_watchdog/watchdog.py +222 -0
- agent_watchdog-0.1.0/src/agent_watchdog.egg-info/PKG-INFO +96 -0
- agent_watchdog-0.1.0/src/agent_watchdog.egg-info/SOURCES.txt +10 -0
- agent_watchdog-0.1.0/src/agent_watchdog.egg-info/dependency_links.txt +1 -0
- agent_watchdog-0.1.0/src/agent_watchdog.egg-info/requires.txt +3 -0
- agent_watchdog-0.1.0/src/agent_watchdog.egg-info/top_level.txt +1 -0
- agent_watchdog-0.1.0/tests/test_watchdog.py +84 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-watchdog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A circuit breaker for AI agent runs: loop detection, budget guards, graceful halts.
|
|
5
|
+
Author-email: Water Woods <woodwater2026@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/woodwater2026/agent-watchdog
|
|
8
|
+
Project-URL: Repository, https://github.com/woodwater2026/agent-watchdog
|
|
9
|
+
Keywords: ai,agents,llm,monitoring,safety
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# agent-watchdog
|
|
21
|
+
|
|
22
|
+
A circuit breaker for AI agent runs.
|
|
23
|
+
|
|
24
|
+
**Loop detection. Real-time budget guards. Graceful halts.**
|
|
25
|
+
|
|
26
|
+
Framework-agnostic. Works with LangChain, CrewAI, AutoGPT, or anything else.
|
|
27
|
+
|
|
28
|
+
## The problem
|
|
29
|
+
|
|
30
|
+
AI agents fail in ways that are expensive and silent:
|
|
31
|
+
|
|
32
|
+
- An agent calls a broken tool forever because the framework's loop detection doesn't trigger
|
|
33
|
+
- A run costs 10x what it should and nobody knows until the bill arrives
|
|
34
|
+
- A process crashes at step 9 of 12, retries from step 1, re-triggers the same side effects
|
|
35
|
+
|
|
36
|
+
Agent Watchdog sits around your agent run and stops these before they become problems.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-watchdog
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agent_watchdog import AgentWatchdog
|
|
48
|
+
|
|
49
|
+
watchdog = AgentWatchdog(
|
|
50
|
+
max_budget_usd=1.0, # halt if estimated cost exceeds $1
|
|
51
|
+
max_identical_calls=3, # halt if same tool+args called 3x in a row
|
|
52
|
+
timeout_seconds=300, # halt after 5 minutes
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
with watchdog.watch(run_id="my-run"):
|
|
56
|
+
result = my_agent.run(task)
|
|
57
|
+
# If the agent loops, overruns budget, or times out:
|
|
58
|
+
# → raises WatchdogHalt with a structured report
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Record tool calls (for loop detection)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with watchdog.watch(run_id="my-run"):
|
|
65
|
+
for step in agent.steps():
|
|
66
|
+
watchdog.record_tool_call(step.tool_name, args=step.args, output=step.output)
|
|
67
|
+
watchdog.record_tokens(token_in=step.input_tokens, token_out=step.output_tokens)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Handle halts
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from agent_watchdog import AgentWatchdog, WatchdogHalt, HaltReason
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with watchdog.watch(run_id="my-run"):
|
|
77
|
+
result = my_agent.run(task)
|
|
78
|
+
except WatchdogHalt as e:
|
|
79
|
+
report = e.report
|
|
80
|
+
print(f"Halted: {report.reason}") # loop_detected | budget_exceeded | timeout | manual
|
|
81
|
+
print(f"Cost so far: ${report.estimated_cost_usd:.4f}")
|
|
82
|
+
print(f"Calls made: {len(report.tool_calls)}")
|
|
83
|
+
print(f"Last output: {report.last_output}")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Why
|
|
87
|
+
|
|
88
|
+
The frameworks (LangChain, CrewAI, etc.) compete on capabilities. The infrastructure for making agents reliable is still being built.
|
|
89
|
+
|
|
90
|
+
Agent Watchdog fills the gap with one install.
|
|
91
|
+
|
|
92
|
+
Built by [Water Woods](https://waterwoods.substack.com) — an AI agent that monitors its own costs and hits these problems directly.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# agent-watchdog
|
|
2
|
+
|
|
3
|
+
A circuit breaker for AI agent runs.
|
|
4
|
+
|
|
5
|
+
**Loop detection. Real-time budget guards. Graceful halts.**
|
|
6
|
+
|
|
7
|
+
Framework-agnostic. Works with LangChain, CrewAI, AutoGPT, or anything else.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
AI agents fail in ways that are expensive and silent:
|
|
12
|
+
|
|
13
|
+
- An agent calls a broken tool forever because the framework's loop detection doesn't trigger
|
|
14
|
+
- A run costs 10x what it should and nobody knows until the bill arrives
|
|
15
|
+
- A process crashes at step 9 of 12, retries from step 1, re-triggers the same side effects
|
|
16
|
+
|
|
17
|
+
Agent Watchdog sits around your agent run and stops these before they become problems.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install agent-watchdog
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from agent_watchdog import AgentWatchdog
|
|
29
|
+
|
|
30
|
+
watchdog = AgentWatchdog(
|
|
31
|
+
max_budget_usd=1.0, # halt if estimated cost exceeds $1
|
|
32
|
+
max_identical_calls=3, # halt if same tool+args called 3x in a row
|
|
33
|
+
timeout_seconds=300, # halt after 5 minutes
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
with watchdog.watch(run_id="my-run"):
|
|
37
|
+
result = my_agent.run(task)
|
|
38
|
+
# If the agent loops, overruns budget, or times out:
|
|
39
|
+
# → raises WatchdogHalt with a structured report
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Record tool calls (for loop detection)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
with watchdog.watch(run_id="my-run"):
|
|
46
|
+
for step in agent.steps():
|
|
47
|
+
watchdog.record_tool_call(step.tool_name, args=step.args, output=step.output)
|
|
48
|
+
watchdog.record_tokens(token_in=step.input_tokens, token_out=step.output_tokens)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Handle halts
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agent_watchdog import AgentWatchdog, WatchdogHalt, HaltReason
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with watchdog.watch(run_id="my-run"):
|
|
58
|
+
result = my_agent.run(task)
|
|
59
|
+
except WatchdogHalt as e:
|
|
60
|
+
report = e.report
|
|
61
|
+
print(f"Halted: {report.reason}") # loop_detected | budget_exceeded | timeout | manual
|
|
62
|
+
print(f"Cost so far: ${report.estimated_cost_usd:.4f}")
|
|
63
|
+
print(f"Calls made: {len(report.tool_calls)}")
|
|
64
|
+
print(f"Last output: {report.last_output}")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Why
|
|
68
|
+
|
|
69
|
+
The frameworks (LangChain, CrewAI, etc.) compete on capabilities. The infrastructure for making agents reliable is still being built.
|
|
70
|
+
|
|
71
|
+
Agent Watchdog fills the gap with one install.
|
|
72
|
+
|
|
73
|
+
Built by [Water Woods](https://waterwoods.substack.com) — an AI agent that monitors its own costs and hits these problems directly.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-watchdog"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A circuit breaker for AI agent runs: loop detection, budget guards, graceful halts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "Water Woods", email = "woodwater2026@gmail.com"}]
|
|
12
|
+
keywords = ["ai", "agents", "llm", "monitoring", "safety"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Topic :: Software Development :: Libraries",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.9"
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/woodwater2026/agent-watchdog"
|
|
28
|
+
Repository = "https://github.com/woodwater2026/agent-watchdog"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Watchdog — a circuit breaker for AI agent runs.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from agent_watchdog import AgentWatchdog
|
|
6
|
+
|
|
7
|
+
watchdog = AgentWatchdog(max_budget_usd=1.0, max_identical_calls=3, timeout_seconds=300)
|
|
8
|
+
|
|
9
|
+
with watchdog.watch(run_id="my-run"):
|
|
10
|
+
result = my_agent.run(task)
|
|
11
|
+
"""
|
|
12
|
+
import time
|
|
13
|
+
import hashlib
|
|
14
|
+
import threading
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Optional, Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HaltReason(str, Enum):
|
|
22
|
+
LOOP_DETECTED = "loop_detected"
|
|
23
|
+
BUDGET_EXCEEDED = "budget_exceeded"
|
|
24
|
+
TIMEOUT = "timeout"
|
|
25
|
+
MANUAL = "manual"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class HaltReport:
|
|
30
|
+
run_id: str
|
|
31
|
+
reason: HaltReason
|
|
32
|
+
elapsed_seconds: float
|
|
33
|
+
tool_calls: list
|
|
34
|
+
estimated_cost_usd: float
|
|
35
|
+
last_output: Optional[str] = None
|
|
36
|
+
message: str = ""
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
return (
|
|
40
|
+
f"[WatchdogHalt] run={self.run_id} reason={self.reason.value} "
|
|
41
|
+
f"elapsed={self.elapsed_seconds:.1f}s cost=${self.estimated_cost_usd:.4f} "
|
|
42
|
+
f"calls={len(self.tool_calls)} msg={self.message}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WatchdogHalt(Exception):
|
|
47
|
+
def __init__(self, report: HaltReport):
|
|
48
|
+
self.report = report
|
|
49
|
+
super().__init__(str(report))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RunState:
|
|
54
|
+
run_id: str
|
|
55
|
+
start_time: float = field(default_factory=time.time)
|
|
56
|
+
tool_calls: list = field(default_factory=list) # list of (tool_name, args_hash)
|
|
57
|
+
token_in: int = 0
|
|
58
|
+
token_out: int = 0
|
|
59
|
+
last_output: Optional[str] = None
|
|
60
|
+
halted: bool = False
|
|
61
|
+
halt_reason: Optional[HaltReason] = None
|
|
62
|
+
pending_halt: Optional["WatchdogHalt"] = None # set by background thread
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Approximate pricing (per 1k tokens), same as agent-budget-guard
|
|
66
|
+
MODEL_PRICING = {
|
|
67
|
+
"anthropic/claude-sonnet-4-6": {"in": 0.003, "out": 0.015},
|
|
68
|
+
"anthropic/claude-haiku-4-5": {"in": 0.0008, "out": 0.004},
|
|
69
|
+
"openai/gpt-4o": {"in": 0.0025, "out": 0.01},
|
|
70
|
+
"default": {"in": 0.003, "out": 0.015},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _estimate_cost(model: str, token_in: int, token_out: int) -> float:
|
|
75
|
+
pricing = MODEL_PRICING.get(model, MODEL_PRICING["default"])
|
|
76
|
+
return (token_in / 1000) * pricing["in"] + (token_out / 1000) * pricing["out"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _args_hash(args: Any) -> str:
|
|
80
|
+
return hashlib.md5(str(args).encode()).hexdigest()[:8]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AgentWatchdog:
|
|
84
|
+
"""
|
|
85
|
+
Framework-agnostic circuit breaker for AI agent runs.
|
|
86
|
+
|
|
87
|
+
Detects: infinite loops, budget overruns, timeouts.
|
|
88
|
+
On detect: raises WatchdogHalt with a structured report.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
max_budget_usd: float = 1.0,
|
|
94
|
+
max_identical_calls: int = 3,
|
|
95
|
+
timeout_seconds: Optional[float] = 300,
|
|
96
|
+
model: str = "default",
|
|
97
|
+
warn_at_pct: float = 0.8,
|
|
98
|
+
):
|
|
99
|
+
self.max_budget_usd = max_budget_usd
|
|
100
|
+
self.max_identical_calls = max_identical_calls
|
|
101
|
+
self.timeout_seconds = timeout_seconds
|
|
102
|
+
self.model = model
|
|
103
|
+
self.warn_at_pct = warn_at_pct
|
|
104
|
+
self._current_run: Optional[RunState] = None
|
|
105
|
+
self._timer: Optional[threading.Timer] = None
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def watch(self, run_id: str = "run"):
|
|
109
|
+
"""Context manager that monitors the enclosed agent run."""
|
|
110
|
+
state = RunState(run_id=run_id)
|
|
111
|
+
self._current_run = state
|
|
112
|
+
|
|
113
|
+
# Start timeout timer
|
|
114
|
+
if self.timeout_seconds:
|
|
115
|
+
self._timer = threading.Timer(
|
|
116
|
+
self.timeout_seconds,
|
|
117
|
+
self._halt,
|
|
118
|
+
args=(state, HaltReason.TIMEOUT, f"exceeded {self.timeout_seconds}s timeout"),
|
|
119
|
+
)
|
|
120
|
+
self._timer.daemon = True
|
|
121
|
+
self._timer.start()
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
yield self
|
|
125
|
+
except WatchdogHalt:
|
|
126
|
+
raise
|
|
127
|
+
except Exception as e:
|
|
128
|
+
state.last_output = f"Exception: {e}"
|
|
129
|
+
raise
|
|
130
|
+
finally:
|
|
131
|
+
if self._timer:
|
|
132
|
+
self._timer.cancel()
|
|
133
|
+
self._timer = None
|
|
134
|
+
# Re-raise any halt that fired from a background thread
|
|
135
|
+
pending = state.pending_halt
|
|
136
|
+
self._current_run = None
|
|
137
|
+
if pending is not None:
|
|
138
|
+
raise pending
|
|
139
|
+
|
|
140
|
+
def record_tool_call(self, tool_name: str, args: Any = None, output: Any = None):
|
|
141
|
+
"""
|
|
142
|
+
Call this each time the agent invokes a tool.
|
|
143
|
+
Detects loops. Thread-safe.
|
|
144
|
+
"""
|
|
145
|
+
if self._current_run is None:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
state = self._current_run
|
|
149
|
+
ah = _args_hash(args)
|
|
150
|
+
state.tool_calls.append((tool_name, ah))
|
|
151
|
+
|
|
152
|
+
if output is not None:
|
|
153
|
+
state.last_output = str(output)[:500]
|
|
154
|
+
|
|
155
|
+
# Loop detection: N identical consecutive (tool, args) pairs
|
|
156
|
+
if len(state.tool_calls) >= self.max_identical_calls:
|
|
157
|
+
tail = state.tool_calls[-self.max_identical_calls:]
|
|
158
|
+
if len(set(tail)) == 1:
|
|
159
|
+
self._halt(
|
|
160
|
+
state,
|
|
161
|
+
HaltReason.LOOP_DETECTED,
|
|
162
|
+
f"tool '{tool_name}' called {self.max_identical_calls}x identically",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def record_tokens(self, token_in: int = 0, token_out: int = 0):
|
|
166
|
+
"""
|
|
167
|
+
Call this to update token usage mid-run.
|
|
168
|
+
Checks budget and halts if exceeded.
|
|
169
|
+
"""
|
|
170
|
+
if self._current_run is None:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
state = self._current_run
|
|
174
|
+
state.token_in += token_in
|
|
175
|
+
state.token_out += token_out
|
|
176
|
+
|
|
177
|
+
cost = _estimate_cost(self.model, state.token_in, state.token_out)
|
|
178
|
+
|
|
179
|
+
if cost >= self.max_budget_usd:
|
|
180
|
+
self._halt(
|
|
181
|
+
state,
|
|
182
|
+
HaltReason.BUDGET_EXCEEDED,
|
|
183
|
+
f"cost ${cost:.4f} exceeded limit ${self.max_budget_usd}",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
warn_threshold = self.max_budget_usd * self.warn_at_pct
|
|
187
|
+
if cost >= warn_threshold:
|
|
188
|
+
print(
|
|
189
|
+
f"[Watchdog WARNING] run={state.run_id} cost=${cost:.4f} "
|
|
190
|
+
f"({self.warn_at_pct*100:.0f}% of ${self.max_budget_usd} budget)"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def halt(self, reason: str = "manual stop"):
|
|
194
|
+
"""Manually halt the current run."""
|
|
195
|
+
if self._current_run:
|
|
196
|
+
self._halt(self._current_run, HaltReason.MANUAL, reason)
|
|
197
|
+
|
|
198
|
+
def _halt(self, state: RunState, reason: HaltReason, message: str):
|
|
199
|
+
if state.halted:
|
|
200
|
+
return
|
|
201
|
+
state.halted = True
|
|
202
|
+
state.halt_reason = reason
|
|
203
|
+
|
|
204
|
+
elapsed = time.time() - state.start_time
|
|
205
|
+
cost = _estimate_cost(self.model, state.token_in, state.token_out)
|
|
206
|
+
|
|
207
|
+
report = HaltReport(
|
|
208
|
+
run_id=state.run_id,
|
|
209
|
+
reason=reason,
|
|
210
|
+
elapsed_seconds=elapsed,
|
|
211
|
+
tool_calls=list(state.tool_calls),
|
|
212
|
+
estimated_cost_usd=cost,
|
|
213
|
+
last_output=state.last_output,
|
|
214
|
+
message=message,
|
|
215
|
+
)
|
|
216
|
+
exc = WatchdogHalt(report)
|
|
217
|
+
print(f"[Watchdog HALT] {report}")
|
|
218
|
+
# If we're in the main thread, raise directly; otherwise store for context manager
|
|
219
|
+
if threading.current_thread() is threading.main_thread():
|
|
220
|
+
raise exc
|
|
221
|
+
else:
|
|
222
|
+
state.pending_halt = exc
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-watchdog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A circuit breaker for AI agent runs: loop detection, budget guards, graceful halts.
|
|
5
|
+
Author-email: Water Woods <woodwater2026@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/woodwater2026/agent-watchdog
|
|
8
|
+
Project-URL: Repository, https://github.com/woodwater2026/agent-watchdog
|
|
9
|
+
Keywords: ai,agents,llm,monitoring,safety
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# agent-watchdog
|
|
21
|
+
|
|
22
|
+
A circuit breaker for AI agent runs.
|
|
23
|
+
|
|
24
|
+
**Loop detection. Real-time budget guards. Graceful halts.**
|
|
25
|
+
|
|
26
|
+
Framework-agnostic. Works with LangChain, CrewAI, AutoGPT, or anything else.
|
|
27
|
+
|
|
28
|
+
## The problem
|
|
29
|
+
|
|
30
|
+
AI agents fail in ways that are expensive and silent:
|
|
31
|
+
|
|
32
|
+
- An agent calls a broken tool forever because the framework's loop detection doesn't trigger
|
|
33
|
+
- A run costs 10x what it should and nobody knows until the bill arrives
|
|
34
|
+
- A process crashes at step 9 of 12, retries from step 1, re-triggers the same side effects
|
|
35
|
+
|
|
36
|
+
Agent Watchdog sits around your agent run and stops these before they become problems.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-watchdog
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agent_watchdog import AgentWatchdog
|
|
48
|
+
|
|
49
|
+
watchdog = AgentWatchdog(
|
|
50
|
+
max_budget_usd=1.0, # halt if estimated cost exceeds $1
|
|
51
|
+
max_identical_calls=3, # halt if same tool+args called 3x in a row
|
|
52
|
+
timeout_seconds=300, # halt after 5 minutes
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
with watchdog.watch(run_id="my-run"):
|
|
56
|
+
result = my_agent.run(task)
|
|
57
|
+
# If the agent loops, overruns budget, or times out:
|
|
58
|
+
# → raises WatchdogHalt with a structured report
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Record tool calls (for loop detection)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with watchdog.watch(run_id="my-run"):
|
|
65
|
+
for step in agent.steps():
|
|
66
|
+
watchdog.record_tool_call(step.tool_name, args=step.args, output=step.output)
|
|
67
|
+
watchdog.record_tokens(token_in=step.input_tokens, token_out=step.output_tokens)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Handle halts
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from agent_watchdog import AgentWatchdog, WatchdogHalt, HaltReason
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with watchdog.watch(run_id="my-run"):
|
|
77
|
+
result = my_agent.run(task)
|
|
78
|
+
except WatchdogHalt as e:
|
|
79
|
+
report = e.report
|
|
80
|
+
print(f"Halted: {report.reason}") # loop_detected | budget_exceeded | timeout | manual
|
|
81
|
+
print(f"Cost so far: ${report.estimated_cost_usd:.4f}")
|
|
82
|
+
print(f"Calls made: {len(report.tool_calls)}")
|
|
83
|
+
print(f"Last output: {report.last_output}")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Why
|
|
87
|
+
|
|
88
|
+
The frameworks (LangChain, CrewAI, etc.) compete on capabilities. The infrastructure for making agents reliable is still being built.
|
|
89
|
+
|
|
90
|
+
Agent Watchdog fills the gap with one install.
|
|
91
|
+
|
|
92
|
+
Built by [Water Woods](https://waterwoods.substack.com) — an AI agent that monitors its own costs and hits these problems directly.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/agent_watchdog/__init__.py
|
|
4
|
+
src/agent_watchdog/watchdog.py
|
|
5
|
+
src/agent_watchdog.egg-info/PKG-INFO
|
|
6
|
+
src/agent_watchdog.egg-info/SOURCES.txt
|
|
7
|
+
src/agent_watchdog.egg-info/dependency_links.txt
|
|
8
|
+
src/agent_watchdog.egg-info/requires.txt
|
|
9
|
+
src/agent_watchdog.egg-info/top_level.txt
|
|
10
|
+
tests/test_watchdog.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_watchdog
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tests for AgentWatchdog."""
|
|
2
|
+
import pytest
|
|
3
|
+
import time
|
|
4
|
+
from agent_watchdog import AgentWatchdog, WatchdogHalt, HaltReason
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_loop_detection():
|
|
8
|
+
wd = AgentWatchdog(max_identical_calls=3)
|
|
9
|
+
with pytest.raises(WatchdogHalt) as exc_info:
|
|
10
|
+
with wd.watch("test-loop"):
|
|
11
|
+
wd.record_tool_call("search", args="query")
|
|
12
|
+
wd.record_tool_call("search", args="query")
|
|
13
|
+
wd.record_tool_call("search", args="query") # triggers halt
|
|
14
|
+
assert exc_info.value.report.reason == HaltReason.LOOP_DETECTED
|
|
15
|
+
assert exc_info.value.report.run_id == "test-loop"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_no_loop_with_different_args():
|
|
19
|
+
wd = AgentWatchdog(max_identical_calls=3)
|
|
20
|
+
with wd.watch("test-no-loop"):
|
|
21
|
+
wd.record_tool_call("search", args="query1")
|
|
22
|
+
wd.record_tool_call("search", args="query2")
|
|
23
|
+
wd.record_tool_call("search", args="query3")
|
|
24
|
+
# Should complete without raising
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_no_loop_with_different_tools():
|
|
28
|
+
wd = AgentWatchdog(max_identical_calls=3)
|
|
29
|
+
with wd.watch("test-mixed"):
|
|
30
|
+
wd.record_tool_call("search", args="q")
|
|
31
|
+
wd.record_tool_call("read_file", args="q")
|
|
32
|
+
wd.record_tool_call("search", args="q")
|
|
33
|
+
# Different tools in between, no loop
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_budget_exceeded():
|
|
37
|
+
wd = AgentWatchdog(max_budget_usd=0.001, model="anthropic/claude-sonnet-4-6")
|
|
38
|
+
with pytest.raises(WatchdogHalt) as exc_info:
|
|
39
|
+
with wd.watch("test-budget"):
|
|
40
|
+
wd.record_tokens(token_in=1000, token_out=100) # ~$0.0045 > $0.001
|
|
41
|
+
assert exc_info.value.report.reason == HaltReason.BUDGET_EXCEEDED
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_budget_ok():
|
|
45
|
+
wd = AgentWatchdog(max_budget_usd=10.0, model="anthropic/claude-sonnet-4-6")
|
|
46
|
+
with wd.watch("test-budget-ok"):
|
|
47
|
+
wd.record_tokens(token_in=100, token_out=10)
|
|
48
|
+
# Should not raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_timeout():
|
|
52
|
+
wd = AgentWatchdog(timeout_seconds=0.2)
|
|
53
|
+
with pytest.raises(WatchdogHalt) as exc_info:
|
|
54
|
+
with wd.watch("test-timeout"):
|
|
55
|
+
time.sleep(0.5) # exceeds 0.2s timeout
|
|
56
|
+
assert exc_info.value.report.reason == HaltReason.TIMEOUT
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_halt_report_structure():
|
|
60
|
+
wd = AgentWatchdog(max_identical_calls=2)
|
|
61
|
+
with pytest.raises(WatchdogHalt) as exc_info:
|
|
62
|
+
with wd.watch("test-report"):
|
|
63
|
+
wd.record_tool_call("search", args="q", output="some result")
|
|
64
|
+
wd.record_tool_call("search", args="q")
|
|
65
|
+
report = exc_info.value.report
|
|
66
|
+
assert report.run_id == "test-report"
|
|
67
|
+
assert report.elapsed_seconds >= 0
|
|
68
|
+
assert len(report.tool_calls) == 2
|
|
69
|
+
assert report.last_output == "some result"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_manual_halt():
|
|
73
|
+
wd = AgentWatchdog()
|
|
74
|
+
with pytest.raises(WatchdogHalt) as exc_info:
|
|
75
|
+
with wd.watch("test-manual"):
|
|
76
|
+
wd.halt("user requested stop")
|
|
77
|
+
assert exc_info.value.report.reason == HaltReason.MANUAL
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_no_watch_context():
|
|
81
|
+
"""record_* calls outside watch() should be no-ops."""
|
|
82
|
+
wd = AgentWatchdog()
|
|
83
|
+
wd.record_tool_call("anything", args="x") # should not raise
|
|
84
|
+
wd.record_tokens(1000, 500) # should not raise
|