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.
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/PKG-INFO +1 -1
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/__init__.py +1 -1
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/analysis.py +102 -8
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/engine.py +22 -3
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/store.py +47 -21
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/PKG-INFO +1 -1
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/SOURCES.txt +1 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/pyproject.toml +1 -1
- agentdynamics-0.6.0/tests/test_incremental.py +240 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/LICENSE +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/README.md +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/__main__.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/autotrace.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/__init__.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/aegis_audit.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/claude_code.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/generic.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/inbox.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/langfuse.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/langsmith.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/otlp.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/collectors/spans.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/config.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/flows.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/govern.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/integrations/__init__.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/integrations/aegis.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/phases.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/pricing.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/privacy.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/sdk.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/server.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/slo.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/app.js +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/charts.js +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/index.html +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics/web/style.css +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/entry_points.txt +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/requires.txt +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/agentdynamics.egg-info/top_level.txt +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/setup.cfg +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_aegis_integration.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_autotrace.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_console_ui.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_core.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_ecosystem.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_integrations.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_live_anthropic.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_outcomes.py +0 -0
- {agentdynamics-0.5.0 → agentdynamics-0.6.0}/tests/test_policy_coverage.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
177
|
+
for t in ("tasks", "events"):
|
|
151
178
|
con.execute(f"DELETE FROM {t}")
|
|
152
|
-
|
|
153
|
-
for
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
for
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
_ins(con, "events", EVENT_COLS,
|
|
170
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|