agentdynamics 0.4.1__tar.gz → 0.6.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.
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/PKG-INFO +39 -7
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/README.md +38 -6
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/__init__.py +3 -3
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/__main__.py +58 -11
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/analysis.py +162 -16
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/autotrace.py +36 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/langsmith.py +8 -3
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/otlp.py +19 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/spans.py +30 -3
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/engine.py +44 -3
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/govern.py +69 -28
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/server.py +74 -7
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/store.py +92 -27
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/app.js +53 -7
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/PKG-INFO +39 -7
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/SOURCES.txt +6 -1
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/pyproject.toml +1 -1
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_aegis_integration.py +1 -1
- agentdynamics-0.6.0/tests/test_console_ui.py +253 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_core.py +51 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_ecosystem.py +12 -2
- agentdynamics-0.6.0/tests/test_incremental.py +240 -0
- agentdynamics-0.6.0/tests/test_outcomes.py +288 -0
- agentdynamics-0.6.0/tests/test_policy_coverage.py +222 -0
- agentdynamics-0.6.0/tests/test_token_accounting.py +145 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/LICENSE +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/__init__.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/aegis_audit.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/claude_code.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/generic.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/inbox.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/langfuse.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/config.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/flows.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/integrations/__init__.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/integrations/aegis.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/phases.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/pricing.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/privacy.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/sdk.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/slo.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/charts.js +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/index.html +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/style.css +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/entry_points.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/requires.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/top_level.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/setup.cfg +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_autotrace.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_integrations.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_live_anthropic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdynamics
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -61,9 +61,15 @@ def handle_ticket(question):
|
|
|
61
61
|
plan = client.messages.create(...)
|
|
62
62
|
with agentdynamics.span("act"):
|
|
63
63
|
lookup_order(order_id) # decorate tools with @agentdynamics.tool
|
|
64
|
+
if escalated:
|
|
65
|
+
agentdynamics.outcome("failed", reason="customer escalated") # state it, don't let it be guessed
|
|
64
66
|
...
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
|
|
70
|
+
which outcomes were stated and which were guessed; grades can also be posted afterwards by a person or an
|
|
71
|
+
eval pipeline (`POST /api/outcomes`).
|
|
72
|
+
|
|
67
73
|
### 2. Python, zero code changes
|
|
68
74
|
|
|
69
75
|
```bash
|
|
@@ -107,6 +113,31 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
107
113
|
|
|
108
114
|
---
|
|
109
115
|
|
|
116
|
+
## What it looks like
|
|
117
|
+
|
|
118
|
+
The Flow Map: where work goes, what each hop costs, and what is failing. Red edges are errors.
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
The Overview: cost, Apdex and flow health against baselines learned from your own history.
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
Governance: what Aegis refused, which rule refused it, and which grants are never used.
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
Reproduce these locally from synthetic traffic, no API keys needed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
|
|
134
|
+
LANGSMITH_ENDPOINT=http://127.0.0.1:8790/langsmith python examples/langgraph_style_app.py 70
|
|
135
|
+
python examples/otel_multiagent.py 45 http://127.0.0.1:8790
|
|
136
|
+
AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
110
141
|
## What you get
|
|
111
142
|
|
|
112
143
|
| | |
|
|
@@ -118,7 +149,7 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
118
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 |
|
|
119
150
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
120
151
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
121
|
-
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export |
|
|
152
|
+
| **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 |
|
|
122
153
|
| **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
|
|
123
154
|
| **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
|
|
124
155
|
|
|
@@ -137,7 +168,7 @@ That call sets up four flows:
|
|
|
137
168
|
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.
|
|
138
169
|
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.
|
|
139
170
|
3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
|
|
140
|
-
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour
|
|
171
|
+
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.
|
|
141
172
|
|
|
142
173
|
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).
|
|
143
174
|
|
|
@@ -184,12 +215,13 @@ See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
|
184
215
|
## Development
|
|
185
216
|
|
|
186
217
|
```bash
|
|
187
|
-
|
|
188
|
-
python -m
|
|
189
|
-
python examples/langgraph_style_app.py 60 # LangGraph-style demo traffic
|
|
190
|
-
python examples/otel_multiagent.py 60 http://127.0.0.1:8790
|
|
218
|
+
pip install -e ".[test,toml]" && pip install "aegis-kernel>=0.4.0"
|
|
219
|
+
python -m unittest discover tests # real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
|
|
191
220
|
```
|
|
192
221
|
|
|
222
|
+
Demo traffic is under **What it looks like** above. [CLAUDE.md](CLAUDE.md) has the architecture and the
|
|
223
|
+
invariants to work within; [ROADMAP.md](ROADMAP.md) is what is planned and what is deliberately not.
|
|
224
|
+
|
|
193
225
|
## License
|
|
194
226
|
|
|
195
227
|
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -38,9 +38,15 @@ def handle_ticket(question):
|
|
|
38
38
|
plan = client.messages.create(...)
|
|
39
39
|
with agentdynamics.span("act"):
|
|
40
40
|
lookup_order(order_id) # decorate tools with @agentdynamics.tool
|
|
41
|
+
if escalated:
|
|
42
|
+
agentdynamics.outcome("failed", reason="customer escalated") # state it, don't let it be guessed
|
|
41
43
|
...
|
|
42
44
|
```
|
|
43
45
|
|
|
46
|
+
Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
|
|
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`).
|
|
49
|
+
|
|
44
50
|
### 2. Python, zero code changes
|
|
45
51
|
|
|
46
52
|
```bash
|
|
@@ -84,6 +90,31 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
84
90
|
|
|
85
91
|
---
|
|
86
92
|
|
|
93
|
+
## What it looks like
|
|
94
|
+
|
|
95
|
+
The Flow Map: where work goes, what each hop costs, and what is failing. Red edges are errors.
|
|
96
|
+
|
|
97
|
+

