agentdynamics 0.5.0__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.5.0 → agentdynamics-0.6.0}/PKG-INFO +1 -1
  2. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/__init__.py +1 -1
  3. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/analysis.py +102 -8
  4. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/engine.py +22 -3
  5. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/store.py +47 -21
  6. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/PKG-INFO +1 -1
  7. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/SOURCES.txt +1 -0
  8. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/pyproject.toml +1 -1
  9. agentdynamics-0.6.0/tests/test_incremental.py +240 -0
  10. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/LICENSE +0 -0
  11. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/README.md +0 -0
  12. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/__main__.py +0 -0
  13. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/autotrace.py +0 -0
  14. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  15. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/__init__.py +0 -0
  16. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  17. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/claude_code.py +0 -0
  18. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/generic.py +0 -0
  19. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/inbox.py +0 -0
  20. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/langfuse.py +0 -0
  21. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/langsmith.py +0 -0
  22. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/otlp.py +0 -0
  23. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/spans.py +0 -0
  24. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/config.py +0 -0
  25. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/flows.py +0 -0
  26. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/govern.py +0 -0
  27. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/integrations/__init__.py +0 -0
  28. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/integrations/aegis.py +0 -0
  29. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/phases.py +0 -0
  30. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/pricing.py +0 -0
  31. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/privacy.py +0 -0
  32. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/sdk.py +0 -0
  33. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/server.py +0 -0
  34. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/slo.py +0 -0
  35. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/app.js +0 -0
  36. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/charts.js +0 -0
  37. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/index.html +0 -0
  38. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/style.css +0 -0
  39. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  40. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  41. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/requires.txt +0 -0
  42. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/top_level.txt +0 -0
  43. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/setup.cfg +0 -0
  44. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_aegis_integration.py +0 -0
  45. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_autotrace.py +0 -0
  46. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_console_ui.py +0 -0
  47. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_core.py +0 -0
  48. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_ecosystem.py +0 -0
  49. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_integrations.py +0 -0
  50. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_live_anthropic.py +0 -0
  51. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_outcomes.py +0 -0
  52. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_policy_coverage.py +0 -0
  53. {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_token_accounting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentdynamics
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
@@ -5,7 +5,7 @@
5
5
 
6
6
  See agentdynamics.autotrace for @trace / @tool / span.
7
7
  """
8
- __version__ = "0.5.0"
8
+ __version__ = "0.6.0"
9
9
 
10
10
  from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
11
11
 
@@ -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
@@ -623,8 +624,69 @@ def _apply_grades(tasks, grades):
623
624
  t["outcome_source"], t["outcome_reason"] = "inferred", None
624
625
 
625
626
 
626
- def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
627
- """Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events."""
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
+ """
628
690
  rules = rules or DEFAULT_RULES
629
691
  now = now or time.time()
630
692
  all_tasks = [t for r in runs for t in tasks_by_run[r["id"]]]
@@ -637,11 +699,19 @@ def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
637
699
  if run["source"] == "claude-code" or not run.get("workflow"):
638
700
  _outcomes_claude(ts, now)
639
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
640
708
  if run.get("thread_id"):
641
709
  threads[(run["source"], run["thread_id"])].extend(ts)
642
710
  # traced conversations: the next trace in the same thread plays the role of "your next message"
643
711
  for group in threads.values():
644
- 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"]))
645
715
  for a, b in zip(group, group[1:]):
646
716
  a["next_prompt"] = b["prompt"][:300]
647
717
  a["rework"] = 1 if CORRECTION.search(b["prompt"][:200]) else 0
@@ -694,10 +764,12 @@ def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
694
764
  for k, g in groups.items():
695
765
  if len(g) < 3 and k != "__all__":
696
766
  continue
767
+ n = len(g)
768
+ g = sorted(g, key=lambda t: (t["started"] or 0, t["id"]))[:baseline_sample_size(n)]
697
769
  costs = [t["cost"] + t["subagent_cost"] for t in g]
698
770
  durs = [t["duration_s"] for t in g]
699
771
  baselines[k] = {
700
- "n": len(g),
772
+ "n": n, "sample": len(g),
701
773
  "cost_p50": pct(costs, 0.5), "cost_p90": pct(costs, 0.9),
702
774
  "duration_p50": pct(durs, 0.5), "duration_p90": pct(durs, 0.9),
703
775
  "tokens_p50": pct([t["total_tokens"] for t in g], 0.5),
@@ -706,13 +778,31 @@ def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
706
778
  }
707
779
 
708
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)
709
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
710
795
  t["failed"] = 1 if t.get("outcome") == "failed" else 0
