agentdynamics 0.6.0__tar.gz → 0.8.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 (76) hide show
  1. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/PKG-INFO +15 -4
  2. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/README.md +12 -3
  3. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/__init__.py +1 -1
  4. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/__main__.py +103 -3
  5. agentdynamics-0.8.0/agentdynamics/alerts.py +272 -0
  6. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/analysis.py +79 -16
  7. agentdynamics-0.8.0/agentdynamics/api/__init__.py +1 -0
  8. agentdynamics-0.8.0/agentdynamics/api/assess.py +160 -0
  9. agentdynamics-0.8.0/agentdynamics/api/base.py +191 -0
  10. agentdynamics-0.8.0/agentdynamics/api/diagnose.py +87 -0
  11. agentdynamics-0.8.0/agentdynamics/api/governance.py +169 -0
  12. agentdynamics-0.8.0/agentdynamics/api/monitor.py +270 -0
  13. agentdynamics-0.8.0/agentdynamics/api/ops.py +142 -0
  14. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/config.py +35 -4
  15. agentdynamics-0.8.0/agentdynamics/engine.py +935 -0
  16. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/flows.py +3 -2
  17. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/integrations/aegis.py +158 -5
  18. agentdynamics-0.8.0/agentdynamics/pg.py +492 -0
  19. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/privacy.py +1 -0
  20. agentdynamics-0.8.0/agentdynamics/server.py +419 -0
  21. agentdynamics-0.8.0/agentdynamics/slo.py +185 -0
  22. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/store.py +217 -6
  23. agentdynamics-0.8.0/agentdynamics/web/app.js +224 -0
  24. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/web/index.html +5 -0
  25. agentdynamics-0.8.0/agentdynamics/web/pages/assess.js +117 -0
  26. agentdynamics-0.8.0/agentdynamics/web/pages/configure.js +224 -0
  27. agentdynamics-0.8.0/agentdynamics/web/pages/diagnose.js +70 -0
  28. agentdynamics-0.8.0/agentdynamics/web/pages/governance.js +125 -0
  29. agentdynamics-0.8.0/agentdynamics/web/pages/monitor.js +449 -0
  30. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/PKG-INFO +15 -4
  31. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/SOURCES.txt +19 -0
  32. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/requires.txt +3 -0
  33. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/pyproject.toml +3 -2
  34. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_aegis_integration.py +118 -0
  35. agentdynamics-0.8.0/tests/test_alerts.py +414 -0
  36. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_core.py +123 -3
  37. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_incremental.py +132 -15
  38. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_outcomes.py +2 -2
  39. agentdynamics-0.8.0/tests/test_postgres.py +373 -0
  40. agentdynamics-0.8.0/tests/test_revocations.py +200 -0
  41. agentdynamics-0.8.0/tests/test_rollups.py +179 -0
  42. agentdynamics-0.8.0/tests/test_scoped_keys.py +378 -0
  43. agentdynamics-0.6.0/agentdynamics/engine.py +0 -437
  44. agentdynamics-0.6.0/agentdynamics/server.py +0 -1041
  45. agentdynamics-0.6.0/agentdynamics/slo.py +0 -84
  46. agentdynamics-0.6.0/agentdynamics/web/app.js +0 -1116
  47. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/LICENSE +0 -0
  48. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/autotrace.py +0 -0
  49. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  50. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/__init__.py +0 -0
  51. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  52. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/claude_code.py +0 -0
  53. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/generic.py +0 -0
  54. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/inbox.py +0 -0
  55. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langfuse.py +0 -0
  56. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/langsmith.py +0 -0
  57. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/otlp.py +0 -0
  58. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/collectors/spans.py +0 -0
  59. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/govern.py +0 -0
  60. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/integrations/__init__.py +0 -0
  61. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/phases.py +0 -0
  62. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/pricing.py +0 -0
  63. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/sdk.py +0 -0
  64. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/web/charts.js +0 -0
  65. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics/web/style.css +0 -0
  66. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  67. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  68. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/agentdynamics.egg-info/top_level.txt +0 -0
  69. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/setup.cfg +0 -0
  70. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_autotrace.py +0 -0
  71. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_console_ui.py +0 -0
  72. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_ecosystem.py +0 -0
  73. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_integrations.py +0 -0
  74. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_live_anthropic.py +0 -0
  75. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_policy_coverage.py +0 -0
  76. {agentdynamics-0.6.0 → agentdynamics-0.8.0}/tests/test_token_accounting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentdynamics
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
@@ -14,6 +14,8 @@ Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Provides-Extra: toml
16
16
  Requires-Dist: tomli; python_version < "3.11" and extra == "toml"