|
|
98
|
+
|
|
99
|
+
The Overview: cost, Apdex and flow health against baselines learned from your own history.
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
103
|
+
Governance: what Aegis refused, which rule refused it, and which grants are never used.
|
|
104
|
+
|
|
105
|
+

|
|
106
|
+
|
|
107
|
+
Reproduce these locally from synthetic traffic, no API keys needed:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
|
|
111
|
+
LANGSMITH_ENDPOINT=http://127.0.0.1:8790/langsmith python examples/langgraph_style_app.py 70
|
|
112
|
+
python examples/otel_multiagent.py 45 http://127.0.0.1:8790
|
|
113
|
+
AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
87
118
|
## What you get
|
|
88
119
|
|
|
89
120
|
| | |
|
|
@@ -95,7 +126,7 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
95
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 |
|
|
96
127
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
97
128
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
98
|
-
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export |
|
|
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 |
|
|
99
130
|
| **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
|
|
100
131
|
| **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
|
|
101
132
|
|
|
@@ -114,7 +145,7 @@ That call sets up four flows:
|
|
|
114
145
|
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.
|
|
115
146
|
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.
|
|
116
147
|
3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
|
|
117
|
-
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour
|
|
148
|
+
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.
|
|
118
149
|
|
|
119
150
|
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).
|
|
120
151
|
|
|
@@ -161,12 +192,13 @@ See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
|
161
192
|
## Development
|
|
162
193
|
|
|
163
194
|
```bash
|
|
164
|
-
|
|
165
|
-
python -m
|
|
166
|
-
python examples/langgraph_style_app.py 60 # LangGraph-style demo traffic
|
|
167
|
-
python examples/otel_multiagent.py 60 http://127.0.0.1:8790
|
|
195
|
+
pip install -e ".[test,toml]" && pip install "aegis-kernel>=0.4.0"
|
|
196
|
+
python -m unittest discover tests # real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
|
|
168
197
|
```
|
|
169
198
|
|
|
199
|
+
Demo traffic is under **What it looks like** above. [CLAUDE.md](CLAUDE.md) has the architecture and the
|
|
200
|
+
invariants to work within; [ROADMAP.md](ROADMAP.md) is what is planned and what is deliberately not.
|
|
201
|
+
|
|
170
202
|
## License
|
|
171
203
|
|
|
172
204
|
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
See agentdynamics.autotrace for @trace / @tool / span.
|
|
7
7
|
"""
|
|
8
|
-
__version__ = "0.
|
|
8
|
+
__version__ = "0.6.0"
|
|
9
9
|
|
|
10
|
-
from .autotrace import flush, init, llm_call, record_llm, span, tool, trace # noqa: E402,F401
|
|
10
|
+
from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
|
|
11
11
|
|
|
12
|
-
__all__ = ["init", "trace", "tool", "span", "llm_call", "record_llm", "flush", "__version__"]
|
|
12
|
+
__all__ = ["init", "trace", "tool", "span", "llm_call", "record_llm", "outcome", "flush", "__version__"]
|
|
@@ -191,6 +191,22 @@ def cmd_policy(a, eng):
|
|
|
191
191
|
for r in g["by_rule"][:8]:
|
|
192
192
|
print(f" {r['n']:>5} {r['rule']}")
|
|
193
193
|
return 0
|
|
194
|
+
if a.action == "check":
|
|
195
|
+
if not a.candidate:
|
|
196
|
+
print("policy check needs --candidate <policy.yaml>", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
try:
|
|
199
|
+
from aegis import dump_policy, load_policy
|
|
200
|
+
except ImportError:
|
|
201
|
+
print("policy check needs aegis-kernel: pip install aegis-kernel", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
q["candidate_doc"] = dump_policy(load_policy(a.candidate))
|
|
204
|
+
res = api.check_policy(q)
|
|
205
|
+
if res.get("error"):
|
|
206
|
+
print(res["error"], file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
print(f"{a.candidate} against {res['tasks']} governed task(s) in scope:")
|
|
209
|
+
return _report_coverage(res["coverage"], a.max_denied_fraction, sys.stdout)
|
|
194
210
|
res = api.export_policy(q)
|
|
195
211
|
if res.get("error"):
|
|
196
212
|
print(res["error"], file=sys.stderr)
|
|
@@ -203,16 +219,41 @@ def cmd_policy(a, eng):
|
|
|
203
219
|
print(f" - {c}", file=sys.stderr)
|
|
204
220
|
else:
|
|
205
221
|
print(res["yaml"])
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
if res.get("coverage") is None:
|
|
223
|
+
return 0
|
|
224
|
+
print("", file=sys.stderr)
|
|
225
|
+
print("Against the traffic it was generated from:", file=sys.stderr)
|
|
226
|
+
return _report_coverage(res["coverage"], a.max_denied_fraction, sys.stderr)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _report_coverage(cov, max_fraction, out):
|
|
230
|
+
"""Print how much observed traffic a candidate would refuse; non-zero exit past the threshold.
|
|
231
|
+
|
|
232
|
+
This is the one check that catches a policy which is strictly narrower than its base, ratifies
|
|
233
|
+
cleanly and shows no drift, and still refuses production traffic -- `aegis ratify` and
|
|
234
|
+
`aegis drift` compare declarations and never see a single call.
|
|
235
|
+
"""
|
|
236
|
+
w = lambda *x: print(*x, file=out) # noqa: E731
|
|
237
|
+
if not cov["calls"]:
|
|
238
|
+
w(" no governed calls in scope to check against")
|
|
239
|
+
return 0
|
|
240
|
+
w(f" would deny {cov['denied']} of {cov['calls']} calls that were allowed ({cov['denied_fraction']:.1%})")
|
|
241
|
+
rows = [r for r in cov["by_tool"] if r["denied"]]
|
|
242
|
+
if rows:
|
|
243
|
+
width = max(len(r["tool"]) for r in rows)
|
|
244
|
+
for r in rows:
|
|
245
|
+
why = ", ".join(f"{k} x{n}" for k, n in sorted(r["rules"].items(), key=lambda kv: -kv[1]))
|
|
246
|
+
w(f" {r['tool']:<{width}} {r['denied']:>5} of {r['calls']:<5} {r['denied_fraction']:>6.1%} {why}")
|
|
247
|
+
for ex in cov["examples"][:5]:
|
|
248
|
+
if ex.get("arg"):
|
|
249
|
+
w(f" e.g. {ex['tool']}({ex['arg']}={ex['value']!r}) -> {ex['rule']}")
|
|
250
|
+
else: # a missing argument or a dropped tool: Aegis's own reason says which
|
|
251
|
+
w(f" e.g. {ex['tool']} -> {ex['rule']}: {ex.get('reason') or ''}")
|
|
252
|
+
if cov["args_unrecorded"]:
|
|
253
|
+
w(f" {cov['args_unrecorded']} call(s) had no recorded arguments (content capture off); "
|
|
254
|
+
f"only their tool grant was checked")
|
|
255
|
+
if cov["denied_fraction"] > max_fraction:
|
|
256
|
+
w(f" FAIL: {cov['denied_fraction']:.1%} exceeds --max-denied-fraction {max_fraction:.1%}")
|
|
216
257
|
return 1
|
|
217
258
|
return 0
|
|
218
259
|
|
|
@@ -257,7 +298,9 @@ def main(argv=None):
|
|
|
257
298
|
k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
|
|
258
299
|
k.add_argument("--name", default=None)
|
|
259
300
|
po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
|
|
260
|
-
po.add_argument("action", choices=["export", "report"]
|
|
301
|
+
po.add_argument("action", choices=["export", "report", "check"],
|
|
302
|
+
help="export: generate a tightened policy; report: governance summary; "
|
|
303
|
+
"check: judge --candidate against recorded traffic")
|
|
261
304
|
po.add_argument("--workflow")
|
|
262
305
|
po.add_argument("--project")
|
|
263
306
|
po.add_argument("--environment")
|
|
@@ -266,6 +309,10 @@ def main(argv=None):
|
|
|
266
309
|
po.add_argument("--base", help="Aegis policy file to tighten (needs aegis-kernel installed)")
|
|
267
310
|
po.add_argument("--headroom", type=float, default=1.5)
|
|
268
311
|
po.add_argument("--out", help="write the YAML here (default: stdout)")
|
|
312
|
+
po.add_argument("--candidate", help="policy file to judge (check)")
|
|
313
|
+
po.add_argument("--max-denied-fraction", type=float, default=0.0,
|
|
314
|
+
help="fail if the policy would refuse more than this share of observed, allowed calls "
|
|
315
|
+
"(export, check; default 0: any refusal fails)")
|
|
269
316
|
sub.add_parser("ingest", help="scan sources once and rebuild the database")
|
|
270
317
|
rp = sub.add_parser("report", help="print a text summary")
|
|
271
318
|
rp.add_argument("--project")
|
|
@@ -9,6 +9,7 @@ AppDynamics -> AgentDynamics mapping implemented here:
|
|
|
9
9
|
Code-level hotspots -> waste findings (redundant reads, loops, error streaks)
|
|
10
10
|
"""
|
|
11
11
|
import math
|
|
12
|
+
import operator
|
|
12
13
|
import re
|
|
13
14
|
import statistics
|
|
14
15
|
import time
|
|
@@ -40,23 +41,34 @@ CORRECTION = re.compile(
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def classify_task(prompt_kind, text):
|
|
44
|
+
return classify_task_detail(prompt_kind, text)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_task_detail(prompt_kind, text):
|
|
48
|
+
"""(task_type, how it was decided, what matched).
|
|
49
|
+
|
|
50
|
+
how: "prompt kind" when the prompt says what it is (a slash command, a scheduled job, a subagent's
|
|
51
|
+
brief); "follow-up" for a short "continue"/"yes" that carries on the previous task; "keywords" when an
|
|
52
|
+
intent rule matched, with the matched text; "unmatched" when nothing did. Keyword typing is a guess
|
|
53
|
+
about what someone meant, so it says so -- the same way outcome_source separates graded from inferred.
|
|
54
|
+
"""
|
|
43
55
|
if prompt_kind == "scheduled":
|
|
44
|
-
return "scheduled job"
|
|
56
|
+
return "scheduled job", "prompt kind", None
|
|
45
57
|
if prompt_kind == "delegated":
|
|
46
|
-
return "subagent"
|
|
58
|
+
return "subagent", "prompt kind", None
|
|
47
59
|
t = (text or "").strip()
|
|
48
60
|
low = t.lower()
|
|
49
61
|
if prompt_kind == "command":
|
|
50
|
-
return "slash command"
|
|
62
|
+
return "slash command", "prompt kind", None
|
|
51
63
|
if len(t) < 60 and FOLLOWUP.search(t):
|
|
52
|
-
return "follow-up"
|
|
64
|
+
return "follow-up", "follow-up", None
|
|
53
65
|
# The earliest intent keyword wins: "Create X, then test it" is a build, not a testing task.
|
|
54
66
|
best = None
|
|
55
67
|
for rank, (name, pat) in enumerate(TYPE_RULES):
|
|
56
68
|
m = re.search(pat, low[:600])
|
|
57
69
|
if m and (best is None or (m.start(), rank) < best[0]):
|
|
58
|
-
best = ((m.start(), rank), name)
|
|
59
|
-
return best[1] if best else "other"
|
|
70
|
+
best = ((m.start(), rank), name, m.group(0).strip())
|
|
71
|
+
return (best[1], "keywords", best[2][:40]) if best else ("other", "unmatched", None)
|
|
60
72
|
|
|
61
73
|
|
|
62
74
|
# ---------------------------------------------------------------- helpers
|
|
@@ -146,9 +158,10 @@ def task_metrics(run, idx, seg):
|
|
|
146
158
|
in_side = t["input_tokens"] + t["cache_read"] + t["cache_write"]
|
|
147
159
|
t["cache_hit"] = round(t["cache_read"] / in_side, 3) if in_side else None
|
|
148
160
|
t["tool_error_rate"] = round(t["tool_errors"] / len(tools), 3) if tools else 0
|
|
149
|
-
t["task_type"] =
|
|
161
|
+
t["task_type"], t["task_type_source"], t["task_type_match"] = classify_task_detail(t["prompt_kind"], t["prompt"])
|
|
150
162
|
if run.get("workflow") and run.get("source") != "claude-code":
|
|
151
|
-
|
|
163
|
+
# traced apps: the entry point names the business transaction, which beats any guess
|
|
164
|
+
t["task_type"], t["task_type_source"], t["task_type_match"] = run["workflow"], "workflow", None
|
|
152
165
|
|
|
153
166
|
# --- phase attribution: split each LLM call's cost across the tool calls it issued
|
|
154
167
|
phase_calls = Counter(s["phase"] for s in tools)
|
|
@@ -301,6 +314,9 @@ def governance_metrics(t, run, steps, tools):
|
|
|
301
314
|
t["repeated_denials"] = best
|
|
302
315
|
|
|
303
316
|
|
|
317
|
+
OUTCOMES = ("completed", "failed", "rework", "interrupted") # what a grade may state (store.OUTCOMES)
|
|
318
|
+
FEEDBACK_PASS = 0.5 # a feedback score at or above this is a pass
|
|
319
|
+
|
|
304
320
|
TRUNCATION = {"max_tokens", "length", "max_output_tokens", "MAX_TOKENS"}
|
|
305
321
|
REFUSAL = {"refusal", "content_filter", "SAFETY", "safety", "blocked"}
|
|
306
322
|
RATE_HINTS = ("429", "rate limit", "rate_limit", "overloaded", "529", "too many requests", "quota")
|
|
@@ -314,6 +330,13 @@ def flow_metrics(t, run, steps, llm, tools):
|
|
|
314
330
|
t["framework"] = run.get("framework") or run.get("source")
|
|
315
331
|
t["workflow"] = run.get("workflow") or (t["task_type"] if run.get("source") == "claude-code" else None)
|
|
316
332
|
t["steps_total"] = len(llm) + len(tools)
|
|
333
|
+
# model calls whose cache accounting rests on a guess (collectors/spans.uncached_input); None is
|
|
334
|
+
# a source that is exact by construction, like the SDK, so only an explicit False counts
|
|
335
|
+
t["tokens_unverified"] = sum(1 for s in llm if s.get("tokens_verified") is False)
|
|
336
|
+
# agentdynamics.outcome() records a notice; the last one in the task wins. Kept on a transient
|
|
337
|
+
# key -- finalize decides precedence, and write_analysis persists only TASK_COLS.
|
|
338
|
+
graded = [s for s in notices if s.get("name") == "outcome" and s.get("outcome") in OUTCOMES]
|
|
339
|
+
t["_sdk_grade"] = {"outcome": graded[-1]["outcome"], "reason": graded[-1].get("text")} if graded else None
|
|
317
340
|
t["llm_errors"] = sum(1 for s in llm if s.get("is_error"))
|
|
318
341
|
t["truncations"] = sum(1 for s in llm if s.get("stop_reason") in TRUNCATION)
|
|
319
342
|
t["refusals"] = sum(1 for s in llm if s.get("stop_reason") in REFUSAL)
|
|
@@ -538,6 +561,7 @@ def _outcomes_claude(ts, now):
|
|
|
538
561
|
# "continue" / "yes" carries on the previous request, so it belongs to that task type
|
|
539
562
|
if t["task_type"] == "follow-up" and i > 0 and ts[i - 1]["task_type"] not in ("follow-up",):
|
|
540
563
|
t["task_type"] = ts[i - 1]["task_type"]
|
|
564
|
+
t["task_type_source"], t["task_type_match"] = "follow-up", None # inherited, not re-guessed
|
|
541
565
|
if t["source"] == "claude-code":
|
|
542
566
|
t["workflow"] = t["task_type"]
|
|
543
567
|
nxt = next((x for x in ts[i + 1:] if x["prompt_kind"] in ("human", "command")), None)
|
|
@@ -573,8 +597,96 @@ def _outcome_trace(t, run, now):
|
|
|
573
597
|
t["root_error"] = run.get("root_error")
|
|
574
598
|
|
|
575
599
|
|
|
576
|
-
def
|
|
577
|
-
"""
|
|
600
|
+
def _apply_grades(tasks, grades):
|
|
601
|
+
"""Settle each outcome by the strongest evidence available, and record which one it was.
|
|
602
|
+
|
|
603
|
+
stated after the fact (API) > stated in the run (agentdynamics.outcome) > recorded feedback
|
|
604
|
+
> inference from signals.
|
|
605
|
+
|
|
606
|
+
Inference is a guess built from errors, interrupts and corrections. Apdex, success rate and the
|
|
607
|
+
process score all inherit it, so anything that states the outcome outright must win, and the
|
|
608
|
+
console has to be able to say how much of a success rate is guessed. Idempotent: the cached
|
|
609
|
+
per-run tasks are re-finalized on every refresh, so nothing here may consume its input.
|
|
610
|
+
"""
|
|
611
|
+
for t in tasks:
|
|
612
|
+
api, sdk = grades.get(t["id"]), t.get("_sdk_grade")
|
|
613
|
+
if api:
|
|
614
|
+
t["outcome"], t["outcome_source"] = api["outcome"], "graded"
|
|
615
|
+
t["outcome_reason"] = api.get("reason") or f"graded by {api.get('graded_by') or 'api'}"
|
|
616
|
+
elif sdk:
|
|
617
|
+
t["outcome"], t["outcome_source"] = sdk["outcome"], "graded"
|
|
618
|
+
t["outcome_reason"] = sdk.get("reason") or "graded in the run"
|
|
619
|
+
elif t.get("feedback_score") is not None:
|
|
620
|
+
ok = t["feedback_score"] >= FEEDBACK_PASS
|
|
621
|
+
t["outcome"] = "completed" if ok else "rework"
|
|
622
|
+
t["outcome_source"], t["outcome_reason"] = "feedback", f"feedback score {t['feedback_score']:g}"
|
|
623
|
+
else:
|
|
624
|
+
t["outcome_source"], t["outcome_reason"] = "inferred", None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# Everything finalize writes onto a task *before* scoring, for tasks whose run did not change. If one of
|
|
628
|
+
# these moves, the task's row must be rewritten and re-scored; if none did and its baseline didn't either,
|
|
629
|
+
# the previous score and events still hold. A field added to the settle phase below must be added here,
|
|
630
|
+
# or incremental refreshes will serve it stale -- test_incremental.py compares every column against a
|
|
631
|
+
# full rebuild to catch exactly that.
|
|
632
|
+
SETTLED_FIELDS = ("outcome", "outcome_source", "outcome_reason", "next_prompt", "rework", "root_error",
|
|
633
|
+
"task_type", "task_type_source", "task_type_match", "workflow",
|
|
634
|
+
"subagent_cost", "subagents", "parent_task_id")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
_settled = operator.itemgetter(*SETTLED_FIELDS) # built in C: this runs for every task on every refresh
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _settled_values(t):
|
|
641
|
+
try:
|
|
642
|
+
return _settled(t)
|
|
643
|
+
except KeyError: # a field some sources never set (Claude Code tasks carry no root_error)
|
|
644
|
+
return tuple(t.get(f) for f in SETTLED_FIELDS)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class ScoreCache:
|
|
648
|
+
"""What finalize needs to skip unchanged tasks on the next refresh.
|
|
649
|
+
|
|
650
|
+
sig[task_id] the settled fields plus the two baseline numbers scoring reads
|
|
651
|
+
events[task_id] the health events that signature produced
|
|
652
|
+
changed task ids scored this time (their rows must be written)
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
def __init__(self):
|
|
656
|
+
self.sig, self.events, self.changed = {}, {}, set()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
BASELINE_EXACT_UP_TO = 20 # below this many tasks, a type's baseline uses all of them
|
|
660
|
+
BASELINE_GROWTH = 1.05 # above it, only when the count has grown 5% since the last step
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def baseline_sample_size(n):
|
|
664
|
+
"""How many of a type's n tasks its baseline is computed from.
|
|
665
|
+
|
|
666
|
+
Every task is scored against its type's baseline, so a baseline that moved with each arrival would
|
|
667
|
+
make every refresh re-score the whole type -- no cheaper than rebuilding. Instead the baseline is
|
|
668
|
+
computed from the type's *earliest* M tasks (by start, then id), and M only advances in 5% steps: a
|
|
669
|
+
type re-scores once per 5% of growth, about 20 re-scores per new task however large the store, and
|
|
670
|
+
never oscillates. It depends on the data alone, so a full rebuild computes the same baseline. It
|
|
671
|
+
leaves out at most the newest 5% of a type's history, which is noise for a "what is normal" figure.
|
|
672
|
+
"""
|
|
673
|
+
if n <= BASELINE_EXACT_UP_TO:
|
|
674
|
+
return n
|
|
675
|
+
m = BASELINE_EXACT_UP_TO
|
|
676
|
+
while True:
|
|
677
|
+
nxt = max(m + 1, int(m * BASELINE_GROWTH))
|
|
678
|
+
if nxt > n:
|
|
679
|
+
return m
|
|
680
|
+
m = nxt
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def finalize(runs, tasks_by_run, rules=None, now=None, grades=None, cache=None, dirty=()):
|
|
684
|
+
"""Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events.
|
|
685
|
+
|
|
686
|
+
With a `cache` (a ScoreCache kept between calls), tasks from runs not in `dirty` whose settled
|
|
687
|
+
fields and baseline are unchanged keep their previous score and events instead of being re-scored;
|
|
688
|
+
`cache.changed` then lists the task ids that were. The result is the same as without a cache.
|
|
689
|
+
"""
|
|
578
690
|
rules = rules or DEFAULT_RULES
|
|
579
691
|
now = now or time.time()
|
|
580
692
|
all_tasks = [t for r in runs for t in tasks_by_run[r["id"]]]
|
|
@@ -587,11 +699,19 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
|
|
|
587
699
|
if run["source"] == "claude-code" or not run.get("workflow"):
|
|
588
700
|
_outcomes_claude(ts, now)
|
|
589
701
|
else:
|
|
702
|
+
# Derived afresh every pass, never carried over. The engine keeps an unchanged run's task
|
|
703
|
+
# objects between refreshes, and linking below only *sets* these on tasks that have a
|
|
704
|
+
# successor: a task whose follow-up was deleted, edited or moved to another thread kept the
|
|
705
|
+
# old values -- and so its "rework" outcome -- until a full rebuild. test_incremental found it.
|
|
706
|
+
for t in ts:
|
|
707
|
+
t["next_prompt"], t["rework"] = None, 0
|
|
590
708
|
if run.get("thread_id"):
|
|
591
709
|
threads[(run["source"], run["thread_id"])].extend(ts)
|
|
592
710
|
# traced conversations: the next trace in the same thread plays the role of "your next message"
|
|
593
711
|
for group in threads.values():
|
|
594
|
-
|
|
712
|
+
# ties on start time break by id, not by whatever order the runs happen to be held in -- which
|
|
713
|
+
# differs between a long-running engine and a fresh rebuild
|
|
714
|
+
group.sort(key=lambda x: (x["started"] or 0, x["id"]))
|
|
595
715
|
for a, b in zip(group, group[1:]):
|
|
596
716
|
a["next_prompt"] = b["prompt"][:300]
|
|
597
717
|
a["rework"] = 1 if CORRECTION.search(b["prompt"][:200]) else 0
|
|
@@ -599,6 +719,8 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
|
|
|
599
719
|
if not (run["source"] == "claude-code" or not run.get("workflow")):
|
|
600
720
|
for t in tasks_by_run[run["id"]]:
|
|
601
721
|
_outcome_trace(t, run, now)
|
|
722
|
+
# before the roll-up, so baselines, scores and health events all see the settled outcome
|
|
723
|
+
_apply_grades(all_tasks, grades or {})
|
|
602
724
|
|
|
603
725
|
# subagent roll-up: link child runs to the parent task that spawned them
|
|
604
726
|
task_by_id = {t["id"]: t for t in all_tasks}
|
|
@@ -642,10 +764,12 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
|
|
|
642
764
|
for k, g in groups.items():
|
|
643
765
|
if len(g) < 3 and k != "__all__":
|
|
644
766
|
continue
|
|
767
|
+
n = len(g)
|
|
768
|
+
g = sorted(g, key=lambda t: (t["started"] or 0, t["id"]))[:baseline_sample_size(n)]
|
|
645
769
|
costs = [t["cost"] + t["subagent_cost"] for t in g]
|
|
646
770
|
durs = [t["duration_s"] for t in g]
|
|
647
771
|
baselines[k] = {
|
|
648
|
-
"n": len(g),
|
|
772
|
+
"n": n, "sample": len(g),
|
|
649
773
|
"cost_p50": pct(costs, 0.5), "cost_p90": pct(costs, 0.9),
|
|
650
774
|
"duration_p50": pct(durs, 0.5), "duration_p90": pct(durs, 0.9),
|
|
651
775
|
"tokens_p50": pct([t["total_tokens"] for t in g], 0.5),
|
|
@@ -654,13 +778,31 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
|
|
|
654
778
|
}
|
|
655
779
|
|
|
656
780
|
events = []
|
|
781
|
+
dirty = set(dirty)
|
|
782
|
+
if cache is not None:
|
|
783
|
+
cache.changed = set()
|
|
784
|
+
live = {t["id"] for t in all_tasks}
|
|
785
|
+
for tid in [k for k in cache.sig if k not in live]: # tasks that no longer exist
|
|
786
|
+
cache.sig.pop(tid, None)
|
|
787
|
+
cache.events.pop(tid, None)
|
|
657
788
|
for t in all_tasks:
|
|
789
|
+
if cache is not None:
|
|
790
|
+
b = baselines.get(t["task_type"]) or baselines.get("__all__") or {}
|
|
791
|
+
sig = (_settled_values(t), b.get("cost_p50"), b.get("duration_p50"))
|
|
792
|
+
if t["run_id"] not in dirty and cache.sig.get(t["id"]) == sig:
|
|
793
|
+
events.extend(cache.events[t["id"]]) # nothing it depends on moved
|
|
794
|
+
continue
|
|
658
795
|
t["failed"] = 1 if t.get("outcome") == "failed" else 0
|
|
659
796
|
if t["llm_calls"] == 0 and t["tool_calls"] == 0:
|
|
660
797
|
t.update({"scores": {}, "score": None, "apdex": None, "cost_vs_baseline": None, "duration_vs_baseline": None})
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
798
|
+
ev = []
|
|
799
|
+
else:
|
|
800
|
+
score_task(t, baselines)
|
|
801
|
+
ev = evaluate_rules(t, rules)
|
|
802
|
+
events.extend(ev)
|
|
803
|
+
if cache is not None:
|
|
804
|
+
cache.sig[t["id"]], cache.events[t["id"]] = sig, ev
|
|
805
|
+
cache.changed.add(t["id"])
|
|
664
806
|
return all_tasks, baselines, events
|
|
665
807
|
|
|
666
808
|
|
|
@@ -673,7 +815,11 @@ def analyze(runs, rules=None, now=None):
|
|
|
673
815
|
|
|
674
816
|
def process_insights(tasks):
|
|
675
817
|
"""Aggregate findings meant to help a human assess how the agent works."""
|
|
676
|
-
|
|
818
|
+
# One canonical order. The engine holds tasks in whatever order runs arrived, which differs between
|
|
819
|
+
# a long-running process and a fresh rebuild; float sums round differently in a different order,
|
|
820
|
+
# and example lists are cut to their first few. Sorting makes the result depend on the data alone.
|
|
821
|
+
main = sorted((t for t in tasks if not t["is_subagent"] and t["llm_calls"] > 0),
|
|
822
|
+
key=lambda t: (t["started"] or 0, t["id"]))
|
|
677
823
|
if not main:
|
|
678
824
|
return []
|
|
679
825
|
out = []
|
|
@@ -215,6 +215,10 @@ class trace:
|
|
|
215
215
|
"""Attach a user/eval score (0..1) to this task."""
|
|
216
216
|
self.run.feedback.append({"key": key, "score": score})
|
|
217
217
|
|
|
218
|
+
def outcome(self, result, reason=None):
|
|
219
|
+
"""State how this task ended. See agentdynamics.outcome."""
|
|
220
|
+
_record_outcome(self.run, result, reason)
|
|
221
|
+
|
|
218
222
|
# decorator
|
|
219
223
|
def __call__(self, *args, **kwargs):
|
|
220
224
|
if self._fn is None: # used as @trace("name")
|
|
@@ -402,6 +406,38 @@ class llm_call:
|
|
|
402
406
|
return False
|
|
403
407
|
|
|
404
408
|
|
|
409
|
+
OUTCOMES = ("completed", "failed", "rework", "interrupted")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _record_outcome(run, result, reason):
|
|
413
|
+
if result not in OUTCOMES:
|
|
414
|
+
# telemetry never breaks the agent: a typo is a warning, not an exception in their code path
|
|
415
|
+
_warn(f"outcome:{result!r}", f"outcome({result!r}) ignored: must be one of {', '.join(OUTCOMES)}")
|
|
416
|
+
return
|
|
417
|
+
run.add({"kind": "notice", "name": "outcome", "outcome": result, "ts": time.time(),
|
|
418
|
+
"text": _clip(str(reason), 500) if reason is not None else None})
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def outcome(result, reason=None):
|
|
422
|
+
"""State how the current task ended, instead of leaving it to inference.
|
|
423
|
+
|
|
424
|
+
@agentdynamics.trace
|
|
425
|
+
def handle(ticket):
|
|
426
|
+
...
|
|
427
|
+
if not resolved:
|
|
428
|
+
agentdynamics.outcome("failed", reason="customer escalated")
|
|
429
|
+
|
|
430
|
+
`result` is completed | failed | rework | interrupted. The last call in a task wins, and it
|
|
431
|
+
overrides both inference and recorded feedback; only a grade set later through the API ranks
|
|
432
|
+
above it. Outside a traced task there is nothing to grade, so it warns once and does nothing.
|
|
433
|
+
"""
|
|
434
|
+
run = _current.get()
|
|
435
|
+
if run is None:
|
|
436
|
+
_warn("outcome:no-run", "agentdynamics.outcome() called outside a traced task; ignored")
|
|
437
|
+
return
|
|
438
|
+
_record_outcome(run, result, reason)
|
|
439
|
+
|
|
440
|
+
|
|
405
441
|
def record_llm(model, input_tokens=0, output_tokens=0, cache_read=0, cache_write=0, stop_reason=None,
|
|
406
442
|
start=None, end=None, text="", provider="custom"):
|
|
407
443
|
"""Record a model call after the fact (no gating). Prefer `llm_call` when you can wrap the call."""
|