agentdynamics 0.7.0__tar.gz → 0.9.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 (97) hide show
  1. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/PKG-INFO +14 -6
  2. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/README.md +13 -5
  3. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/__init__.py +1 -1
  4. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/__main__.py +87 -0
  5. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/alerts.py +50 -3
  6. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/analysis.py +473 -76
  7. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/assess.py +28 -4
  8. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/base.py +33 -3
  9. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/diagnose.py +52 -1
  10. agentdynamics-0.9.0/agentdynamics/api/governance.py +327 -0
  11. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/monitor.py +7 -2
  12. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/ops.py +2 -0
  13. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/autotrace.py +45 -7
  14. agentdynamics-0.9.0/agentdynamics/calibrate.py +173 -0
  15. agentdynamics-0.9.0/agentdynamics/checker.py +197 -0
  16. agentdynamics-0.9.0/agentdynamics/collectors/billing.py +76 -0
  17. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/claude_code.py +6 -1
  18. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/generic.py +5 -1
  19. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/langfuse.py +2 -1
  20. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/langsmith.py +1 -0
  21. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/otlp.py +1 -1
  22. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/spans.py +26 -1
  23. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/config.py +57 -1
  24. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/engine.py +415 -5
  25. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/govern.py +15 -0
  26. agentdynamics-0.9.0/agentdynamics/incidents.py +123 -0
  27. agentdynamics-0.9.0/agentdynamics/integrations/aegis.py +766 -0
  28. agentdynamics-0.9.0/agentdynamics/migrate.py +169 -0
  29. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/pg.py +31 -4
  30. agentdynamics-0.9.0/agentdynamics/pricing.py +111 -0
  31. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/privacy.py +11 -1
  32. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/server.py +101 -7
  33. agentdynamics-0.9.0/agentdynamics/sketch.py +73 -0
  34. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/slo.py +6 -2
  35. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/store.py +314 -10
  36. agentdynamics-0.9.0/agentdynamics/tripwires.py +96 -0
  37. agentdynamics-0.9.0/agentdynamics/trust.py +147 -0
  38. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/app.js +8 -5
  39. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/charts.js +2 -2
  40. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/pages/assess.js +21 -5
  41. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/pages/diagnose.js +17 -4
  42. agentdynamics-0.9.0/agentdynamics/web/pages/governance.js +330 -0
  43. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/pages/monitor.js +25 -10
  44. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/PKG-INFO +14 -6
  45. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/SOURCES.txt +21 -1
  46. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/pyproject.toml +1 -1
  47. agentdynamics-0.9.0/tests/test_aegis_integration.py +843 -0
  48. agentdynamics-0.9.0/tests/test_allocation.py +84 -0
  49. agentdynamics-0.9.0/tests/test_apdex.py +169 -0
  50. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_autotrace.py +21 -1
  51. agentdynamics-0.9.0/tests/test_billing.py +166 -0
  52. agentdynamics-0.9.0/tests/test_calibrate.py +170 -0
  53. agentdynamics-0.9.0/tests/test_checker.py +196 -0
  54. agentdynamics-0.9.0/tests/test_console_interaction.py +422 -0
  55. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_core.py +117 -0
  56. agentdynamics-0.9.0/tests/test_ground_truth.py +266 -0
  57. agentdynamics-0.9.0/tests/test_incidents.py +350 -0
  58. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_incremental.py +141 -3
  59. agentdynamics-0.9.0/tests/test_live_anthropic.py +123 -0
  60. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_policy_coverage.py +66 -0
  61. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_postgres.py +297 -42
  62. agentdynamics-0.9.0/tests/test_revocations.py +253 -0
  63. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_rollups.py +52 -1
  64. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_scoped_keys.py +14 -1
  65. agentdynamics-0.9.0/tests/test_sketch.py +49 -0
  66. agentdynamics-0.9.0/tests/test_tripwires.py +211 -0
  67. agentdynamics-0.9.0/tests/test_trust.py +222 -0
  68. agentdynamics-0.7.0/agentdynamics/api/governance.py +0 -160
  69. agentdynamics-0.7.0/agentdynamics/integrations/aegis.py +0 -365
  70. agentdynamics-0.7.0/agentdynamics/pricing.py +0 -69
  71. agentdynamics-0.7.0/agentdynamics/web/pages/governance.js +0 -87
  72. agentdynamics-0.7.0/tests/test_aegis_integration.py +0 -335
  73. agentdynamics-0.7.0/tests/test_live_anthropic.py +0 -66
  74. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/LICENSE +0 -0
  75. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/api/__init__.py +0 -0
  76. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  77. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/__init__.py +0 -0
  78. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  79. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/collectors/inbox.py +0 -0
  80. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/flows.py +0 -0
  81. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/integrations/__init__.py +0 -0
  82. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/phases.py +0 -0
  83. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/sdk.py +0 -0
  84. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/index.html +0 -0
  85. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/pages/configure.js +0 -0
  86. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics/web/style.css +0 -0
  87. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  88. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  89. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/requires.txt +0 -0
  90. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/agentdynamics.egg-info/top_level.txt +0 -0
  91. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/setup.cfg +0 -0
  92. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_alerts.py +0 -0
  93. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_console_ui.py +0 -0
  94. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_ecosystem.py +0 -0
  95. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_integrations.py +0 -0
  96. {agentdynamics-0.7.0 → agentdynamics-0.9.0}/tests/test_outcomes.py +0 -0
  97. {agentdynamics-0.7.0 → agentdynamics-0.9.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.7.0
3
+ Version: 0.9.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
@@ -70,7 +70,9 @@ def handle_ticket(question):
70
70
 
71
71
  Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
72
72
  which outcomes were stated and which were guessed; grades can also be posted afterwards by a person or an
73
- eval pipeline (`POST /api/outcomes`).
73
+ eval pipeline (`POST /api/outcomes`), or by your own systems using your own ids: trace with
74
+ `agentdynamics.trace("support", ticket_id="T-123")` and post `{"key": {"ticket_id": "T-123"}, "outcome": "rework"}`
75
+ when the ticket is reopened.
74
76
 
75
77
  ### 2. Python, zero code changes
76
78
 
@@ -148,10 +150,11 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
148
150
  | **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
149
151
  | **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
150
152
  | **Baselines & Apdex** | "Normal" cost and latency per workflow, learned automatically, plus an agent Apdex score |
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 |
152
- | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
153
- | **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
153
+ | **Health rules & alerts** | 29 rules (runaway cost, policy denials, boundary probing, tripwires, 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 |
154
+ | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend, and the estimate beside what Anthropic billed (with an Admin API key) |
155
+ | **SLOs** | Success rate, Apdex, good-task rate, latency and cost objectives with error budgets and burn rates |
154
156
  | **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 |
157
+ | **Incidents & trust** | Tripwires, probing, refused calls and revocations about one agent, grouped into one incident with its evidence; revoke the agent or resolve it as real or a false alarm, and alert once per incident. Each agent's trust score (0-100) weighs that evidence by age and by the verdicts given, and is exported to Prometheus. An optional checker has a model review each incident in shadow mode, and keeps score of how often it agreed with you |
155
158
  | **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
156
159
  | **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
157
160
 
@@ -169,7 +172,10 @@ governance.instrument(kernel, root, watchdog=governance.Watchdog(max_repeated_de
169
172
  That call sets up four flows:
170
173
  1. **Every Aegis decision appears in its task.** Denials carry the rule id, and the Aegis audit log carries the task's run id so the two logs join.
171
174
  2. **Model calls are charged to the Aegis budget before they're sent.** When the budget runs out, the call is refused and never made.
172
- 3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
175
+ 3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection,
176
+ and **tripwires** (decoy tools and planted canary values) stop a run before the call that touches one is made.
177
+ For tools that can't be undone, an opt-in **reviewer** model checks each call against what the user asked
178
+ before Aegis admits it, and refuses what goes beyond it (`review={"tools": ["payments.refund"]}`).
173
179
  4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour. `aegis ratify` and `aegis drift` verify its declaration; `agentdynamics policy check` replays the traffic that actually ran through it and reports how much it would refuse, so a policy change can be gated in CI.
174
180
 
175
181
  The console's **Governance** page shows denials, budget stops, revocations, unused grants and budget headroom per policy version. See [docs/GOVERNANCE.md](docs/GOVERNANCE.md).
@@ -213,6 +219,8 @@ The store is a SQLite file by default. For several instances behind a load balan
213
219
  and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
214
220
  `AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
215
221
  write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
222
+ An existing install moves with its history: `agentdynamics store copy --to postgresql://...`, then
223
+ `agentdynamics store verify`.
216
224
 
217
225
  - **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
218
226
  runs, steps and events, on every endpoint, and can't write into or over another project's traces.
@@ -45,7 +45,9 @@ def handle_ticket(question):
45
45
 
46
46
  Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
47
47
  which outcomes were stated and which were guessed; grades can also be posted afterwards by a person or an
48
- eval pipeline (`POST /api/outcomes`).
48
+ eval pipeline (`POST /api/outcomes`), or by your own systems using your own ids: trace with
49
+ `agentdynamics.trace("support", ticket_id="T-123")` and post `{"key": {"ticket_id": "T-123"}, "outcome": "rework"}`
50
+ when the ticket is reopened.
49
51
 
50
52
  ### 2. Python, zero code changes
51
53
 
@@ -123,10 +125,11 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
123
125
  | **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
124
126
  | **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
125
127
  | **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, PagerDuty or webhooks, routed by project, rule and severity |
127
- | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
128
- | **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
128
+ | **Health rules & alerts** | 29 rules (runaway cost, policy denials, boundary probing, tripwires, 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 |
129
+ | **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend, and the estimate beside what Anthropic billed (with an Admin API key) |
130
+ | **SLOs** | Success rate, Apdex, good-task rate, latency and cost objectives with error budgets and burn rates |
129
131
  | **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 |
132
+ | **Incidents & trust** | Tripwires, probing, refused calls and revocations about one agent, grouped into one incident with its evidence; revoke the agent or resolve it as real or a false alarm, and alert once per incident. Each agent's trust score (0-100) weighs that evidence by age and by the verdicts given, and is exported to Prometheus. An optional checker has a model review each incident in shadow mode, and keeps score of how often it agreed with you |
130
133
  | **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
131
134
  | **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
132
135
 
@@ -144,7 +147,10 @@ governance.instrument(kernel, root, watchdog=governance.Watchdog(max_repeated_de
144
147
  That call sets up four flows:
145
148
  1. **Every Aegis decision appears in its task.** Denials carry the rule id, and the Aegis audit log carries the task's run id so the two logs join.
146
149
  2. **Model calls are charged to the Aegis budget before they're sent.** When the budget runs out, the call is refused and never made.
147
- 3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
150
+ 3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection,
151
+ and **tripwires** (decoy tools and planted canary values) stop a run before the call that touches one is made.
152
+ For tools that can't be undone, an opt-in **reviewer** model checks each call against what the user asked
153
+ before Aegis admits it, and refuses what goes beyond it (`review={"tools": ["payments.refund"]}`).
148
154
  4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour. `aegis ratify` and `aegis drift` verify its declaration; `agentdynamics policy check` replays the traffic that actually ran through it and reports how much it would refuse, so a policy change can be gated in CI.
149
155
 
150
156
  The console's **Governance** page shows denials, budget stops, revocations, unused grants and budget headroom per policy version. See [docs/GOVERNANCE.md](docs/GOVERNANCE.md).
@@ -188,6 +194,8 @@ The store is a SQLite file by default. For several instances behind a load balan
188
194
  and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
189
195
  `AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
190
196
  write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
197
+ An existing install moves with its history: `agentdynamics store copy --to postgresql://...`, then
198
+ `agentdynamics store verify`.
191
199
 
192
200
  - **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
193
201
  runs, steps and events, on every endpoint, and can't write into or over another project's traces.
@@ -5,7 +5,7 @@
5
5
 
6
6
  See agentdynamics.autotrace for @trace / @tool / span.
7
7
  """
8
- __version__ = "0.7.0"
8
+ __version__ = "0.9.0"
9
9
 
10
10
  from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
11
11
 
@@ -187,6 +187,74 @@ def cmd_alerts(a, data):
187
187
  return 1 if failed else 0
188
188
 
189
189
 
190
+ def cmd_store(a, data):
191
+ from . import config as cfgmod, migrate
192
+ from .pg import redact_url, schema_name
193
+ cfg = cfgmod.load(data)
194
+ url = a.to or (cfg.get("store") or {}).get("url")
195
+ if not url:
196
+ print("say where to: --to postgresql://... (or set [store] url / AGENTDYNAMICS_DB_URL)")
197
+ return 2
198
+ schema = schema_name(a.schema or (cfg.get("store") or {}).get("schema") or "agentdynamics", data)
199
+ if a.action == "verify":
200
+ ok = True
201
+ for what, (lite, pg) in migrate.verify(data, url, schema).items():
202
+ same = lite == pg
203
+ ok &= same
204
+ print(f" {what:<14} SQLite {lite:>10} Postgres {pg:>10} {'ok' if same else 'DIFFERENT'}")
205
+ return 0 if ok else 1
206
+ print(f"copying {os.path.join(data, 'agentdynamics.db')} -> {redact_url(url)} (schema {schema})")
207
+ try:
208
+ migrate.copy(data, url, schema, force=a.force)
209
+ except migrate.CopyError as ex:
210
+ print(f"not copied: {ex}")
211
+ return 1
212
+ print("done. Point the servers at it ([store] url or AGENTDYNAMICS_DB_URL); their first refresh rebuilds the "
213
+ "analysis from what was copied. API keys and pricing.json are configuration: copy them yourself.")
214
+ return 0
215
+
216
+
217
+ def cmd_revoke(a, data):
218
+ from . import config as cfgmod, store
219
+ con = store.connect(*store.target(cfgmod.load(data), data))
220
+ now = time.time()
221
+ if a.clear:
222
+ n = store.clear_revocation(con, a.clear, now)
223
+ print("cleared" if n else f"no active directive {a.clear}")
224
+ return 0 if n else 1
225
+ if a.list:
226
+ rows_ = store.revocations(con, now)
227
+ for d in rows_:
228
+ status = "cleared" if d["cleared"] else "expired" if d["expires"] <= now else "active"
229
+ what = "revoke" if d["kind"] == "revoke" else "restrict: " + ", ".join(
230
+ (d["spec"].get("tools") or []) + ([f"budget x{d['spec']['budget']:g}"] if "budget" in d["spec"] else []))
231
+ print(f"{d['id']} {status:<8} {d['project'] or '(all projects)':<18} {d['agent'] or '(all agents)':<18} "
232
+ f"until {time.strftime('%Y-%m-%d %H:%M', time.localtime(d['expires']))} [{d['source']}] {what} {d['reason']}")
233
+ if not rows_:
234
+ print("no directives")
235
+ return 0
236
+ if not a.reason:
237
+ print("--reason is required: it goes into the kernel's audit log")
238
+ return 2
239
+ who = f"{'agent ' + a.agent if a.agent else 'every agent'} in {a.project or 'every project'}"
240
+ if a.tools or a.budget is not None:
241
+ from .engine import restrict_spec
242
+ try:
243
+ spec = restrict_spec((a.tools or "").split(","), a.budget)
244
+ except ValueError as ex:
245
+ print(ex)
246
+ return 2
247
+ rid = store.add_revocation(con, a.project, a.agent, a.reason, "operator", now, now + a.minutes * 60,
248
+ kind="restrict", spec=spec)
249
+ print(f"{rid}: {who} loses {', '.join(spec.get('tools', [])) or 'part of its budget'} for {a.minutes:g} min, "
250
+ "in processes using agentdynamics.integrations.aegis.instrument(..., revocations=True)")
251
+ return 0
252
+ rid = store.add_revocation(con, a.project, a.agent, a.reason, "operator", now, now + a.minutes * 60)
253
+ print(f"{rid}: {who} is revoked "
254
+ f"for {a.minutes:g} min, in processes using agentdynamics.integrations.aegis.instrument(..., revocations=True)")
255
+ return 0
256
+
257
+
190
258
  def cmd_keys(a, data):
191
259
  from .config import keys_path, load_keys, save_keys
192
260
  keys = load_keys(data)
@@ -373,6 +441,21 @@ def main(argv=None):
373
441
  al.add_argument("action", choices=["status", "test"],
374
442
  help="status: destinations, queue and firing SLO alerts; test: send a test alert to each")
375
443
  al.add_argument("--to", metavar="NAME", help="test only this destination (its name, as status shows it)")
444
+ st = sub.add_parser("store", help="copy a SQLite store into Postgres (copy), or compare the two (verify)")
445
+ st.add_argument("action", choices=["copy", "verify"])
446
+ st.add_argument("--to", metavar="URL", help="the Postgres URL (default: [store] url / AGENTDYNAMICS_DB_URL)")
447
+ st.add_argument("--schema", help="the Postgres schema (default: [store] schema, else agentdynamics)")
448
+ st.add_argument("--force", action="store_true", help="merge into a schema that already holds another store's data")
449
+ rv = sub.add_parser("revoke", help="revoke an agent's Aegis grants wherever it runs (applied in-process by "
450
+ "agentdynamics.integrations.aegis), or list/clear directives")
451
+ rv.add_argument("--agent", help="the agent (grant) name; omit to revoke every grant in the project")
452
+ rv.add_argument("--project", help="omit for every project")
453
+ rv.add_argument("--reason", help="why (it goes into the kernel's audit log)")
454
+ rv.add_argument("--minutes", type=float, default=60, help="how long new grants of that agent are refused too")
455
+ rv.add_argument("--tools", help="restrict instead of revoke: take these tools away (comma-separated)")
456
+ rv.add_argument("--budget", type=float, help="restrict: keep only this share (0-1) of what remains of the budget")
457
+ rv.add_argument("--list", action="store_true", help="list directives")
458
+ rv.add_argument("--clear", metavar="ID", help="stop a directive (grants it revoked stay revoked)")
376
459
  sub.add_parser("ingest", help="scan sources once and rebuild the database")
377
460
  rp = sub.add_parser("report", help="print a text summary")
378
461
  rp.add_argument("--project")
@@ -397,6 +480,10 @@ def main(argv=None):
397
480
  return cmd_keys(a, a.data)
398
481
  if cmd == "alerts":
399
482
  return cmd_alerts(a, a.data)
483
+ if cmd == "revoke":
484
+ return cmd_revoke(a, a.data)
485
+ if cmd == "store":
486
+ return cmd_store(a, a.data)
400
487
 
401
488
  from .engine import Engine
402
489
  eng = Engine(a.data, a.claude_root or None)
@@ -10,7 +10,9 @@ Destinations are `[[alerts.webhooks]]` in agentdynamics.toml:
10
10
  format = "pagerduty" # json (default) | slack | pagerduty
11
11
  routing_key_env = "PD_ROUTING_KEY" # PagerDuty Events API v2 integration key (or routing_key = "...")
12
12
  min_severity = "critical" # info | warning (default) | critical
13
- kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts
13
+ kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts,
14
+ # "revocations" the directives that stop an agent (server-side #8),
15
+ # "incidents" one alert per security incident (incidents.py)
14
16
  projects = ["checkout"] # optional: only these projects
15
17
  rules = ["run_failed"] # optional: only these health rules
16
18
 
@@ -18,6 +20,9 @@ What is sent:
18
20
  - A health-rule event is a point in time. Slack and JSON get each one (JSON in the original
19
21
  `{"source": "agentdynamics", "events": [...]}` shape, a contract). PagerDuty gets one alert per rule,
20
22
  project and task type (the dedup key), so a burst of runaway-cost tasks is one incident, not fifty.
23
+ - An incident is several security signals about one agent: a trigger when it opens, another when it
24
+ escalates (warning to critical), a resolve when a person gives it a verdict. A destination that takes
25
+ incidents instead of events hears once about an agent tripping five health rules, not five times.
21
26
  - An SLO alert has a start and an end: a trigger when a burn-rate policy starts firing and a resolve when
22
27
  it stops (slo.alert_conditions). What is firing is kept in the durable `alert_state` table, so a restart
23
28
  neither repeats a trigger nor forgets to resolve.
@@ -37,7 +42,7 @@ from collections import OrderedDict
37
42
  from urllib.parse import quote
38
43
 
39
44
  FORMATS = ("json", "slack", "pagerduty")
40
- KINDS = ("events", "slos")
45
+ KINDS = ("events", "slos", "revocations", "incidents")
41
46
  SEV = {"info": 1, "warning": 2, "critical": 3}
42
47
  # https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/events-v2/openapiv3.json
43
48
  PAGERDUTY_URL = "https://events.pagerduty.com/v2/enqueue"
@@ -135,6 +140,23 @@ def from_slo(key, c, action, now):
135
140
  "project": (c.get("scope") or {}).get("project") or None, "slo": c["slo"], "ts": now, "condition": c}
136
141
 
137
142
 
143
+ def from_revocation(d):
144
+ who = f"agent {d['agent']}" if d.get("agent") else "every agent"
145
+ where = f" in project {d['project']}" if d.get("project") else ""
146
+ until = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["expires"]))
147
+ return {"kind": "revocation", "action": "trigger", "key": f"revocation/{d['id']}", "severity": "critical",
148
+ "summary": f"Revoked {who}{where} until {until}: {d['reason']}", "project": d.get("project"),
149
+ "ts": d["created"], "revocation": d}
150
+
151
+
152
+ def from_incident(inc, summary, action="trigger"):
153
+ """`action`: trigger (opened), escalate (now more severe) or resolve (a person gave a verdict)."""
154
+ return {"kind": "incident", "action": action, "key": f"incident/{inc['id']}", "severity": inc["severity"],
155
+ "summary": summary, "project": inc["project"], "ts": inc["resolved_at"] if action == "resolve" else inc["updated"],
156
+ "incident": {k: inc[k] for k in ("id", "project", "agent", "workflow", "opened", "updated", "status",
157
+ "severity", "signals", "verdict", "note")}}
158
+
159
+
138
160
  # ---------------------------------------------------------------- formats
139
161
 
140
162
  def _dedup_key(key):
@@ -148,6 +170,10 @@ def link(console_url, alert):
148
170
  base = console_url.rstrip("/")
149
171
  if alert["kind"] == "event":
150
172
  return f"{base}/#/task/{quote(alert['event']['task_id'], safe='')}"
173
+ if alert["kind"] == "revocation":
174
+ return f"{base}/#/governance"
175
+ if alert["kind"] == "incident":
176
+ return f"{base}/#/incident/{quote(alert['incident']['id'], safe='')}"
151
177
  return f"{base}/#/slos"
152
178
 
153
179
 
@@ -172,6 +198,13 @@ def render(dest, alerts, console_url=""):
172
198
  "link": link(console_url, a), **a["condition"]} for a in alerts if a["kind"] == "slo"]
173
199
  if slos:
174
200
  bodies.append({"source": "agentdynamics", "slo_alerts": slos})
201
+ revs = [dict(a["revocation"], summary=a["summary"]) for a in alerts if a["kind"] == "revocation"]
202
+ if revs:
203
+ bodies.append({"source": "agentdynamics", "revocations": revs})
204
+ incs = [dict(a["incident"], action=a["action"], summary=a["summary"], link=link(console_url, a))
205
+ for a in alerts if a["kind"] == "incident"]
206
+ if incs:
207
+ bodies.append({"source": "agentdynamics", "incidents": incs})
175
208
  return bodies
176
209
  # one line (Slack) or one alert (PagerDuty) per dedup key, so a burst of the same violation reads as one
177
210
  groups = OrderedDict()
@@ -185,6 +218,14 @@ def render(dest, alerts, console_url=""):
185
218
  where = " / ".join(_slack_escape(x) for x in (a.get("project"), a.get("task_type")) if x)
186
219
  url = link(console_url, a)
187
220
  ref = f" · <{url}|{'latest' if len(g) > 1 else 'open'}>" if url else ""
221
+ if a["kind"] == "revocation":
222
+ lines.append(f"*REVOKED* · {_slack_escape(a['summary'])}{ref}")
223
+ continue
224
+ if a["kind"] == "incident":
225
+ head = {"trigger": "INCIDENT", "escalate": "ESCALATED", "resolve": "RESOLVED"}[a["action"]]
226
+ verdict = f" ({a['incident']['verdict'].replace('_', ' ')})" if a["action"] == "resolve" else ""
227
+ lines.append(f"*{head}*{verdict} · {_slack_escape(a['summary'])}{ref}")
228
+ continue
188
229
  if a["kind"] == "event":
189
230
  e = a["event"]
190
231
  times = f" ×{len(g)}" if len(g) > 1 else ""
@@ -200,7 +241,13 @@ def render(dest, alerts, console_url=""):
200
241
  if a["action"] == "resolve":
201
242
  bodies.append({"event_action": "resolve", "dedup_key": _dedup_key(key)})
202
243
  continue
203
- if a["kind"] == "event":
244
+ if a["kind"] == "revocation":
245
+ d = a["revocation"]
246
+ details, group, klass = dict(d), d.get("agent") or "all agents", "revocation"
247
+ elif a["kind"] == "incident":
248
+ i = a["incident"]
249
+ details, group, klass = dict(i), i.get("agent") or i.get("workflow") or "all agents", "incident"
250
+ elif a["kind"] == "event":
204
251
  e = a["event"]
205
252
  details = {"rule": e["rule"], "rule_id": e["rule_id"], "message": e["message"], "value": e.get("value"),
206
253
  "task_id": e["task_id"], "run_id": e.get("run_id"), "project": e["project"],