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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .watchdog import AgentWatchdog, WatchdogHalt, HaltReason
2
+ __all__ = ["AgentWatchdog", "WatchdogHalt", "HaltReason"]
3
+ __version__ = "0.1.0"
@@ -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,3 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
@@ -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