opentel-mcp 0.7.0 → 0.9.0
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.
- package/CHANGELOG.md +300 -0
- package/README.md +836 -10
- package/package.json +4 -2
- package/src/attributes.js +3 -0
- package/src/config.js +67 -0
- package/src/cost/budget.js +13 -4
- package/src/fingerprint/classify/auth.js +41 -3
- package/src/fingerprint/classify/validation-paths.js +135 -18
- package/src/index.d.ts +95 -0
- package/src/instrument.js +325 -11
- package/src/observation/integrity.js +108 -0
- package/src/observation/tool-outcome-counter.js +131 -0
- package/src/observation/types.d.ts +78 -0
- package/src/registry/instance-registry.js +170 -0
- package/src/registry/types.d.ts +55 -0
- package/src/schema-drift/attributes.js +68 -0
- package/src/schema-drift/canonicalize.js +80 -0
- package/src/schema-drift/compose.js +94 -0
- package/src/schema-drift/config.js +111 -0
- package/src/schema-drift/detector.js +135 -0
- package/src/schema-drift/diff.js +176 -0
- package/src/schema-drift/emitter.js +106 -0
- package/src/schema-drift/store.js +75 -0
- package/src/schema-drift/types.d.ts +134 -0
- package/src/thrash/attributes.js +38 -0
- package/src/thrash/config.js +14 -8
- package/src/thrash/detector.js +17 -6
- package/src/thrash/emitter.js +15 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,305 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
**⚠️ Fixed, with a fingerprint behavior change — read this before the
|
|
6
|
+
feature below.** The `auth` failure classifier
|
|
7
|
+
(`src/fingerprint/classify/auth.js`) missed "permission denied" and
|
|
8
|
+
"access denied" — the standard phrasing from Unix, git, AWS IAM, and GCP
|
|
9
|
+
for a permission/authorization failure. It only recognized
|
|
10
|
+
HTTP-status-derived wording (`unauthorized`, `forbidden`,
|
|
11
|
+
`authenticat(e|ion)`) plus 401/403 status codes and a handful of known
|
|
12
|
+
auth-library error names. Messages using the OS/CLI phrasing above fell
|
|
13
|
+
through every classifier and landed in the `internal` catch-all instead.
|
|
14
|
+
Found by running realistic error text through this project's own UI demo
|
|
15
|
+
(`packages/ui/demo/populate.js`) and checking what `DEFAULT_CLASSIFIERS`
|
|
16
|
+
actually returned for it — not assumed. Now also matches "not authorized",
|
|
17
|
+
"permission(s) denied", "access denied", "insufficient permission(s)", and
|
|
18
|
+
Node's own `EACCES`/`EPERM` error codes; still does not match bare
|
|
19
|
+
"authorized" or "permission" alone (see the classifier's own docblock for
|
|
20
|
+
the false-positive cases this deliberately excludes, e.g. "user denied the
|
|
21
|
+
permission request").
|
|
22
|
+
|
|
23
|
+
**This changes fingerprints for affected messages.** `category` is one of
|
|
24
|
+
the hashed inputs `computeFingerprint()` combines into
|
|
25
|
+
`mcp.failure.fingerprint` (see ADR 006). A permission-denied failure that
|
|
26
|
+
previously classified as `internal` now classifies as `auth` — the fields
|
|
27
|
+
feeding the hash change, so the fingerprint itself changes for anyone whose
|
|
28
|
+
tool emits this wording. This does **not** amend the closed 8-category
|
|
29
|
+
taxonomy ADR 006 established (`validation | timeout | network | auth |
|
|
30
|
+
dependency | serialization | internal | unknown`) — `auth` already existed;
|
|
31
|
+
this is a pattern-coverage fix to when the existing category fires, not a
|
|
32
|
+
new category. If you alert or dashboard on a specific `mcp.failure.fingerprint`
|
|
33
|
+
value for a permission error, expect a new value after upgrading.
|
|
34
|
+
|
|
35
|
+
### Added — `instanceKey`: sharing tracker state across `instrumentMcpServer()` calls
|
|
36
|
+
|
|
37
|
+
`instanceKey` (a string option on `instrumentMcpServer()`, or the
|
|
38
|
+
`OTEL_MCP_INSTANCE_KEY` env var — lower precedence than the option) lets
|
|
39
|
+
repeated `instrumentMcpServer()` calls that pass the same key share Agent
|
|
40
|
+
Thrash Detection, budget tracking, schema drift detection, and the
|
|
41
|
+
`ToolOutcome` counter's state, instead of each call constructing all four
|
|
42
|
+
fresh and discarding them. Fixes the gap documented in the README's
|
|
43
|
+
"In-memory tracker state is scoped to one `instrumentMcpServer()` call"
|
|
44
|
+
section and `docs/known-gaps.md` entry 6, under a "stateless" Streamable
|
|
45
|
+
HTTP deployment shape (a fresh `Server`/`McpServer` re-instrumented on
|
|
46
|
+
every incoming request). Full design: ADR 012
|
|
47
|
+
(`docs/adr/012-tracker-lifecycle-and-shared-state.md`).
|
|
48
|
+
|
|
49
|
+
Backed by an internal, bounded, TTL-evicting registry (1000 distinct keys
|
|
50
|
+
per process, 24h TTL renewed on every use — both ADR 012's proposed
|
|
51
|
+
defaults) — fully internal, no new public type. Omit `instanceKey` (the
|
|
52
|
+
default) for behavior byte-identical to every prior version: trackers are
|
|
53
|
+
constructed fresh on every call, and the registry is never touched.
|
|
54
|
+
|
|
55
|
+
**⚠️ Composition requirement: `instanceKey` alone does not fix Agent
|
|
56
|
+
Thrash Detection.** `ThrashDetector` looks episodes up by `(sessionId,
|
|
57
|
+
toolName, fingerprint)` — `instanceKey` shares the tracker object, but
|
|
58
|
+
without a real, transport-provided `extra.sessionId` on every call, each
|
|
59
|
+
`instrumentMcpServer()` call still generates its own random per-connection
|
|
60
|
+
fallback session id, fresh, regardless of `instanceKey`. Sharing the
|
|
61
|
+
tracker doesn't help if the lookup key inside it differs every call —
|
|
62
|
+
each request lands as its own one-off episode instead of contributing to
|
|
63
|
+
one shared loop. Real Streamable HTTP transports provide a real session id
|
|
64
|
+
automatically, so the common case works with `instanceKey` alone — but a
|
|
65
|
+
custom `Transport`, `assumeSingleSession: true`, or anything else on the
|
|
66
|
+
generated-fallback path will set `instanceKey`, see nothing happen, and
|
|
67
|
+
have every reason to think the fix is broken. Same silent-inertness shape
|
|
68
|
+
as the original gap, one layer deeper. See the README's new "instanceKey"
|
|
69
|
+
section for the full explanation and what to do about it — this is not a
|
|
70
|
+
footnote there either.
|
|
71
|
+
|
|
72
|
+
**⚠️ Does not help across process boundaries.** `instanceKey`'s registry
|
|
73
|
+
is one process's in-memory state. On Lambda, Cloud Run, or any
|
|
74
|
+
horizontally-scaled deployment, concurrent/recycled instances each hold
|
|
75
|
+
their own independent registry — passing the identical `instanceKey`
|
|
76
|
+
string everywhere does not change that. Counters remain instance-local and
|
|
77
|
+
best-effort by design; this is a structural limitation, not a
|
|
78
|
+
configuration gap, and this library deliberately does not add an external
|
|
79
|
+
store (Redis/DynamoDB) to close it — see ADR 012's Update section for the
|
|
80
|
+
full reasoning.
|
|
81
|
+
|
|
82
|
+
**Documentation:** the "Metrics" section now covers wiring the Prometheus
|
|
83
|
+
exporter specifically (`@opentelemetry/exporter-prometheus`), not just the
|
|
84
|
+
OTLP example that was already there. Its pull-based text exposition format
|
|
85
|
+
does not attach resource attributes (including `service.name`) to
|
|
86
|
+
individual metric points by default — only to a separate `target_info`
|
|
87
|
+
series — which is invisible with one service but means every series looks
|
|
88
|
+
identical the moment you're scraping more than one instrumented server
|
|
89
|
+
into the same Prometheus. Documents the fix
|
|
90
|
+
(`withResourceConstantLabels: /^service\.name$/`) with a worked example.
|
|
91
|
+
No code change; this behavior was always there, just undocumented. Found
|
|
92
|
+
building `dashboards/grafana-mcp-health.json`'s verification harness.
|
|
93
|
+
|
|
94
|
+
## 0.8.0
|
|
95
|
+
|
|
96
|
+
Three features. **Tool schema drift detection**: a server that silently
|
|
97
|
+
changes a tool's `inputSchema` between deployments — a parameter renamed, a
|
|
98
|
+
type tightened, a `required` field added — currently breaks agents with no
|
|
99
|
+
signal pointing at the actual cause. Full investigation and design: ADR 010
|
|
100
|
+
(`docs/adr/010-schema-drift.md`). **Two-axis observation contract**:
|
|
101
|
+
prompted by external review (Massimiliano Brighindi), who first raised that
|
|
102
|
+
`instrumentMcpServer()` with no `TracerProvider`/`MeterProvider` registered
|
|
103
|
+
silently no-ops — a failed tool call in that state produces zero telemetry,
|
|
104
|
+
indistinguishable from one that never failed — and then supplied the reframe
|
|
105
|
+
that shaped what shipped: not "detect a broken pipeline," but "stop implying
|
|
106
|
+
health by omission." Full investigation and design: ADR 008
|
|
107
|
+
(`docs/adr/008-observation-liveness.md`, "Update (2026-08-05)" section).
|
|
108
|
+
**Cost-aware trace sampling**: investigated whether traces that were
|
|
109
|
+
expensive or thrashed could be kept regardless of the head sampler's
|
|
110
|
+
decision. Found that this cannot be an in-process library feature — a
|
|
111
|
+
`Sampler` decides at span start, cost/thrash are only known at span end, and
|
|
112
|
+
this package doesn't own the `Sampler`/`SpanProcessor` chain in its default
|
|
113
|
+
configuration anyway. Ships a marker attribute plus a documented Collector
|
|
114
|
+
recipe instead of a sampler. Full investigation and design: ADR 011
|
|
115
|
+
(`docs/adr/011-cost-aware-sampling.md`).
|
|
116
|
+
|
|
117
|
+
**⚠️ Read "Changed — behavior change on upgrade" immediately below before
|
|
118
|
+
updating.** One of the three features above changes when
|
|
119
|
+
`instrumentMcpServer()` throws, for a subset of low-level `Server` users,
|
|
120
|
+
purely from the new default-on config — no code change of your own
|
|
121
|
+
required to hit it.
|
|
122
|
+
|
|
123
|
+
### Changed — behavior change on upgrade, read before updating
|
|
124
|
+
|
|
125
|
+
- **The instrument-first ordering requirement now also covers `tools/list`,
|
|
126
|
+
for low-level `Server` users specifically, because `schemaDrift.enabled`
|
|
127
|
+
defaults to `true`.** `instrumentMcpServer()` has always required being
|
|
128
|
+
called before any `tools/call` handler is registered; as of this release,
|
|
129
|
+
with schema drift enabled (the default), it also requires being called
|
|
130
|
+
before any `tools/list` handler is registered. If your low-level `Server`
|
|
131
|
+
code registers `server.setRequestHandler(ListToolsRequestSchema, ...)`
|
|
132
|
+
before calling `instrumentMcpServer()` — never previously an error, since
|
|
133
|
+
this library was blind to `tools/list` entirely before this release —
|
|
134
|
+
upgrading will make that throw `INSTRUMENT_FIRST_ERROR` where it didn't
|
|
135
|
+
before, with no other code changes on your part.
|
|
136
|
+
- **`McpServer` users are unaffected.** `McpServer` registers `tools/list`
|
|
137
|
+
and `tools/call` together, atomically, the first time `.tool()` or
|
|
138
|
+
`.registerTool()` is called — so anyone already following the
|
|
139
|
+
documented instrument-before-registration rule for `tools/call`
|
|
140
|
+
automatically satisfies it for `tools/list` too.
|
|
141
|
+
- **Migration**: either reorder your `tools/list` registration to after
|
|
142
|
+
`instrumentMcpServer()`, or pass `schemaDrift: { enabled: false }` to
|
|
143
|
+
opt out and keep your existing registration order — both fully
|
|
144
|
+
restore v0.7.0 behavior. There is no change if you don't use a
|
|
145
|
+
low-level `Server` with an independently-registered `tools/list`
|
|
146
|
+
handler.
|
|
147
|
+
- Only `schemaDrift` (schema drift detection, below) causes this — the
|
|
148
|
+
two-axis observation contract and cost-aware sampling features in this
|
|
149
|
+
same release are purely additive, with no effect on when
|
|
150
|
+
`instrumentMcpServer()` throws.
|
|
151
|
+
|
|
152
|
+
### Added
|
|
153
|
+
|
|
154
|
+
- **Tool schema drift detection.** Every `tools/list` response is captured,
|
|
155
|
+
canonicalized, and hashed per tool (`inputSchema` only — `description` is
|
|
156
|
+
a deliberately separate, not-yet-built dimension; see ADR 010). When a
|
|
157
|
+
previously-observed tool's schema hash changes, this emits:
|
|
158
|
+
- An `mcp.tool.schema_drift.detected` counter, labeled `gen_ai.tool.name`
|
|
159
|
+
and `mcp.tool.schema_drift.type` (both bounded — see
|
|
160
|
+
`METRIC_SAFE_ATTRIBUTES`, `src/schema-drift/attributes.js`).
|
|
161
|
+
- An `mcp.tool.schema_drift.detected` span event on the new `tools/list`
|
|
162
|
+
span (see Changed, below), carrying the drift `type`, the previous/
|
|
163
|
+
current schema hash, and — only when non-empty, never set to `[]` —
|
|
164
|
+
which field names were added/removed/changed. Field names are
|
|
165
|
+
span-only, never a metric label (unbounded across tools/deployments,
|
|
166
|
+
same reasoning as `mcp.failure.validation_paths`, ADR 009).
|
|
167
|
+
- `type` is one of `field_added` \| `field_removed` \| `type_changed` \|
|
|
168
|
+
`required_changed` \| `multiple` (more than one at once, never guessed
|
|
169
|
+
down to a single answer) \| `unknown` (a change this differ can't
|
|
170
|
+
confidently characterize — e.g. a top-level `oneOf`/`anyOf`/`allOf`
|
|
171
|
+
composition, or a change hidden behind a `$ref`/`$defs` indirection
|
|
172
|
+
that doesn't touch the referencing property's own value — still
|
|
173
|
+
reported as drift, just not attributable to a specific field).
|
|
174
|
+
- The **first** observation of any given tool is never reported as
|
|
175
|
+
drift (nothing to compare against yet — cold start). A tool that
|
|
176
|
+
stops appearing in `tools/list` responses for a while and later
|
|
177
|
+
reappears is compared against its last-seen schema, not treated as a
|
|
178
|
+
fresh cold start — see the README's "Tool schema drift detection"
|
|
179
|
+
section for why this is the correct, and possibly counter-intuitive,
|
|
180
|
+
behavior.
|
|
181
|
+
- State is scoped **per instrumented server instance, not per session**
|
|
182
|
+
(ADR 010, Q4) — every client session sees the same tool registry, so
|
|
183
|
+
session-keyed state would produce false cold-starts per new session
|
|
184
|
+
and could silently swallow drift that happened between sessions.
|
|
185
|
+
- New `schemaDrift` option on `instrumentMcpServer()`, following the
|
|
186
|
+
exact `thrashDetection`/`costTracking` partial-overrides-individual-
|
|
187
|
+
defaults pattern: `enabled` (default `true`) and `maxTrackedTools`
|
|
188
|
+
(default `1000`, an LRU cap — defense-in-depth, not a response to an
|
|
189
|
+
expected failure mode; a server's own tool count is normally small).
|
|
190
|
+
Each independently overridable via `OTEL_MCP_SCHEMA_DRIFT_ENABLED` /
|
|
191
|
+
`OTEL_MCP_SCHEMA_DRIFT_MAX_TRACKED_TOOLS`, following the existing
|
|
192
|
+
`OTEL_MCP_THRASH_*` env-var convention.
|
|
193
|
+
**Unlike** `thrashDetection`/`costTracking`, `schemaDrift.enabled: false`
|
|
194
|
+
is a true no-op: `tools/list` is not wrapped at all (no span, no
|
|
195
|
+
capture, no detector/emitter construction) — the `tools/list` span
|
|
196
|
+
this feature introduces exists purely for schema drift, unlike the
|
|
197
|
+
`tools/call` span, which already serves other purposes regardless of
|
|
198
|
+
sub-feature flags.
|
|
199
|
+
- `SchemaDriftConfig`, `SchemaDriftKind`, `SchemaDriftEvent` types,
|
|
200
|
+
exported from the package root.
|
|
201
|
+
|
|
202
|
+
### Added — Two-axis observation contract
|
|
203
|
+
|
|
204
|
+
- **`getObservationState()`**, a new accessor attached to the object
|
|
205
|
+
`instrumentMcpServer()` returns — unconditional (not gated behind
|
|
206
|
+
`setupNodeSdk`), same additive pattern as `shutdown()`/`getThrashSummary()`,
|
|
207
|
+
omitted entirely when `options.enabled` is `false`. Returns:
|
|
208
|
+
```
|
|
209
|
+
{
|
|
210
|
+
toolOutcome: { success, failure, unknown },
|
|
211
|
+
observationIntegrity: 'DEGRADED' | 'UNKNOWN',
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
No OTel emission — purely in-process, nothing sent anywhere, safe to
|
|
215
|
+
call from application code (a health-check endpoint, a periodic
|
|
216
|
+
`console.log`, a debugger). See the README's "Two-axis observation
|
|
217
|
+
contract" section for the full design rationale.
|
|
218
|
+
- **`toolOutcome`**: cumulative tool-call outcome counts since
|
|
219
|
+
instrumentation, from a **new counter that increments on every tool
|
|
220
|
+
call unconditionally** — independent of `fingerprinting`,
|
|
221
|
+
`thrashDetection`, and `enableMetrics`. Deliberately NOT read off
|
|
222
|
+
`ThrashDetector`/`getThrashSummary()`: that bookkeeping only runs
|
|
223
|
+
when a fingerprint was computed, so with `fingerprinting: false` (a
|
|
224
|
+
fully supported configuration) it would silently report zero
|
|
225
|
+
failures regardless of how many actually occurred — the exact
|
|
226
|
+
silent-success failure mode this feature exists to close. A
|
|
227
|
+
malformed, unrecognizable tool result (not a real `CallToolResult`
|
|
228
|
+
shape) increments `unknown` rather than silently defaulting to
|
|
229
|
+
`success`.
|
|
230
|
+
- **`observationIntegrity`**: `'DEGRADED' | 'UNKNOWN'` — note there is
|
|
231
|
+
no `'HEALTHY'` value, and this is not an oversight. Investigated and
|
|
232
|
+
found structurally unreachable in every configuration: the one lead
|
|
233
|
+
(OTel SDK self-observability metrics) is a write-only `Counter` with
|
|
234
|
+
no synchronous read-back API in `@opentelemetry/api`, so this
|
|
235
|
+
library's own code can never positively confirm telemetry is
|
|
236
|
+
flowing, no matter how it's wired up. `HEALTHY` is therefore absent
|
|
237
|
+
from the type entirely, not merely never returned — enforced by
|
|
238
|
+
TypeScript, not just documentation (see the new type-level tests in
|
|
239
|
+
`test/index.exports.test-d.ts`).
|
|
240
|
+
- `DEGRADED` is detected via a fragile `ProxyTracerProvider`
|
|
241
|
+
reference-equality check, and is reachable **only** under
|
|
242
|
+
`setupNodeSdk: false` (the default) — when no `TracerProvider` has
|
|
243
|
+
been registered globally at all.
|
|
244
|
+
- Under `setupNodeSdk: true`, this library registers the provider
|
|
245
|
+
itself, so absence can never be confirmed — `observationIntegrity`
|
|
246
|
+
is **always** `'UNKNOWN'` in that configuration, without even
|
|
247
|
+
attempting the check.
|
|
248
|
+
- Recomputed fresh on **every call** to `getObservationState()`,
|
|
249
|
+
never cached from instrument time — a host may register a
|
|
250
|
+
`TracerProvider` asynchronously after `instrumentMcpServer()`
|
|
251
|
+
already ran, and a value cached at startup would go stale the
|
|
252
|
+
moment that happens.
|
|
253
|
+
- `ToolOutcome`, `ToolOutcomeCounts`, `ObservationIntegrity`,
|
|
254
|
+
`ObservationState` types, exported from the package root.
|
|
255
|
+
|
|
256
|
+
### Added — Cost-aware trace sampling (marker attribute + Collector recipe)
|
|
257
|
+
|
|
258
|
+
- **`mcp.tool.thrash_detected`, a new boolean span attribute**, set
|
|
259
|
+
alongside (never instead of) the existing `mcp.loop.detected` span
|
|
260
|
+
event, in the same `thrash/emitter.js` call site — set only when
|
|
261
|
+
`thrashDetection` is enabled and a loop was actually detected on this
|
|
262
|
+
call, same reachability as the existing event, no new failure mode.
|
|
263
|
+
Exists specifically so an OpenTelemetry Collector's
|
|
264
|
+
`tailsamplingprocessor` has an unambiguous, attribute-level signal to
|
|
265
|
+
key on: whether a `boolean_attribute` policy can also match span-*event*
|
|
266
|
+
data was investigated and left genuinely unverified (the processor is
|
|
267
|
+
Go source in a separate repository, not installed here), so this
|
|
268
|
+
attribute removes that uncertainty entirely rather than leaving tail
|
|
269
|
+
sampling dependent on an unconfirmed answer. **Named deliberately
|
|
270
|
+
differently** from the pre-existing `mcp.tool.loop.detected` **metric**
|
|
271
|
+
counter, not reusing its string as ADR 011 originally specified — see
|
|
272
|
+
that ADR's "Update" note. A metric name and a span attribute key are
|
|
273
|
+
unrelated OTel namespaces with no technical conflict, but reusing the
|
|
274
|
+
name left the one reader who most needs it to be unambiguous — someone
|
|
275
|
+
writing a Collector tail-sampling policy — unable to tell, from the
|
|
276
|
+
name alone, which of the two same-named signals they were keying on.
|
|
277
|
+
- **No new cost-threshold attribute or config.** `mcp.tool.cost.usd` and
|
|
278
|
+
`mcp.tool.cost.budget_exceeded` (both already shipped, v0.5.0) already
|
|
279
|
+
fully suffice for a Collector `numeric_attribute` / `boolean_attribute`
|
|
280
|
+
policy — the numeric threshold itself lives entirely in the
|
|
281
|
+
Collector's own policy config (the YAML), not in this package's
|
|
282
|
+
`InstrumentOptions`. No new env var, no new `instrumentMcpServer()`
|
|
283
|
+
option.
|
|
284
|
+
- **A documented, pasteable OpenTelemetry Collector `tailsamplingprocessor`
|
|
285
|
+
config** (README's "Cost-aware trace sampling" section) keeping any
|
|
286
|
+
trace with an expensive call, a budget-exceeded call, or a detected
|
|
287
|
+
thrash loop, alongside an ordinary probabilistic sample for everything
|
|
288
|
+
else.
|
|
289
|
+
- **No in-process sampler or buffering `SpanProcessor` was built, and none
|
|
290
|
+
is planned** — investigated and rejected on two independent grounds
|
|
291
|
+
(ADR 011): this package doesn't own the `Sampler`/`SpanProcessor` chain
|
|
292
|
+
in its default configuration (no public API to inject either into a
|
|
293
|
+
host-owned `TracerProvider`), and even where a custom processor could
|
|
294
|
+
theoretically be installed, an in-process decision can only ever rescue
|
|
295
|
+
the one span this package itself creates — never an already-finished
|
|
296
|
+
child span from other instrumentation, never an upstream span in a
|
|
297
|
+
different process. "Keep the trace" is not achievable in-process; at
|
|
298
|
+
best, "keep this one span" is, which is a materially smaller guarantee
|
|
299
|
+
than the stated goal. Real cross-span, cross-process trace buffering is
|
|
300
|
+
what the Collector's `tailsamplingprocessor` already does correctly —
|
|
301
|
+
not something to partially re-implement inside this package.
|
|
302
|
+
|
|
3
303
|
## 0.7.0
|
|
4
304
|
|
|
5
305
|
Origin-aware failure classification for Agent Thrash Detection. Prompted by
|