agentdynamics 0.4.0__tar.gz → 0.5.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 (52) hide show
  1. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/PKG-INFO +39 -7
  2. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/README.md +38 -6
  3. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/__init__.py +3 -3
  4. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/__main__.py +62 -2
  5. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/analysis.py +74 -14
  6. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/autotrace.py +36 -0
  7. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/langsmith.py +8 -3
  8. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/otlp.py +19 -0
  9. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/spans.py +30 -3
  10. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/engine.py +47 -5
  11. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/govern.py +111 -6
  12. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/integrations/aegis.py +21 -8
  13. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/server.py +99 -7
  14. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/store.py +45 -6
  15. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/web/app.js +56 -9
  16. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/PKG-INFO +39 -7
  17. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/SOURCES.txt +5 -1
  18. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/pyproject.toml +1 -1
  19. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/tests/test_aegis_integration.py +93 -2
  20. agentdynamics-0.5.0/tests/test_console_ui.py +253 -0
  21. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/tests/test_core.py +108 -0
  22. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/tests/test_ecosystem.py +12 -2
  23. agentdynamics-0.5.0/tests/test_outcomes.py +288 -0
  24. agentdynamics-0.5.0/tests/test_policy_coverage.py +222 -0
  25. agentdynamics-0.5.0/tests/test_token_accounting.py +145 -0
  26. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/LICENSE +0 -0
  27. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  28. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/__init__.py +0 -0
  29. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  30. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/claude_code.py +0 -0
  31. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/generic.py +0 -0
  32. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/inbox.py +0 -0
  33. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/collectors/langfuse.py +0 -0
  34. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/config.py +0 -0
  35. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/flows.py +0 -0
  36. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/integrations/__init__.py +0 -0
  37. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/phases.py +0 -0
  38. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/pricing.py +0 -0
  39. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/privacy.py +0 -0
  40. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/sdk.py +0 -0
  41. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/slo.py +0 -0
  42. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/web/charts.js +0 -0
  43. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/web/index.html +0 -0
  44. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics/web/style.css +0 -0
  45. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  46. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  47. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/requires.txt +0 -0
  48. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/agentdynamics.egg-info/top_level.txt +0 -0
  49. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/setup.cfg +0 -0
  50. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/tests/test_autotrace.py +0 -0
  51. {agentdynamics-0.4.0 → agentdynamics-0.5.0}/tests/test_integrations.py +0 -0
  52. {agentdynamics-0.4.0 → agentdynamics-0.5.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.0
3
+ Version: 0.5.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.0"
8
+ __version__ = "0.5.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__"]
@@ -184,11 +184,29 @@ def cmd_policy(a, eng):
184
184
  for p in g["policies"]:
185
185
  print()
186
186
  print(f"{p['policy']}: {p['tasks']} tasks, success {p['success_rate']:.0%}, {p['denials']} denials")
187
- print(f" granted {len(p['granted'])} tools, used {len(p['used'])}; unused: {', '.join(p['unused']) or 'none'}")
187
+ print(f" used {len(p['used'])} of {len(p['granted'])} granted tools; unused: {', '.join(p['unused']) or 'none'}")
188
+ if p.get("ungoverned"):
189
+ print(f" called but not in the policy: {', '.join(p['ungoverned'])}")
188
190
  print(" budget headroom (limit / p95 used): " + ", ".join(f"{k2} {v}x" for k2, v in p["headroom"].items() if v))
189
191
  for r in g["by_rule"][:8]:
190
192
  print(f" {r['n']:>5} {r['rule']}")
191
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)
192
210
  res = api.export_policy(q)
193
211
  if res.get("error"):
194
212
  print(res["error"], file=sys.stderr)
@@ -201,6 +219,42 @@ def cmd_policy(a, eng):
201
219
  print(f" - {c}", file=sys.stderr)
202
220
  else:
203
221
  print(res["yaml"])
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%}")
257
+ return 1
204
258
  return 0
205
259
 
206
260
 
@@ -244,7 +298,9 @@ def main(argv=None):
244
298
  k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
245
299
  k.add_argument("--name", default=None)
246
300
  po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
247
- 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")
248
304
  po.add_argument("--workflow")
249
305
  po.add_argument("--project")
250
306
  po.add_argument("--environment")
@@ -253,6 +309,10 @@ def main(argv=None):
253
309
  po.add_argument("--base", help="Aegis policy file to tighten (needs aegis-kernel installed)")