711
796
  if t["llm_calls"] == 0 and t["tool_calls"] == 0:
712
797
  t.update({"scores": {}, "score": None, "apdex": None, "cost_vs_baseline": None, "duration_vs_baseline": None})
713
- continue
714
- score_task(t, baselines)
715
- 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"])
716
806
  return all_tasks, baselines, events
717
807
 
718
808
 
@@ -725,7 +815,11 @@ def analyze(runs, rules=None, now=None):
725
815
 
726
816
  def process_insights(tasks):
727
817
  """Aggregate findings meant to help a human assess how the agent works."""
728
- 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"]))
729
823
  if not main:
730
824
  return []
731
825
  out = []
@@ -64,6 +64,11 @@ class Engine:
64
64
  # traffic happened to dirty something. force=True would also work, but it throws away every
65
65
  # cache and re-parses every source to re-settle outcomes that only need re-finalizing.
66
66
  self._regrade = False
67
+ # Scores carried between refreshes, so a refresh re-scores and rewrites only the tasks whose
68
+ # inputs changed instead of the whole store (analysis.ScoreCache). The clock is injectable
69
+ # because outcomes depend on it: a recent task is "in progress", an old one is not.
70
+ self._cache = analysis.ScoreCache()
71
+ self._clock = time.time
67
72
  self.stats = {"spans_ingested": 0, "refreshes": 0, "alerts_sent": 0}
68
73
  self.sources = {}
69
74
  if claude_root:
@@ -319,6 +324,7 @@ class Engine:
319
324
  self._span_since = 0
320
325
  self._runs.clear()
321
326
  self._tasks.clear()
327
+ self._cache = analysis.ScoreCache()
322
328
  changed, removed = self._scan_files()
323
329
  dirty = {}
324
330
  for op, x in changed:
@@ -341,6 +347,10 @@ class Engine:
341
347
  if not dirty and not removed and not force and not self._first and not self._regrade:
342
348
  return False
343
349
  self._regrade = False
350
+ full = force or self._first
351
+ # every task id that may no longer exist: those of removed runs, and the previous tasks of
352
+ # runs being re-analysed (a PATCH can re-segment a run into fewer tasks)
353
+ gone = {t["id"] for rid in list(removed) + list(dirty) for t in self._tasks.get(rid, [])}
344
354
  for rid in removed:
345
355
  self._runs.pop(rid, None)
346
356
  self._tasks.pop(rid, None)
@@ -351,12 +361,21 @@ class Engine:
351
361
  self._runs[rid] = run
352
362
  self._tasks[rid] = analysis.run_tasks(run)
353
363
  runs = list(self._runs.values())
354
- tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules(),
355
- grades=store.get_grades(self.con))
364
+ tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules(), now=self._clock(),
365
+ grades=store.get_grades(self.con),
366
+ cache=self._cache, dirty=dirty.keys())
356
367
  insights = analysis.process_insights(tasks)
357
368
  meta = {"refreshed": time.time(), "runs": len(runs), "tasks": len(tasks), "insights": insights}
358
369
  store.write_runs(self.con, list(dirty.values()), removed, self.redactor)
