agentdynamics 0.4.1__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.1 → agentdynamics-0.5.0}/PKG-INFO +39 -7
  2. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/README.md +38 -6
  3. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/__init__.py +3 -3
  4. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/__main__.py +58 -11
  5. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/analysis.py +61 -9
  6. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/autotrace.py +36 -0
  7. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/langsmith.py +8 -3
  8. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/otlp.py +19 -0
  9. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/spans.py +30 -3
  10. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/engine.py +24 -2
  11. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/govern.py +69 -28
  12. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/server.py +74 -7
  13. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/store.py +45 -6
  14. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/app.js +53 -7
  15. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/PKG-INFO +39 -7
  16. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/SOURCES.txt +5 -1
  17. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/pyproject.toml +1 -1
  18. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_aegis_integration.py +1 -1
  19. agentdynamics-0.5.0/tests/test_console_ui.py +253 -0
  20. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_core.py +51 -0
  21. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_ecosystem.py +12 -2
  22. agentdynamics-0.5.0/tests/test_outcomes.py +288 -0
  23. agentdynamics-0.5.0/tests/test_policy_coverage.py +222 -0
  24. agentdynamics-0.5.0/tests/test_token_accounting.py +145 -0
  25. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/LICENSE +0 -0
  26. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  27. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/__init__.py +0 -0
  28. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  29. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/claude_code.py +0 -0
  30. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/generic.py +0 -0
  31. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/inbox.py +0 -0
  32. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/langfuse.py +0 -0
  33. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/config.py +0 -0
  34. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/flows.py +0 -0
  35. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/integrations/__init__.py +0 -0
  36. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/integrations/aegis.py +0 -0
  37. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/phases.py +0 -0
  38. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/pricing.py +0 -0
  39. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/privacy.py +0 -0
  40. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/sdk.py +0 -0
  41. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/slo.py +0 -0
  42. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/charts.js +0 -0
  43. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/index.html +0 -0
  44. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/style.css +0 -0
  45. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  46. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  47. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/requires.txt +0 -0
  48. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/top_level.txt +0 -0
  49. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/setup.cfg +0 -0
  50. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_autotrace.py +0 -0
  51. {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_integrations.py +0 -0
  52. {agentdynamics-0.4.1 → 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.1
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.1"
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__"]
@@ -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")
@@ -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)
@@ -301,6 +313,9 @@ def governance_metrics(t, run, steps, tools):
301
313
  t["repeated_denials"] = best
302
314
 
303
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
+
304
319
  TRUNCATION = {"max_tokens", "length", "max_output_tokens", "MAX_TOKENS"}
305
320
  REFUSAL = {"refusal", "content_filter", "SAFETY", "safety", "blocked"}
306
321
  RATE_HINTS = ("429", "rate limit", "rate_limit", "overloaded", "529", "too many requests", "quota")
@@ -314,6 +329,13 @@ def flow_metrics(t, run, steps, llm, tools):
314
329
  t["framework"] = run.get("framework") or run.get("source")
315
330
  t["workflow"] = run.get("workflow") or (t["task_type"] if run.get("source") == "claude-code" else None)
316
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
317
339
  t["llm_errors"] = sum(1 for s in llm if s.get("is_error"))
318
340
  t["truncations"] = sum(1 for s in llm if s.get("stop_reason") in TRUNCATION)
319
341
  t["refusals"] = sum(1 for s in llm if s.get("stop_reason") in REFUSAL)
@@ -538,6 +560,7 @@ def _outcomes_claude(ts, now):
538
560
  # "continue" / "yes" carries on the previous request, so it belongs to that task type
539
561
  if t["task_type"] == "follow-up" and i > 0 and ts[i - 1]["task_type"] not in ("follow-up",):
540
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
541
564
  if t["source"] == "claude-code":
542
565
  t["workflow"] = t["task_type"]
543
566
  nxt = next((x for x in ts[i + 1:] if x["prompt_kind"] in ("human", "command")), None)
@@ -573,7 +596,34 @@ def _outcome_trace(t, run, now):
573
596
  t["root_error"] = run.get("root_error")
574
597
 
575
598
 
576
- 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):
577
627
  """Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events."""
578
628
  rules = rules or DEFAULT_RULES
579
629
  now = now or time.time()
@@ -599,6 +649,8 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
599
649
  if not (run["source"] == "claude-code" or not run.get("workflow")):
600
650
  for t in tasks_by_run[run["id"]]:
601
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 {})
602
654
 
603
655
  # subagent roll-up: link child runs to the parent task that spawned them
604
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"):
@@ -60,6 +60,10 @@ class Engine:
60
60
  # health has to be judged on the failures too, not just on a timestamp existing.
61
61
  self.last_refresh_error = None
62
62
  self.failed_refreshes = 0
63
+ # A grade changes no run, so without this a new grade would sit unapplied until unrelated
64
+ # traffic happened to dirty something. force=True would also work, but it throws away every
65
+ # cache and re-parses every source to re-settle outcomes that only need re-finalizing.
66
+ self._regrade = False
63
67
  self.stats = {"spans_ingested": 0, "refreshes": 0, "alerts_sent": 0}
64
68
  self.sources = {}
65
69
  if claude_root:
@@ -120,6 +124,22 @@ class Engine:
120
124
  time.sleep(interval)
121
125
  threading.Thread(target=loop, daemon=True, name=f"src-{name}").start()
122
126
 
127
+ # ------------------------------------------------------------------ outcome grades
128
+ def grade(self, task_id, outcome, reason=None, graded_by=None):
129
+ """State a task's outcome, overriding inference. Durable: survives schema rebuilds."""
130
+ with self.lock:
131
+ store.set_grade(self.con, task_id, outcome, reason, graded_by)
132
+ self._regrade = True
133
+ self._wake.set()
134
+
135
+ def ungrade(self, task_id):
136
+ """Drop a stated outcome; the task falls back to feedback, then to inference."""
137
+ with self.lock:
138
+ n = store.delete_grade(self.con, task_id)
139
+ self._regrade = True
140
+ self._wake.set()
141
+ return n
142
+
123
143
  # ------------------------------------------------------------------ push ingestion
124
144
  def ingest_spans(self, spans_, source, count_source=True):
125
145
  """Canonical spans (OTLP, Langfuse, inbox) -> durable store."""
@@ -318,8 +338,9 @@ class Engine:
318
338
  removed.append(rid)
319
339
  dirty.pop(rid, None)
320
340
  dirty = {k: v for k, v in dirty.items() if v["steps"]}
321
- if not dirty and not removed and not force and not self._first:
341
+ if not dirty and not removed and not force and not self._first and not self._regrade:
322
342
  return False
343
+ self._regrade = False
323
344
  for rid in removed:
324
345
  self._runs.pop(rid, None)
325
346
  self._tasks.pop(rid, None)
@@ -330,7 +351,8 @@ class Engine:
330
351
  self._runs[rid] = run
331
352
  self._tasks[rid] = analysis.run_tasks(run)
332
353
  runs = list(self._runs.values())
333
- tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules())
354
+ tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules(),
355
+ grades=store.get_grades(self.con))
334
356
  insights = analysis.process_insights(tasks)
335
357
  meta = {"refreshed": time.time(), "runs": len(runs), "tasks": len(tasks), "insights": insights}
336
358
  store.write_runs(self.con, list(dirty.values()), removed, self.redactor)