254
310
  po.add_argument("--headroom", type=float, default=1.5)
255
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)")
256
316
  sub.add_parser("ingest", help="scan sources once and rebuild the database")
257
317
  rp = sub.add_parser("report", help="print a text summary")
258
318
  rp.add_argument("--project")
@@ -40,23 +40,34 @@ CORRECTION = re.compile(
40
40
 
41
41
 
42
42
  def classify_task(prompt_kind, text):
43
+ return classify_task_detail(prompt_kind, text)[0]
44
+
45
+
46
+ def classify_task_detail(prompt_kind, text):
47
+ """(task_type, how it was decided, what matched).
48
+
49
+ how: "prompt kind" when the prompt says what it is (a slash command, a scheduled job, a subagent's
50
+ brief); "follow-up" for a short "continue"/"yes" that carries on the previous task; "keywords" when an
51
+ intent rule matched, with the matched text; "unmatched" when nothing did. Keyword typing is a guess
52
+ about what someone meant, so it says so -- the same way outcome_source separates graded from inferred.
53
+ """
43
54
  if prompt_kind == "scheduled":
44
- return "scheduled job"
55
+ return "scheduled job", "prompt kind", None
45
56
  if prompt_kind == "delegated":
46
- return "subagent"
57
+ return "subagent", "prompt kind", None
47
58
  t = (text or "").strip()
48
59
  low = t.lower()
49
60
  if prompt_kind == "command":
50
- return "slash command"
61
+ return "slash command", "prompt kind", None
51
62
  if len(t) < 60 and FOLLOWUP.search(t):
52
- return "follow-up"
63
+ return "follow-up", "follow-up", None
53
64
  # The earliest intent keyword wins: "Create X, then test it" is a build, not a testing task.
54
65
  best = None
55
66
  for rank, (name, pat) in enumerate(TYPE_RULES):
56
67
  m = re.search(pat, low[:600])
57
68
  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"
69
+ best = ((m.start(), rank), name, m.group(0).strip())
70
+ return (best[1], "keywords", best[2][:40]) if best else ("other", "unmatched", None)
60
71
 
61
72
 
62
73
  # ---------------------------------------------------------------- helpers
@@ -146,9 +157,10 @@ def task_metrics(run, idx, seg):
146
157
  in_side = t["input_tokens"] + t["cache_read"] + t["cache_write"]
147
158
  t["cache_hit"] = round(t["cache_read"] / in_side, 3) if in_side else None
148
159
  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"])
160
+ t["task_type"], t["task_type_source"], t["task_type_match"] = classify_task_detail(t["prompt_kind"], t["prompt"])
150
161
  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
162
+ # traced apps: the entry point names the business transaction, which beats any guess
163
+ t["task_type"], t["task_type_source"], t["task_type_match"] = run["workflow"], "workflow", None
152
164
 
153
165
  # --- phase attribution: split each LLM call's cost across the tool calls it issued
154
166
  phase_calls = Counter(s["phase"] for s in tools)
@@ -283,16 +295,27 @@ def governance_metrics(t, run, steps, tools):
283
295
  t["revocations"] = sum(1 for s in steps if s["kind"] == "notice" and s.get("name") == "revoked")
284
296
  t["blocked_cost"] = round(sum(s.get("attributed_cost") or 0 for s in tool_denied), 6)
285
297
  t["denied_rules"] = dict(Counter(s.get("rule") or "unknown" for s in denied))
286
- streak = best = 0
298
+ # Probing is "refused again and again without getting anywhere". Counting only repeats of the
299
+ # *same* tool misses the agent that alternates between two forbidden ones -- which is what a
300
+ # prompt-injected agent told to "refund, then confirm by email" does without trying to evade
301
+ # anything. Track both, and report the longer: consecutive denials end at the first success.
302
+ streak = run_ = best = 0
287
303
  last = None
288
304
  for s in tools:
289
- key = s.get("name") if s.get("denied") else None # same tool refused in a row, whatever the rule
290
- streak = streak + 1 if key is not None and key == last else (1 if key else 0)
291
- last = key
292
- best = max(best, streak)
305
+ if s.get("denied"):
306
+ name = s.get("name")
307
+ streak = streak + 1 if name == last else 1
308
+ run_ += 1
309
+ last = name
310
+ else:
311
+ streak, run_, last = 0, 0, None
312
+ best = max(best, streak, run_)
293
313
  t["repeated_denials"] = best
294
314
 
295
315
 
316
+ OUTCOMES = ("completed", "failed", "rework", "interrupted") # what a grade may state (store.OUTCOMES)
317
+ FEEDBACK_PASS = 0.5 # a feedback score at or above this is a pass
318
+
296
319
  TRUNCATION = {"max_tokens", "length", "max_output_tokens", "MAX_TOKENS"}
297
320
  REFUSAL = {"refusal", "content_filter", "SAFETY", "safety", "blocked"}
298
321
  RATE_HINTS = ("429", "rate limit", "rate_limit", "overloaded", "529", "too many requests", "quota")
@@ -306,6 +329,13 @@ def flow_metrics(t, run, steps, llm, tools):
306
329
  t["framework"] = run.get("framework") or run.get("source")
307
330
  t["workflow"] = run.get("workflow") or (t["task_type"] if run.get("source") == "claude-code" else None)
308
331
  t["steps_total"] = len(llm) + len(tools)
332
+ # model calls whose cache accounting rests on a guess (collectors/spans.uncached_input); None is
333
+ # a source that is exact by construction, like the SDK, so only an explicit False counts
334
+ t["tokens_unverified"] = sum(1 for s in llm if s.get("tokens_verified") is False)
335
+ # agentdynamics.outcome() records a notice; the last one in the task wins. Kept on a transient
336
+ # key -- finalize decides precedence, and write_analysis persists only TASK_COLS.
337
+ graded = [s for s in notices if s.get("name") == "outcome" and s.get("outcome") in OUTCOMES]
338
+ t["_sdk_grade"] = {"outcome": graded[-1]["outcome"], "reason": graded[-1].get("text")} if graded else None
309
339
  t["llm_errors"] = sum(1 for s in llm if s.get("is_error"))
310
340
  t["truncations"] = sum(1 for s in llm if s.get("stop_reason") in TRUNCATION)
311
341
  t["refusals"] = sum(1 for s in llm if s.get("stop_reason") in REFUSAL)
@@ -530,6 +560,7 @@ def _outcomes_claude(ts, now):
530
560
  # "continue" / "yes" carries on the previous request, so it belongs to that task type
531
561
  if t["task_type"] == "follow-up" and i > 0 and ts[i - 1]["task_type"] not in ("follow-up",):
532
562
  t["task_type"] = ts[i - 1]["task_type"]
563
+ t["task_type_source"], t["task_type_match"] = "follow-up", None # inherited, not re-guessed
533
564
  if t["source"] == "claude-code":
534
565
  t["workflow"] = t["task_type"]
535
566
  nxt = next((x for x in ts[i + 1:] if x["prompt_kind"] in ("human", "command")), None)
@@ -565,7 +596,34 @@ def _outcome_trace(t, run, now):
565
596
  t["root_error"] = run.get("root_error")
566
597
 
567
598
 
568
- def finalize(runs, tasks_by_run, rules=None, now=None):
599
+ def _apply_grades(tasks, grades):
600
+ """Settle each outcome by the strongest evidence available, and record which one it was.
601
+
602
+ stated after the fact (API) > stated in the run (agentdynamics.outcome) > recorded feedback
603
+ > inference from signals.
604
+
605
+ Inference is a guess built from errors, interrupts and corrections. Apdex, success rate and the
606
+ process score all inherit it, so anything that states the outcome outright must win, and the
607
+ console has to be able to say how much of a success rate is guessed. Idempotent: the cached
608
+ per-run tasks are re-finalized on every refresh, so nothing here may consume its input.
609
+ """
610
+ for t in tasks:
611
+ api, sdk = grades.get(t["id"]), t.get("_sdk_grade")
612
+ if api:
613
+ t["outcome"], t["outcome_source"] = api["outcome"], "graded"
614
+ t["outcome_reason"] = api.get("reason") or f"graded by {api.get('graded_by') or 'api'}"
615
+ elif sdk:
616
+ t["outcome"], t["outcome_source"] = sdk["outcome"], "graded"
617
+ t["outcome_reason"] = sdk.get("reason") or "graded in the run"
618
+ elif t.get("feedback_score") is not None:
619
+ ok = t["feedback_score"] >= FEEDBACK_PASS
620
+ t["outcome"] = "completed" if ok else "rework"
621
+ t["outcome_source"], t["outcome_reason"] = "feedback", f"feedback score {t['feedback_score']:g}"
622
+ else:
623
+ t["outcome_source"], t["outcome_reason"] = "inferred", None
624
+
625
+
626
+ def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
569
627
  """Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events."""
570
628
  rules = rules or DEFAULT_RULES
571
629
  now = now or time.time()
@@ -591,6 +649,8 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
591
649
  if not (run["source"] == "claude-code" or not run.get("workflow")):
592
650
  for t in tasks_by_run[run["id"]]:
593
651
  _outcome_trace(t, run, now)
652
+ # before the roll-up, so baselines, scores and health events all see the settled outcome
653
+ _apply_grades(all_tasks, grades or {})
594
654
 
595
655
  # subagent roll-up: link child runs to the parent task that spawned them
596
656
  task_by_id = {t["id"]: t for t in all_tasks}
@@ -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."""
@@ -83,11 +83,15 @@ def _usage(outputs, run):
83
83
  det = um.get("input_token_details") or {}
84
84
  cr = det.get("cache_read") or 0
85
85
  cw = det.get("cache_creation") or 0
86
+ # langchain-core UsageMetadata.input_tokens: "Sum of all input token types" -- cache reads and
87
+ # cache writes are already inside it. The legacy llm_output.token_usage path carries no cache
88
+ # breakdown, so there is nothing to double count and no convention to claim.
89
+ conv = "inclusive" if um else None
86
90
  tu = (o.get("llm_output") or {}).get("token_usage") or (o.get("llm_output") or {}).get("usage") or {}
87
91
  if tu and not (it or ot):
88
92
  it = tu.get("prompt_tokens") or tu.get("input_tokens") or 0
89
93
  ot = tu.get("completion_tokens") or tu.get("output_tokens") or 0
90
- return it, ot, cr, cw, stop, model
94
+ return it, ot, cr, cw, stop, model, conv
91
95
 
92
96
 
93
97
  KIND = {"llm": "llm", "tool": "tool", "retriever": "retriever", "embedding": "embedding", "chain": "chain", "prompt": "chain", "parser": "chain"}
@@ -108,9 +112,9 @@ def to_span(r):
108
112
  if r.get("name") in ("__interrupt__",) or "GraphInterrupt" in str(r.get("error") or ""):
109
113
  kind = "human"
110
114
  it = ot = cr = cw = 0
111
- stop = model_out = None
115
+ stop = model_out = conv = None
112
116
  if kind in ("llm", "embedding"):
113
- it, ot, cr, cw, stop, model_out = _usage(r.get("outputs"), r)
117
+ it, ot, cr, cw, stop, model_out, conv = _usage(r.get("outputs"), r)
114
118
  start = parse_time(r.get("start_time"))
115
119
  ttft = None
116
120
  for ev in r.get("events") or []:
@@ -136,6 +140,7 @@ def to_span(r):
136
140
  "status": "error" if err else ("ok" if r.get("end_time") else "unset"), "error": err,
137
141
  "model": md.get("ls_model_name") or inv.get("model") or inv.get("model_name") or model_out,
138
142
  "provider": md.get("ls_provider"), "input_tokens": it, "output_tokens": ot, "cache_read": cr, "cache_write": cw,
143
+ "input_convention": conv,
139
144
  "cost": r.get("total_cost") if kind == "llm" and r.get("total_cost") is not None else None,
140
145
  "stop_reason": stop, "ttft_ms": ttft, "input": r.get("inputs"), "output": r.get("outputs"),
141
146
  "node": node, "agent": md.get("agent_name") or md.get("lc_agent_name"),
@@ -204,6 +204,24 @@ def decode_json(doc):
204
204
 
205
205
  # ---------------------------------------------------------------- semantic-convention mapping
206
206
 
207
+ def _input_convention(a):
208
+ """Whether this span's input_tokens already counts its cache reads and writes, per the documented
209
+ contract of whichever attribute supplied it. Mirrors _g's order so it describes the same key.
210
+
211
+ gen_ai.usage.input_tokens GenAI semconv: "SHOULD include all types of input tokens, including
212
+ cached tokens."
213
+ llm.token_count.prompt OpenInference: cache reads and writes are "tokens in the prompt".
214
+ anything else (gen_ai.usage.prompt_tokens from older semconv / OpenLLMetry, llm.usage.*)
215
+ has no documented rule we can cite: None, and the call is reported as
216
+ unverified if it involves cache tokens.
217
+ """
218
+ for k, conv in (("gen_ai.usage.input_tokens", "inclusive"), ("gen_ai.usage.prompt_tokens", None),
219
+ ("llm.token_count.prompt", "inclusive"), ("llm.usage.prompt_tokens", None)):
220
+ if a.get(k) not in (None, "", []):
221
+ return conv
222
+ return None
223
+
224
+
207
225
  def _g(a, *keys):
208
226
  for k in keys:
209
227
  v = a.get(k)
@@ -319,6 +337,7 @@ def map_span(res, scope, sp):
319
337
  "model": _g(a, "gen_ai.response.model", "gen_ai.request.model", "llm.model_name", "llm.request.model"),
320
338
  "provider": _g(a, "gen_ai.system", "gen_ai.provider.name", "llm.provider", "llm.system"),
321
339
  "input_tokens": _num(_g(a, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "llm.token_count.prompt", "llm.usage.prompt_tokens")) or 0,
340
+ "input_convention": _input_convention(a),
322
341
  "output_tokens": _num(_g(a, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "llm.token_count.completion", "llm.usage.completion_tokens")) or 0,
323
342
  "cache_read": _num(_g(a, "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_read_input_tokens", "llm.token_count.prompt_details.cache_read",
324
343
  "gen_ai.usage.input_tokens.cached")) or 0,
@@ -22,6 +22,34 @@ STRUCTURAL = {"agent", "chain", "node", "guardrail", "evaluator", "human", "span
22
22
  RATE_LIMIT_HINTS = ("429", "rate limit", "rate_limit", "ratelimit", "overloaded", "529", "too many requests", "quota")
23
23
 
24
24
 
25
+ def uncached_input(input_tokens, cache_read, cache_write, convention):
26
+ """Split a reported input count into its uncached part, and say whether we know it's right.
27
+
28
+ Canonical steps keep input_tokens, cache_read and cache_write disjoint, because each is priced
29
+ differently. Sources disagree on whether their input count already contains the cache tokens, and
30
+ getting it wrong double counts them in the cost column.
31
+
32
+ inclusive documented as containing reads and writes (GenAI semconv, OpenInference,
33
+ LangChain usage_metadata): subtract both.
34
+ exclusive documented as not containing them: leave it.
35
+ None no rule we can cite. Fall back to the old guess -- inclusive of reads unless that is
36
+ arithmetically impossible -- and report the call as unverified, the way a model with
37
+ no known price is reported as unpriced rather than guessed.
38
+
39
+ A declared-inclusive count smaller than its own cache tokens cannot be inclusive; the emitter is
40
+ breaking its format's contract. That falls back too, and is reported rather than absorbed.
41
+ Returns (uncached_input, verified).
42
+ """
43
+ if not (cache_read or cache_write):
44
+ return input_tokens, True # nothing cached, nothing to disagree about
45
+ if convention == "exclusive":
46
+ return input_tokens, True
47
+ if convention == "inclusive" and input_tokens >= cache_read + cache_write:
48
+ return input_tokens - cache_read - cache_write, True
49
+ if cache_read and input_tokens >= cache_read:
50
+ return input_tokens - cache_read, False
51
+ return input_tokens, False
52
+
25
53
  def preview(v, n=600):
26
54
  if v is None:
27
55
  return ""
@@ -156,9 +184,7 @@ def build_run(trace_id, spans, source):
156
184
  model = s.get("model") or s.get("name")
157
185
  it, ot = int(s.get("input_tokens") or 0), int(s.get("output_tokens") or 0)
158
186
  cr, cw = int(s.get("cache_read") or 0), int(s.get("cache_write") or 0)
159
- # OTel/LangSmith report input_tokens inclusive of cache reads for most providers; keep uncached part separate
160
- if cr and it >= cr:
161
- it -= cr
187
+ it, verified = uncached_input(it, cr, cw, s.get("input_convention"))
162
188
  cost = s.get("cost")
163
189
  if cost is None:
164
190
  cost = pricing.cost(model, it, ot, cr, cw, 0)
@@ -168,6 +194,7 @@ def build_run(trace_id, spans, source):
168
194
  "context_tokens": it + cr + cw, "stop_reason": s.get("stop_reason"), "ttft_ms": s.get("ttft_ms"),
169
195
  "text": preview(s.get("output"), 600), "tool_calls": 0, "thinking_tokens": int(s.get("thinking_tokens") or 0),
170
196
  "rate_limited": bool(err) and any(h in err.lower() for h in RATE_LIMIT_HINTS),
197
+ "tokens_verified": verified,
171
198
  })
172
199
  last_llm = st
173
200
  elif kind in ("tool", "retriever"):