agentguard47 0.2.0__tar.gz → 0.3.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.
Files changed (32) hide show
  1. {agentguard47-0.2.0 → agentguard47-0.3.0}/PKG-INFO +46 -2
  2. {agentguard47-0.2.0 → agentguard47-0.3.0}/README.md +45 -1
  3. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/__init__.py +4 -0
  4. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/cli.py +20 -0
  5. agentguard47-0.3.0/agentguard/evaluation.py +220 -0
  6. agentguard47-0.3.0/agentguard/instrument.py +183 -0
  7. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/integrations/langchain.py +1 -1
  8. agentguard47-0.3.0/agentguard/viewer.py +281 -0
  9. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/PKG-INFO +46 -2
  10. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/SOURCES.txt +8 -1
  11. {agentguard47-0.2.0 → agentguard47-0.3.0}/pyproject.toml +1 -1
  12. agentguard47-0.3.0/tests/test_evaluation.py +182 -0
  13. agentguard47-0.3.0/tests/test_instrument.py +125 -0
  14. agentguard47-0.3.0/tests/test_viewer.py +97 -0
  15. agentguard47-0.2.0/agentguard/viewer.py +0 -149
  16. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/guards.py +0 -0
  17. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/integrations/__init__.py +0 -0
  18. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/recording.py +0 -0
  19. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/sinks/__init__.py +0 -0
  20. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/sinks/http.py +0 -0
  21. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard/tracing.py +0 -0
  22. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/dependency_links.txt +0 -0
  23. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/entry_points.txt +0 -0
  24. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/requires.txt +0 -0
  25. {agentguard47-0.2.0 → agentguard47-0.3.0}/agentguard47.egg-info/top_level.txt +0 -0
  26. {agentguard47-0.2.0 → agentguard47-0.3.0}/setup.cfg +0 -0
  27. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_cli_report.py +0 -0
  28. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_guards.py +0 -0
  29. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_http_sink.py +0 -0
  30. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_langchain_integration.py +0 -0
  31. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_recording.py +0 -0
  32. {agentguard47-0.2.0 → agentguard47-0.3.0}/tests/test_tracing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentguard47
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Lightweight observability and evaluation primitives for multi-agent systems
5
5
  Author: AgentGuard
6
6
  License-Expression: MIT
@@ -105,6 +105,45 @@ replayer = Replayer("runs.jsonl")
105
105
  resp = replayer.replay_call("llm", {"prompt": "hi"})
106
106
  ```
107
107
 
108
+ ## Evaluation as Code
109
+
110
+ ```python
111
+ from agentguard import EvalSuite
112
+
113
+ result = (
114
+ EvalSuite("traces.jsonl")
115
+ .assert_no_loops()
116
+ .assert_tool_called("search", min_times=1)
117
+ .assert_budget_under(tokens=50000)
118
+ .assert_completes_within(30.0)
119
+ .assert_no_errors()
120
+ .run()
121
+ )
122
+ print(result.summary)
123
+ ```
124
+
125
+ ## Auto-Instrumentation
126
+
127
+ ```python
128
+ from agentguard import Tracer
129
+ from agentguard.instrument import trace_agent, trace_tool
130
+
131
+ tracer = Tracer()
132
+
133
+ @trace_agent(tracer)
134
+ def my_agent(query):
135
+ return search(query)
136
+
137
+ @trace_tool(tracer)
138
+ def search(q):
139
+ return f"results for {q}"
140
+
141
+ # Monkey-patch OpenAI/Anthropic (safe if not installed)
142
+ from agentguard.instrument import patch_openai, patch_anthropic
143
+ patch_openai(tracer)
144
+ patch_anthropic(tracer)
145
+ ```
146
+
108
147
  ## CLI
109
148
 
110
149
  ```bash
@@ -114,8 +153,11 @@ agentguard summarize traces.jsonl
114
153
  # Human-readable report
115
154
  agentguard report traces.jsonl
116
155
 
117
- # Open trace viewer in browser
156
+ # Open Gantt trace viewer in browser
118
157
  agentguard view traces.jsonl
