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.
- agentdynamics/__init__.py +12 -0
- agentdynamics/__main__.py +322 -0
- agentdynamics/analysis.py +755 -0
- agentdynamics/autotrace.py +697 -0
- agentdynamics/bootstrap/sitecustomize.py +27 -0
- agentdynamics/collectors/__init__.py +0 -0
- agentdynamics/collectors/aegis_audit.py +72 -0
- agentdynamics/collectors/claude_code.py +257 -0
- agentdynamics/collectors/generic.py +103 -0
- agentdynamics/collectors/inbox.py +95 -0
- agentdynamics/collectors/langfuse.py +98 -0
- agentdynamics/collectors/langsmith.py +258 -0
- agentdynamics/collectors/otlp.py +349 -0
- agentdynamics/collectors/spans.py +202 -0
- agentdynamics/config.py +139 -0
- agentdynamics/engine.py +376 -0
- agentdynamics/flows.py +142 -0
- agentdynamics/govern.py +231 -0
- agentdynamics/integrations/__init__.py +1 -0
- agentdynamics/integrations/aegis.py +352 -0
- agentdynamics/phases.py +82 -0
- agentdynamics/pricing.py +69 -0
- agentdynamics/privacy.py +41 -0
- agentdynamics/sdk.py +105 -0
- agentdynamics/server.py +949 -0
- agentdynamics/slo.py +84 -0
- agentdynamics/store.py +228 -0
- agentdynamics/web/app.js +1069 -0
- agentdynamics/web/charts.js +185 -0
- agentdynamics/web/index.html +40 -0
- agentdynamics/web/style.css +244 -0
- agentdynamics-0.4.0.dist-info/METADATA +195 -0
- agentdynamics-0.4.0.dist-info/RECORD +37 -0
- agentdynamics-0.4.0.dist-info/WHEEL +5 -0
- agentdynamics-0.4.0.dist-info/entry_points.txt +2 -0
- agentdynamics-0.4.0.dist-info/licenses/LICENSE +202 -0
- agentdynamics-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Canonical span model + trace assembler.
|
|
2
|
+
|
|
3
|
+
Every trace-based integration (OTLP, LangSmith, Langfuse, inbox files) converts its
|
|
4
|
+
native records into *canonical spans* and hands them to `build_run`, which assembles
|
|
5
|
+
one run (= one task) per trace. Analysis only ever sees runs.
|
|
6
|
+
|
|
7
|
+
Canonical span (dict):
|
|
8
|
+
trace_id, span_id, parent_id, name, kind, start, end (epoch seconds), status ("ok"|"error"|"unset"),
|
|
9
|
+
error, model, provider, input_tokens, output_tokens, cache_read, cache_write, cost, stop_reason,
|
|
10
|
+
ttft_ms, input, output, node, agent, workflow, project, environment, session_id, user_id,
|
|
11
|
+
framework, docs (retriever result count), feedback ([{key, score}]), tags, attrs, source
|
|
12
|
+
|
|
13
|
+
kind: llm | tool | retriever | embedding | agent | chain | node | guardrail | evaluator | human | span
|
|
14
|
+
"""
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
from .. import pricing
|
|
19
|
+
from ..phases import classify
|
|
20
|
+
|
|
21
|
+
STRUCTURAL = {"agent", "chain", "node", "guardrail", "evaluator", "human", "span", "handoff"}
|
|
22
|
+
RATE_LIMIT_HINTS = ("429", "rate limit", "rate_limit", "ratelimit", "overloaded", "529", "too many requests", "quota")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def preview(v, n=600):
|
|
26
|
+
if v is None:
|
|
27
|
+
return ""
|
|
28
|
+
if isinstance(v, str):
|
|
29
|
+
return v[:n]
|
|
30
|
+
try:
|
|
31
|
+
return json.dumps(v, default=str)[:n]
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return str(v)[:n]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_prompt(v):
|
|
37
|
+
"""Best-effort human-readable request text from a root span input."""
|
|
38
|
+
if v is None:
|
|
39
|
+
return ""
|
|
40
|
+
if isinstance(v, str):
|
|
41
|
+
s = v.strip()
|
|
42
|
+
if s.startswith("{") or s.startswith("["):
|
|
43
|
+
try:
|
|
44
|
+
return extract_prompt(json.loads(s))
|
|
45
|
+
except ValueError:
|
|
46
|
+
return s
|
|
47
|
+
return s
|
|
48
|
+
if isinstance(v, dict):
|
|
49
|
+
for k in ("input", "question", "query", "prompt", "task", "text", "content", "message", "user_input"):
|
|
50
|
+
if k in v and v[k]:
|
|
51
|
+
return extract_prompt(v[k])
|
|
52
|
+
if "messages" in v and v["messages"]:
|
|
53
|
+
return extract_prompt(v["messages"])
|
|
54
|
+
# graph state such as {"q": "...", "steps": 0}: use the first substantial string field
|
|
55
|
+
for val in v.values():
|
|
56
|
+
if isinstance(val, str) and len(val.strip()) > 3:
|
|
57
|
+
return val.strip()
|
|
58
|
+
return preview(v, 2000)
|
|
59
|
+
if isinstance(v, list):
|
|
60
|
+
# chat messages: take the last human/user message
|
|
61
|
+
for m in reversed(v):
|
|
62
|
+
if isinstance(m, list):
|
|
63
|
+
r = extract_prompt(m)
|
|
64
|
+
if r:
|
|
65
|
+
return r
|
|
66
|
+
if isinstance(m, dict):
|
|
67
|
+
role = m.get("role") or m.get("type") or (m.get("kwargs") or {}).get("type") or (m.get("id") or [""])[-1]
|
|
68
|
+
if str(role).lower() in ("user", "human", "humanmessage"):
|
|
69
|
+
c = m.get("content") if "content" in m else (m.get("kwargs") or {}).get("content")
|
|
70
|
+
return extract_prompt(c)
|
|
71
|
+
if isinstance(m, str):
|
|
72
|
+
return m
|
|
73
|
+
return preview(v, 2000)
|
|
74
|
+
return str(v)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hash(name, inp):
|
|
78
|
+
return hashlib.sha1((str(name) + preview(inp, 4000)).encode()).hexdigest()[:16]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_run(trace_id, spans, source):
|
|
82
|
+
"""Assemble canonical spans of one trace into a normalized run."""
|
|
83
|
+
if not spans:
|
|
84
|
+
return None
|
|
85
|
+
by_id = {s["span_id"]: s for s in spans}
|
|
86
|
+
roots = [s for s in spans if not s.get("parent_id") or s["parent_id"] not in by_id]
|
|
87
|
+
roots.sort(key=lambda s: s.get("start") or 0)
|
|
88
|
+
root = roots[0]
|
|
89
|
+
|
|
90
|
+
depth_cache = {}
|
|
91
|
+
|
|
92
|
+
def depth(s):
|
|
93
|
+
sid = s["span_id"]
|
|
94
|
+
if sid in depth_cache:
|
|
95
|
+
return depth_cache[sid]
|
|
96
|
+
d, cur, seen = 0, s, set()
|
|
97
|
+
while cur.get("parent_id") in by_id and cur["span_id"] not in seen:
|
|
98
|
+
seen.add(cur["span_id"])
|
|
99
|
+
cur = by_id[cur["parent_id"]]
|
|
100
|
+
d += 1
|
|
101
|
+
depth_cache[sid] = d
|
|
102
|
+
return d
|
|
103
|
+
|
|
104
|
+
def inherited(s, field):
|
|
105
|
+
cur, seen = s, set()
|
|
106
|
+
while cur is not None and cur["span_id"] not in seen:
|
|
107
|
+
if cur.get(field):
|
|
108
|
+
return cur[field]
|
|
109
|
+
seen.add(cur["span_id"])
|
|
110
|
+
cur = by_id.get(cur.get("parent_id"))
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
first = lambda f: next((s.get(f) for s in [root] + spans if s.get(f)), None) # noqa: E731
|
|
114
|
+
# the entry point names the workflow; GenAI-semconv agent roots are named "invoke_agent <agent>", so prefer the agent name
|
|
115
|
+
workflow = root.get("workflow") or (root.get("agent") if root.get("kind") == "agent" else None) or root.get("name") or "trace"
|
|
116
|
+
run = {
|
|
117
|
+
"id": f"{source}:{trace_id}",
|
|
118
|
+
"source": source,
|
|
119
|
+
"file": None,
|
|
120
|
+
"project": first("project") or "default",
|
|
121
|
+
"environment": first("environment") or "default",
|
|
122
|
+
"framework": "langgraph" if any(s.get("framework") == "langgraph" for s in spans) else (first("framework") or source),
|
|
123
|
+
"workflow": workflow,
|
|
124
|
+
"cwd": None,
|
|
125
|
+
"title": extract_prompt(root.get("input"))[:200] or workflow,
|
|
126
|
+
"agent_name": first("agent"),
|
|
127
|
+
"thread_id": first("session_id"),
|
|
128
|
+
"user_id": first("user_id"),
|
|
129
|
+
"parent_id": None,
|
|
130
|
+
"is_subagent": False,
|
|
131
|
+
"version": None,
|
|
132
|
+
"git_branch": None,
|
|
133
|
+
"entrypoint": None,
|
|
134
|
+
"tags": sorted({t for s in spans for t in (s.get("tags") or [])})[:20],
|
|
135
|
+
"feedback": [f for s in spans for f in (s.get("feedback") or [])],
|
|
136
|
+
"steps": [],
|
|
137
|
+
}
|
|
138
|
+
steps = run["steps"]
|
|
139
|
+
prompt_text = extract_prompt(root.get("input"))
|
|
140
|
+
steps.append({"kind": "prompt", "name": "trace", "ts": root.get("start"), "text": prompt_text[:2000] or workflow})
|
|
141
|
+
|
|
142
|
+
ordered = sorted(spans, key=lambda s: ((s.get("start") or 0), depth(s)))
|
|
143
|
+
last_llm = None
|
|
144
|
+
for s in ordered:
|
|
145
|
+
kind = s.get("kind") or "span"
|
|
146
|
+
err = s.get("error") or ""
|
|
147
|
+
st = {
|
|
148
|
+
"span_id": s["span_id"], "parent_span_id": s.get("parent_id"), "depth": depth(s),
|
|
149
|
+
"name": s.get("name") or kind, "ts": s.get("start"), "start_ts": s.get("start"), "end_ts": s.get("end"),
|
|
150
|
+
"is_error": s.get("status") == "error", "error": preview(err, 400) if err else None,
|
|
151
|
+
"node": inherited(s, "node"), "agent": inherited(s, "agent"), "span_kind": kind,
|
|
152
|
+
}
|
|
153
|
+
if st["start_ts"] and st["end_ts"]:
|
|
154
|
+
st["duration_ms"] = max(0, int((st["end_ts"] - st["start_ts"]) * 1000))
|
|
155
|
+
if kind in ("llm", "embedding"):
|
|
156
|
+
model = s.get("model") or s.get("name")
|
|
157
|
+
it, ot = int(s.get("input_tokens") or 0), int(s.get("output_tokens") or 0)
|
|
158
|
+
cr, cw = int(s.get("cache_read") or 0), int(s.get("cache_write") or 0)
|
|
159
|
+
# OTel/LangSmith report input_tokens inclusive of cache reads for most providers; keep uncached part separate
|
|
160
|
+
if cr and it >= cr:
|
|
161
|
+
it -= cr
|
|
162
|
+
cost = s.get("cost")
|
|
163
|
+
if cost is None:
|
|
164
|
+
cost = pricing.cost(model, it, ot, cr, cw, 0)
|
|
165
|
+
st.update({
|
|
166
|
+
"kind": "llm", "model": model, "name": model, "input_tokens": it, "output_tokens": ot,
|
|
167
|
+
"cache_read": cr, "cache_write": cw, "cost": float(cost or 0), "priced": bool(cost) or pricing.rates(model) is not None,
|
|
168
|
+
"context_tokens": it + cr + cw, "stop_reason": s.get("stop_reason"), "ttft_ms": s.get("ttft_ms"),
|
|
169
|
+
"text": preview(s.get("output"), 600), "tool_calls": 0, "thinking_tokens": int(s.get("thinking_tokens") or 0),
|
|
170
|
+
"rate_limited": bool(err) and any(h in err.lower() for h in RATE_LIMIT_HINTS),
|
|
171
|
+
})
|
|
172
|
+
last_llm = st
|
|
173
|
+
elif kind in ("tool", "retriever"):
|
|
174
|
+
inp = s.get("input")
|
|
175
|
+
phase, target = classify(s.get("name"), inp if isinstance(inp, dict) else {})
|
|
176
|
+
if kind == "retriever":
|
|
177
|
+
phase = "retrieve"
|
|
178
|
+
elif phase == "other":
|
|
179
|
+
phase = "execute"
|
|
180
|
+
st.update({
|
|
181
|
+
"kind": "tool", "phase": phase, "target": target or preview(inp, 200),
|
|
182
|
+
"input_hash": _hash(s.get("name"), inp), "input_preview": preview(inp, 400),
|
|
183
|
+
"output_chars": len(preview(s.get("output"), 10**7)), "docs": s.get("docs"),
|
|
184
|
+
"text": preview(s.get("output"), 300),
|
|
185
|
+
})
|
|
186
|
+
if last_llm is not None:
|
|
187
|
+
st["llm_msg"] = last_llm["span_id"]
|
|
188
|
+
last_llm["tool_calls"] += 1
|
|
189
|
+
else:
|
|
190
|
+
st.update({"kind": "span", "input_preview": preview(s.get("input"), 400), "text": preview(s.get("output"), 300)})
|
|
191
|
+
if kind == "human" or "interrupt" in (s.get("name") or "").lower() or "GraphInterrupt" in err:
|
|
192
|
+
st["hitl"] = True
|
|
193
|
+
steps.append(st)
|
|
194
|
+
for i, st in enumerate(steps):
|
|
195
|
+
st["seq"] = i
|
|
196
|
+
tss = [s["ts"] for s in steps if s.get("ts")] + [s["end_ts"] for s in steps if s.get("end_ts")]
|
|
197
|
+
run["started"] = min(tss) if tss else None
|
|
198
|
+
run["ended"] = max(tss) if tss else None
|
|
199
|
+
run["root_status"] = root.get("status")
|
|
200
|
+
run["root_error"] = preview(root.get("error"), 400) if root.get("error") else None
|
|
201
|
+
run["complete"] = root.get("end") is not None
|
|
202
|
+
return run
|
agentdynamics/config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Configuration: <data_dir>/agentdynamics.toml (optional) + environment overrides.
|
|
2
|
+
|
|
3
|
+
Example agentdynamics.toml:
|
|
4
|
+
|
|
5
|
+
[server]
|
|
6
|
+
host = "0.0.0.0"
|
|
7
|
+
port = 8787
|
|
8
|
+
|
|
9
|
+
[auth]
|
|
10
|
+
enabled = true
|
|
11
|
+
# role: ingest (write telemetry), read (view console/API), admin (rules, SLOs, config)
|
|
12
|
+
keys = [
|
|
13
|
+
{ name = "otel-collector", key = "ad_ingest_xxx", role = "ingest" },
|
|
14
|
+
{ name = "sre-team", key = "ad_read_xxx", role = "read" },
|
|
15
|
+
{ name = "platform-admin", key = "ad_admin_xxx", role = "admin" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[privacy]
|
|
19
|
+
store_content = true # false keeps only sizes/metadata, never prompt or tool text
|
|
20
|
+
redact = ["email", "api_key", "credit_card", "bearer", "aws_key"]
|
|
21
|
+
extra_patterns = [] # additional regexes to mask
|
|
22
|
+
|
|
23
|
+
[retention]
|
|
24
|
+
days = 90 # spans/runs older than this are purged
|
|
25
|
+
|
|
26
|
+
[[alerts.webhooks]]
|
|
27
|
+
url = "https://hooks.slack.com/services/..."
|
|
28
|
+
min_severity = "warning"
|
|
29
|
+
format = "slack" # slack | json
|
|
30
|
+
|
|
31
|
+
[[sources]]
|
|
32
|
+
type = "claude_code" # built-in, on by default
|
|
33
|
+
[[sources]]
|
|
34
|
+
type = "langsmith_api" # pull runs from LangSmith
|
|
35
|
+
project = "my-agent-prod"
|
|
36
|
+
api_key_env = "LANGSMITH_API_KEY"
|
|
37
|
+
interval = 60
|
|
38
|
+
[[sources]]
|
|
39
|
+
type = "langfuse_api"
|
|
40
|
+
host = "https://cloud.langfuse.com"
|
|
41
|
+
public_key_env = "LANGFUSE_PUBLIC_KEY"
|
|
42
|
+
secret_key_env = "LANGFUSE_SECRET_KEY"
|
|
43
|
+
[[sources]]
|
|
44
|
+
type = "inbox" # tail *.jsonl / *.json dropped by Fluent Bit, Vector, S3 sync...
|
|
45
|
+
path = "/var/log/agent-traces"
|
|
46
|
+
"""
|
|
47
|
+
import copy
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
import tomllib # Python 3.11+
|
|
52
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
53
|
+
try:
|
|
54
|
+
import tomli as tomllib
|
|
55
|
+
except ImportError:
|
|
56
|
+
tomllib = None
|
|
57
|
+
|
|
58
|
+
DEFAULTS = {
|
|
59
|
+
"server": {"host": "127.0.0.1", "port": 8787},
|
|
60
|
+
"auth": {"enabled": False, "keys": []},
|
|
61
|
+
"privacy": {"store_content": True, "redact": ["email", "api_key", "credit_card", "bearer", "aws_key"], "extra_patterns": []},
|
|
62
|
+
"retention": {"days": 0},
|
|
63
|
+
"alerts": {"webhooks": []},
|
|
64
|
+
"analysis": {"interval": 15, "idle_cap_seconds": 300},
|
|
65
|
+
"sources": [],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ROLES = {"ingest": 1, "read": 2, "admin": 3}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _merge(base, over):
|
|
72
|
+
out = copy.deepcopy(base)
|
|
73
|
+
for k, v in (over or {}).items():
|
|
74
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
75
|
+
out[k] = _merge(out[k], v)
|
|
76
|
+
else:
|
|
77
|
+
out[k] = v
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def keys_path(data_dir):
|
|
82
|
+
return os.path.join(data_dir, "keys.json")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_keys(data_dir):
|
|
86
|
+
p = keys_path(data_dir)
|
|
87
|
+
if os.path.exists(p):
|
|
88
|
+
import json
|
|
89
|
+
with open(p, encoding="utf-8") as f:
|
|
90
|
+
return json.load(f)
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_keys(data_dir, keys):
|
|
95
|
+
import json
|
|
96
|
+
p = keys_path(data_dir)
|
|
97
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
98
|
+
json.dump(keys, f, indent=2)
|
|
99
|
+
try:
|
|
100
|
+
os.chmod(p, 0o600)
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load(data_dir):
|
|
106
|
+
cfg = copy.deepcopy(DEFAULTS)
|
|
107
|
+
path = os.environ.get("AGENTDYNAMICS_CONFIG") or os.path.join(data_dir, "agentdynamics.toml")
|
|
108
|
+
if os.path.exists(path):
|
|
109
|
+
if tomllib is None:
|
|
110
|
+
raise RuntimeError("reading agentdynamics.toml on Python 3.10 needs `pip install tomli`")
|
|
111
|
+
with open(path, "rb") as f:
|
|
112
|
+
cfg = _merge(cfg, tomllib.load(f))
|
|
113
|
+
cfg["_path"] = path
|
|
114
|
+
# Env overrides for container deployments: AGENTDYNAMICS_API_KEYS="name:key:role,name2:key2:role2"
|
|
115
|
+
env_keys = os.environ.get("AGENTDYNAMICS_API_KEYS")
|
|
116
|
+
if env_keys:
|
|
117
|
+
cfg["auth"]["enabled"] = True
|
|
118
|
+
for item in env_keys.split(","):
|
|
119
|
+
parts = item.strip().split(":")
|
|
120
|
+
if len(parts) == 3:
|
|
121
|
+
cfg["auth"]["keys"].append({"name": parts[0], "key": parts[1], "role": parts[2]})
|
|
122
|
+
file_keys = load_keys(data_dir) # created with `agentdynamics keys create`
|
|
123
|
+
if file_keys:
|
|
124
|
+
cfg["auth"]["enabled"] = True
|
|
125
|
+
cfg["auth"]["keys"] = list(cfg["auth"]["keys"]) + file_keys
|
|
126
|
+
if os.environ.get("AGENTDYNAMICS_STORE_CONTENT") in ("0", "false"):
|
|
127
|
+
cfg["privacy"]["store_content"] = False
|
|
128
|
+
if os.environ.get("AGENTDYNAMICS_RETENTION_DAYS"):
|
|
129
|
+
cfg["retention"]["days"] = int(os.environ["AGENTDYNAMICS_RETENTION_DAYS"])
|
|
130
|
+
return cfg
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def public_view(cfg):
|
|
134
|
+
"""Config safe to show in the UI (no secrets)."""
|
|
135
|
+
v = copy.deepcopy(cfg)
|
|
136
|
+
v["auth"]["keys"] = [{"name": k.get("name"), "role": k.get("role"), "key": (k.get("key") or "")[:6] + "…"} for k in v["auth"]["keys"]]
|
|
137
|
+
for w in v["alerts"]["webhooks"]:
|
|
138
|
+
w["url"] = (w.get("url") or "")[:28] + "…"
|
|
139
|
+
return v
|