agentdynamics 0.4.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.
@@ -0,0 +1,12 @@
1
+ """AgentDynamics: application performance monitoring for AI agents.
2
+
3
+ import agentdynamics
4
+ agentdynamics.init() # auto-instruments LangChain/LangGraph, OpenTelemetry, Anthropic and OpenAI
5
+
6
+ See agentdynamics.autotrace for @trace / @tool / span.
7
+ """
8
+ __version__ = "0.4.0"
9
+
10
+ from .autotrace import flush, init, llm_call, record_llm, span, tool, trace # noqa: E402,F401
11
+
12
+ __all__ = ["init", "trace", "tool", "span", "llm_call", "record_llm", "flush", "__version__"]
@@ -0,0 +1,322 @@
1
+ """CLI.
2
+
3
+ agentdynamics serve start console + receivers (default command)
4
+ agentdynamics run <cmd ...> run a Python program with zero-code instrumentation
5
+ agentdynamics connect <framework> print the exact setup for a framework
6
+ agentdynamics doctor check connectivity/auth and send a test trace
7
+ agentdynamics keys create|list|revoke
8
+ agentdynamics report | ingest | push <file>
9
+ """
10
+ import argparse
11
+ import json
12
+ import os
13
+ import secrets
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ import webbrowser
20
+
21
+ from .collectors.claude_code import DEFAULT_ROOT
22
+
23
+ DEFAULT_DATA = os.path.join(os.path.expanduser("~"), ".agentdynamics")
24
+ DEFAULT_URL = os.environ.get("AGENTDYNAMICS_URL", "http://127.0.0.1:8787")
25
+
26
+ SNIPPETS = {
27
+ "python": ("Any Python agent (Anthropic / OpenAI SDKs, LangChain, LangGraph, OpenTelemetry auto-detected)", """\
28
+ pip install agentdynamics
29
+
30
+ # in your entry point, before creating clients:
31
+ import agentdynamics
32
+ agentdynamics.init(url="{url}", project="my-agent") # api_key=... if auth is on
33
+
34
+ # optional: group each request into one task and name its stages
35
+ @agentdynamics.trace
36
+ def handle(question):
37
+ with agentdynamics.span("plan"):
38
+ ...
39
+
40
+ # or, with no code changes at all:
41
+ agentdynamics run python app.py"""),
42
+ "langgraph": ("LangGraph / LangChain (Python or JS): environment variables only", """\
43
+ export LANGSMITH_TRACING=true
44
+ export LANGSMITH_ENDPOINT={url}/langsmith
45
+ export LANGSMITH_API_KEY=<ingest key, or anything when auth is off>
46
+ export LANGSMITH_PROJECT=my-agent
47
+ # works for langchain / langgraph (Python) and langchainjs / @langchain/langgraph (JS)"""),
48
+ "otel": ("Anything that speaks OpenTelemetry (OpenAI Agents SDK, Strands, Semantic Kernel, Vercel AI SDK, CrewAI/LlamaIndex via OpenInference, Java/Go/.NET/JS)", """\
49
+ export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT={url}/v1/traces
50
+ export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer <ingest key>"
51
+ export OTEL_SERVICE_NAME=my-agent
52
+ export OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production"""),
53
+ "langsmith": ("Keep LangSmith, copy runs into AgentDynamics (pull)", """\
54
+ # <data>/agentdynamics.toml
55
+ [[sources]]
56
+ type = "langsmith_api"
57
+ project = "my-agent-prod"
58
+ api_key_env = "LANGSMITH_API_KEY\""""),
59
+ "langfuse": ("Keep Langfuse, copy traces into AgentDynamics (pull)", """\
60
+ # <data>/agentdynamics.toml
61
+ [[sources]]
62
+ type = "langfuse_api"
63
+ host = "https://cloud.langfuse.com"
64
+ public_key_env = "LANGFUSE_PUBLIC_KEY"
65
+ secret_key_env = "LANGFUSE_SECRET_KEY\""""),
66
+ "logs": ("Log pipelines: Fluent Bit / Vector / Logstash HTTP output, or files", """\
67
+ POST {url}/api/ingest/records (NDJSON or JSON array; OTLP JSON, LangSmith runs, Langfuse traces, spans)
68
+ Authorization: Bearer <ingest key>
69
+
70
+ # or tail a directory: [[sources]] type = "inbox" path = "/var/log/agent-traces\""""),
71
+ "http": ("Any language, plain HTTP", """\
72
+ curl -X POST {url}/api/ingest -H 'Content-Type: application/json' -d '{{
73
+ "workflow": "support_bot", "project": "helpdesk",
74
+ "steps": [
75
+ {{"kind": "prompt", "ts": 1726700000, "text": "Refund order 42"}},
76
+ {{"kind": "llm", "ts": 1726700000, "end_ts": 1726700002, "model": "claude-sonnet-5",
77
+ "input_tokens": 1200, "output_tokens": 300, "stop_reason": "end_turn"}}]}}'"""),
78
+ "aegis": ("Agents governed by Aegis: every decision recorded, model spend gated, watchdog kill switch", """\
79
+ pip install agentdynamics aegis-kernel
80
+
81
+ import agentdynamics
82
+ from agentdynamics.integrations import aegis as governance
83
+ from aegis import build_kernel, load_policy
84
+
85
+ agentdynamics.init(url="{url}", project="my-agent")
86
+ kernel, root = build_kernel(load_policy("policy.yaml"), registry)
87
+ governance.instrument(kernel, root, watchdog=governance.Watchdog(max_repeated_denials=3))
88
+
89
+ # later: least-privilege policy from what the agent actually did
90
+ agentdynamics policy export --workflow my_workflow --base policy.yaml --out tightened.yaml"""),
91
+ "claude-code": ("Claude Code", "Nothing to do: sessions in ~/.claude/projects are read automatically by `agentdynamics serve`."),
92
+ }
93
+
94
+
95
+ def _http(method, url, key=None, body=None, timeout=10):
96
+ headers = {"Content-Type": "application/json"}
97
+ if key:
98
+ headers["Authorization"] = f"Bearer {key}"
99
+ req = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, headers=headers, method=method)
100
+ try:
101
+ with urllib.request.urlopen(req, timeout=timeout) as r:
102
+ return r.status, json.loads(r.read() or b"null")
103
+ except urllib.error.HTTPError as e:
104
+ return e.code, None
105
+
106
+
107
+ def cmd_doctor(a):
108
+ url, key = a.url.rstrip("/"), a.key or os.environ.get("AGENTDYNAMICS_API_KEY")
109
+ ok = True
110
+
111
+ def line(good, msg, hint=""):
112
+ nonlocal ok
113
+ ok &= good
114
+ print(f" [{'ok' if good else '!!'}] {msg}" + (f"\n -> {hint}" if hint and not good else ""))
115
+
116
+ print(f"AgentDynamics doctor - {url}")
117
+ try:
118
+ st, h = _http("GET", url + "/healthz")
119
+ except OSError as ex:
120
+ line(False, f"server reachable ({ex})", "start it with: agentdynamics serve (or set AGENTDYNAMICS_URL)")
121
+ return 1
122
+ line(st == 200, f"server reachable (status {h and h.get('status')})")
123
+ st, who = _http("GET", url + "/api/whoami", key)
124
+ role = (who or {}).get("role") if st == 200 else ("ingest" if st == 403 else None)
125
+ line(st in (200, 403), f"credentials accepted (role: {role or 'none'})",
126
+ "set AGENTDYNAMICS_API_KEY, or create a key on the server: agentdynamics keys create --role ingest")
127
+ run_id = f"doctor-{int(time.time())}"
128
+ st, _ = _http("POST", url + "/api/ingest", key, {"id": run_id, "workflow": "doctor_check", "project": "agentdynamics-doctor", "steps": [
129
+ {"kind": "prompt", "ts": time.time() - 2, "text": "connectivity check"},
130
+ {"kind": "llm", "ts": time.time() - 2, "end_ts": time.time(), "model": "claude-haiku-4-5", "input_tokens": 10, "output_tokens": 5,
131
+ "stop_reason": "end_turn"}]})
132
+ line(st == 200, "test trace accepted", "the key needs the 'ingest' (or 'admin') role")
133
+ if st == 200 and role in ("read", "admin"):
134
+ _http("POST", url + "/api/refresh", key, {})
135
+ st, d = _http("GET", url + "/api/tasks?project=agentdynamics-doctor&days=&limit=5", key)
136
+ seen = st == 200 and any(t["run_id"] == run_id for t in (d or {}).get("tasks", []))
137
+ line(seen, "test trace analyzed and visible in the console")
138
+ if ok:
139
+ print(f"\nAll good. Open {url} and look for project 'agentdynamics-doctor'.")
140
+ else:
141
+ print("\nFix the items above and run again.")
142
+ return 0 if ok else 1
143
+
144
+
145
+ def cmd_keys(a, data):
146
+ from .config import keys_path, load_keys, save_keys
147
+ keys = load_keys(data)
148
+ if a.action == "create":
149
+ k = {"name": a.name or f"{a.role}-{len(keys) + 1}", "role": a.role, "key": f"ad_{a.role[0]}_{secrets.token_urlsafe(24)}",
150
+ "created": int(time.time())}
151
+ keys.append(k)
152
+ save_keys(data, keys)
153
+ print(f"Created {k['role']} key '{k['name']}':\n\n {k['key']}\n\nAuth is now ON for the server using {keys_path(data)} (restart it).")
154
+ if a.role == "ingest":
155
+ print(f"Use it in your app: export AGENTDYNAMICS_API_KEY={k['key']}")
156
+ elif a.action == "list":
157
+ if not keys:
158
+ print("No keys: auth is off (local mode).")
159
+ for k in keys:
160
+ print(f" {k['name']:<20} {k['role']:<7} {k['key'][:8]}... created {time.strftime('%Y-%m-%d', time.localtime(k.get('created', 0)))}")
161
+ elif a.action == "revoke":
162
+ left = [k for k in keys if k["name"] != a.name and not k["key"].startswith(a.name or "\0")]
163
+ save_keys(data, left)
164
+ print(f"Revoked {len(keys) - len(left)} key(s).")
165
+
166
+
167
+ def cmd_policy(a, eng):
168
+ from .server import Api
169
+ api = Api(eng)
170
+ q = {k: v for k, v in (("workflow", a.workflow), ("project", a.project), ("environment", a.environment),
171
+ ("days", a.days), ("policy", a.policy), ("headroom", a.headroom)) if v}
172
+ if a.base:
173
+ try:
174
+ from aegis import dump_policy, load_policy
175
+ except ImportError:
176
+ print("--base needs aegis-kernel: pip install aegis-kernel", file=sys.stderr)
177
+ return 2
178
+ q["base_doc"] = dump_policy(load_policy(a.base))
179
+ if a.action == "report":
180
+ g = api.governance(q)
181
+ k = g["kpis"]
182
+ print(f"Governed tasks {k['governed_tasks']} - decisions {k['decisions']} - denials {k['denials']} "
183
+ f"({k['denial_rate']:.1%} of tool calls) - budget stops {k['budget_stops']} - revocations {k['revocations']}")
184
+ for p in g["policies"]:
185
+ print()
186
+ print(f"{p['policy']}: {p['tasks']} tasks, success {p['success_rate']:.0%}, {p['denials']} denials")
187
+ print(f" granted {len(p['granted'])} tools, used {len(p['used'])}; unused: {', '.join(p['unused']) or 'none'}")
188
+ print(" budget headroom (limit / p95 used): " + ", ".join(f"{k2} {v}x" for k2, v in p["headroom"].items() if v))
189
+ for r in g["by_rule"][:8]:
190
+ print(f" {r['n']:>5} {r['rule']}")
191
+ return 0
192
+ res = api.export_policy(q)
193
+ if res.get("error"):
194
+ print(res["error"], file=sys.stderr)
195
+ return 1
196
+ if a.out:
197
+ with open(a.out, "w", encoding="utf-8") as f:
198
+ f.write(res["yaml"])
199
+ print(f"wrote {a.out}: {len(res['changes'])} change(s) vs {res['base'] or 'no base'}", file=sys.stderr)
200
+ for c in res["changes"]:
201
+ print(f" - {c}", file=sys.stderr)
202
+ else:
203
+ print(res["yaml"])
204
+ return 0
205
+
206
+
207
+ def cmd_run(a):
208
+ if not a.command:
209
+ print("usage: agentdynamics run python app.py [args...]")
210
+ return 2
211
+ boot = os.path.join(os.path.dirname(__file__), "bootstrap")
212
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
213
+ env = dict(os.environ)
214
+ env["PYTHONPATH"] = os.pathsep.join(p for p in (boot, root, env.get("PYTHONPATH")) if p)
215
+ if a.project:
216
+ env["AGENTDYNAMICS_PROJECT"] = a.project
217
+ cmd = a.command[1:] if a.command[0] == "--" else a.command
218
+ return subprocess.call(cmd, env=env)
219
+
220
+
221
+ def main(argv=None):
222
+ from . import __version__
223
+ ap = argparse.ArgumentParser(prog="agentdynamics", description="APM for AI agents")
224
+ ap.add_argument("--version", action="version", version=f"agentdynamics {__version__}")
225
+ ap.add_argument("--data", default=os.environ.get("AGENTDYNAMICS_DATA", DEFAULT_DATA), help="data directory")
226
+ ap.add_argument("--claude-root", default=DEFAULT_ROOT, help="Claude Code projects dir ('' to disable)")
227
+ sub = ap.add_subparsers(dest="cmd")
228
+ s = sub.add_parser("serve", help="run the console and receivers")
229
+ s.add_argument("--host", default=None, help="default from config [server] or 127.0.0.1")
230
+ s.add_argument("--port", type=int, default=None)
231
+ s.add_argument("--interval", type=int, default=None, help="seconds between source re-scans")
232
+ s.add_argument("--open", action="store_true", help="open the browser")
233
+ r = sub.add_parser("run", help="run a Python program with zero-code instrumentation")
234
+ r.add_argument("--project", default=None)
235
+ r.add_argument("command", nargs=argparse.REMAINDER)
236
+ c = sub.add_parser("connect", help="print setup instructions for a framework")
237
+ c.add_argument("framework", nargs="?", choices=sorted(SNIPPETS), default=None)
238
+ c.add_argument("--url", default=DEFAULT_URL)
239
+ d = sub.add_parser("doctor", help="check connectivity and auth, send a test trace")
240
+ d.add_argument("--url", default=DEFAULT_URL)
241
+ d.add_argument("--key", default=None)
242
+ k = sub.add_parser("keys", help="manage API keys")
243
+ k.add_argument("action", choices=["create", "list", "revoke"])
244
+ k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
245
+ k.add_argument("--name", default=None)
246
+ po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
247
+ po.add_argument("action", choices=["export", "report"])
248
+ po.add_argument("--workflow")
249
+ po.add_argument("--project")
250
+ po.add_argument("--environment")
251
+ po.add_argument("--days", type=float)
252
+ po.add_argument("--policy", help="policy label as shown in the console (name@vN#digest)")
253
+ po.add_argument("--base", help="Aegis policy file to tighten (needs aegis-kernel installed)")
254
+ po.add_argument("--headroom", type=float, default=1.5)
255
+ po.add_argument("--out", help="write the YAML here (default: stdout)")
256
+ sub.add_parser("ingest", help="scan sources once and rebuild the database")
257
+ rp = sub.add_parser("report", help="print a text summary")
258
+ rp.add_argument("--project")
259
+ rp.add_argument("--days", type=float)
260
+ p = sub.add_parser("push", help="ingest a JSON file (OTLP export, LangSmith runs, Langfuse traces or generic run)")
261
+ p.add_argument("file")
262
+ a = ap.parse_args(argv)
263
+ cmd = a.cmd or "serve"
264
+
265
+ if cmd == "run":
266
+ return cmd_run(a)
267
+ if cmd == "doctor":
268
+ return cmd_doctor(a)
269
+ if cmd == "connect":
270
+ items = [a.framework] if a.framework else list(SNIPPETS)
271
+ for name in items:
272
+ title, body = SNIPPETS[name]
273
+ print(f"\n== {name}: {title}\n\n{body.format(url=a.url.rstrip('/'))}\n")
274
+ return 0
275
+ os.makedirs(a.data, exist_ok=True)
276
+ if cmd == "keys":
277
+ return cmd_keys(a, a.data)
278
+
279
+ from .engine import Engine
280
+ eng = Engine(a.data, a.claude_root or None)
281
+ if cmd == "push":
282
+ with open(a.file, encoding="utf-8") as f:
283
+ doc = json.load(f)
284
+ from .collectors.inbox import detect
285
+ recs = doc if isinstance(doc, list) else [doc]
286
+ print("accepted", eng.ingest_records([(detect(x), x) for x in recs if detect(x)]))
287
+ eng.refresh()
288
+ return 0
289
+ eng.refresh(force=True)
290
+ print(f"indexed in {eng.last_duration}s -> {os.path.join(a.data, 'agentdynamics.db')}", file=sys.stderr)
291
+ if cmd == "ingest":
292
+ return 0
293
+ if cmd == "policy":
294
+ return cmd_policy(a, eng)
295
+ if cmd == "report":
296
+ from .server import Api
297
+ api = Api(eng)
298
+ q = {k2: v for k2, v in (("project", a.project), ("days", a.days)) if v}
299
+ o = api.overview(q)
300
+ kp = o["kpis"]
301
+ print(f"\nTasks {kp['tasks']} sessions {kp['sessions']} cost ${kp['cost']:.2f} Apdex {kp['apdex']} "
302
+ f"success {kp['success_rate'] or 0:.0%} tool errors {kp['tool_error_rate']:.1%} waste ${kp['waste_cost']:.2f}")
303
+ print("\nTask types:")
304
+ for t in o["types"]:
305
+ print(f" {t['type']:<20} {t['tasks']:>4} tasks ${t['cost']:>9.2f} apdex {t['apdex']} {t['health']}")
306
+ print("\nProcess review:")
307
+ for i in api.process(q)["insights"]:
308
+ print(f" [{i['severity']:>7}] {i['title']}: {i['metric']}\n {i['detail']}")
309
+ return 0
310
+ host = a.host or os.environ.get("AGENTDYNAMICS_HOST") or eng.cfg["server"]["host"]
311
+ port = a.port or int(os.environ.get("AGENTDYNAMICS_PORT") or eng.cfg["server"]["port"])
312
+ if (a.interval or 1) > 0:
313
+ eng.watch(a.interval)
314
+ from .server import serve
315
+ if a.open:
316
+ webbrowser.open(f"http://{host}:{port}/#/start")
317
+ serve(eng, host, port)
318
+ return 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())