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 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,5 @@
1
+ """Framework integration stubs."""
2
+
3
+ from .langchain import AgentGuardCallbackHandler
4
+
5
+ __all__ = ["AgentGuardCallbackHandler"]
@@ -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
@@ -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=(",", ":"))
@@ -0,0 +1,3 @@
1
+ from .http import HttpSink
2
+
3
+ __all__ = ["HttpSink"]
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/agentguard47)](https://pypi.org/project/agentguard47/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentguard = agentguard.cli:main
@@ -0,0 +1 @@
1
+ agentguard