158
+
159
+ # Run evaluation assertions
160
+ agentguard eval traces.jsonl
119
161
  ```
120
162
 
121
163
  ## Trace Viewer
@@ -124,6 +166,8 @@ agentguard view traces.jsonl
124
166
  agentguard view traces.jsonl --port 8080
125
167
  ```
126
168
 
169
+ Gantt-style timeline with color-coded spans (reasoning, tool, LLM, guard, error), click-to-expand detail panel, and aggregate stats.
170
+
127
171
  ## Integrations
128
172
 
129
173
  - LangChain: `agentguard.integrations.langchain`
@@ -80,6 +80,45 @@ replayer = Replayer("runs.jsonl")
80
80
  resp = replayer.replay_call("llm", {"prompt": "hi"})
81
81
  ```
82
82
 
83
+ ## Evaluation as Code
84
+
85
+ ```python
86
+ from agentguard import EvalSuite
87
+
88
+ result = (
89
+ EvalSuite("traces.jsonl")
90
+ .assert_no_loops()
91
+ .assert_tool_called("search", min_times=1)
92
+ .assert_budget_under(tokens=50000)
93
+ .assert_completes_within(30.0)
94
+ .assert_no_errors()
95
+ .run()
96
+ )
97
+ print(result.summary)
98
+ ```
99
+
100
+ ## Auto-Instrumentation
101
+
102
+ ```python
103
+ from agentguard import Tracer
104
+ from agentguard.instrument import trace_agent, trace_tool
105
+
106
+ tracer = Tracer()
107
+
108
+ @trace_agent(tracer)
109
+ def my_agent(query):
110
+ return search(query)
111
+
112
+ @trace_tool(tracer)
113
+ def search(q):
114
+ return f"results for {q}"
115
+
116
+ # Monkey-patch OpenAI/Anthropic (safe if not installed)
117
+ from agentguard.instrument import patch_openai, patch_anthropic
118
+ patch_openai(tracer)
119
+ patch_anthropic(tracer)
120
+ ```
121
+
83
122
  ## CLI
84
123
 
85
124
  ```bash
@@ -89,8 +128,11 @@ agentguard summarize traces.jsonl
89
128
  # Human-readable report
90
129
  agentguard report traces.jsonl
91
130
 
92
- # Open trace viewer in browser
131
+ # Open Gantt trace viewer in browser
93
132
  agentguard view traces.jsonl
133
+
134
+ # Run evaluation assertions
135
+ agentguard eval traces.jsonl
94
136
  ```
95
137
 
96
138
  ## Trace Viewer
@@ -99,6 +141,8 @@ agentguard view traces.jsonl
99
141
  agentguard view traces.jsonl --port 8080
100
142
  ```
101
143
 
144
+ Gantt-style timeline with color-coded spans (reasoning, tool, LLM, guard, error), click-to-expand detail panel, and aggregate stats.
145
+
102
146
  ## Integrations
103
147
 
104
148
  - LangChain: `agentguard.integrations.langchain`
@@ -9,6 +9,7 @@ from .guards import (
9
9
  )
10
10
  from .recording import Recorder, Replayer
11
11
  from .sinks import HttpSink
12
+ from .evaluation import EvalSuite, EvalResult, AssertionResult
12
13
 
