agentguard47 0.2.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.
- agentguard47-0.2.0/PKG-INFO +135 -0
- agentguard47-0.2.0/README.md +110 -0
- agentguard47-0.2.0/agentguard/__init__.py +24 -0
- agentguard47-0.2.0/agentguard/cli.py +113 -0
- agentguard47-0.2.0/agentguard/guards.py +100 -0
- agentguard47-0.2.0/agentguard/integrations/__init__.py +5 -0
- agentguard47-0.2.0/agentguard/integrations/langchain.py +240 -0
- agentguard47-0.2.0/agentguard/recording.py +70 -0
- agentguard47-0.2.0/agentguard/sinks/__init__.py +3 -0
- agentguard47-0.2.0/agentguard/sinks/http.py +83 -0
- agentguard47-0.2.0/agentguard/tracing.py +152 -0
- agentguard47-0.2.0/agentguard/viewer.py +149 -0
- agentguard47-0.2.0/agentguard47.egg-info/PKG-INFO +135 -0
- agentguard47-0.2.0/agentguard47.egg-info/SOURCES.txt +34 -0
- agentguard47-0.2.0/agentguard47.egg-info/dependency_links.txt +1 -0
- agentguard47-0.2.0/agentguard47.egg-info/entry_points.txt +2 -0
- agentguard47-0.2.0/agentguard47.egg-info/requires.txt +3 -0
- agentguard47-0.2.0/agentguard47.egg-info/top_level.txt +1 -0
- agentguard47-0.2.0/pyproject.toml +45 -0
- agentguard47-0.2.0/setup.cfg +4 -0
- agentguard47-0.2.0/tests/test_cli_report.py +58 -0
- agentguard47-0.2.0/tests/test_guards.py +66 -0
- agentguard47-0.2.0/tests/test_http_sink.py +85 -0
- agentguard47-0.2.0/tests/test_langchain_integration.py +107 -0
- agentguard47-0.2.0/tests/test_recording.py +24 -0
- agentguard47-0.2.0/tests/test_tracing.py +19 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentguard47
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Lightweight observability and evaluation primitives for multi-agent systems
|
|
5
|
+
Author: AgentGuard
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bmdhodl/agent47
|
|
8
|
+
Project-URL: Repository, https://github.com/bmdhodl/agent47
|
|
9
|
+
Project-URL: Issues, https://github.com/bmdhodl/agent47/issues
|
|
10
|
+
Keywords: agents,observability,tracing,multi-agent,llm,guardrails,replay
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: langchain
|
|
24
|
+
Requires-Dist: langchain-core>=0.1; extra == "langchain"
|
|
25
|
+
|
|
26
|
+
# AgentGuard SDK (Python)
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/agentguard47/)
|
|
29
|
+
[](https://github.com/bmdhodl/agent47/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
Lightweight, zero-dependency observability for multi-agent AI systems. Trace reasoning steps, catch loops, guard budgets, and replay runs deterministically.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install agentguard47
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With LangChain support:
|
|
40
|
+
```bash
|
|
41
|
+
pip install agentguard47[langchain]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agentguard import Tracer, LoopGuard, BudgetGuard
|
|
48
|
+
|
|
49
|
+
tracer = Tracer()
|
|
50
|
+
loop_guard = LoopGuard(max_repeats=3)
|
|
51
|
+
budget_guard = BudgetGuard(max_tokens=10000)
|
|
52
|
+
|
|
53
|
+
with tracer.trace("agent.run") as span:
|
|
54
|
+
span.event("reasoning.step", data={"thought": "search docs"})
|
|
55
|
+
|
|
56
|
+
loop_guard.check(tool_name="search", tool_args={"query": "agent loops"})
|
|
57
|
+
budget_guard.record_tokens(150)
|
|
58
|
+
|
|
59
|
+
with span.span("tool.call", data={"tool": "search"}):
|
|
60
|
+
pass # call your tool here
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Tracing
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from agentguard.tracing import Tracer
|
|
67
|
+
|
|
68
|
+
tracer = Tracer()
|
|
69
|
+
|
|
70
|
+
with tracer.trace("agent.run", data={"user_id": "u123"}) as span:
|
|
71
|
+
span.event("reasoning.step", data={"step": 1, "thought": "search docs"})
|
|
72
|
+
with span.span("tool.call", data={"tool": "search", "query": "agent loops"}):
|
|
73
|
+
pass
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Guards
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from agentguard.guards import LoopGuard, BudgetGuard, TimeoutGuard
|
|
80
|
+
|
|
81
|
+
# Detect repeated tool calls
|
|
82
|
+
guard = LoopGuard(max_repeats=3)
|
|
83
|
+
guard.check(tool_name="search", tool_args={"query": "agent loops"})
|
|
84
|
+
|
|
85
|
+
# Track token and call budgets
|
|
86
|
+
budget = BudgetGuard(max_tokens=50000, max_calls=100)
|
|
87
|
+
budget.record_tokens(150)
|
|
88
|
+
budget.record_call()
|
|
89
|
+
|
|
90
|
+
# Enforce wall-clock time limits
|
|
91
|
+
timeout = TimeoutGuard(max_seconds=30)
|
|
92
|
+
timeout.start()
|
|
93
|
+
timeout.check() # raises TimeoutExceeded if over limit
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Replay
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from agentguard.recording import Recorder, Replayer
|
|
100
|
+
|
|
101
|
+
recorder = Recorder("runs.jsonl")
|
|
102
|
+
recorder.record_call("llm", {"prompt": "hi"}, {"text": "hello"})
|
|
103
|
+
|
|
104
|
+
replayer = Replayer("runs.jsonl")
|
|
105
|
+
resp = replayer.replay_call("llm", {"prompt": "hi"})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## CLI
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Summarize trace events
|
|
112
|
+
agentguard summarize traces.jsonl
|
|
113
|
+
|
|
114
|
+
# Human-readable report
|
|
115
|
+
agentguard report traces.jsonl
|
|
116
|
+
|
|
117
|
+
# Open trace viewer in browser
|
|
118
|
+
agentguard view traces.jsonl
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Trace Viewer
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
agentguard view traces.jsonl --port 8080
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Integrations
|
|
128
|
+
|
|
129
|
+
- LangChain: `agentguard.integrations.langchain`
|
|
130
|
+
|
|
131
|
+
## Links
|
|
132
|
+
|
|
133
|
+
- [GitHub](https://github.com/bmdhodl/agent47)
|
|
134
|
+
- [Trace Schema](https://github.com/bmdhodl/agent47/blob/main/docs/trace_schema.md)
|
|
135
|
+
- [Examples](https://github.com/bmdhodl/agent47/tree/main/sdk/examples)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# AgentGuard SDK (Python)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/agentguard47/)
|
|
4
|
+
[](https://github.com/bmdhodl/agent47/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
Lightweight, zero-dependency observability for multi-agent AI systems. Trace reasoning steps, catch loops, guard budgets, and replay runs deterministically.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install agentguard47
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With LangChain support:
|
|
15
|
+
```bash
|
|
16
|
+
pip install agentguard47[langchain]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from agentguard import Tracer, LoopGuard, BudgetGuard
|
|
23
|
+
|
|
24
|
+
tracer = Tracer()
|
|
25
|
+
loop_guard = LoopGuard(max_repeats=3)
|
|
26
|
+
budget_guard = BudgetGuard(max_tokens=10000)
|
|
27
|
+
|
|
28
|
+
with tracer.trace("agent.run") as span:
|
|
29
|
+
span.event("reasoning.step", data={"thought": "search docs"})
|
|
30
|
+
|
|
31
|
+
loop_guard.check(tool_name="search", tool_args={"query": "agent loops"})
|
|
32
|
+
budget_guard.record_tokens(150)
|
|
33
|
+
|
|
34
|
+
with span.span("tool.call", data={"tool": "search"}):
|
|
35
|
+
pass # call your tool here
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Tracing
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from agentguard.tracing import Tracer
|
|
42
|
+
|
|
43
|
+
tracer = Tracer()
|
|
44
|
+
|
|
45
|
+
with tracer.trace("agent.run", data={"user_id": "u123"}) as span:
|
|
46
|
+
span.event("reasoning.step", data={"step": 1, "thought": "search docs"})
|
|
47
|
+
with span.span("tool.call", data={"tool": "search", "query": "agent loops"}):
|
|
48
|
+
pass
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Guards
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agentguard.guards import LoopGuard, BudgetGuard, TimeoutGuard
|
|
55
|
+
|
|
56
|
+
# Detect repeated tool calls
|
|
57
|
+
guard = LoopGuard(max_repeats=3)
|
|
58
|
+
guard.check(tool_name="search", tool_args={"query": "agent loops"})
|
|
59
|
+
|
|
60
|
+
# Track token and call budgets
|
|
61
|
+
budget = BudgetGuard(max_tokens=50000, max_calls=100)
|
|
62
|
+
budget.record_tokens(150)
|
|
63
|
+
budget.record_call()
|
|
64
|
+
|
|
65
|
+
# Enforce wall-clock time limits
|
|
66
|
+
timeout = TimeoutGuard(max_seconds=30)
|
|
67
|
+
timeout.start()
|
|
68
|
+
timeout.check() # raises TimeoutExceeded if over limit
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Replay
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from agentguard.recording import Recorder, Replayer
|
|
75
|
+
|
|
76
|
+
recorder = Recorder("runs.jsonl")
|
|
77
|
+
recorder.record_call("llm", {"prompt": "hi"}, {"text": "hello"})
|
|
78
|
+
|
|
79
|
+
replayer = Replayer("runs.jsonl")
|
|
80
|
+
resp = replayer.replay_call("llm", {"prompt": "hi"})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## CLI
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Summarize trace events
|
|
87
|
+
agentguard summarize traces.jsonl
|
|
88
|
+
|
|
89
|
+
# Human-readable report
|
|
90
|
+
agentguard report traces.jsonl
|
|
91
|
+
|
|
92
|
+
# Open trace viewer in browser
|
|
93
|
+
agentguard view traces.jsonl
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Trace Viewer
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
agentguard view traces.jsonl --port 8080
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Integrations
|
|
103
|
+
|
|
104
|
+
- LangChain: `agentguard.integrations.langchain`
|
|
105
|
+
|
|
106
|
+
## Links
|
|
107
|
+
|
|
108
|
+
- [GitHub](https://github.com/bmdhodl/agent47)
|
|
109
|
+
- [Trace Schema](https://github.com/bmdhodl/agent47/blob/main/docs/trace_schema.md)
|
|
110
|
+
- [Examples](https://github.com/bmdhodl/agent47/tree/main/sdk/examples)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .tracing import Tracer
|
|
2
|
+
from .guards import (
|
|
3
|
+
LoopGuard,
|
|
4
|
+
BudgetGuard,
|
|
5
|
+
TimeoutGuard,
|
|
6
|
+
LoopDetected,
|
|
7
|
+
BudgetExceeded,
|
|
8
|
+
TimeoutExceeded,
|
|
9
|
+
)
|
|
10
|
+
from .recording import Recorder, Replayer
|
|
11
|
+
from .sinks import HttpSink
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Tracer",
|
|
15
|
+
"LoopGuard",
|
|
16
|
+
"BudgetGuard",
|
|
17
|
+
"TimeoutGuard",
|
|
18
|
+
"LoopDetected",
|
|
19
|
+
"BudgetExceeded",
|
|
20
|
+
"TimeoutExceeded",
|
|
21
|
+
"Recorder",
|
|
22
|
+
"Replayer",
|
|
23
|
+
"HttpSink",
|
|
24
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _summarize(path: str) -> None:
|
|
10
|
+
total = 0
|
|
11
|
+
name_counts = Counter()
|
|
12
|
+
kind_counts = Counter()
|
|
13
|
+
|
|
14
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
15
|
+
for line in f:
|
|
16
|
+
line = line.strip()
|
|
17
|
+
if not line:
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
event: Dict[str, Any] = json.loads(line)
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
continue
|
|
23
|
+
total += 1
|
|
24
|
+
name = event.get("name", "(unknown)")
|
|
25
|
+
kind = event.get("kind", "(unknown)")
|
|
26
|
+
name_counts[name] += 1
|
|
27
|
+
kind_counts[kind] += 1
|
|
28
|
+
|
|
29
|
+
print(f"events: {total}")
|
|
30
|
+
print("kinds:")
|
|
31
|
+
for kind, count in kind_counts.most_common():
|
|
32
|
+
print(f" {kind}: {count}")
|
|
33
|
+
print("names:")
|
|
34
|
+
for name, count in name_counts.most_common(10):
|
|
35
|
+
print(f" {name}: {count}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _report(path: str) -> None:
|
|
39
|
+
events: List[Dict[str, Any]] = []
|
|
40
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
41
|
+
for line in f:
|
|
42
|
+
line = line.strip()
|
|
43
|
+
if not line:
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
events.append(json.loads(line))
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
if not events:
|
|
51
|
+
print("No events found.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
total = len(events)
|
|
55
|
+
kinds = Counter(e.get("kind", "(unknown)") for e in events)
|
|
56
|
+
names = Counter(e.get("name", "(unknown)") for e in events)
|
|
57
|
+
loop_hits = names.get("guard.loop_detected", 0)
|
|
58
|
+
|
|
59
|
+
span_durations: List[float] = []
|
|
60
|
+
for e in events:
|
|
61
|
+
if e.get("kind") == "span" and e.get("phase") == "end":
|
|
62
|
+
dur = e.get("duration_ms")
|
|
63
|
+
if isinstance(dur, (int, float)):
|
|
64
|
+
span_durations.append(float(dur))
|
|
65
|
+
|
|
66
|
+
total_ms: Optional[float] = None
|
|
67
|
+
if span_durations:
|
|
68
|
+
total_ms = max(span_durations)
|
|
69
|
+
|
|
70
|
+
print("AgentGuard report")
|
|
71
|
+
print(f" Total events: {total}")
|
|
72
|
+
print(f" Spans: {kinds.get('span', 0)} Events: {kinds.get('event', 0)}")
|
|
73
|
+
if total_ms is not None:
|
|
74
|
+
print(f" Approx run time: {total_ms:.1f} ms")
|
|
75
|
+
print(f" Reasoning steps: {names.get('reasoning.step', 0)}")
|
|
76
|
+
print(f" Tool results: {names.get('tool.result', 0)}")
|
|
77
|
+
print(f" LLM results: {names.get('llm.result', 0)}")
|
|
78
|
+
if loop_hits:
|
|
79
|
+
print(f" Loop guard triggered: {loop_hits} time(s)")
|
|
80
|
+
else:
|
|
81
|
+
print(" Loop guard triggered: 0")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
parser = argparse.ArgumentParser(prog="agentguard")
|
|
86
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
87
|
+
|
|
88
|
+
summarize = sub.add_parser("summarize", help="Summarize a JSONL trace file")
|
|
89
|
+
summarize.add_argument("path")
|
|
90
|
+
|
|
91
|
+
report = sub.add_parser("report", help="Human-readable report for a JSONL trace file")
|
|
92
|
+
report.add_argument("path")
|
|
93
|
+
|
|
94
|
+
view = sub.add_parser("view", help="Open a local trace viewer in the browser")
|
|
95
|
+
view.add_argument("path")
|
|
96
|
+
view.add_argument("--port", type=int, default=8080)
|
|
97
|
+
view.add_argument("--no-open", action="store_true")
|
|
98
|
+
|
|
99
|
+
args = parser.parse_args()
|
|
100
|
+
if args.cmd == "summarize":
|
|
101
|
+
_summarize(args.path)
|
|
102
|
+
elif args.cmd == "report":
|
|
103
|
+
_report(args.path)
|
|
104
|
+
elif args.cmd == "view":
|
|
105
|
+
from agentguard.viewer import serve
|
|
106
|
+
|
|
107
|
+
serve(args.path, port=args.port, open_browser=not args.no_open)
|
|
108
|
+
else:
|
|
109
|
+
parser.print_help()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Deque, Dict, Optional, Tuple
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoopDetected(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BudgetExceeded(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TimeoutExceeded(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LoopGuard:
|
|
23
|
+
def __init__(self, max_repeats: int = 3, window: int = 6) -> None:
|
|
24
|
+
if max_repeats < 2:
|
|
25
|
+
raise ValueError("max_repeats must be >= 2")
|
|
26
|
+
if window < max_repeats:
|
|
27
|
+
raise ValueError("window must be >= max_repeats")
|
|
28
|
+
self._max_repeats = max_repeats
|
|
29
|
+
self._history: Deque[Tuple[str, str]] = deque(maxlen=window)
|
|
30
|
+
|
|
31
|
+
def check(self, tool_name: str, tool_args: Optional[Dict[str, Any]] = None) -> None:
|
|
32
|
+
args = tool_args or {}
|
|
33
|
+
signature = (tool_name, _stable_json(args))
|
|
34
|
+
self._history.append(signature)
|
|
35
|
+
if len(self._history) < self._max_repeats:
|
|
36
|
+
return
|
|
37
|
+
last_n = list(self._history)[-self._max_repeats :]
|
|
38
|
+
if len(set(last_n)) == 1:
|
|
39
|
+
raise LoopDetected(
|
|
40
|
+
f"Detected repeated tool call {tool_name} {self._max_repeats} times"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def reset(self) -> None:
|
|
44
|
+
self._history.clear()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BudgetState:
|
|
49
|
+
tokens_used: int = 0
|
|
50
|
+
calls_used: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BudgetGuard:
|
|
54
|
+
def __init__(self, max_tokens: Optional[int] = None, max_calls: Optional[int] = None) -> None:
|
|
55
|
+
if max_tokens is None and max_calls is None:
|
|
56
|
+
raise ValueError("Provide max_tokens or max_calls")
|
|
57
|
+
self._max_tokens = max_tokens
|
|
58
|
+
self._max_calls = max_calls
|
|
59
|
+
self.state = BudgetState()
|
|
60
|
+
|
|
61
|
+
def consume(self, tokens: int = 0, calls: int = 0) -> None:
|
|
62
|
+
self.state.tokens_used += tokens
|
|
63
|
+
self.state.calls_used += calls
|
|
64
|
+
if self._max_tokens is not None and self.state.tokens_used > self._max_tokens:
|
|
65
|
+
raise BudgetExceeded(
|
|
66
|
+
f"Token budget exceeded: {self.state.tokens_used} > {self._max_tokens}"
|
|
67
|
+
)
|
|
68
|
+
if self._max_calls is not None and self.state.calls_used > self._max_calls:
|
|
69
|
+
raise BudgetExceeded(
|
|
70
|
+
f"Call budget exceeded: {self.state.calls_used} > {self._max_calls}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def reset(self) -> None:
|
|
74
|
+
self.state = BudgetState()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TimeoutGuard:
|
|
78
|
+
def __init__(self, max_seconds: float) -> None:
|
|
79
|
+
if max_seconds <= 0:
|
|
80
|
+
raise ValueError("max_seconds must be > 0")
|
|
81
|
+
self._max_seconds = max_seconds
|
|
82
|
+
self._start: Optional[float] = None
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
self._start = time.monotonic()
|
|
86
|
+
|
|
87
|
+
def check(self) -> None:
|
|
88
|
+
if self._start is None:
|
|
89
|
+
raise RuntimeError("TimeoutGuard.start() must be called before check()")
|
|
90
|
+
if (time.monotonic() - self._start) > self._max_seconds:
|
|
91
|
+
raise TimeoutExceeded(
|
|
92
|
+
f"Run exceeded {self._max_seconds}s timeout"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def reset(self) -> None:
|
|
96
|
+
self._start = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _stable_json(data: Dict[str, Any]) -> str:
|
|
100
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|