359
- store.write_analysis(self.con, tasks, baselines, events, meta, self.redactor)
370
+ if full:
371
+ # the first refresh of a process must replace whatever the last process left behind
372
+ store.write_analysis(self.con, tasks, baselines, events, meta, self.redactor)
373
+ else:
374
+ changed = self._cache.changed
375
+ events = [e for e in events if e["task_id"] in changed]
376
+ store.write_analysis_delta(self.con, [t for t in tasks if t["id"] in changed],
377
+ gone - changed, events, baselines, meta, self.redactor)
378
+ self.stats["tasks_rescored"] = len(self._cache.changed)
360
379
  self._dispatch_alerts(events)
361
380
  self._first = False
362
381
  self.last_refresh = time.time()
@@ -61,6 +61,7 @@ CREATE INDEX IF NOT EXISTS steps_run ON steps(run_id);
61
61
  CREATE INDEX IF NOT EXISTS steps_node ON steps(node);
62
62
  CREATE INDEX IF NOT EXISTS tasks_run ON tasks(run_id);
63
63
  CREATE INDEX IF NOT EXISTS tasks_started ON tasks(started);
64
+ CREATE INDEX IF NOT EXISTS events_task ON events(task_id);
64
65
 
65
66
  CREATE TABLE IF NOT EXISTS spans_raw (source, trace_id, span_id, fmt, doc, updated REAL, PRIMARY KEY(source, span_id)) WITHOUT ROWID;
66
67
  CREATE INDEX IF NOT EXISTS spans_trace ON spans_raw(source, trace_id);
@@ -145,30 +146,55 @@ def update_step_analysis(con, runs):
145
146
  con.executemany("UPDATE steps SET task_id=?, flags=?, attributed_cost=? WHERE run_id=? AND seq=?", rows_)
146
147
 
147
148
 
149
+ def _task_row(t, red):
150
+ row = [t.get(c) for c in TASK_COLS] + [json.dumps(t.get(c) if t.get(c) is not None else ([] if c == "path" else {}))
151
+ for c in TASK_JSON]
152
+ if red:
153
+ for f in ("prompt", "final_text", "next_prompt", "root_error"):
154
+ i = TASK_COLS.index(f)
155
+ row[i] = red.text(row[i])
156
+ return row
157
+
158
+
159
+ def _event_row(e, red):
160
+ row = [e.get(c) for c in EVENT_COLS]
161
+ if red and red.rx is not None: # messages can quote user text (e.g. the correcting follow-up)
162
+ i = EVENT_COLS.index("message")
163
+ row[i] = red.rx.sub("[REDACTED]", row[i] or "")
164
+ return row
165
+
166
+
167
+ def _write_small(con, baselines, meta):
168
+ for t in ("baselines", "meta"):
169
+ con.execute(f"DELETE FROM {t}")
170
+ _ins(con, "baselines", ["task_type", "data"], [[k, json.dumps(v)] for k, v in baselines.items()])
171
+ _ins(con, "meta", ["k", "v"], [[k, json.dumps(v)] for k, v in meta.items()])
172
+
173
+
148
174
  def write_analysis(con, tasks, baselines, events, meta, red=None):
175
+ """Replace every derived analysis row: after a full rebuild."""
149
176
  with con:
150
- for t in ("tasks", "events", "baselines", "meta"):
177
+ for t in ("tasks", "events"):
151
178
  con.execute(f"DELETE FROM {t}")