13
14
  __all__ = [
14
15
  "Tracer",
@@ -21,4 +22,7 @@ __all__ = [
21
22
  "Recorder",
22
23
  "Replayer",
23
24
  "HttpSink",
25
+ "EvalSuite",
26
+ "EvalResult",
27
+ "AssertionResult",
24
28
  ]
@@ -81,6 +81,21 @@ def _report(path: str) -> None:
81
81
  print(" Loop guard triggered: 0")
82
82
 
83
83
 
84
+ def _eval(path: str) -> None:
85
+ from agentguard.evaluation import EvalSuite
86
+
87
+ result = (
88
+ EvalSuite(path)
89
+ .assert_no_loops()
90
+ .assert_no_errors()
91
+ .assert_completes_within(30.0)
92
+ .run()
93
+ )
94
+ print(result.summary)
95
+ if not result.passed:
96
+ raise SystemExit(1)
97
+
98
+
84
99
  def main() -> None:
85
100
  parser = argparse.ArgumentParser(prog="agentguard")
86
101
  sub = parser.add_subparsers(dest="cmd")
@@ -96,6 +111,9 @@ def main() -> None:
96
111
  view.add_argument("--port", type=int, default=8080)
97
112
  view.add_argument("--no-open", action="store_true")
98
113
 
114
+ eval_cmd = sub.add_parser("eval", help="Run evaluation assertions on a trace")
115
+ eval_cmd.add_argument("path")
116
+
99
117
  args = parser.parse_args()
100
118
  if args.cmd == "summarize":
101
119
  _summarize(args.path)
@@ -105,6 +123,8 @@ def main() -> None:
105
123
  from agentguard.viewer import serve
106
124
 
107
125
  serve(args.path, port=args.port, open_browser=not args.no_open)
126
+ elif args.cmd == "eval":
127
+ _eval(args.path)
108
128
  else:
109
129
  parser.print_help()
110
130
 
@@ -0,0 +1,220 @@
1
+ """Evaluation as Code — assertion-based trace analysis."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ @dataclass
10
+ class AssertionResult:
11
+ name: str
12
+ passed: bool
13
+ message: str
14
+
15
+
16
+ @dataclass
17
+ class EvalResult:
18
+ assertions: List[AssertionResult] = field(default_factory=list)
19
+
20
+ @property
21
+ def passed(self) -> bool:
22
+ return all(a.passed for a in self.assertions)
23
+
24
+ @property
25
+ def summary(self) -> str:
26
+ total = len(self.assertions)
27
+ passed = sum(1 for a in self.assertions if a.passed)
28
+ failed = total - passed
29
+ lines = [f"EvalResult: {passed}/{total} passed, {failed} failed"]
30
+ for a in self.assertions:
31
+ status = "PASS" if a.passed else "FAIL"
32
+ lines.append(f" [{status}] {a.name}: {a.message}")
33
+ return "\n".join(lines)
34
+
35
+
36
+ class EvalSuite:
37
+ """Load a trace from JSONL and run assertions against it."""
38
+
39
+ def __init__(self, path: str) -> None:
40
+ self._events = _load_events(path)
41
+ self._assertions: List[_Assertion] = []
42
+
43
+ @property
44
+ def events(self) -> List[Dict[str, Any]]:
45
+ return list(self._events)
46
+
47
+ def assert_no_loops(self) -> "EvalSuite":
48
+ """Assert that no loop guard events were recorded."""
49
+ self._assertions.append(_Assertion(
50
+ name="no_loops",
51
+ check=_check_no_loops,
52
+ ))
53
+ return self
54
+
55
+ def assert_tool_called(self, name: str, min_times: int = 1) -> "EvalSuite":
56
+ """Assert a tool was called at least min_times."""
57
+ self._assertions.append(_Assertion(
58
+ name=f"tool_called:{name}>={min_times}",
59
+ check=lambda events, n=name, m=min_times: _check_tool_called(events, n, m),
60
+ ))
61
+ return self
62
+
63
+ def assert_budget_under(self, tokens: Optional[int] = None, calls: Optional[int] = None) -> "EvalSuite":
64
+ """Assert total token/call usage is under a limit."""
65
+ label_parts = []
66
+ if tokens is not None:
67
+ label_parts.append(f"tokens<{tokens}")
68
+ if calls is not None:
69
+ label_parts.append(f"calls<{calls}")
70
+ self._assertions.append(_Assertion(
71
+ name=f"budget_under:{','.join(label_parts)}",
72
+ check=lambda events, t=tokens, c=calls: _check_budget_under(events, t, c),
73
+ ))
74
+ return self
75
+
76
+ def assert_completes_within(self, seconds: float) -> "EvalSuite":
77
+ """Assert the longest span completed within a time limit."""
78
+ self._assertions.append(_Assertion(
79
+ name=f"completes_within:{seconds}s",
80
+ check=lambda events, s=seconds: _check_completes_within(events, s),
81
+ ))
82
+ return self
83
+
84
+ def assert_event_exists(self, name: str) -> "EvalSuite":
85
+ """Assert that at least one event with the given name exists."""
86
+ self._assertions.append(_Assertion(
87
+ name=f"event_exists:{name}",
88
+ check=lambda events, n=name: _check_event_exists(events, n),
89
+ ))
90
+ return self
91
+
92
+ def assert_no_errors(self) -> "EvalSuite":
93
+ """Assert no events have error data."""
94
+ self._assertions.append(_Assertion(
95
+ name="no_errors",
96
+ check=_check_no_errors,
97
+ ))
98
+ return self
99
+
100
+ def run(self) -> EvalResult:
101
+ result = EvalResult()
102
+ for assertion in self._assertions:
103
+ ar = assertion.check(self._events)
104
+ result.assertions.append(ar)
105
+ return result
106
+
107
+
108
+ @dataclass
109
+ class _Assertion:
110
+ name: str
111
+ check: Any # Callable[[List[Dict]], AssertionResult]
112
+
113
+
114
+ # --- check functions ---
115
+
116
+
117
+ def _check_no_loops(events: List[Dict[str, Any]]) -> AssertionResult:
118
+ loop_events = [e for e in events if e.get("name") == "guard.loop_detected"]
119
+ if loop_events:
120
+ return AssertionResult(
121
+ name="no_loops",
122
+ passed=False,
123
+ message=f"Found {len(loop_events)} loop detection event(s)",
124
+ )
125
+ return AssertionResult(name="no_loops", passed=True, message="No loops detected")
126
+
127
+
128
+ def _check_tool_called(events: List[Dict[str, Any]], tool_name: str, min_times: int) -> AssertionResult:
129
+ name = f"tool_called:{tool_name}>={min_times}"
130
+ # Count tool.result events or span events with matching tool name
131
+ count = 0
132
+ for e in events:
133
+ ename = e.get("name", "")
134
+ if ename == "tool.result":
135
+ count += 1
136
+ elif ename.startswith(f"tool.{tool_name}"):
137
+ if e.get("phase") == "start" or e.get("kind") == "event":
138
+ count += 1
139
+ if count >= min_times:
140
+ return AssertionResult(name=name, passed=True, message=f"Tool called {count} time(s)")
141
+ return AssertionResult(name=name, passed=False, message=f"Tool called {count} time(s), expected >= {min_times}")
142
+
143
+
144
+ def _check_budget_under(events: List[Dict[str, Any]], max_tokens: Optional[int], max_calls: Optional[int]) -> AssertionResult:
145
+ parts = []
146
+ if max_tokens is not None:
147
+ parts.append(f"tokens<{max_tokens}")
148
+ if max_calls is not None:
149
+ parts.append(f"calls<{max_calls}")
150
+ name = f"budget_under:{','.join(parts)}"
151
+
152
+ total_tokens = 0
153
+ total_calls = 0
154
+ for e in events:
155
+ data = e.get("data", {})
156
+ if isinstance(data, dict):
157
+ usage = data.get("token_usage") or data.get("usage") or {}
158
+ if isinstance(usage, dict):
159
+ total_tokens += usage.get("total_tokens", 0)
160
+ if e.get("name", "").startswith("tool.") and e.get("kind") == "span" and e.get("phase") == "start":
161
+ total_calls += 1
162
+
163
+ failures = []
164
+ if max_tokens is not None and total_tokens >= max_tokens:
165
+ failures.append(f"tokens={total_tokens} >= {max_tokens}")
166
+ if max_calls is not None and total_calls >= max_calls:
167
+ failures.append(f"calls={total_calls} >= {max_calls}")
168
+
169
+ if failures:
170
+ return AssertionResult(name=name, passed=False, message="; ".join(failures))
171
+ return AssertionResult(name=name, passed=True, message=f"tokens={total_tokens}, calls={total_calls}")
172
+
173
+
174
+ def _check_completes_within(events: List[Dict[str, Any]], max_seconds: float) -> AssertionResult:
175
+ name = f"completes_within:{max_seconds}s"
176
+ max_ms = 0.0
177
+ for e in events:
178
+ dur = e.get("duration_ms")
179
+ if isinstance(dur, (int, float)) and dur > max_ms:
180
+ max_ms = dur
181
+ actual_seconds = max_ms / 1000.0
182
+ if actual_seconds <= max_seconds:
183
+ return AssertionResult(name=name, passed=True, message=f"Completed in {actual_seconds:.3f}s")
184
+ return AssertionResult(name=name, passed=False, message=f"Took {actual_seconds:.3f}s, limit is {max_seconds}s")
185
+
186
+
187
+ def _check_event_exists(events: List[Dict[str, Any]], event_name: str) -> AssertionResult:
188
+ name = f"event_exists:{event_name}"
189
+ found = any(e.get("name") == event_name for e in events)
190
+ if found:
191
+ return AssertionResult(name=name, passed=True, message="Event found")
192
+ return AssertionResult(name=name, passed=False, message="Event not found")
193
+
194
+
195
+ def _check_no_errors(events: List[Dict[str, Any]]) -> AssertionResult:
196
+ errors = [e for e in events if e.get("error") is not None]
197
+ if errors:
198
+ return AssertionResult(
199
+ name="no_errors",
200
+ passed=False,
201
+ message=f"Found {len(errors)} event(s) with errors",
202
+ )
203
+ return AssertionResult(name="no_errors", passed=True, message="No errors found")
204
+
205
+
206
+ # --- loader ---
207
+
208
+
209
+ def _load_events(path: str) -> List[Dict[str, Any]]:
210
+ events: List[Dict[str, Any]] = []
211
+ with open(path, "r", encoding="utf-8") as f:
212
+ for line in f:
213
+ line = line.strip()
214
+ if not line:
215
+ continue
216
+ try:
217
+ events.append(json.loads(line))
218
+ except json.JSONDecodeError:
219
+ continue
220
+ return events
@@ -0,0 +1,183 @@
1
+ """Auto-instrumentation decorators and monkey-patches."""
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ from typing import Any, Callable, Optional, TypeVar
6
+
7
+ F = TypeVar("F", bound=Callable[..., Any])
8
+
9
+
10
+ def trace_agent(tracer: Any, name: Optional[str] = None) -> Callable[[F], F]:
11
+ """Decorator that wraps a function in a top-level trace span.
12
+
13
+ Usage::
14
+
15
+ @trace_agent(tracer)
16
+ def my_agent(query: str) -> str:
17
+ ...
18
+ """
19
+
20
+ def decorator(fn: F) -> F:
21
+ span_name = name or f"agent.{fn.__name__}"
22
+
23
+ @functools.wraps(fn)
24
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
25
+ with tracer.trace(span_name) as ctx:
26
+ kwargs["_trace_ctx"] = ctx
27
+ try:
28
+ return fn(*args, **kwargs)
29
+ except Exception:
30
+ raise
31
+ finally:
32
+ kwargs.pop("_trace_ctx", None)
33
+
34
+ # If the function doesn't accept **kwargs, fall back to simple wrapping
35
+ @functools.wraps(fn)
36
+ def simple_wrapper(*args: Any, **kwargs: Any) -> Any:
37
+ with tracer.trace(span_name):
38
+ return fn(*args, **kwargs)
39
+
40
+ # Check if function can accept _trace_ctx kwarg
41
+ import inspect
42
+
43
+ sig = inspect.signature(fn)
44
+ has_var_keyword = any(
45
+ p.kind == inspect.Parameter.VAR_KEYWORD
46
+ for p in sig.parameters.values()
47
+ )
48
+ has_trace_ctx = "_trace_ctx" in sig.parameters
49
+
50
+ if has_var_keyword or has_trace_ctx:
51
+ return wrapper # type: ignore[return-value]
52
+ return simple_wrapper # type: ignore[return-value]
53
+
54
+ return decorator
55
+
56
+
57
+ def trace_tool(tracer: Any, name: Optional[str] = None) -> Callable[[F], F]:
58
+ """Decorator that wraps a function in a tool span.
59
+
60
+ Usage::
61
+
62
+ @trace_tool(tracer)
63
+ def search(query: str) -> str:
64
+ ...
65
+ """
66
+
67
+ def decorator(fn: F) -> F:
68
+ span_name = name or f"tool.{fn.__name__}"
69
+
70
+ @functools.wraps(fn)
71
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
72
+ with tracer.trace(span_name) as ctx:
73
+ result = fn(*args, **kwargs)
74
+ ctx.event("tool.result", data={"result": str(result)[:500]})
75
+ return result
76
+
77
+ return wrapper # type: ignore[return-value]
78
+
79
+ return decorator
80
+
81
+
82
+ def patch_openai(tracer: Any) -> None:
83
+ """Monkey-patch OpenAI's ChatCompletion.create to auto-trace calls.
84
+
85
+ Safe to call even if openai is not installed — silently returns.
86
+ """
87
+ try:
88
+ import openai # noqa: F811
89
+ except ImportError:
90
+ return
91
+
92
+ _original = None
93
+
94
+ # Support openai >= 1.0 (client-based) and < 1.0 (module-based)
95
+ client_cls = getattr(openai, "OpenAI", None)
96
+ if client_cls is not None:
97
+ # openai >= 1.0: patch the completions create method on the class
98
+ chat_completions = getattr(
99
+ getattr(client_cls, "chat", None), "completions", None
100
+ )
101
+ if chat_completions is not None:
102
+ _original = getattr(chat_completions, "create", None)
103
+ else:
104
+ # openai < 1.0
105
+ chat = getattr(openai, "ChatCompletion", None)
106
+ if chat is not None:
107
+ _original = getattr(chat, "create", None)
108
+
109
+ if _original is None:
110
+ return
111
+
112
+ @functools.wraps(_original)
113
+ def traced_create(*args: Any, **kwargs: Any) -> Any:
114
+ model = kwargs.get("model", "unknown")
115
+ with tracer.trace(f"llm.openai.{model}") as ctx:
116
+ result = _original(*args, **kwargs)
117
+ # Try to extract usage
118
+ usage = getattr(result, "usage", None)
119
+ if usage is not None:
120
+ ctx.event(
121
+ "llm.result",
122
+ data={
123
+ "model": model,
124
+ "usage": {
125
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0),
126
+ "completion_tokens": getattr(usage, "completion_tokens", 0),
127
+ "total_tokens": getattr(usage, "total_tokens", 0),
128
+ },
129
+ },
130
+ )
131
+ return result
132
+
133
+ # Patch it back
134
+ if client_cls is not None and chat_completions is not None:
135
+ chat_completions.create = traced_create # type: ignore[attr-defined]
136
+ else:
137
+ chat = getattr(openai, "ChatCompletion", None)
138
+ if chat is not None:
139
+ chat.create = traced_create # type: ignore[attr-defined]
140
+
141
+
142
+ def patch_anthropic(tracer: Any) -> None:
143
+ """Monkey-patch Anthropic's messages.create to auto-trace calls.
144
+
145
+ Safe to call even if anthropic is not installed — silently returns.
146
+ """
147
+ try:
148
+ import anthropic # noqa: F811
149
+ except ImportError:
150
+ return
151
+
152
+ client_cls = getattr(anthropic, "Anthropic", None)
153
+ if client_cls is None:
154
+ return
155
+
156
+ messages = getattr(client_cls, "messages", None)
157
+ if messages is None:
158
+ return
159
+
160
+ _original = getattr(messages, "create", None)
161
+ if _original is None:
162
+ return
163
+
164
+ @functools.wraps(_original)
165
+ def traced_create(*args: Any, **kwargs: Any) -> Any:
166
+ model = kwargs.get("model", "unknown")
167
+ with tracer.trace(f"llm.anthropic.{model}") as ctx:
168
+ result = _original(*args, **kwargs)
169
+ usage = getattr(result, "usage", None)
170
+ if usage is not None:
171
+ ctx.event(
172
+ "llm.result",
173
+ data={
174
+ "model": model,
175
+ "usage": {
176
+ "input_tokens": getattr(usage, "input_tokens", 0),
177
+ "output_tokens": getattr(usage, "output_tokens", 0),
178
+ },
179
+ },
180
+ )
181
+ return result
182
+
183
+ messages.create = traced_create # type: ignore[attr-defined]
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import uuid
4
- from typing import Any, Dict, List, Optional, Sequence
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
6
  from agentguard.guards import BudgetGuard, LoopGuard
7
7
  from agentguard.tracing import Tracer, TraceContext