mcp-debugger 0.1.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.
- mcp_debugger/__init__.py +5 -0
- mcp_debugger/analytics.py +443 -0
- mcp_debugger/cli.py +2185 -0
- mcp_debugger/config.py +377 -0
- mcp_debugger/display/__init__.py +0 -0
- mcp_debugger/exporters/__init__.py +6 -0
- mcp_debugger/exporters/json_exporter.py +178 -0
- mcp_debugger/exporters/markdown_exporter.py +196 -0
- mcp_debugger/exporters/otlp_exporter.py +206 -0
- mcp_debugger/exporters/otlp_replay_exporter.py +221 -0
- mcp_debugger/protocol/__init__.py +0 -0
- mcp_debugger/protocol/error_classifier.py +108 -0
- mcp_debugger/protocol/schemas.py +92 -0
- mcp_debugger/protocol/validator.py +471 -0
- mcp_debugger/proxy/__init__.py +0 -0
- mcp_debugger/proxy/stdio_proxy.py +408 -0
- mcp_debugger/py.typed +1 -0
- mcp_debugger/replay/__init__.py +14 -0
- mcp_debugger/replay/diff.py +168 -0
- mcp_debugger/replay/engine.py +446 -0
- mcp_debugger/storage/__init__.py +0 -0
- mcp_debugger/storage/database.py +959 -0
- mcp_debugger/validate_live.py +250 -0
- mcp_debugger/version.py +3 -0
- mcp_debugger-0.1.0.dist-info/METADATA +207 -0
- mcp_debugger-0.1.0.dist-info/RECORD +29 -0
- mcp_debugger-0.1.0.dist-info/WHEEL +4 -0
- mcp_debugger-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_debugger-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_debugger/__init__.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Analytics engine for calculating session statistics and comparisons."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from mcp_debugger.storage.database import Database
|
|
10
|
+
|
|
11
|
+
# Unicode block elements for sparkline: 8 levels
|
|
12
|
+
BLOCKS = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolMetric(BaseModel):
|
|
16
|
+
"""Calculated metrics for a single tool."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
calls: int = 0
|
|
20
|
+
avg_latency_ms: Optional[float] = None
|
|
21
|
+
error_rate: float = 0.0
|
|
22
|
+
errors_count: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionStats(BaseModel):
|
|
26
|
+
"""Container for computed session statistics."""
|
|
27
|
+
|
|
28
|
+
session_id: int
|
|
29
|
+
friendly_name: Optional[str] = None
|
|
30
|
+
server_command: str
|
|
31
|
+
started_at: Optional[str] = None
|
|
32
|
+
ended_at: Optional[str] = None
|
|
33
|
+
status: str
|
|
34
|
+
duration_seconds: Optional[int] = None
|
|
35
|
+
total_messages: int = 0
|
|
36
|
+
client_to_server_count: int = 0
|
|
37
|
+
server_to_client_count: int = 0
|
|
38
|
+
top_tools: List[ToolMetric] = Field(default_factory=list)
|
|
39
|
+
errors_by_category: Dict[str, int] = Field(default_factory=dict)
|
|
40
|
+
latency_min: Optional[float] = None
|
|
41
|
+
latency_max: Optional[float] = None
|
|
42
|
+
latency_avg: Optional[float] = None
|
|
43
|
+
latency_trend: List[float] = Field(default_factory=list)
|
|
44
|
+
method_distribution: Dict[str, int] = Field(default_factory=dict)
|
|
45
|
+
error_trend: List[int] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolChange(BaseModel):
|
|
49
|
+
"""Calculated deltas for a specific tool between two sessions."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
calls_a: int = 0
|
|
53
|
+
calls_b: int = 0
|
|
54
|
+
change_pct: Optional[float] = None # None if division by zero
|
|
55
|
+
change_str: str # e.g. "+15%", "-75%", "removed", "new"
|
|
56
|
+
avg_latency_a: Optional[float] = None
|
|
57
|
+
avg_latency_b: Optional[float] = None
|
|
58
|
+
avg_latency_change_pct: Optional[float] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ComparisonStats(BaseModel):
|
|
62
|
+
"""Container for comparative session statistics."""
|
|
63
|
+
|
|
64
|
+
session_id_a: int
|
|
65
|
+
session_id_b: int
|
|
66
|
+
duration_a: Optional[int] = None
|
|
67
|
+
duration_b: Optional[int] = None
|
|
68
|
+
duration_change_pct: Optional[float] = None
|
|
69
|
+
duration_change_str: str = ""
|
|
70
|
+
messages_a: int = 0
|
|
71
|
+
messages_b: int = 0
|
|
72
|
+
messages_change_abs: int = 0
|
|
73
|
+
tool_changes: List[ToolChange] = Field(default_factory=list)
|
|
74
|
+
new_tools: List[str] = Field(default_factory=list)
|
|
75
|
+
removed_tools: List[str] = Field(default_factory=list)
|
|
76
|
+
errors_a: int = 0
|
|
77
|
+
errors_b: int = 0
|
|
78
|
+
error_rate_a: float = 0.0
|
|
79
|
+
error_rate_b: float = 0.0
|
|
80
|
+
error_rate_change_str: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def generate_sparkline(values: List[float], width: int = 30) -> str:
|
|
84
|
+
"""Generate a horizontal Unicode sparkline representing values over time.
|
|
85
|
+
|
|
86
|
+
If length of values is greater than target width, downsamples by averaging.
|
|
87
|
+
"""
|
|
88
|
+
if not values:
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
# Downsample if needed
|
|
92
|
+
if len(values) > width:
|
|
93
|
+
chunk_size = len(values) / width
|
|
94
|
+
sampled = []
|
|
95
|
+
for i in range(width):
|
|
96
|
+
start = int(i * chunk_size)
|
|
97
|
+
end = int((i + 1) * chunk_size)
|
|
98
|
+
if start == end:
|
|
99
|
+
end = start + 1
|
|
100
|
+
chunk = values[start:end]
|
|
101
|
+
if chunk:
|
|
102
|
+
sampled.append(sum(chunk) / len(chunk))
|
|
103
|
+
values = sampled
|
|
104
|
+
|
|
105
|
+
min_v = min(values)
|
|
106
|
+
max_v = max(values)
|
|
107
|
+
|
|
108
|
+
if max_v == min_v:
|
|
109
|
+
# If all values are 0 (common for error count), show empty/lowest bar
|
|
110
|
+
if max_v == 0:
|
|
111
|
+
return " " * len(values)
|
|
112
|
+
# Otherwise show middle bar
|
|
113
|
+
return "▄" * len(values)
|
|
114
|
+
|
|
115
|
+
sparkline = []
|
|
116
|
+
for val in values:
|
|
117
|
+
# Scale to index 0-7
|
|
118
|
+
idx = int(((val - min_v) / (max_v - min_v)) * 7)
|
|
119
|
+
idx = max(0, min(7, idx))
|
|
120
|
+
sparkline.append(BLOCKS[idx])
|
|
121
|
+
|
|
122
|
+
return "".join(sparkline)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def generate_bar_chart(
|
|
126
|
+
counts: Dict[str, int], max_width: int = 30
|
|
127
|
+
) -> List[Tuple[str, int, float, str]]:
|
|
128
|
+
"""Generate bar chart segments (label, count, percentage, bar_string) sorted by count descending."""
|
|
129
|
+
if not counts:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
total = sum(counts.values())
|
|
133
|
+
sorted_items = sorted(counts.items(), key=lambda x: x[1], reverse=True)
|
|
134
|
+
|
|
135
|
+
results = []
|
|
136
|
+
for label, count in sorted_items:
|
|
137
|
+
pct = count / total if total > 0 else 0.0
|
|
138
|
+
filled = int(pct * max_width)
|
|
139
|
+
empty = max_width - filled
|
|
140
|
+
bar_str = "█" * filled + "░" * empty
|
|
141
|
+
results.append((label, count, pct, bar_str))
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def aggregate_session_stats(db: Database, session_id: int) -> SessionStats:
|
|
147
|
+
"""Query database and calculate aggregates for a single session."""
|
|
148
|
+
session = await db.get_session(session_id)
|
|
149
|
+
if not session:
|
|
150
|
+
raise ValueError(f"Session {session_id} not found")
|
|
151
|
+
|
|
152
|
+
# Fetch messages and errors
|
|
153
|
+
messages = await db.get_messages(session_id)
|
|
154
|
+
errors = await db.get_errors(session_id)
|
|
155
|
+
|
|
156
|
+
# Compute client vs server message counts, and method distribution
|
|
157
|
+
c2s_count = 0
|
|
158
|
+
s2c_count = 0
|
|
159
|
+
method_dist: Dict[str, int] = defaultdict(int)
|
|
160
|
+
|
|
161
|
+
# We also keep track of error flags chronologically for the error trend sparkline
|
|
162
|
+
chrono_errors: List[int] = []
|
|
163
|
+
|
|
164
|
+
for msg in messages:
|
|
165
|
+
direction = msg.get("direction")
|
|
166
|
+
if direction == "client_to_server":
|
|
167
|
+
c2s_count += 1
|
|
168
|
+
elif direction == "server_to_client":
|
|
169
|
+
s2c_count += 1
|
|
170
|
+
|
|
171
|
+
method = msg.get("method")
|
|
172
|
+
if method:
|
|
173
|
+
method_dist[method] += 1
|
|
174
|
+
|
|
175
|
+
# Check for error status
|
|
176
|
+
is_err = False
|
|
177
|
+
if msg.get("error") is not None:
|
|
178
|
+
is_err = True
|
|
179
|
+
elif msg.get("message_type") == "response" and msg.get("result") is not None:
|
|
180
|
+
try:
|
|
181
|
+
res = json.loads(msg["result"])
|
|
182
|
+
if isinstance(res, dict) and res.get("isError") is True:
|
|
183
|
+
is_err = True
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# We only look at response messages (server_to_client) or requests that errored for trend
|
|
188
|
+
# For simplicity, track error flags for all server_to_client responses chronologically
|
|
189
|
+
if direction == "server_to_client":
|
|
190
|
+
chrono_errors.append(1 if is_err else 0)
|
|
191
|
+
|
|
192
|
+
# Compute latency list
|
|
193
|
+
latency_list = [msg["latency_ms"] for msg in messages if msg.get("latency_ms") is not None]
|
|
194
|
+
|
|
195
|
+
# Compute tool usage metrics
|
|
196
|
+
# Query all request-response pairs for tools/call
|
|
197
|
+
conn = await db._get_conn()
|
|
198
|
+
tool_calls: Dict[str, List[Tuple[Optional[float], bool]]] = defaultdict(list)
|
|
199
|
+
try:
|
|
200
|
+
async with conn.execute(
|
|
201
|
+
"""
|
|
202
|
+
SELECT
|
|
203
|
+
req.params as req_params,
|
|
204
|
+
resp.latency_ms as latency_ms,
|
|
205
|
+
resp.result as resp_result,
|
|
206
|
+
resp.error as resp_error
|
|
207
|
+
FROM messages req
|
|
208
|
+
LEFT JOIN messages resp ON req.session_id = resp.session_id
|
|
209
|
+
AND req.message_id = resp.message_id
|
|
210
|
+
AND resp.message_type = 'response'
|
|
211
|
+
WHERE req.session_id = ?
|
|
212
|
+
AND req.method = 'tools/call'
|
|
213
|
+
AND req.message_type = 'request'
|
|
214
|
+
""",
|
|
215
|
+
(session_id,),
|
|
216
|
+
) as cursor:
|
|
217
|
+
rows = await cursor.fetchall()
|
|
218
|
+
for row in rows:
|
|
219
|
+
req_params_str = row[0]
|
|
220
|
+
latency_ms = row[1]
|
|
221
|
+
resp_result_str = row[2]
|
|
222
|
+
resp_error_str = row[3]
|
|
223
|
+
|
|
224
|
+
tool_name = "unknown"
|
|
225
|
+
if req_params_str:
|
|
226
|
+
try:
|
|
227
|
+
params = json.loads(req_params_str)
|
|
228
|
+
if isinstance(params, dict) and "name" in params:
|
|
229
|
+
tool_name = params["name"]
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
is_error = False
|
|
234
|
+
if resp_error_str:
|
|
235
|
+
is_error = True
|
|
236
|
+
elif resp_result_str:
|
|
237
|
+
try:
|
|
238
|
+
res = json.loads(resp_result_str)
|
|
239
|
+
if isinstance(res, dict) and res.get("isError") is True:
|
|
240
|
+
is_error = True
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
tool_calls[tool_name].append((latency_ms, is_error))
|
|
245
|
+
except Exception:
|
|
246
|
+
# Fallback or log warning
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
top_tools: List[ToolMetric] = []
|
|
250
|
+
for tname, calls in tool_calls.items():
|
|
251
|
+
total_calls = len(calls)
|
|
252
|
+
errors_count = sum(1 for c in calls if c[1])
|
|
253
|
+
error_rate = (errors_count / total_calls) if total_calls > 0 else 0.0
|
|
254
|
+
|
|
255
|
+
latencies = [c[0] for c in calls if c[0] is not None]
|
|
256
|
+
avg_lat = (sum(latencies) / len(latencies)) if latencies else None
|
|
257
|
+
|
|
258
|
+
top_tools.append(
|
|
259
|
+
ToolMetric(
|
|
260
|
+
name=tname,
|
|
261
|
+
calls=total_calls,
|
|
262
|
+
avg_latency_ms=avg_lat,
|
|
263
|
+
error_rate=error_rate,
|
|
264
|
+
errors_count=errors_count,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
# Sort tools by call count descending
|
|
268
|
+
top_tools.sort(key=lambda x: x.calls, reverse=True)
|
|
269
|
+
|
|
270
|
+
# Compute errors by category
|
|
271
|
+
errors_by_cat: Dict[str, int] = defaultdict(int)
|
|
272
|
+
for err in errors:
|
|
273
|
+
cat = err.get("error_type") or "unknown"
|
|
274
|
+
errors_by_cat[cat] += 1
|
|
275
|
+
|
|
276
|
+
# Latency aggregates
|
|
277
|
+
lat_min = min(latency_list) if latency_list else None
|
|
278
|
+
lat_max = max(latency_list) if latency_list else None
|
|
279
|
+
lat_avg = (sum(latency_list) / len(latency_list)) if latency_list else None
|
|
280
|
+
|
|
281
|
+
# Parse timestamps for duration if needed
|
|
282
|
+
duration_sec = session.get("duration_seconds")
|
|
283
|
+
if duration_sec is None and session.get("started_at"):
|
|
284
|
+
try:
|
|
285
|
+
started = datetime.strptime(session["started_at"], "%Y-%m-%d %H:%M:%S")
|
|
286
|
+
ended = (
|
|
287
|
+
datetime.strptime(session["ended_at"], "%Y-%m-%d %H:%M:%S")
|
|
288
|
+
if session.get("ended_at")
|
|
289
|
+
else datetime.now()
|
|
290
|
+
)
|
|
291
|
+
duration_sec = int((ended - started).total_seconds())
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
return SessionStats(
|
|
296
|
+
session_id=session_id,
|
|
297
|
+
friendly_name=session.get("friendly_name"),
|
|
298
|
+
server_command=session.get("server_command") or "",
|
|
299
|
+
started_at=session.get("started_at"),
|
|
300
|
+
ended_at=session.get("ended_at"),
|
|
301
|
+
status=session.get("status") or "unknown",
|
|
302
|
+
duration_seconds=duration_sec,
|
|
303
|
+
total_messages=len(messages),
|
|
304
|
+
client_to_server_count=c2s_count,
|
|
305
|
+
server_to_client_count=s2c_count,
|
|
306
|
+
top_tools=top_tools,
|
|
307
|
+
errors_by_category=dict(errors_by_cat),
|
|
308
|
+
latency_min=lat_min,
|
|
309
|
+
latency_max=lat_max,
|
|
310
|
+
latency_avg=lat_avg,
|
|
311
|
+
latency_trend=latency_list,
|
|
312
|
+
method_distribution=dict(method_dist),
|
|
313
|
+
error_trend=chrono_errors,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def compare_sessions_stats(stats_a: SessionStats, stats_b: SessionStats) -> ComparisonStats:
|
|
318
|
+
"""Compare two SessionStats aggregates and calculate deltas."""
|
|
319
|
+
dur_a = stats_a.duration_seconds
|
|
320
|
+
dur_b = stats_b.duration_seconds
|
|
321
|
+
dur_pct = None
|
|
322
|
+
dur_change_str = "—"
|
|
323
|
+
if dur_a is not None and dur_b is not None:
|
|
324
|
+
if dur_a > 0:
|
|
325
|
+
dur_pct = ((dur_b - dur_a) / dur_a) * 100
|
|
326
|
+
if dur_b < dur_a:
|
|
327
|
+
dur_change_str = f"↓ {abs(dur_pct):.0f}% faster"
|
|
328
|
+
elif dur_b > dur_a:
|
|
329
|
+
dur_change_str = f"↑ {abs(dur_pct):.0f}% slower"
|
|
330
|
+
else:
|
|
331
|
+
dur_change_str = "no change"
|
|
332
|
+
else:
|
|
333
|
+
dur_change_str = "—"
|
|
334
|
+
|
|
335
|
+
msg_a = stats_a.total_messages
|
|
336
|
+
msg_b = stats_b.total_messages
|
|
337
|
+
msg_diff = msg_b - msg_a
|
|
338
|
+
|
|
339
|
+
# Maps tool names to stats
|
|
340
|
+
tools_a_map = {t.name: t for t in stats_a.top_tools}
|
|
341
|
+
tools_b_map = {t.name: t for t in stats_b.top_tools}
|
|
342
|
+
|
|
343
|
+
all_tool_names = set(tools_a_map.keys()) | set(tools_b_map.keys())
|
|
344
|
+
tool_changes: List[ToolChange] = []
|
|
345
|
+
new_tools: List[str] = []
|
|
346
|
+
removed_tools: List[str] = []
|
|
347
|
+
|
|
348
|
+
for name in all_tool_names:
|
|
349
|
+
ta = tools_a_map.get(name)
|
|
350
|
+
tb = tools_b_map.get(name)
|
|
351
|
+
|
|
352
|
+
if ta and not tb:
|
|
353
|
+
removed_tools.append(name)
|
|
354
|
+
tool_changes.append(
|
|
355
|
+
ToolChange(
|
|
356
|
+
name=name,
|
|
357
|
+
calls_a=ta.calls,
|
|
358
|
+
calls_b=0,
|
|
359
|
+
change_pct=None,
|
|
360
|
+
change_str="✗ removed",
|
|
361
|
+
avg_latency_a=ta.avg_latency_ms,
|
|
362
|
+
avg_latency_b=None,
|
|
363
|
+
avg_latency_change_pct=None,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
elif tb and not ta:
|
|
367
|
+
new_tools.append(name)
|
|
368
|
+
tool_changes.append(
|
|
369
|
+
ToolChange(
|
|
370
|
+
name=name,
|
|
371
|
+
calls_a=0,
|
|
372
|
+
calls_b=tb.calls,
|
|
373
|
+
change_pct=None,
|
|
374
|
+
change_str="★ new",
|
|
375
|
+
avg_latency_a=None,
|
|
376
|
+
avg_latency_b=tb.avg_latency_ms,
|
|
377
|
+
avg_latency_change_pct=None,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
elif ta and tb:
|
|
381
|
+
diff = tb.calls - ta.calls
|
|
382
|
+
pct = ((tb.calls - ta.calls) / ta.calls) * 100 if ta.calls > 0 else 0.0
|
|
383
|
+
if diff > 0:
|
|
384
|
+
change_str = f"↑ +{pct:.0f}%"
|
|
385
|
+
elif diff < 0:
|
|
386
|
+
change_str = f"↓ {pct:.0f}%"
|
|
387
|
+
else:
|
|
388
|
+
change_str = "no change"
|
|
389
|
+
|
|
390
|
+
lat_change_pct = None
|
|
391
|
+
if (
|
|
392
|
+
ta.avg_latency_ms is not None
|
|
393
|
+
and tb.avg_latency_ms is not None
|
|
394
|
+
and ta.avg_latency_ms > 0
|
|
395
|
+
):
|
|
396
|
+
lat_change_pct = ((tb.avg_latency_ms - ta.avg_latency_ms) / ta.avg_latency_ms) * 100
|
|
397
|
+
|
|
398
|
+
tool_changes.append(
|
|
399
|
+
ToolChange(
|
|
400
|
+
name=name,
|
|
401
|
+
calls_a=ta.calls,
|
|
402
|
+
calls_b=tb.calls,
|
|
403
|
+
change_pct=pct,
|
|
404
|
+
change_str=change_str,
|
|
405
|
+
avg_latency_a=ta.avg_latency_ms,
|
|
406
|
+
avg_latency_b=tb.avg_latency_ms,
|
|
407
|
+
avg_latency_change_pct=lat_change_pct,
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Errors calculation
|
|
412
|
+
err_a = sum(stats_a.errors_by_category.values())
|
|
413
|
+
err_b = sum(stats_b.errors_by_category.values())
|
|
414
|
+
|
|
415
|
+
err_rate_a = (err_a / msg_a) * 100 if msg_a > 0 else 0.0
|
|
416
|
+
err_rate_b = (err_b / msg_b) * 100 if msg_b > 0 else 0.0
|
|
417
|
+
|
|
418
|
+
if err_rate_b < err_rate_a:
|
|
419
|
+
err_rate_change_str = "↓ improvement"
|
|
420
|
+
elif err_rate_b > err_rate_a:
|
|
421
|
+
err_rate_change_str = "↑ regression"
|
|
422
|
+
else:
|
|
423
|
+
err_rate_change_str = "no change"
|
|
424
|
+
|
|
425
|
+
return ComparisonStats(
|
|
426
|
+
session_id_a=stats_a.session_id,
|
|
427
|
+
session_id_b=stats_b.session_id,
|
|
428
|
+
duration_a=dur_a,
|
|
429
|
+
duration_b=dur_b,
|
|
430
|
+
duration_change_pct=dur_pct,
|
|
431
|
+
duration_change_str=dur_change_str,
|
|
432
|
+
messages_a=msg_a,
|
|
433
|
+
messages_b=msg_b,
|
|
434
|
+
messages_change_abs=msg_diff,
|
|
435
|
+
tool_changes=tool_changes,
|
|
436
|
+
new_tools=new_tools,
|
|
437
|
+
removed_tools=removed_tools,
|
|
438
|
+
errors_a=err_a,
|
|
439
|
+
errors_b=err_b,
|
|
440
|
+
error_rate_a=err_rate_a,
|
|
441
|
+
error_rate_b=err_rate_b,
|
|
442
|
+
error_rate_change_str=err_rate_change_str,
|
|
443
|
+
)
|