152
- trows = []
153
- for t in tasks:
154
- row = [t.get(c) for c in TASK_COLS] + [json.dumps(t.get(c) if t.get(c) is not None else ([] if c == "path" else {}))
155
- for c in TASK_JSON]
156
- if red:
157
- for f in ("prompt", "final_text", "next_prompt", "root_error"):
158
- i = TASK_COLS.index(f)
159
- row[i] = red.text(row[i])
160
- trows.append(row)
161
- _ins(con, "tasks", TASK_COLS + TASK_JSON, trows)
162
- erows = []
163
- for e in events:
164
- row = [e.get(c) for c in EVENT_COLS]
165
- if red and red.rx is not None: # messages can quote user text (e.g. the correcting follow-up)
166
- i = EVENT_COLS.index("message")
167
- row[i] = red.rx.sub("[REDACTED]", row[i] or "")
168
- erows.append(row)
169
- _ins(con, "events", EVENT_COLS, erows)
170
- _ins(con, "baselines", ["task_type", "data"], [[k, json.dumps(v)] for k, v in baselines.items()])
171
- _ins(con, "meta", ["k", "v"], [[k, json.dumps(v)] for k, v in meta.items()])
179
+ _ins(con, "tasks", TASK_COLS + TASK_JSON, [_task_row(t, red) for t in tasks])
180
+ _ins(con, "events", EVENT_COLS, [_event_row(e, red) for e in events])
181
+ _write_small(con, baselines, meta)
182
+
183
+
184
+ def write_analysis_delta(con, changed, removed_ids, events, baselines, meta, red=None):
185
+ """Write only what an incremental refresh changed: the rows of `changed` tasks and their events,
186
+ the removal of `removed_ids`, and the small baselines/meta tables. `events` must be exactly the
187
+ events of the changed tasks. Rewriting every row cost 2.1 s of a 5.6 s refresh at 20k tasks."""
188
+ ids = [t["id"] for t in changed] + list(removed_ids)
189
+ with con:
190
+ for i in range(0, len(ids), 500):
191
+ chunk = ids[i:i + 500]
192
+ marks = ",".join("?" * len(chunk))
193
+ con.execute(f"DELETE FROM tasks WHERE id IN ({marks})", chunk)
194
+ con.execute(f"DELETE FROM events INDEXED BY events_task WHERE task_id IN ({marks})", chunk)
195
+ _ins(con, "tasks", TASK_COLS + TASK_JSON, [_task_row(t, red) for t in changed])
196
+ _ins(con, "events", EVENT_COLS, [_event_row(e, red) for e in events])
197
+ _write_small(con, baselines, meta)
172
198
 
173
199
 
174
200
  # ---------------------------------------------------------------- durable span store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentdynamics
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
@@ -43,6 +43,7 @@ tests/test_autotrace.py
43
43
  tests/test_console_ui.py
44
44
  tests/test_core.py
45
45
  tests/test_ecosystem.py
46
+ tests/test_incremental.py
46
47
  tests/test_integrations.py
47
48
  tests/test_live_anthropic.py
48
49
  tests/test_outcomes.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentdynamics"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Application performance monitoring for AI agents: flows, cost, reliability and process quality"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,240 @@
