agentguard47 0.2.0__py3-none-any.whl
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.
- agentguard/__init__.py +24 -0
- agentguard/cli.py +113 -0
- agentguard/guards.py +100 -0
- agentguard/integrations/__init__.py +5 -0
- agentguard/integrations/langchain.py +240 -0
- agentguard/recording.py +70 -0
- agentguard/sinks/__init__.py +3 -0
- agentguard/sinks/http.py +83 -0
- agentguard/tracing.py +152 -0
- agentguard/viewer.py +149 -0
- agentguard47-0.2.0.dist-info/METADATA +135 -0
- agentguard47-0.2.0.dist-info/RECORD +15 -0
- agentguard47-0.2.0.dist-info/WHEEL +5 -0
- agentguard47-0.2.0.dist-info/entry_points.txt +2 -0
- agentguard47-0.2.0.dist-info/top_level.txt +1 -0
agentguard/__init__.py
ADDED
|
@@ -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
|
+
]
|
agentguard/cli.py
ADDED
|
@@ -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()
|
agentguard/guards.py
ADDED
|
@@ -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=(",", ":"))
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from agentguard.guards import BudgetGuard, LoopGuard
|
|
7
|
+
from agentguard.tracing import Tracer, TraceContext
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from langchain_core.callbacks.base import BaseCallbackHandler as _Base
|
|
11
|
+
|
|
12
|
+
_HAS_LANGCHAIN = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
_Base = object # type: ignore[assignment,misc]
|
|
15
|
+
_HAS_LANGCHAIN = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentGuardCallbackHandler(_Base): # type: ignore[misc]
|
|
19
|
+
"""LangChain callback handler that emits AgentGuard traces.
|
|
20
|
+
|
|
21
|
+
Tracks nested chains/tool calls as a span stack, optionally wiring
|
|
22
|
+
LoopGuard and BudgetGuard checks into tool invocations.
|
|
23
|
+
|
|
24
|
+
Works with ``langchain-core >= 0.1``. Install via::
|
|
25
|
+
|
|
26
|
+
pip install agentguard47[langchain]
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
tracer: Optional[Tracer] = None,
|
|
32
|
+
loop_guard: Optional[LoopGuard] = None,
|
|
33
|
+
budget_guard: Optional[BudgetGuard] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if _HAS_LANGCHAIN:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._tracer = tracer or Tracer()
|
|
38
|
+
self._loop_guard = loop_guard
|
|
39
|
+
self._budget_guard = budget_guard
|
|
40
|
+
self._root_ctx: Optional[Any] = None
|
|
41
|
+
self._span_stack: List[TraceContext] = []
|
|
42
|
+
self._run_to_span: Dict[str, TraceContext] = {}
|
|
43
|
+
|
|
44
|
+
# -- chains ---------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def on_chain_start(
|
|
47
|
+
self,
|
|
48
|
+
serialized: Dict[str, Any],
|
|
49
|
+
inputs: Dict[str, Any],
|
|
50
|
+
*,
|
|
51
|
+
run_id: Optional[uuid.UUID] = None,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
name = serialized.get("name") or serialized.get("id", ["chain"])[-1] if isinstance(serialized.get("id"), list) else serialized.get("name") or "chain"
|
|
55
|
+
if not self._span_stack:
|
|
56
|
+
ctx_mgr = self._tracer.trace(f"chain.{name}", data={"inputs": _safe_dict(inputs)})
|
|
57
|
+
ctx = ctx_mgr.__enter__()
|
|
58
|
+
self._root_ctx = ctx_mgr
|
|
59
|
+
self._span_stack.append(ctx)
|
|
60
|
+
else:
|
|
61
|
+
parent = self._span_stack[-1]
|
|
62
|
+
ctx = parent.span(f"chain.{name}", data={"inputs": _safe_dict(inputs)})
|
|
63
|
+
ctx.__enter__()
|
|
64
|
+
self._span_stack.append(ctx)
|
|
65
|
+
if run_id:
|
|
66
|
+
self._run_to_span[str(run_id)] = ctx
|
|
67
|
+
|
|
68
|
+
def on_chain_end(
|
|
69
|
+
self,
|
|
70
|
+
outputs: Dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
run_id: Optional[uuid.UUID] = None,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
ctx = self._pop_span(run_id)
|
|
76
|
+
if ctx is None:
|
|
77
|
+
return
|
|
78
|
+
ctx.event("chain.outputs", data={"outputs": _safe_dict(outputs)})
|
|
79
|
+
ctx.__exit__(None, None, None)
|
|
80
|
+
|
|
81
|
+
def on_chain_error(
|
|
82
|
+
self,
|
|
83
|
+
error: BaseException,
|
|
84
|
+
*,
|
|
85
|
+
run_id: Optional[uuid.UUID] = None,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> None:
|
|
88
|
+
ctx = self._pop_span(run_id)
|
|
89
|
+
if ctx is None:
|
|
90
|
+
return
|
|
91
|
+
ctx.__exit__(type(error), error, error.__traceback__)
|
|
92
|
+
|
|
93
|
+
# -- llm ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def on_llm_start(
|
|
96
|
+
self,
|
|
97
|
+
serialized: Dict[str, Any],
|
|
98
|
+
prompts: List[str],
|
|
99
|
+
*,
|
|
100
|
+
run_id: Optional[uuid.UUID] = None,
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> None:
|
|
103
|
+
parent = self._span_stack[-1] if self._span_stack else None
|
|
104
|
+
if parent is None:
|
|
105
|
+
self.on_chain_start({"name": "llm"}, {"prompts": prompts}, run_id=run_id)
|
|
106
|
+
return
|
|
107
|
+
ctx = parent.span("llm.call", data={"prompts": prompts})
|
|
108
|
+
ctx.__enter__()
|
|
109
|
+
self._span_stack.append(ctx)
|
|
110
|
+
if run_id:
|
|
111
|
+
self._run_to_span[str(run_id)] = ctx
|
|
112
|
+
|
|
113
|
+
def on_llm_end(
|
|
114
|
+
self,
|
|
115
|
+
response: Any,
|
|
116
|
+
*,
|
|
117
|
+
run_id: Optional[uuid.UUID] = None,
|
|
118
|
+
**kwargs: Any,
|
|
119
|
+
) -> None:
|
|
120
|
+
ctx = self._pop_span(run_id)
|
|
121
|
+
if ctx is None:
|
|
122
|
+
return
|
|
123
|
+
usage = _extract_token_usage(response)
|
|
124
|
+
payload: Dict[str, Any] = {"response": _safe_response(response)}
|
|
125
|
+
if usage:
|
|
126
|
+
payload["token_usage"] = usage
|
|
127
|
+
if self._budget_guard and "total_tokens" in usage:
|
|
128
|
+
self._budget_guard.consume(tokens=usage["total_tokens"])
|
|
129
|
+
ctx.event("llm.end", data=payload)
|
|
130
|
+
ctx.__exit__(None, None, None)
|
|
131
|
+
|
|
132
|
+
def on_llm_error(
|
|
133
|
+
self,
|
|
134
|
+
error: BaseException,
|
|
135
|
+
*,
|
|
136
|
+
run_id: Optional[uuid.UUID] = None,
|
|
137
|
+
**kwargs: Any,
|
|
138
|
+
) -> None:
|
|
139
|
+
ctx = self._pop_span(run_id)
|
|
140
|
+
if ctx is None:
|
|
141
|
+
return
|
|
142
|
+
ctx.__exit__(type(error), error, error.__traceback__)
|
|
143
|
+
|
|
144
|
+
# -- tools ----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def on_tool_start(
|
|
147
|
+
self,
|
|
148
|
+
serialized: Dict[str, Any],
|
|
149
|
+
input_str: str,
|
|
150
|
+
*,
|
|
151
|
+
run_id: Optional[uuid.UUID] = None,
|
|
152
|
+
**kwargs: Any,
|
|
153
|
+
) -> None:
|
|
154
|
+
tool_name = serialized.get("name") or serialized.get("id", "tool")
|
|
155
|
+
if isinstance(tool_name, list):
|
|
156
|
+
tool_name = tool_name[-1]
|
|
157
|
+
if self._loop_guard:
|
|
158
|
+
self._loop_guard.check(tool_name=tool_name, tool_args={"input": input_str})
|
|
159
|
+
if self._budget_guard:
|
|
160
|
+
self._budget_guard.consume(calls=1)
|
|
161
|
+
parent = self._span_stack[-1] if self._span_stack else None
|
|
162
|
+
if parent is None:
|
|
163
|
+
self.on_chain_start({"name": "tool"}, {"input": input_str}, run_id=run_id)
|
|
164
|
+
return
|
|
165
|
+
ctx = parent.span(f"tool.{tool_name}", data={"input": input_str})
|
|
166
|
+
ctx.__enter__()
|
|
167
|
+
self._span_stack.append(ctx)
|
|
168
|
+
if run_id:
|
|
169
|
+
self._run_to_span[str(run_id)] = ctx
|
|
170
|
+
|
|
171
|
+
def on_tool_end(
|
|
172
|
+
self,
|
|
173
|
+
output: str,
|
|
174
|
+
*,
|
|
175
|
+
run_id: Optional[uuid.UUID] = None,
|
|
176
|
+
**kwargs: Any,
|
|
177
|
+
) -> None:
|
|
178
|
+
ctx = self._pop_span(run_id)
|
|
179
|
+
if ctx is None:
|
|
180
|
+
return
|
|
181
|
+
ctx.event("tool.result", data={"output": str(output)})
|
|
182
|
+
ctx.__exit__(None, None, None)
|
|
183
|
+
|
|
184
|
+
def on_tool_error(
|
|
185
|
+
self,
|
|
186
|
+
error: BaseException,
|
|
187
|
+
*,
|
|
188
|
+
run_id: Optional[uuid.UUID] = None,
|
|
189
|
+
**kwargs: Any,
|
|
190
|
+
) -> None:
|
|
191
|
+
ctx = self._pop_span(run_id)
|
|
192
|
+
if ctx is None:
|
|
193
|
+
return
|
|
194
|
+
ctx.__exit__(type(error), error, error.__traceback__)
|
|
195
|
+
|
|
196
|
+
# -- helpers --------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _pop_span(self, run_id: Optional[uuid.UUID]) -> Optional[TraceContext]:
|
|
199
|
+
if run_id and str(run_id) in self._run_to_span:
|
|
200
|
+
ctx = self._run_to_span.pop(str(run_id))
|
|
201
|
+
if ctx in self._span_stack:
|
|
202
|
+
self._span_stack.remove(ctx)
|
|
203
|
+
return ctx
|
|
204
|
+
if self._span_stack:
|
|
205
|
+
return self._span_stack.pop()
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# -- utility functions --------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _safe_dict(d: Any) -> Dict[str, Any]:
|
|
213
|
+
if isinstance(d, dict):
|
|
214
|
+
return d
|
|
215
|
+
return {"value": repr(d)}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _safe_response(response: Any) -> Dict[str, Any]:
|
|
219
|
+
try:
|
|
220
|
+
if hasattr(response, "dict"):
|
|
221
|
+
return response.dict()
|
|
222
|
+
if hasattr(response, "model_dump"):
|
|
223
|
+
return response.model_dump()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
return {"repr": repr(response)}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _extract_token_usage(response: Any) -> Optional[Dict[str, Any]]:
|
|
230
|
+
for attr in ("llm_output", "response_metadata", "metadata"):
|
|
231
|
+
if hasattr(response, attr):
|
|
232
|
+
try:
|
|
233
|
+
data = getattr(response, attr)
|
|
234
|
+
if isinstance(data, dict):
|
|
235
|
+
usage = data.get("token_usage") or data.get("usage")
|
|
236
|
+
if isinstance(usage, dict):
|
|
237
|
+
return usage
|
|
238
|
+
except Exception:
|
|
239
|
+
continue
|
|
240
|
+
return None
|
agentguard/recording.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Recorder:
|
|
10
|
+
def __init__(self, path: str) -> None:
|
|
11
|
+
self._path = path
|
|
12
|
+
|
|
13
|
+
def record_call(
|
|
14
|
+
self,
|
|
15
|
+
name: str,
|
|
16
|
+
request: Dict[str, Any],
|
|
17
|
+
response: Dict[str, Any],
|
|
18
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
event = {
|
|
21
|
+
"ts": time.time(),
|
|
22
|
+
"name": name,
|
|
23
|
+
"request": request,
|
|
24
|
+
"response": response,
|
|
25
|
+
"meta": meta or {},
|
|
26
|
+
}
|
|
27
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
28
|
+
f.write(json.dumps(event, sort_keys=True) + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ReplayEntry:
|
|
33
|
+
name: str
|
|
34
|
+
request_key: str
|
|
35
|
+
response: Dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Replayer:
|
|
39
|
+
def __init__(self, path: str) -> None:
|
|
40
|
+
self._entries = _load_entries(path)
|
|
41
|
+
|
|
42
|
+
def replay_call(self, name: str, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
43
|
+
key = (name, _stable_json(request))
|
|
44
|
+
if key not in self._entries:
|
|
45
|
+
raise KeyError(f"No replay entry for {name}")
|
|
46
|
+
return self._entries[key]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_entries(path: str) -> Dict[Tuple[str, str], Dict[str, Any]]:
|
|
50
|
+
entries: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
for line in f:
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
data = json.loads(line)
|
|
58
|
+
name = data.get("name")
|
|
59
|
+
request = data.get("request")
|
|
60
|
+
response = data.get("response")
|
|
61
|
+
if not name or request is None or response is None:
|
|
62
|
+
continue
|
|
63
|
+
entries[(name, _stable_json(request))] = response
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return {}
|
|
66
|
+
return entries
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stable_json(data: Dict[str, Any]) -> str:
|
|
70
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
agentguard/sinks/http.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from agentguard.tracing import TraceSink
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("agentguard.sinks.http")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpSink(TraceSink):
|
|
16
|
+
"""Batched HTTP sink that POSTs JSONL trace events to a remote endpoint.
|
|
17
|
+
|
|
18
|
+
Uses only stdlib (urllib.request). Events are buffered and flushed
|
|
19
|
+
periodically in a background thread. Network failures are logged
|
|
20
|
+
but never crash the calling agent.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
url: str,
|
|
26
|
+
api_key: Optional[str] = None,
|
|
27
|
+
batch_size: int = 10,
|
|
28
|
+
flush_interval: float = 5.0,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._url = url
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._batch_size = batch_size
|
|
33
|
+
self._flush_interval = flush_interval
|
|
34
|
+
|
|
35
|
+
self._buffer: List[Dict[str, Any]] = []
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
self._stop = threading.Event()
|
|
38
|
+
|
|
39
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
40
|
+
self._thread.start()
|
|
41
|
+
atexit.register(self.shutdown)
|
|
42
|
+
|
|
43
|
+
def emit(self, event: Dict[str, Any]) -> None:
|
|
44
|
+
batch = None
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._buffer.append(event)
|
|
47
|
+
if len(self._buffer) >= self._batch_size:
|
|
48
|
+
batch = self._buffer[:]
|
|
49
|
+
self._buffer.clear()
|
|
50
|
+
if batch:
|
|
51
|
+
self._send(batch)
|
|
52
|
+
|
|
53
|
+
def _run(self) -> None:
|
|
54
|
+
while not self._stop.wait(self._flush_interval):
|
|
55
|
+
self._flush()
|
|
56
|
+
|
|
57
|
+
def _flush(self) -> None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
if not self._buffer:
|
|
60
|
+
return
|
|
61
|
+
batch = self._buffer[:]
|
|
62
|
+
self._buffer.clear()
|
|
63
|
+
self._send(batch)
|
|
64
|
+
|
|
65
|
+
def _send(self, batch: List[Dict[str, Any]]) -> None:
|
|
66
|
+
if not batch:
|
|
67
|
+
return
|
|
68
|
+
body = "\n".join(json.dumps(e, sort_keys=True) for e in batch).encode("utf-8")
|
|
69
|
+
headers: Dict[str, str] = {"Content-Type": "application/x-ndjson"}
|
|
70
|
+
if self._api_key:
|
|
71
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
72
|
+
req = urllib.request.Request(self._url, data=body, headers=headers, method="POST")
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
75
|
+
resp.read()
|
|
76
|
+
except Exception:
|
|
77
|
+
logger.warning("Failed to send trace batch to %s", self._url, exc_info=True)
|
|
78
|
+
|
|
79
|
+
def shutdown(self) -> None:
|
|
80
|
+
"""Flush remaining events and stop the background thread."""
|
|
81
|
+
self._stop.set()
|
|
82
|
+
self._flush()
|
|
83
|
+
self._thread.join(timeout=5)
|
agentguard/tracing.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TraceSink:
|
|
13
|
+
def emit(self, event: Dict[str, Any]) -> None: # pragma: no cover - interface
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StdoutSink(TraceSink):
|
|
18
|
+
def emit(self, event: Dict[str, Any]) -> None:
|
|
19
|
+
print(json.dumps(event, sort_keys=True))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JsonlFileSink(TraceSink):
|
|
23
|
+
def __init__(self, path: str) -> None:
|
|
24
|
+
self._path = path
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def emit(self, event: Dict[str, Any]) -> None:
|
|
28
|
+
line = json.dumps(event, sort_keys=True)
|
|
29
|
+
with self._lock:
|
|
30
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
31
|
+
f.write(line + "\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TraceContext:
|
|
36
|
+
tracer: "Tracer"
|
|
37
|
+
trace_id: str
|
|
38
|
+
span_id: str
|
|
39
|
+
parent_id: Optional[str]
|
|
40
|
+
name: str
|
|
41
|
+
data: Optional[Dict[str, Any]]
|
|
42
|
+
_start_time: Optional[float] = None
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "TraceContext":
|
|
45
|
+
self._start_time = time.perf_counter()
|
|
46
|
+
self.tracer._emit(
|
|
47
|
+
kind="span",
|
|
48
|
+
phase="start",
|
|
49
|
+
trace_id=self.trace_id,
|
|
50
|
+
span_id=self.span_id,
|
|
51
|
+
parent_id=self.parent_id,
|
|
52
|
+
name=self.name,
|
|
53
|
+
data=self.data,
|
|
54
|
+
)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
58
|
+
end = time.perf_counter()
|
|
59
|
+
duration_ms = None
|
|
60
|
+
if self._start_time is not None:
|
|
61
|
+
duration_ms = (end - self._start_time) * 1000.0
|
|
62
|
+
error = None
|
|
63
|
+
if exc is not None:
|
|
64
|
+
error = {
|
|
65
|
+
"type": getattr(exc_type, "__name__", "Exception"),
|
|
66
|
+
"message": str(exc),
|
|
67
|
+
}
|
|
68
|
+
self.tracer._emit(
|
|
69
|
+
kind="span",
|
|
70
|
+
phase="end",
|
|
71
|
+
trace_id=self.trace_id,
|
|
72
|
+
span_id=self.span_id,
|
|
73
|
+
parent_id=self.parent_id,
|
|
74
|
+
name=self.name,
|
|
75
|
+
data=self.data,
|
|
76
|
+
duration_ms=duration_ms,
|
|
77
|
+
error=error,
|
|
78
|
+
)
|
|
79
|
+
# Do not suppress exceptions
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def span(self, name: str, data: Optional[Dict[str, Any]] = None) -> "TraceContext":
|
|
83
|
+
return TraceContext(
|
|
84
|
+
tracer=self.tracer,
|
|
85
|
+
trace_id=self.trace_id,
|
|
86
|
+
span_id=_new_id(),
|
|
87
|
+
parent_id=self.span_id,
|
|
88
|
+
name=name,
|
|
89
|
+
data=data,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def event(self, name: str, data: Optional[Dict[str, Any]] = None) -> None:
|
|
93
|
+
self.tracer._emit(
|
|
94
|
+
kind="event",
|
|
95
|
+
phase="emit",
|
|
96
|
+
trace_id=self.trace_id,
|
|
97
|
+
span_id=self.span_id,
|
|
98
|
+
parent_id=self.parent_id,
|
|
99
|
+
name=name,
|
|
100
|
+
data=data,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Tracer:
|
|
105
|
+
def __init__(self, sink: Optional[TraceSink] = None, service: str = "app") -> None:
|
|
106
|
+
self._sink = sink or StdoutSink()
|
|
107
|
+
self._service = service
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def trace(self, name: str, data: Optional[Dict[str, Any]] = None) -> TraceContext:
|
|
111
|
+
ctx = TraceContext(
|
|
112
|
+
tracer=self,
|
|
113
|
+
trace_id=_new_id(),
|
|
114
|
+
span_id=_new_id(),
|
|
115
|
+
parent_id=None,
|
|
116
|
+
name=name,
|
|
117
|
+
data=data,
|
|
118
|
+
)
|
|
119
|
+
with ctx:
|
|
120
|
+
yield ctx
|
|
121
|
+
|
|
122
|
+
def _emit(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
kind: str,
|
|
126
|
+
phase: str,
|
|
127
|
+
trace_id: str,
|
|
128
|
+
span_id: str,
|
|
129
|
+
parent_id: Optional[str],
|
|
130
|
+
name: str,
|
|
131
|
+
data: Optional[Dict[str, Any]] = None,
|
|
132
|
+
duration_ms: Optional[float] = None,
|
|
133
|
+
error: Optional[Dict[str, Any]] = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
event = {
|
|
136
|
+
"service": self._service,
|
|
137
|
+
"kind": kind,
|
|
138
|
+
"phase": phase,
|
|
139
|
+
"trace_id": trace_id,
|
|
140
|
+
"span_id": span_id,
|
|
141
|
+
"parent_id": parent_id,
|
|
142
|
+
"name": name,
|
|
143
|
+
"ts": time.time(),
|
|
144
|
+
"duration_ms": duration_ms,
|
|
145
|
+
"data": data or {},
|
|
146
|
+
"error": error,
|
|
147
|
+
}
|
|
148
|
+
self._sink.emit(event)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _new_id() -> str:
|
|
152
|
+
return uuid.uuid4().hex
|
agentguard/viewer.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_HTML = """<!doctype html>
|
|
13
|
+
<html lang=\"en\">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset=\"utf-8\" />
|
|
16
|
+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
17
|
+
<title>AgentGuard Trace Viewer</title>
|
|
18
|
+
<style>
|
|
19
|
+
:root { --bg:#0f1115; --ink:#f1f5f9; --muted:#9aa4b2; --card:#171a21; --accent:#4ade80; }
|
|
20
|
+
body { margin:0; font-family: 'IBM Plex Sans', system-ui, sans-serif; background:var(--bg); color:var(--ink); }
|
|
21
|
+
header { padding:20px; border-bottom:1px solid #252a34; }
|
|
22
|
+
h1 { margin:0; font-size:22px; }
|
|
23
|
+
.wrap { padding:20px; }
|
|
24
|
+
.stats { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap:12px; }
|
|
25
|
+
.card { background:var(--card); padding:12px; border-radius:10px; border:1px solid #222833; }
|
|
26
|
+
.label { color:var(--muted); font-size:12px; }
|
|
27
|
+
table { width:100%; border-collapse: collapse; margin-top:16px; }
|
|
28
|
+
th, td { text-align:left; padding:8px; border-bottom:1px solid #222833; font-size:13px; }
|
|
29
|
+
code { color: var(--accent); }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<header>
|
|
34
|
+
<h1>AgentGuard Trace Viewer</h1>
|
|
35
|
+
<div class=\"label\">Loaded from local JSONL trace</div>
|
|
36
|
+
</header>
|
|
37
|
+
<div class=\"wrap\">
|
|
38
|
+
<div class=\"stats\" id=\"stats\"></div>
|
|
39
|
+
<table>
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<th>ts</th>
|
|
43
|
+
<th>kind</th>
|
|
44
|
+
<th>name</th>
|
|
45
|
+
<th>trace_id</th>
|
|
46
|
+
<th>span_id</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody id=\"rows\"></tbody>
|
|
50
|
+
</table>
|
|
51
|
+
</div>
|
|
52
|
+
<script>
|
|
53
|
+
async function load() {
|
|
54
|
+
const res = await fetch('/trace');
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
const lines = text.split('\n').filter(Boolean);
|
|
57
|
+
const events = lines.map(l => JSON.parse(l));
|
|
58
|
+
|
|
59
|
+
const stats = {
|
|
60
|
+
total: events.length,
|
|
61
|
+
spans: events.filter(e => e.kind === 'span').length,
|
|
62
|
+
events: events.filter(e => e.kind === 'event').length,
|
|
63
|
+
reasoning: events.filter(e => e.name === 'reasoning.step').length,
|
|
64
|
+
loops: events.filter(e => e.name === 'guard.loop_detected').length,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const statsEl = document.getElementById('stats');
|
|
68
|
+
statsEl.innerHTML = [
|
|
69
|
+
['Total events', stats.total],
|
|
70
|
+
['Spans', stats.spans],
|
|
71
|
+
['Events', stats.events],
|
|
72
|
+
['Reasoning steps', stats.reasoning],
|
|
73
|
+
['Loop hits', stats.loops],
|
|
74
|
+
].map(([k,v]) => `
|
|
75
|
+
<div class=\"card\"><div class=\"label\">${k}</div><div><code>${v}</code></div></div>
|
|
76
|
+
`).join('');
|
|
77
|
+
|
|
78
|
+
const rows = document.getElementById('rows');
|
|
79
|
+
rows.innerHTML = events.map(e => `
|
|
80
|
+
<tr>
|
|
81
|
+
<td>${new Date(e.ts * 1000).toISOString()}</td>
|
|
82
|
+
<td>${e.kind}</td>
|
|
83
|
+
<td>${e.name}</td>
|
|
84
|
+
<td>${e.trace_id}</td>
|
|
85
|
+
<td>${e.span_id}</td>
|
|
86
|
+
</tr>
|
|
87
|
+
`).join('');
|
|
88
|
+
}
|
|
89
|
+
load();
|
|
90
|
+
</script>
|
|
91
|
+
</body>
|
|
92
|
+
</html>
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
97
|
+
trace_path: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
def do_GET(self) -> None: # noqa: N802
|
|
100
|
+
if self.path == "/":
|
|
101
|
+
self._send(200, _HTML, content_type="text/html")
|
|
102
|
+
return
|
|
103
|
+
if self.path == "/trace":
|
|
104
|
+
if not self.trace_path or not os.path.exists(self.trace_path):
|
|
105
|
+
self._send(404, "trace not found")
|
|
106
|
+
return
|
|
107
|
+
with open(self.trace_path, "r", encoding="utf-8") as f:
|
|
108
|
+
data = f.read()
|
|
109
|
+
self._send(200, data, content_type="text/plain")
|
|
110
|
+
return
|
|
111
|
+
self._send(404, "not found")
|
|
112
|
+
|
|
113
|
+
def log_message(self, format: str, *args) -> None: # noqa: A003
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
def _send(self, status: int, body: str, content_type: str = "text/plain") -> None:
|
|
117
|
+
encoded = body.encode("utf-8")
|
|
118
|
+
self.send_response(status)
|
|
119
|
+
self.send_header("Content-Type", content_type)
|
|
120
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
121
|
+
self.end_headers()
|
|
122
|
+
self.wfile.write(encoded)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def serve(trace_path: str, port: int = 8080, open_browser: bool = True) -> None:
|
|
126
|
+
handler = _Handler
|
|
127
|
+
handler.trace_path = trace_path
|
|
128
|
+
|
|
129
|
+
server = HTTPServer(("127.0.0.1", port), handler)
|
|
130
|
+
|
|
131
|
+
if open_browser:
|
|
132
|
+
threading.Timer(0.4, lambda: webbrowser.open(f"http://127.0.0.1:{port}")).start()
|
|
133
|
+
|
|
134
|
+
print(f"Viewer running at http://127.0.0.1:{port}")
|
|
135
|
+
server.serve_forever()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main() -> None:
|
|
139
|
+
parser = argparse.ArgumentParser(prog="agentguard-view")
|
|
140
|
+
parser.add_argument("path", help="Path to JSONL trace")
|
|
141
|
+
parser.add_argument("--port", type=int, default=8080)
|
|
142
|
+
parser.add_argument("--no-open", action="store_true")
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
serve(args.path, port=args.port, open_browser=not args.no_open)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -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,15 @@
|
|
|
1
|
+
agentguard/__init__.py,sha256=r2lM31udiHzasnZFRCSnLwDbQDohUCG-5GKyMPeC6Z8,429
|
|
2
|
+
agentguard/cli.py,sha256=GgCl7Q9VpZPMX-PsW3UYnNrC_1iy_bAcdiSa2qW685U,3485
|
|
3
|
+
agentguard/guards.py,sha256=8GS3lHtr-pGwbbBSsBYKxsla02ipwCNoTj6kMRO_v6o,3147
|
|
4
|
+
agentguard/recording.py,sha256=rOpO_kfVyh4StY0WKLJx_ZNjHYtadO48JgLNZwouFzE,2003
|
|
5
|
+
agentguard/tracing.py,sha256=7yI4H3Q3ndbD7B7F4AOtnnYy-kZ7cbLMn1t_dXTVrQI,4133
|
|
6
|
+
agentguard/viewer.py,sha256=uM21TYTXhq5zHu2tQP52bI5PFO9mmXzkP8z_m3sCkfY,4997
|
|
7
|
+
agentguard/integrations/__init__.py,sha256=4UjBWIi91ESzy_0qwmo2ha5ZhhGGd3-2i8SOq9H9gwU,126
|
|
8
|
+
agentguard/integrations/langchain.py,sha256=ppc27W6zFM2TYAQ1a2JI6bjNVWWMLmP5OapWfHQEP2o,7659
|
|
9
|
+
agentguard/sinks/__init__.py,sha256=PAgY0Y3tptapbNhnXEUYojTldfGokGZTJWVf-b58Qu0,51
|
|
10
|
+
agentguard/sinks/http.py,sha256=TRmgZWd9kl3JEEZh9ppRRbcjuW-yXznYDRrQQGgdQ1Y,2615
|
|
11
|
+
agentguard47-0.2.0.dist-info/METADATA,sha256=uvTKR_RyZrB9yxKuAuz9x1NWZ7rQnE-U3H4b9ozQvks,3742
|
|
12
|
+
agentguard47-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
agentguard47-0.2.0.dist-info/entry_points.txt,sha256=ubpnBAHVcWmjkLmnv-gIH0-GSPofUlZfoviZ4R9i5I0,51
|
|
14
|
+
agentguard47-0.2.0.dist-info/top_level.txt,sha256=rEW5sAnjxXRs63ja2psmsMRMJJfi_3EAxf7crl1nHyw,11
|
|
15
|
+
agentguard47-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentguard
|