17
+ Provides-Extra: postgres
18
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
17
19
  Provides-Extra: test
18
20
  Requires-Dist: langchain-core==0.2.33; extra == "test"
19
21
  Requires-Dist: langsmith==0.1.99; extra == "test"
@@ -146,7 +148,7 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
146
148
  | **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
147
149
  | **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
148
150
  | **Baselines & Apdex** | "Normal" cost and latency per workflow, learned automatically, plus an agent Apdex score |
149
- | **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
151
+ | **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack, PagerDuty or webhooks, routed by project, rule and severity |
150
152
  | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
151
153
  | **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
152
154
  | **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
@@ -203,12 +205,21 @@ Telemetry is sent from a background thread with a bounded queue. Your agent neve
203
205
  agentdynamics keys create --role ingest # for apps / collectors
204
206
  agentdynamics keys create --role read # for people and Grafana
205
207
  agentdynamics keys create --role admin # for rules, SLOs and config
208
+ agentdynamics keys create --role read --project checkout # one team: sees and sends only its projects
206
209
  docker compose -f deploy/docker-compose.yml up -d # AgentDynamics + OpenTelemetry Collector gateway
207
210
  ```
208
211
 
209
- - **Security:** role-based API keys.
212
+ The store is a SQLite file by default. For several instances behind a load balancer, or a database you back up
213
+ and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
214
+ `AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
215
+ write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
216
+
217
+ - **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
218
+ runs, steps and events, on every endpoint, and can't write into or over another project's traces.
210
219
  - **Privacy:** redaction of emails, API keys and card numbers, or `store_content = false` to keep metadata only.
211
- - **Operations:** retention, Prometheus `/metrics`, `/healthz`, Slack and webhook alerts, and a status page for every source.
220
+ - **Operations:** retention, Prometheus `/metrics`, `/healthz`, a status page for every source, and alerts to Slack,
221
+ PagerDuty or any webhook: health-rule events and SLO burn-rate pages, delivered in order with retries
222
+ (`agentdynamics alerts test` checks a destination end to end).
212
223
 
213
224
  See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
214
225
 
@@ -123,7 +123,7 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
123
123
  | **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
124
124
  | **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
125
125
  | **Baselines & Apdex** | "Normal" cost and latency per workflow, learned automatically, plus an agent Apdex score |
126
- | **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
126
+ | **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack, PagerDuty or webhooks, routed by project, rule and severity |
127
127
  | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
128
128
  | **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
129
129
  | **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
@@ -180,12 +180,21 @@ Telemetry is sent from a background thread with a bounded queue. Your agent neve
180
180
  agentdynamics keys create --role ingest # for apps / collectors
181
181
  agentdynamics keys create --role read # for people and Grafana
182
182
  agentdynamics keys create --role admin # for rules, SLOs and config
183
+ agentdynamics keys create --role read --project checkout # one team: sees and sends only its projects
183
184
  docker compose -f deploy/docker-compose.yml up -d # AgentDynamics + OpenTelemetry Collector gateway
184
185
  ```
185
186
 
186
- - **Security:** role-based API keys.
187
+ The store is a SQLite file by default. For several instances behind a load balancer, or a database you back up
188
+ and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
189
+ `AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
190
+ write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
191
+
192
+ - **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
193
+ runs, steps and events, on every endpoint, and can't write into or over another project's traces.
187
194
  - **Privacy:** redaction of emails, API keys and card numbers, or `store_content = false` to keep metadata only.
188
- - **Operations:** retention, Prometheus `/metrics`, `/healthz`, Slack and webhook alerts, and a status page for every source.
195
+ - **Operations:** retention, Prometheus `/metrics`, `/healthz`, a status page for every source, and alerts to Slack,
196
+ PagerDuty or any webhook: health-rule events and SLO burn-rate pages, delivered in order with retries
197
+ (`agentdynamics alerts test` checks a destination end to end).
189
198
 