1
+ """Incremental refresh (#5) must give exactly what a full rebuild gives.
2
+
3
+ An incremental refresh re-scores and rewrites only the tasks whose inputs changed. That is only safe if
4
+ "inputs" is complete, and several of them cross task boundaries: a new trace in a conversation changes
5
+ the *previous* task's rework flag; a subagent run changes its parent's cost; a grade changes a task no
6
+ new data touched; the clock turns "in progress" into "unknown"; and every task is scored against its
7
+ type's baseline, which new traffic moves.
8
+
9
+ So each scenario here is a seeded random sequence of ingests, updates, deletions, grades and clock
10
+ advances, refreshed incrementally after every step. At checkpoints the store is compared, every column
11
+ of every task, event and baseline row, against a full rebuild from the same sources.
12
+ """
13
+ import json
14
+ import os
15
+ import random
16
+ import shutil
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import unittest
21
+
22
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
23
+
24
+ from agentdynamics import analysis # noqa: E402
25
+ from agentdynamics.engine import Engine # noqa: E402
26
+
27
+ WORKFLOWS = ["support", "billing", "research"]
28
+ PROMPTS = ["Where is my refund?", "Update my address", "still not working", "Cancel the order",
29
+ "no, that's wrong", "Thanks, that worked"]
30
+
31
+
32
+ class Scenario:
33
+ def __init__(self, seed, tmp):
34
+ self.rng = random.Random(seed)
35
+ self.clock = 1_900_000_000.0 # fixed and injectable: outcomes depend on "now"
36
+ self.eng = Engine(os.path.join(tmp, "data"), None)
37
+ self.eng._clock = lambda: self.clock
38
+ self.runs = {} # run id -> payload, for updates and subagents
39
+ self.n = 0
40
+
41
+ def run(self, rid=None, **over):
42
+ rng = self.rng
43
+ self.n += 1
44
+ rid = rid or f"r{self.n}"
45
+ ts = self.clock - rng.choice([30, 200, 900, 5000]) # some inside the 600 s "in progress" window
46
+ steps = [{"kind": "prompt", "ts": ts, "text": rng.choice(PROMPTS)}]
47
+ t = ts
48
+ for _ in range(rng.randint(1, 3)):
49
+ steps.append({"kind": "llm", "ts": t, "end_ts": t + 1, "model": "claude-sonnet-5",
50
+ "input_tokens": rng.randint(100, 5000), "output_tokens": rng.randint(10, 800),
51
+ "stop_reason": "end_turn"})
52
+ t += 1.5
53
+ if rng.random() < 0.7:
54
+ steps.append({"kind": "tool", "ts": t, "end_ts": t + 0.2, "name": rng.choice(["kb", "db", "http"]),
55
+ "is_error": rng.random() < 0.15, "input": {"q": rng.randint(1, 9)}})
56
+ t += 0.3
57
+ if rng.random() < 0.15:
58
+ steps.append({"kind": "notice", "name": "outcome", "ts": t,
59
+ "outcome": rng.choice(["completed", "failed", "rework"]), "text": "graded in run"})
60
+ p = {"id": rid, "project": "inc", "workflow": rng.choice(WORKFLOWS), "steps": steps,
61
+ "thread_id": rng.choice([None, "th-a", "th-a", "th-b"]),
62
+ "status": "error" if rng.random() < 0.1 else "ok", "complete": rng.random() > 0.2}
63
+ if rng.random() < 0.2:
64
+ p["feedback"] = [{"key": "user", "score": rng.choice([0.1, 0.9])}]
65
+ p.update(over)
66
+ self.runs[rid] = p
67
+ self.eng.ingest(p)
68
+ return rid
69
+
70
+ def step(self):
71
+ rng, e = self.rng, self.eng
72
+ op = rng.random()
73
+ if op < 0.45 or not self.runs:
74
+ self.run()
75
+ elif op < 0.55: # a subagent: changes its (clean) parent
76
+ parent = rng.choice(sorted(self.runs))
77
+ self.run(parent_id=parent)
78
+ elif op < 0.65: # an existing run is updated
79
+ self.run(rid=rng.choice(sorted(self.runs)))
80
+ elif op < 0.72: # a run is deleted at its source
81
+ rid = rng.choice(sorted(self.runs))
82
+ path = os.path.join(e.runs_dir, f"{rid}.json")
83
+ if os.path.exists(path):
84
+ os.remove(path)
85
+ self.runs.pop(rid)
86
+ elif op < 0.82: # a grade on a task no data touched
87
+ rid = rng.choice(sorted(self.runs))
88
+ e.grade(f"{rid}#0", rng.choice(["completed", "failed", "rework"]), "graded later")
89
+ elif op < 0.86:
90
+ rid = rng.choice(sorted(self.runs))
91
+ e.ungrade(f"{rid}#0")
92
+ elif op < 0.93: # a late span on an existing OTLP trace
93
+ self.otlp(rng.randint(1, 4))
94
+ else: # time passes: "in progress" expires
95
+ self.clock += rng.choice([300, 700, 4000])
96
+ e.refresh()
97
+
98
+ def otlp(self, trace_no):
99
+ rng = self.rng
100
+ tid = f"{trace_no:032x}"
101
+ ts = self.clock - 800
102
+ sid = f"{rng.randint(1, 10**12):016x}"
103
+ body = {"resourceSpans": [{"resource": {"attributes": [
104
+ {"key": "service.name", "value": {"stringValue": "inc-otlp"}}]},
105
+ "scopeSpans": [{"spans": [{
106
+ "traceId": tid, "spanId": sid, "name": "chat", "status": {},
107
+ "startTimeUnixNano": str(int(ts * 1e9)), "endTimeUnixNano": str(int((ts + 1) * 1e9)),
108
+ "attributes": [
109
+ {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
110
+ {"key": "gen_ai.request.model", "value": {"stringValue": "claude-haiku-4-5"}},
111
+ {"key": "gen_ai.usage.input_tokens", "value": {"intValue": str(rng.randint(100, 3000))}},
112
+ {"key": "gen_ai.usage.output_tokens", "value": {"intValue": str(rng.randint(10, 300))}}]}]}]}]}
113
+ self.eng.ingest_otlp(json.dumps(body).encode(), "application/json")
114
+
115
+ def snapshot(self):
116
+ con = self.eng.con
117
+
118
+ def table(q):
119
+ return [dict(r) for r in con.execute(q)]
120
+ meta = {r["k"]: json.loads(r["v"]) for r in con.execute("SELECT k, v FROM meta") if r["k"] != "refreshed"}
121
+ return {"tasks": table("SELECT * FROM tasks ORDER BY id"),
122
+ "events": table("SELECT * FROM events ORDER BY id"),
123
+ "baselines": table("SELECT * FROM baselines ORDER BY task_type"),
124
+ "meta": meta}
125
+
126
+
127
+ class IncrementalEqualsFullTest(unittest.TestCase):
128
+ def setUp(self):
129
+ self.tmp = tempfile.mkdtemp()
130
+
131
+ def tearDown(self):
132
+ shutil.rmtree(self.tmp, ignore_errors=True)
133
+
134
+ def assert_same(self, inc, full, where):
135
+ self.assertEqual(len(inc["tasks"]), len(full["tasks"]), f"{where}: task count")
136
+ for a, b in zip(inc["tasks"], full["tasks"]):
137
+ if a != b:
138
+ diff = {k: (a[k], b[k]) for k in a if a[k] != b.get(k)}
139
+ self.fail(f"{where}: task {a['id']} differs from a full rebuild (incremental, full): {diff}")
140
+ self.assertEqual(inc["events"], full["events"], f"{where}: events")
141
+ self.assertEqual(inc["baselines"], full["baselines"], f"{where}: baselines")
142
+ self.assertEqual(inc["meta"], full["meta"], f"{where}: insights")
143
+
144
+ def test_random_histories(self):
145
+ rescored = total = 0
146
+ for seed in range(12):
147
+ with self.subTest(seed=seed):
148
+ sc = Scenario(seed, os.path.join(self.tmp, f"s{seed}"))
149
+ try:
150
+ sc.eng.refresh(force=True)
151
+ for i in range(1, 121):
152
+ sc.step()
153
+ rescored += sc.eng.stats.get("tasks_rescored", 0)
154
+ total += sc.eng.con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
155
+ if i % 30 == 0:
156
+ inc = sc.snapshot()
157
+ sc.eng.refresh(force=True) # a full rebuild from sources
158
+ self.assert_same(inc, sc.snapshot(), f"seed {seed}, step {i}")
159
+ finally:
160
+ sc.eng.con.close()
161
+ # and it has to actually be incremental: most refreshes re-score a small part of the store
162
+ self.assertLess(rescored / total, 0.5, f"re-scored {rescored} of {total} task-refreshes")
163
+
164
+
165
+ class OnlyNewTrafficIsScoredTest(unittest.TestCase):
166
+ """The point of #5, asserted as a count rather than a timing, so it cannot flake: into a store of
167
+ several hundred tasks, 10 new ones are re-scored and nothing else is -- unless the type has grown
168
+ past its next baseline step, when the whole type is re-scored once."""
169
+
170
+ def test_new_tasks_only_until_the_baseline_steps(self):
171
+ from agentdynamics.analysis import baseline_sample_size as m
172
+ n0 = next(n for n in range(500, 2000) if m(n) == m(n + 10) and m(n + 10) < m(n + 60))
173
+ tmp = tempfile.mkdtemp()
174
+ eng = Engine(os.path.join(tmp, "data"), None)
175
+ try:
176
+ eng.ingest_otlp(otlp_traces(0, n0), "application/json")
177
+ time.sleep(2.1) # outside the 2 s span-reassembly window (see bench/bench.py)
178
+ eng.refresh(force=True)
179
+ eng.ingest_otlp(otlp_traces(n0, 10), "application/json")
180
+ eng.refresh()
181
+ self.assertEqual(eng.stats["tasks_rescored"], 10, "only the new tasks")
182
+ # grow past the next step: the type's baseline moves, so every task of it is re-scored once
183
+ time.sleep(2.1)
184
+ eng.ingest_otlp(otlp_traces(n0 + 10, 50), "application/json")
185
+ eng.refresh()
186
+ self.assertGreater(eng.stats["tasks_rescored"], 50)
187
+ self.assertEqual(eng.stats["tasks_rescored"],
188
+ eng.con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0])
189
+ finally:
190
+ eng.con.close()
191
+ shutil.rmtree(tmp, ignore_errors=True)
192
+
193
+
194
+ def otlp_traces(first, n):
195
+ """n OTLP traces of one workflow, each a single model call with a varying cost."""
196
+ now = time.time() - 3600
197
+ spans = []
198
+ for i in range(first, first + n):
199
+ tid = f"{i + 1:032x}"
200
+ ts = now + i
201
+ spans.append({"traceId": tid, "spanId": f"{i + 1:016x}", "name": "support", "status": {},
202
+ "startTimeUnixNano": str(int(ts * 1e9)), "endTimeUnixNano": str(int((ts + 2) * 1e9)),
203
+ "attributes": [
204
+ {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
205
+ {"key": "gen_ai.agent.name", "value": {"stringValue": "support"}},
206
+ {"key": "gen_ai.request.model", "value": {"stringValue": "claude-sonnet-5"}},
207
+ {"key": "gen_ai.usage.input_tokens", "value": {"intValue": str(500 + (i * 37) % 3000)}},
208
+ {"key": "gen_ai.usage.output_tokens", "value": {"intValue": str(50 + (i * 11) % 400)}}]})
209
+ return json.dumps({"resourceSpans": [{"resource": {"attributes": [
210
+ {"key": "service.name", "value": {"stringValue": "inc"}}]}, "scopeSpans": [{"spans": spans}]}]}).encode()
211
+
212
+
213
+ class SettledFieldsTest(unittest.TestCase):
214
+ def test_every_field_the_settle_phase_writes_is_in_the_signature(self):
215
+ """A field finalize writes before scoring but leaves out of SETTLED_FIELDS is served stale by
216
+ incremental refreshes. Diff a task's fields before and after the settle phase on a history that
217
+ exercises threads, subagents, grades and feedback, and require every changed one be listed."""
218
+ tmp = tempfile.mkdtemp()
219
+ try:
220
+ sc = Scenario(3, tmp)
221
+ for _ in range(60):
222
+ sc.step()
223
+ runs = list(sc.eng._runs.values())
224
+ fresh = {r["id"]: analysis.run_tasks(r) for r in runs}
225
+ before = {t["id"]: dict(t) for ts in fresh.values() for t in ts}
226
+ scored = {"scores", "score", "apdex", "cost_vs_baseline", "duration_vs_baseline", "failed", "_sdk_grade"}
227
+ analysis.finalize(runs, fresh, now=sc.clock, grades={"r1#0": {"outcome": "failed"}})
228
+ written = set()
229
+ for ts in fresh.values():
230
+ for t in ts:
231
+ written |= {k for k, v in t.items() if before[t["id"]].get(k, object()) != v}
232
+ missing = written - scored - set(analysis.SETTLED_FIELDS)
233
+ self.assertEqual(missing, set(), "finalize writes these but SETTLED_FIELDS leaves them out")
234
+ sc.eng.con.close()
235
+ finally:
236
+ shutil.rmtree(tmp, ignore_errors=True)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ unittest.main()
File without changes
File without changes
File without changes