agentdynamics 0.4.0__py3-none-any.whl
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/__init__.py +12 -0
- agentdynamics/__main__.py +322 -0
- agentdynamics/analysis.py +755 -0
- agentdynamics/autotrace.py +697 -0
- agentdynamics/bootstrap/sitecustomize.py +27 -0
- agentdynamics/collectors/__init__.py +0 -0
- agentdynamics/collectors/aegis_audit.py +72 -0
- agentdynamics/collectors/claude_code.py +257 -0
- agentdynamics/collectors/generic.py +103 -0
- agentdynamics/collectors/inbox.py +95 -0
- agentdynamics/collectors/langfuse.py +98 -0
- agentdynamics/collectors/langsmith.py +258 -0
- agentdynamics/collectors/otlp.py +349 -0
- agentdynamics/collectors/spans.py +202 -0
- agentdynamics/config.py +139 -0
- agentdynamics/engine.py +376 -0
- agentdynamics/flows.py +142 -0
- agentdynamics/govern.py +231 -0
- agentdynamics/integrations/__init__.py +1 -0
- agentdynamics/integrations/aegis.py +352 -0
- agentdynamics/phases.py +82 -0
- agentdynamics/pricing.py +69 -0
- agentdynamics/privacy.py +41 -0
- agentdynamics/sdk.py +105 -0
- agentdynamics/server.py +949 -0
- agentdynamics/slo.py +84 -0
- agentdynamics/store.py +228 -0
- agentdynamics/web/app.js +1069 -0
- agentdynamics/web/charts.js +185 -0
- agentdynamics/web/index.html +40 -0
- agentdynamics/web/style.css +244 -0
- agentdynamics-0.4.0.dist-info/METADATA +195 -0
- agentdynamics-0.4.0.dist-info/RECORD +37 -0
- agentdynamics-0.4.0.dist-info/WHEEL +5 -0
- agentdynamics-0.4.0.dist-info/entry_points.txt +2 -0
- agentdynamics-0.4.0.dist-info/licenses/LICENSE +202 -0
- agentdynamics-0.4.0.dist-info/top_level.txt +1 -0
agentdynamics/slo.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Service level objectives with error budgets and burn rates."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from .analysis import apdex_score, pct
|
|
8
|
+
|
|
9
|
+
DEFAULT_SLOS = [
|
|
10
|
+
{"id": "success", "name": "Task success rate", "metric": "success_rate", "op": ">=", "target": 0.95, "window_days": 7, "scope": {}},
|
|
11
|
+
{"id": "apdex", "name": "Agent Apdex", "metric": "apdex", "op": ">=", "target": 0.85, "window_days": 7, "scope": {}},
|
|
12
|
+
{"id": "latency", "name": "p95 task latency (s)", "metric": "p95_seconds", "op": "<=", "target": 900, "window_days": 7, "scope": {}},
|
|
13
|
+
{"id": "cost", "name": "Median cost per task ($)", "metric": "median_cost", "op": "<=", "target": 1.0, "window_days": 7, "scope": {}},
|
|
14
|
+
{"id": "tool_errors", "name": "Tool error rate", "metric": "tool_error_rate", "op": "<=", "target": 0.05, "window_days": 7, "scope": {}},
|
|
15
|
+
]
|
|
16
|
+
RATIO = {"success_rate", "apdex"} # "good event" ratios get an error budget
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def path(data_dir):
|
|
20
|
+
return os.path.join(data_dir, "slos.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(data_dir):
|
|
24
|
+
p = path(data_dir)
|
|
25
|
+
if os.path.exists(p):
|
|
26
|
+
with open(p, encoding="utf-8") as f:
|
|
27
|
+
return json.load(f)
|
|
28
|
+
return DEFAULT_SLOS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save(data_dir, slos):
|
|
32
|
+
with open(path(data_dir), "w", encoding="utf-8") as f:
|
|
33
|
+
json.dump(slos, f, indent=2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def metric(tasks, m):
|
|
37
|
+
if not tasks:
|
|
38
|
+
return None
|
|
39
|
+
if m == "success_rate":
|
|
40
|
+
return sum(1 for t in tasks if t["outcome"] == "completed") / len(tasks)
|
|
41
|
+
if m == "apdex":
|
|
42
|
+
return apdex_score([t for t in tasks if t["apdex"]])
|
|
43
|
+
if m == "p95_seconds":
|
|
44
|
+
return pct([t["wall_s"] or 0 for t in tasks], 0.95)
|
|
45
|
+
if m == "median_cost":
|
|
46
|
+
return pct([t["cost"] + (t["subagent_cost"] or 0) for t in tasks], 0.5)
|
|
47
|
+
if m == "tool_error_rate":
|
|
48
|
+
calls = sum(t["tool_calls"] for t in tasks)
|
|
49
|
+
return sum(t["tool_errors"] for t in tasks) / calls if calls else 0
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def evaluate(all_tasks, slo, now=None):
|
|
54
|
+
now = now or time.time()
|
|
55
|
+
sc = slo.get("scope") or {}
|
|
56
|
+
ts = [t for t in all_tasks if all((t.get(k) or "") == v for k, v in sc.items() if v)]
|
|
57
|
+
win = [t for t in ts if (t["started"] or 0) >= now - slo["window_days"] * 86400]
|
|
58
|
+
val = metric(win, slo["metric"])
|
|
59
|
+
good = (val >= slo["target"]) if slo["op"] == ">=" else (val <= slo["target"]) if val is not None else None
|
|
60
|
+
res = {**slo, "value": val, "n": len(win), "met": good if val is not None else None}
|
|
61
|
+
if slo["metric"] in RATIO and val is not None:
|
|
62
|
+
allowed = 1 - slo["target"]
|
|
63
|
+
bad = 1 - val
|
|
64
|
+
res["budget_remaining"] = round(1 - bad / allowed, 3) if allowed > 0 else None
|
|
65
|
+
last = [t for t in win if (t["started"] or 0) >= now - 86400]
|
|
66
|
+
v24 = metric(last, slo["metric"])
|
|
67
|
+
res["burn_rate_24h"] = round((1 - v24) / allowed, 2) if (v24 is not None and allowed > 0) else None
|
|
68
|
+
daily = defaultdict(list)
|
|
69
|
+
for t in win:
|
|
70
|
+
daily[time.strftime("%Y-%m-%d", time.localtime(t["started"]))].append(t)
|
|
71
|
+
res["daily"] = [{"day": d, "value": metric(g, slo["metric"]), "n": len(g)} for d, g in sorted(daily.items())]
|
|
72
|
+
if res["daily"]:
|
|
73
|
+
ok_days = [d for d in res["daily"] if d["value"] is not None and
|
|
74
|
+
((d["value"] >= slo["target"]) if slo["op"] == ">=" else (d["value"] <= slo["target"]))]
|
|
75
|
+
res["days_met"] = f"{len(ok_days)}/{len(res['daily'])}"
|
|
76
|
+
status = "unknown" if val is None else "ok"
|
|
77
|
+
if val is not None and not good:
|
|
78
|
+
status = "breached"
|
|
79
|
+
elif res.get("budget_remaining") is not None and res["budget_remaining"] < 0.25:
|
|
80
|
+
status = "at risk"
|
|
81
|
+
elif res.get("burn_rate_24h") is not None and res["burn_rate_24h"] > 2:
|
|
82
|
+
status = "at risk"
|
|
83
|
+
res["status"] = status
|
|
84
|
+
return res
|
agentdynamics/store.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""SQLite storage (WAL mode).
|
|
2
|
+
|
|
3
|
+
Two kinds of tables:
|
|
4
|
+
* durable - spans_raw (pushed/pulled telemetry), source_state, alerts_sent. These are a system of record.
|
|
5
|
+
* derived - runs, steps, tasks, events, baselines, meta. Rebuildable from sources; dropped on schema change.
|
|
6
|
+
|
|
7
|
+
The storage layer is intentionally thin so it can be swapped for Postgres/ClickHouse at larger scale.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 5
|
|
14
|
+
|
|
15
|
+
RUN_COLS = ["id", "source", "project", "environment", "framework", "workflow", "cwd", "title", "agent_name", "parent_id",
|
|
16
|
+
"parent_task_id", "is_subagent", "thread_id", "user_id", "tags", "root_status", "complete", "version", "git_branch",
|
|
17
|
+
"entrypoint", "started", "ended", "file", "policy_version", "policy"]
|
|
18
|
+
TASK_COLS = ["id", "run_id", "idx", "project", "environment", "source", "framework", "workflow", "is_subagent", "parent_task_id",
|
|
19
|
+
"prompt_kind", "prompt", "task_type", "started", "ended", "wall_s", "duration_s", "llm_calls", "tool_calls", "tool_errors",
|
|
20
|
+
"tool_error_rate", "input_tokens", "output_tokens", "cache_read", "cache_write", "thinking_tokens", "total_tokens", "cost",
|
|
21
|
+
"subagent_cost", "subagents", "max_context", "cache_hit", "models", "interrupts", "compactions", "api_errors",
|
|
22
|
+
"parallelism", "files_read", "files_edited", "edits", "max_edits_one_file", "churn_file", "redundant_reads",
|
|
23
|
+
"duplicate_calls", "max_error_streak", "large_outputs", "explore_ratio", "steps_to_first_edit", "code_changed",
|
|
24
|
+
"verified", "unverified_edits", "waste_cost", "final_stop", "final_text", "ended_on_error", "next_prompt", "rework",
|
|
25
|
+
"outcome", "cost_vs_baseline", "duration_vs_baseline", "score", "apdex",
|
|
26
|
+
# agent-flow metrics
|
|
27
|
+
"steps_total", "llm_errors", "truncations", "refusals", "rate_limited", "ttft_ms", "out_tps", "retrievals",
|
|
28
|
+
"empty_retrievals", "nodes", "max_node_visits", "loop_node", "handoffs", "pingpong", "hitl", "feedback_score",
|
|
29
|
+
"unpriced", "critical_node", "critical_share", "root_error",
|
|
30
|
+
# governance (Aegis)
|
|
31
|
+
"governed", "policy_version", "policy_denials", "spend_denials", "budget_denials", "repeated_denials",
|
|
32
|
+
"revocations", "blocked_cost"]
|
|
33
|
+
TASK_JSON = ["phase_calls", "phase_cost", "scores", "path", "denied_rules"]
|
|
34
|
+
STEP_COLS = ["run_id", "seq", "task_id", "kind", "name", "model", "phase", "target", "ts", "start_ts", "end_ts",
|
|
35
|
+
"duration_ms", "cost", "attributed_cost", "input_tokens", "output_tokens", "cache_read", "cache_write",
|
|
36
|
+
"context_tokens", "thinking_tokens", "is_error", "output_chars", "text", "input_preview", "error",
|
|
37
|
+
"subagent_id", "stop_reason", "tool_calls", "effort",
|
|
38
|
+
"span_id", "parent_span_id", "depth", "node", "agent", "span_kind", "ttft_ms", "docs", "hitl", "rate_limited",
|
|
39
|
+
"denied", "rule", "guard", "grant_depth", "args_json"]
|
|
40
|
+
EVENT_COLS = ["id", "ts", "rule_id", "rule", "severity", "task_id", "run_id", "project", "task_type", "message", "value"]
|
|
41
|
+
|
|
42
|
+
DERIVED = ["runs", "tasks", "steps", "events", "baselines", "meta"]
|
|
43
|
+
|
|
44
|
+
SCHEMA = f"""
|
|
45
|
+
CREATE TABLE IF NOT EXISTS runs ({", ".join(RUN_COLS)}, PRIMARY KEY(id)) WITHOUT ROWID;
|
|
46
|
+
CREATE TABLE IF NOT EXISTS tasks ({", ".join(TASK_COLS + TASK_JSON)}, PRIMARY KEY(id)) WITHOUT ROWID;
|
|
47
|
+
CREATE TABLE IF NOT EXISTS steps ({", ".join(STEP_COLS)}, flags);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS events ({", ".join(EVENT_COLS)});
|
|
49
|
+
CREATE TABLE IF NOT EXISTS baselines (task_type PRIMARY KEY, data);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS meta (k PRIMARY KEY, v);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS steps_task ON steps(task_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS steps_run ON steps(run_id);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS steps_node ON steps(node);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS tasks_run ON tasks(run_id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS tasks_started ON tasks(started);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS spans_raw (source, trace_id, span_id, fmt, doc, updated REAL, PRIMARY KEY(source, span_id)) WITHOUT ROWID;
|
|
58
|
+
CREATE INDEX IF NOT EXISTS spans_trace ON spans_raw(source, trace_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS spans_updated ON spans_raw(updated);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS source_state (name PRIMARY KEY, data);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS alerts_sent (event_id PRIMARY KEY, ts REAL);
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def connect(path):
|
|
66
|
+
con = sqlite3.connect(path, check_same_thread=False, timeout=30)
|
|
67
|
+
con.row_factory = sqlite3.Row
|
|
68
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
69
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
70
|
+
ver = con.execute("PRAGMA user_version").fetchone()[0]
|
|
71
|
+
if ver != SCHEMA_VERSION:
|
|
72
|
+
for t in DERIVED:
|
|
73
|
+
con.execute(f"DROP TABLE IF EXISTS {t}")
|
|
74
|
+
con.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
|
75
|
+
con.executescript(SCHEMA)
|
|
76
|
+
return con
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def connect_reader(path):
|
|
80
|
+
con = sqlite3.connect(path, check_same_thread=False, timeout=30)
|
|
81
|
+
con.row_factory = sqlite3.Row
|
|
82
|
+
con.execute("PRAGMA query_only=ON")
|
|
83
|
+
return con
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ins(con, table, cols, rows_, verb="INSERT"):
|
|
87
|
+
q = f"{verb} INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})"
|
|
88
|
+
con.executemany(q, rows_)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _redact_step(s, red):
|
|
92
|
+
if red is None:
|
|
93
|
+
return s
|
|
94
|
+
s = dict(s)
|
|
95
|
+
for f in ("text", "input_preview", "error"):
|
|
96
|
+
if s.get(f):
|
|
97
|
+
s[f] = red.text(s[f])
|
|
98
|
+
if s.get("target") and red.rx is not None:
|
|
99
|
+
s["target"] = red.rx.sub("[REDACTED]", s["target"]) if red.store_content else red.text(s["target"])
|
|
100
|
+
return s
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_runs(con, runs, removed_ids=(), red=None):
|
|
104
|
+
"""Replace runs + steps for the given runs (incremental)."""
|
|
105
|
+
ids = [r["id"] for r in runs] + list(removed_ids)
|
|
106
|
+
with con:
|
|
107
|
+
for i in range(0, len(ids), 500):
|
|
108
|
+
chunk = ids[i:i + 500]
|
|
109
|
+
ph = ",".join("?" * len(chunk))
|
|
110
|
+
con.execute(f"DELETE FROM steps WHERE run_id IN ({ph})", chunk)
|
|
111
|
+
con.execute(f"DELETE FROM runs WHERE id IN ({ph})", chunk)
|
|
112
|
+
rrows = []
|
|
113
|
+
for r in runs:
|
|
114
|
+
row = [r.get(c) for c in RUN_COLS]
|
|
115
|
+
row[RUN_COLS.index("tags")] = json.dumps(r.get("tags") or [])
|
|
116
|
+
row[RUN_COLS.index("policy")] = json.dumps(r["policy"]) if r.get("policy") else None
|
|
117
|
+
row[RUN_COLS.index("title")] = red.text(r.get("title")) if red else r.get("title")
|
|
118
|
+
rrows.append(row)
|
|
119
|
+
_ins(con, "runs", RUN_COLS, rrows)
|
|
120
|
+
srows = []
|
|
121
|
+
for r in runs:
|
|
122
|
+
for s in r["steps"]:
|
|
123
|
+
s2 = _redact_step(s, red)
|
|
124
|
+
row = [r["id"] if c == "run_id" else s2.get(c) for c in STEP_COLS]
|
|
125
|
+
for b in ("is_error", "hitl", "rate_limited", "denied"):
|
|
126
|
+
row[STEP_COLS.index(b)] = 1 if s2.get(b) else 0
|
|
127
|
+
srows.append(row + [json.dumps(s2.get("flags") or [])])
|
|
128
|
+
_ins(con, "steps", STEP_COLS + ["flags"], srows)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def update_step_analysis(con, runs):
|
|
132
|
+
"""Persist analysis-time step fields (task id, flags, attributed cost) for the given runs."""
|
|
133
|
+
rows_ = [(s.get("task_id"), json.dumps(s.get("flags") or []), s.get("attributed_cost"), r["id"], s["seq"])
|
|
134
|
+
for r in runs for s in r["steps"]]
|
|
135
|
+
with con:
|
|
136
|
+
con.executemany("UPDATE steps SET task_id=?, flags=?, attributed_cost=? WHERE run_id=? AND seq=?", rows_)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_analysis(con, tasks, baselines, events, meta, red=None):
|
|
140
|
+
with con:
|
|
141
|
+
for t in ("tasks", "events", "baselines", "meta"):
|
|
142
|
+
con.execute(f"DELETE FROM {t}")
|
|
143
|
+
trows = []
|
|
144
|
+
for t in tasks:
|
|
145
|
+
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 {}))
|
|
146
|
+
for c in TASK_JSON]
|
|
147
|
+
if red:
|
|
148
|
+
for f in ("prompt", "final_text", "next_prompt", "root_error"):
|
|
149
|
+
i = TASK_COLS.index(f)
|
|
150
|
+
row[i] = red.text(row[i])
|
|
151
|
+
trows.append(row)
|
|
152
|
+
_ins(con, "tasks", TASK_COLS + TASK_JSON, trows)
|
|
153
|
+
erows = []
|
|
154
|
+
for e in events:
|
|
155
|
+
row = [e.get(c) for c in EVENT_COLS]
|
|
156
|
+
if red and red.rx is not None: # messages can quote user text (e.g. the correcting follow-up)
|
|
157
|
+
i = EVENT_COLS.index("message")
|
|
158
|
+
row[i] = red.rx.sub("[REDACTED]", row[i] or "")
|
|
159
|
+
erows.append(row)
|
|
160
|
+
_ins(con, "events", EVENT_COLS, erows)
|
|
161
|
+
_ins(con, "baselines", ["task_type", "data"], [[k, json.dumps(v)] for k, v in baselines.items()])
|
|
162
|
+
_ins(con, "meta", ["k", "v"], [[k, json.dumps(v)] for k, v in meta.items()])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- durable span store
|
|
166
|
+
|
|
167
|
+
def upsert_spans(con, source, items):
|
|
168
|
+
"""items: list of (trace_id, span_id, fmt, doc_dict). Returns set of touched trace ids."""
|
|
169
|
+
now = time.time()
|
|
170
|
+
with con:
|
|
171
|
+
con.executemany("INSERT INTO spans_raw (source, trace_id, span_id, fmt, doc, updated) VALUES (?,?,?,?,?,?) "
|
|
172
|
+
"ON CONFLICT(source, span_id) DO UPDATE SET trace_id=excluded.trace_id, fmt=excluded.fmt, "
|
|
173
|
+
"doc=excluded.doc, updated=excluded.updated",
|
|
174
|
+
[(source, t, s, f, json.dumps(d, default=str), now) for t, s, f, d in items])
|
|
175
|
+
return {t for t, _, _, _ in items}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_span_docs(con, source, span_ids):
|
|
179
|
+
out = {}
|
|
180
|
+
for i in range(0, len(span_ids), 500):
|
|
181
|
+
chunk = span_ids[i:i + 500]
|
|
182
|
+
for r in con.execute(f"SELECT span_id, doc FROM spans_raw WHERE source=? AND span_id IN ({','.join('?' * len(chunk))})",
|
|
183
|
+
[source] + chunk):
|
|
184
|
+
out[r["span_id"]] = json.loads(r["doc"])
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def trace_spans(con, source, trace_id):
|
|
189
|
+
return [(r["fmt"], json.loads(r["doc"])) for r in con.execute(
|
|
190
|
+
"SELECT fmt, doc FROM spans_raw WHERE source=? AND trace_id=?", (source, trace_id))]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def traces_updated_since(con, since):
|
|
194
|
+
return [(r["source"], r["trace_id"]) for r in con.execute(
|
|
195
|
+
"SELECT DISTINCT source, trace_id FROM spans_raw WHERE updated > ?", (since,))]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def all_traces(con):
|
|
199
|
+
return [(r["source"], r["trace_id"]) for r in con.execute("SELECT DISTINCT source, trace_id FROM spans_raw")]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def purge_spans_before(con, cutoff):
|
|
203
|
+
with con:
|
|
204
|
+
return con.execute("DELETE FROM spans_raw WHERE updated < ?", (cutoff,)).rowcount
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_state(con, name):
|
|
208
|
+
r = con.execute("SELECT data FROM source_state WHERE name=?", (name,)).fetchone()
|
|
209
|
+
return json.loads(r["data"]) if r else {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def set_state(con, name, data):
|
|
213
|
+
with con:
|
|
214
|
+
con.execute("INSERT OR REPLACE INTO source_state (name, data) VALUES (?, ?)", (name, json.dumps(data)))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def rows(con, q, args=()):
|
|
218
|
+
out = []
|
|
219
|
+
for r in con.execute(q, args).fetchall():
|
|
220
|
+
d = dict(r)
|
|
221
|
+
for k in TASK_JSON + ["flags", "data", "tags", "policy"]:
|
|
222
|
+
if k in d and isinstance(d[k], str):
|
|
223
|
+
try:
|
|
224
|
+
d[k] = json.loads(d[k])
|
|
225
|
+
except ValueError:
|
|
226
|
+
pass
|
|
227
|
+
out.append(d)
|
|
228
|
+
return out
|