190
199
  See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
191
200
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  See agentdynamics.autotrace for @trace / @tool / span.
7
7
  """
8
- __version__ = "0.6.0"
8
+ __version__ = "0.8.0"
9
9
 
10
10
  from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
11
11
 
@@ -142,22 +142,102 @@ def cmd_doctor(a):
142
142
  return 0 if ok else 1
143
143
 
144
144
 
145
+ def cmd_alerts(a, data):
146
+ from . import alerts as alertmod, config as cfgmod, store
147
+ cfg = cfgmod.load(data)
148
+ dests, problems = alertmod.destinations(cfg["alerts"])
149
+ for p in problems:
150
+ print(f"ignored: {p}")
151
+ if not dests:
152
+ print("No alert destinations configured: add [[alerts.webhooks]] to agentdynamics.toml "
153
+ "(see `python -c \"import agentdynamics.alerts; help(agentdynamics.alerts)\"`).")
154
+ return 1
155
+ if a.action == "status":
156
+ con = store.connect(*store.target(cfg, data))
157
+ depth = store.outbox_depth(con)
158
+ oldest = {r["dest"]: dict(r) for r in con.execute(
159
+ "SELECT dest, created, attempts, last_error FROM alert_outbox WHERE id IN "
160
+ "(SELECT MIN(id) FROM alert_outbox GROUP BY dest)")}
161
+ for d in dests:
162
+ print(f"{d['id']:<28} {d['format']:<10} {'+'.join(d['kinds']):<12} min {d['min_severity']:<8} "
163
+ f"queued {depth.get(d['id'], 0)}")
164
+ o = oldest.get(d["id"])
165
+ if o and o["attempts"]:
166
+ print(f" retrying since {time.strftime('%Y-%m-%d %H:%M', time.localtime(o['created']))} "
167
+ f"({o['attempts']} attempts): {o['last_error']}")
168
+ firing = store.alert_state(con)
169
+ print(f"\nSLO alerts firing: {len(firing) or 'none'}")
170
+ for k, v in sorted(firing.items(), key=lambda kv: kv[1]["since"]):
171
+ print(f" {k} since {time.strftime('%Y-%m-%d %H:%M', time.localtime(v['since']))} {v.get('severity')}")
172
+ return 0
173
+ failed = 0
174
+ chosen = [d for d in dests if not a.to or d["id"] == a.to]
175
+ if not chosen:
176
+ print(f"no destination named {a.to!r}; names: {', '.join(d['id'] for d in dests)}")
177
+ return 2
178
+ for d in chosen:
179
+ alert = alertmod.test_alert()
180
+ bodies = alertmod.render(d, [alert], cfg["alerts"].get("console_url") or "")
181
+ if d["format"] == "pagerduty": # resolve it straight away, so a test leaves no open incident
182
+ bodies += alertmod.render(d, [dict(alert, action="resolve")])
183
+ results = [alertmod.send(d, b) for b in bodies]
184
+ ok = all(r[0] for r in results)
185
+ failed += not ok
186
+ print(f"{d['id']:<28} {'ok' if ok else 'FAILED'} {'; '.join(r[2] for r in results)}")
187
+ return 1 if failed else 0
188
+
189
+
190
+ def cmd_revoke(a, data):
191
+ from . import config as cfgmod, store
192
+ con = store.connect(*store.target(cfgmod.load(data), data))
193
+ now = time.time()
194
+ if a.clear:
195
+ n = store.clear_revocation(con, a.clear, now)
196
+ print("cleared" if n else f"no active directive {a.clear}")
197
+ return 0 if n else 1
198
+ if a.list:
199
+ rows_ = store.revocations(con, now)
200
+ for d in rows_:
201
+ status = "cleared" if d["cleared"] else "expired" if d["expires"] <= now else "active"
202
+ print(f"{d['id']} {status:<8} {d['project'] or '(all projects)':<18} {d['agent'] or '(all agents)':<18} "
203
+ f"until {time.strftime('%Y-%m-%d %H:%M', time.localtime(d['expires']))} [{d['source']}] {d['reason']}")
204
+ if not rows_:
205
+ print("no directives")
206
+ return 0
207
+ if not a.reason:
208
+ print("--reason is required: it goes into the kernel's audit log")
209
+ return 2
210
+ rid = store.add_revocation(con, a.project, a.agent, a.reason, "operator", now, now + a.minutes * 60)
211
+ print(f"{rid}: {'agent ' + a.agent if a.agent else 'every agent'} in {a.project or 'every project'} is revoked "
212
+ f"for {a.minutes:g} min, in processes using agentdynamics.integrations.aegis.instrument(..., revocations=True)")
213
+ return 0
214
+
215
+
145
216
  def cmd_keys(a, data):
146
217
  from .config import keys_path, load_keys, save_keys
147
218
  keys = load_keys(data)
148
219
  if a.action == "create":
220
+ if a.project and a.role == "admin":
221
+ print("admin keys cover the whole install and cannot be scoped to projects; "
222
+ "use --role read or --role ingest", file=sys.stderr)
223
+ return 2
149
224
  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
225
  "created": int(time.time())}
226
+ if a.project:
227
+ k["projects"] = sorted(set(a.project))
151
228
  keys.append(k)
152
229
  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).")
230
+ scope = f" for project(s) {', '.join(k['projects'])}" if a.project else ""
231
+ print(f"Created {k['role']} key '{k['name']}'{scope}:\n\n {k['key']}\n\nAuth is now ON for the server using {keys_path(data)} (restart it).")
154
232
  if a.role == "ingest":
155
233
  print(f"Use it in your app: export AGENTDYNAMICS_API_KEY={k['key']}")
156
234
  elif a.action == "list":
157
235
  if not keys:
158
236
  print("No keys: auth is off (local mode).")
159
237
  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)))}")
238
+ scope = ", ".join(k["projects"]) or "no projects" if isinstance(k.get("projects"), list) else "all projects"
239
+ print(f" {k['name']:<20} {k['role']:<7} {k['key'][:8]}... created "
240
+ f"{time.strftime('%Y-%m-%d', time.localtime(k.get('created', 0)))} {scope}")
161
241
  elif a.action == "revoke":
162
242
  left = [k for k in keys if k["name"] != a.name and not k["key"].startswith(a.name or "\0")]
163
243
  save_keys(data, left)
@@ -297,6 +377,8 @@ def main(argv=None):
297
377
  k.add_argument("action", choices=["create", "list", "revoke"])
298
378
  k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
299
379
  k.add_argument("--name", default=None)
380
+ k.add_argument("--project", action="append", default=None, metavar="NAME",
381
+ help="scope a read or ingest key to this project (repeat for several); omit for all projects")
300
382
  po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
301
383
  po.add_argument("action", choices=["export", "report", "check"],
302
384
  help="export: generate a tightened policy; report: governance summary; "
@@ -313,6 +395,18 @@ def main(argv=None):
313
395
  po.add_argument("--max-denied-fraction", type=float, default=0.0,
314
396
  help="fail if the policy would refuse more than this share of observed, allowed calls "
315
397
  "(export, check; default 0: any refusal fails)")
398
+ al = sub.add_parser("alerts", help="alert destinations: what is queued and firing, or send a test alert")
399
+ al.add_argument("action", choices=["status", "test"],
400
+ help="status: destinations, queue and firing SLO alerts; test: send a test alert to each")
401
+ al.add_argument("--to", metavar="NAME", help="test only this destination (its name, as status shows it)")
402
+ rv = sub.add_parser("revoke", help="revoke an agent's Aegis grants wherever it runs (applied in-process by "
403
+ "agentdynamics.integrations.aegis), or list/clear directives")
404
+ rv.add_argument("--agent", help="the agent (grant) name; omit to revoke every grant in the project")
405
+ rv.add_argument("--project", help="omit for every project")
406
+ rv.add_argument("--reason", help="why (it goes into the kernel's audit log)")
407
+ rv.add_argument("--minutes", type=float, default=60, help="how long new grants of that agent are refused too")
408
+ rv.add_argument("--list", action="store_true", help="list directives")
409
+ rv.add_argument("--clear", metavar="ID", help="stop a directive (grants it revoked stay revoked)")
316
410
  sub.add_parser("ingest", help="scan sources once and rebuild the database")
317
411
  rp = sub.add_parser("report", help="print a text summary")
318
412
  rp.add_argument("--project")
@@ -335,6 +429,10 @@ def main(argv=None):
335
429
  os.makedirs(a.data, exist_ok=True)
336
430
  if cmd == "keys":
337
431
  return cmd_keys(a, a.data)
432
+ if cmd == "alerts":
433
+ return cmd_alerts(a, a.data)
434
+ if cmd == "revoke":
435
+ return cmd_revoke(a, a.data)
338
436
 
339
437
  from .engine import Engine
340
438
  eng = Engine(a.data, a.claude_root or None)
@@ -347,7 +445,9 @@ def main(argv=None):
347
445
  eng.refresh()
348
446
  return 0
349
447
  eng.refresh(force=True)
350
- print(f"indexed in {eng.last_duration}s -> {os.path.join(a.data, 'agentdynamics.db')}", file=sys.stderr)
448
+ from .pg import redact_url
449
+ where = f"{redact_url(eng.db_path)} (schema {eng.db_schema})" if eng.db_schema else eng.db_path
450
+ print(f"indexed in {eng.last_duration}s -> {where}", file=sys.stderr)
351
451
  if cmd == "ingest":
352
452
  return 0
353
453
  if cmd == "policy":
@@ -0,0 +1,272 @@
1
+ """Alert routing: health-rule events and SLO burn rates, to Slack, PagerDuty and JSON webhooks.
2
+
3
+ Destinations are `[[alerts.webhooks]]` in agentdynamics.toml:
4
+
5
+ [alerts]
6
+ console_url = "https://agentdynamics.internal" # optional: alerts link to the task / SLO page
7
+
8
+ [[alerts.webhooks]]
9
+ name = "sre-pager" # optional; names it in `agentdynamics alerts status/test`
10
+ format = "pagerduty" # json (default) | slack | pagerduty
11
+ routing_key_env = "PD_ROUTING_KEY" # PagerDuty Events API v2 integration key (or routing_key = "...")
12
+ min_severity = "critical" # info | warning (default) | critical
13
+ kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts,
14
+ # "revocations" the directives that stop an agent (server-side #8)
15
+ projects = ["checkout"] # optional: only these projects
16
+ rules = ["run_failed"] # optional: only these health rules
17
+
18
+ What is sent:
19
+ - A health-rule event is a point in time. Slack and JSON get each one (JSON in the original
20
+ `{"source": "agentdynamics", "events": [...]}` shape, a contract). PagerDuty gets one alert per rule,
21
+ project and task type (the dedup key), so a burst of runaway-cost tasks is one incident, not fifty.
22
+ - An SLO alert has a start and an end: a trigger when a burn-rate policy starts firing and a resolve when
23
+ it stops (slo.alert_conditions). What is firing is kept in the durable `alert_state` table, so a restart
24
+ neither repeats a trigger nor forgets to resolve.
25
+
26
+ Delivery is at least once, in order per destination, through the durable `alert_outbox` table: a failed
27
+ send is retried with backoff (429, 5xx, network errors) for up to a day; any other 4xx is a configuration
28
+ error and is dropped and reported. No secret is stored: bodies are queued without the PagerDuty routing key,
29
+ and destinations are identified by name or a hash, never by URL.
30
+ """
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import time
35
+ import urllib.error
36
+ import urllib.request
37
+ from collections import OrderedDict
38
+ from urllib.parse import quote
39
+
40
+ FORMATS = ("json", "slack", "pagerduty")
41
+ KINDS = ("events", "slos", "revocations")
42
+ SEV = {"info": 1, "warning": 2, "critical": 3}
43
+ # https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/events-v2/openapiv3.json
44
+ PAGERDUTY_URL = "https://events.pagerduty.com/v2/enqueue"
45
+ PAGERDUTY_SEVERITY = {"critical": "critical", "warning": "warning", "info": "info"}
46
+ SLACK_LINES = 10 # a Slack message lists this many alerts, then "and N more"
47
+ GIVE_UP_S = 86400 # a message still failing after a day is dropped
48
+ MAX_BACKOFF_S = 3600
49
+
50
+
51
+ def backoff(attempts):
52
+ return min(MAX_BACKOFF_S, 15 * 2 ** attempts)
53
+
54
+
55
+ # ---------------------------------------------------------------- destinations
56
+
57
+ def destinations(alerts_cfg, env=None):
58
+ """([destination], [problem]) from the [alerts] config. A misconfigured destination is reported, not
59
+ raised: one bad entry must not stop the others, or the server."""
60
+ env = os.environ if env is None else env
61
+ out, problems, seen = [], [], set()
62
+ for i, h in enumerate((alerts_cfg or {}).get("webhooks") or []):
63
+ h = dict(h)
64
+ label = h.get("name") or f"webhook #{i + 1}"
65
+ fmt = h.get("format") or "json"
66
+ if fmt not in FORMATS:
67
+ problems.append(f"{label}: unknown format {fmt!r}; use one of {', '.join(FORMATS)}")
68
+ continue
69
+ kinds = h.get("kinds") or ["events"]
70
+ bad = [k for k in kinds if k not in KINDS]
71
+ if bad:
72
+ problems.append(f"{label}: unknown kinds {bad}; use {list(KINDS)}")
73
+ continue
74
+ if h.get("min_severity", "warning") not in SEV:
75
+ problems.append(f"{label}: min_severity must be one of {', '.join(SEV)}")
76
+ continue
77
+ secret = ""
78
+ if fmt == "pagerduty":
79
+ h.setdefault("url", PAGERDUTY_URL)
80
+ secret = h.get("routing_key") or env.get(h.get("routing_key_env") or "", "")
81
+ if not secret:
82
+ problems.append(f"{label}: a pagerduty destination needs routing_key or routing_key_env"
83
+ + (f" ({h['routing_key_env']} is not set)" if h.get("routing_key_env") else ""))
84
+ continue
85
+ if not h.get("url"):
86
+ problems.append(f"{label}: no url")
87
+ continue
88
+ did = h.get("name") or f"{fmt}-{hashlib.sha256((h['url'] + secret).encode()).hexdigest()[:10]}"
89
+ if did in seen:
90
+ problems.append(f"{label}: duplicate destination name {did!r}")
91
+ continue
92
+ seen.add(did)
93
+ out.append({"id": did, "format": fmt, "url": h["url"], "secret": secret, "kinds": list(kinds),
94
+ "min_severity": h.get("min_severity", "warning"),
95
+ "projects": h.get("projects"), "rules": h.get("rules")})
96
+ return out, problems
97
+
98
+
99
+ def routes(dest, alert):
100
+ """Whether this destination takes this alert."""
101
+ if alert["kind"] + "s" not in dest["kinds"]:
102
+ return False
103
+ if SEV.get(alert["severity"], 1) < SEV[dest["min_severity"]]:
104
+ return False
105
+ if dest["projects"] is not None and alert.get("project") not in dest["projects"]:
106
+ return False # an install-wide SLO has no project, so it goes only to unfiltered destinations
107
+ if dest["rules"] is not None and alert["kind"] == "event" and alert.get("rule_id") not in dest["rules"]:
108
+ return False
109
+ return True
110
+
111
+
112
+ # ---------------------------------------------------------------- alerts
113
+
114
+ def from_event(e):
115
+ return {"kind": "event", "action": "trigger", "key": f"event/{e['rule_id']}/{e['project']}/{e['task_type']}",
116
+ "severity": e["severity"], "summary": f"{e['rule']}: {e['message']}", "project": e["project"],
117
+ "rule_id": e["rule_id"], "task_type": e["task_type"], "ts": e.get("ts"), "event": e}
118
+
119
+
120
+ def slo_summary(c, action="trigger"):
121
+ scope = " ".join(f"{k}={v}" for k, v in (c.get("scope") or {}).items() if v)
122
+ where = f" [{scope}]" if scope else ""
123
+ if action == "resolve":
124
+ what = "is back within its objective" if c["alert"] == "breach" else \
125
+ f"is no longer burning its error budget fast enough to {c['alert']}"
126
+ return f"Resolved: SLO '{c['name']}'{where} {what}"
127
+ if c["policy"] == "breach":
128
+ return f"SLO '{c['name']}'{where} breached: {c['value']:.4g} against a target of {c['op']} {c['target']}"
129
+ return (f"SLO '{c['name']}'{where} burning its error budget at {c['burn_rate']}x "
130
+ f"(threshold {c['threshold']}x: {c['budget']:.0%} of the budget in {c['long_h']}h)")
131
+
132
+
133
+ def from_slo(key, c, action, now):
134
+ return {"kind": "slo", "action": action, "key": key, "severity": c["severity"],
135
+ "summary": slo_summary(c, action),
136
+ "project": (c.get("scope") or {}).get("project") or None, "slo": c["slo"], "ts": now, "condition": c}
137
+
138
+
139
+ def from_revocation(d):
140
+ who = f"agent {d['agent']}" if d.get("agent") else "every agent"
141
+ where = f" in project {d['project']}" if d.get("project") else ""
142
+ until = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["expires"]))
143
+ return {"kind": "revocation", "action": "trigger", "key": f"revocation/{d['id']}", "severity": "critical",
144
+ "summary": f"Revoked {who}{where} until {until}: {d['reason']}", "project": d.get("project"),
145
+ "ts": d["created"], "revocation": d}
146
+
147
+
148
+ # ---------------------------------------------------------------- formats
149
+
150
+ def _dedup_key(key):
151
+ key = "agentdynamics/" + key
152
+ return key if len(key) <= 255 else key[:200] + "#" + hashlib.sha256(key.encode()).hexdigest()[:32]
153
+
154
+
155
+ def link(console_url, alert):
156
+ if not console_url:
157
+ return None
158
+ base = console_url.rstrip("/")
159
+ if alert["kind"] == "event":
160
+ return f"{base}/#/task/{quote(alert['event']['task_id'], safe='')}"
161
+ if alert["kind"] == "revocation":
162
+ return f"{base}/#/governance"
163
+ return f"{base}/#/slos"
164
+
165
+
166
+ def _slack_escape(s):
167
+ # Slack mrkdwn: these three are control characters in message text
168
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
169
+
170
+
171
+ def _iso(ts):
172
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts or time.time()))
173
+
174
+
175
+ def render(dest, alerts, console_url=""):
176
+ """The request bodies that deliver `alerts` to `dest`, in order. Secrets are added at send time."""
177
+ fmt = dest["format"]
178
+ if fmt == "json":
179
+ bodies = []
180
+ events = [a["event"] for a in alerts if a["kind"] == "event"]
181
+ if events:
182
+ bodies.append({"source": "agentdynamics", "events": events})
183
+ slos = [{"action": a["action"], "key": a["key"], "severity": a["severity"], "summary": a["summary"],
184
+ "link": link(console_url, a), **a["condition"]} for a in alerts if a["kind"] == "slo"]
185
+ if slos:
186
+ bodies.append({"source": "agentdynamics", "slo_alerts": slos})
187
+ revs = [dict(a["revocation"], summary=a["summary"]) for a in alerts if a["kind"] == "revocation"]
188
+ if revs:
189
+ bodies.append({"source": "agentdynamics", "revocations": revs})
190
+ return bodies
191
+ # one line (Slack) or one alert (PagerDuty) per dedup key, so a burst of the same violation reads as one
192
+ groups = OrderedDict()
193
+ for a in alerts:
194
+ groups.setdefault(a["key"], []).append(a)
195
+ if fmt == "slack":
196
+ lines = []
197
+ for g in list(groups.values())[:SLACK_LINES]:
198
+ a = g[-1]
199
+ head = "RESOLVED" if a["action"] == "resolve" else a["severity"].upper()
200
+ where = " / ".join(_slack_escape(x) for x in (a.get("project"), a.get("task_type")) if x)
201
+ url = link(console_url, a)
202
+ ref = f" · <{url}|{'latest' if len(g) > 1 else 'open'}>" if url else ""
203
+ if a["kind"] == "revocation":
204
+ lines.append(f"*REVOKED* · {_slack_escape(a['summary'])}{ref}")
205
+ continue
206
+ if a["kind"] == "event":
207
+ e = a["event"]
208
+ times = f" ×{len(g)}" if len(g) > 1 else ""
209
+ lines.append(f"*{head}* · {_slack_escape(e['rule'])}{times} · {where}{ref}\n{_slack_escape(e['message'])}")
210
+ else:
211
+ lines.append(f"*{head}* · {_slack_escape(a['summary'])}{ref}")
212
+ if len(groups) > SLACK_LINES:
213
+ lines.append(f"…and {len(groups) - SLACK_LINES} more")
214
+ return [{"text": "AgentDynamics alerts\n" + "\n\n".join(lines)}] if lines else []
215
+ bodies = []
216
+ for key, g in groups.items():
217
+ a = g[-1]
218
+ if a["action"] == "resolve":
219
+ bodies.append({"event_action": "resolve", "dedup_key": _dedup_key(key)})
220
+ continue
221
+ if a["kind"] == "revocation":
222
+ d = a["revocation"]
223
+ details, group, klass = dict(d), d.get("agent") or "all agents", "revocation"
224
+ elif a["kind"] == "event":
225
+ e = a["event"]
226
+ details = {"rule": e["rule"], "rule_id": e["rule_id"], "message": e["message"], "value": e.get("value"),
227
+ "task_id": e["task_id"], "run_id": e.get("run_id"), "project": e["project"],
228
+ "task_type": e["task_type"], "occurrences": len(g)}
229
+ group, klass = e["task_type"], e["rule_id"]
230
+ else:
231
+ details, group, klass = dict(a["condition"]), a["slo"], "slo_" + a["condition"]["alert"]
232
+ body = {"event_action": "trigger", "dedup_key": _dedup_key(key),
233
+ "payload": {"summary": a["summary"][:1024], "source": "agentdynamics",
234
+ "severity": PAGERDUTY_SEVERITY.get(a["severity"], "warning"),
235
+ "timestamp": _iso(a.get("ts")), "component": a.get("project") or "all projects",
236
+ "group": group, "class": klass, "custom_details": details},
237
+ "client": "AgentDynamics"}
238
+ url = link(console_url, a)
239
+ if url:
240
+ body["client_url"] = console_url
241
+ body["links"] = [{"href": url, "text": "Open in AgentDynamics"}]
242
+ bodies.append(body)
243
+ return bodies
244
+
245
+
246
+ # ---------------------------------------------------------------- sending
247
+
248
+ def send(dest, body, timeout=10):
249
+ """POST one body. Returns (ok, retryable, detail)."""
250
+ if dest["format"] == "pagerduty":
251
+ body = dict(body, routing_key=dest["secret"])
252
+ req = urllib.request.Request(dest["url"], data=json.dumps(body, default=str).encode(), method="POST",
253
+ headers={"Content-Type": "application/json", "User-Agent": "agentdynamics"})
254
+ try:
255
+ with urllib.request.urlopen(req, timeout=timeout) as r:
256
+ return True, False, f"HTTP {r.status}"
257
+ except urllib.error.HTTPError as ex:
258
+ text = ex.read(300).decode("utf-8", "replace").strip()
259
+ return False, ex.code == 429 or ex.code >= 500, f"HTTP {ex.code}: {text}"[:300]
260
+ except (urllib.error.URLError, OSError) as ex:
261
+ return False, True, f"{type(ex).__name__}: {getattr(ex, 'reason', ex)}"[:300]
262
+
263
+
264
+ def test_alert(now=None):
265
+ """A harmless alert for `agentdynamics alerts test`: a trigger, and for PagerDuty the resolve after it."""
266
+ now = now or time.time()
267
+ c = {"slo": "agentdynamics-test", "name": "AgentDynamics test alert", "alert": "breach", "policy": "breach",
268
+ "severity": "info",
269
+ "metric": "success_rate", "op": ">=", "target": 1, "value": 0, "window_days": 1, "scope": {}, "tasks": 0}
270
+ a = from_slo("test/agentdynamics", c, "trigger", now)
271
+ a["summary"] = "AgentDynamics test alert: this destination is configured correctly"
272
+ return a