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.
Files changed (53) hide show
  1. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/PKG-INFO +39 -7
  2. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/README.md +38 -6
  3. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/__init__.py +3 -3
  4. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/__main__.py +58 -11
  5. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/analysis.py +162 -16
  6. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/autotrace.py +36 -0
  7. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/langsmith.py +8 -3
  8. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/otlp.py +19 -0
  9. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/spans.py +30 -3
  10. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/engine.py +44 -3
  11. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/govern.py +69 -28
  12. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/server.py +74 -7
  13. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/store.py +92 -27
  14. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/app.js +53 -7
  15. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/PKG-INFO +39 -7
  16. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/SOURCES.txt +6 -1
  17. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/pyproject.toml +1 -1
  18. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_aegis_integration.py +1 -1
  19. agentdynamics-0.6.0/tests/test_console_ui.py +253 -0
  20. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_core.py +51 -0
  21. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_ecosystem.py +12 -2
  22. agentdynamics-0.6.0/tests/test_incremental.py +240 -0
  23. agentdynamics-0.6.0/tests/test_outcomes.py +288 -0
  24. agentdynamics-0.6.0/tests/test_policy_coverage.py +222 -0
  25. agentdynamics-0.6.0/tests/test_token_accounting.py +145 -0
  26. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/LICENSE +0 -0
  27. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  28. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/__init__.py +0 -0
  29. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  30. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/claude_code.py +0 -0
  31. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/generic.py +0 -0
  32. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/inbox.py +0 -0
  33. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/collectors/langfuse.py +0 -0
  34. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/config.py +0 -0
  35. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/flows.py +0 -0
  36. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/integrations/__init__.py +0 -0
  37. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/integrations/aegis.py +0 -0
  38. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/phases.py +0 -0
  39. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/pricing.py +0 -0
  40. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/privacy.py +0 -0
  41. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/sdk.py +0 -0
  42. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/slo.py +0 -0
  43. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/charts.js +0 -0
  44. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/index.html +0 -0
  45. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics/web/style.css +0 -0
  46. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  47. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  48. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/requires.txt +0 -0
  49. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/agentdynamics.egg-info/top_level.txt +0 -0
  50. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/setup.cfg +0 -0
  51. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_autotrace.py +0 -0
  52. {agentdynamics-0.4.1 → agentdynamics-0.6.0}/tests/test_integrations.py +0 -0
  53. {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.4.1
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
+ ![Flow Map](docs/img/flow-map.png)
121
+
122
+ The Overview: cost, Apdex and flow health against baselines learned from your own history.
123
+
124
+ ![Overview](docs/img/overview.png)
125
+
126
+ Governance: what Aegis refused, which rule refused it, and which grants are never used.
127
+
128
+ ![Governance](docs/img/governance.png)
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, which `aegis ratify` and `aegis drift` then verify.
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
- python -m unittest discover tests # 28 tests: real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
188
- python -m agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
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
+ ![Flow Map](docs/img/flow-map.png)
98
+
99
+ The Overview: cost, Apdex and flow health against baselines learned from your own history.
100
+
101
+ ![Overview](docs/img/overview.png)
102
+
103
+ Governance: what Aegis refused, which rule refused it, and which grants are never used.
104
+
105
+ ![Governance](docs/img/governance.png)
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, which `aegis ratify` and `aegis drift` then verify.
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
- python -m unittest discover tests # 28 tests: real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
165
- python -m agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
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.4.1"
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
- # Replaying the observed calls is the only check that catches a policy which is strictly
207
- # narrower than its base, ratifies cleanly, and still refuses everything in production.
208
- if res.get("regressions"):
209
- print("", file=sys.stderr)
210
- print(f"WARNING: this policy would deny {len(res['regressions'])} kind(s) of call that "
211
- f"the observed traffic made successfully:", file=sys.stderr)
212
- for r in res["regressions"][:10]:
213
- print(f" - {r['tool']}({r['arg']}={r['value']!r}) -> {r['rule']}", file=sys.stderr)
214
- print(" Review before deploying; `aegis ratify` and `aegis drift` cannot see this.",
215
- file=sys.stderr)
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"] = classify_task(t["prompt_kind"], t["prompt"])
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
- t["task_type"] = run["workflow"] # traced apps: the entry point names the business transaction
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 finalize(runs, tasks_by_run, rules=None, now=None):
577
- """Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events."""
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
- group.sort(key=lambda x: x["started"] or 0)
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
- continue
662
- score_task(t, baselines)
663
- events.extend(evaluate_rules(t, rules))
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
- main = [t for t in tasks if not t["is_subagent"] and t["llm_calls"] > 0]
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."""