ratel-ai-core 0.8.0

Tool and skill retrieval for AI agents — selectable BM25, dense (semantic), or hybrid search over catalogs. Core of the Ratel context engineering platform.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
use serde::{Deserialize, Serialize};

/// Distinguishes a direct API call (pre-fetch helpers, library callers,
/// benchmarks) from one the agent synthesized inside its loop (capability tool).
/// Used to separate the two paths in trace consumers (rerankers train on agent
/// calls, inspector shows both).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
    /// A direct API call — SDK helpers, library callers, benchmarks. Wire
    /// value `direct`.
    Direct,
    /// A call the agent synthesized inside its loop, via the capability
    /// tools. Wire value `agent`.
    Agent,
}

/// How a registry corpus changed — carried by [`TraceEvent::IndexChurn`]
/// (tools), [`TraceEvent::SkillChurn`] (skills), and [`TraceEvent::FactChurn`]
/// (facts).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChurnKind {
    /// An item was registered — including a replace-in-place re-register of
    /// an existing id. Wire value `add`.
    Add,
    /// An item was removed from the corpus. Wire value `remove`.
    Remove,
}

/// Outcome of the one-time embedding-model load. `Slow` flags a machine that may
/// be underpowered for the model; `Failed` a load that errored (network, cache,
/// corrupt weights).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbedderLoadStatus {
    /// The model loaded within the expected budget. Wire value `ok`.
    Ok,
    /// The model loaded, but slowly — the machine may be underpowered for it.
    /// Wire value `slow`.
    Slow,
    /// The load errored (network, cache, corrupt weights); the accompanying
    /// `reason` carries the error. Wire value `failed`.
    Failed,
}

/// One ranked tool hit inside a [`TraceEvent::Search`] event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchHitTrace {
    /// Id of the matching tool.
    pub tool_id: String,
    /// The engine score, widened to `f64` — same per-method semantics as
    /// [`crate::SearchHit::score`].
    pub score: f64,
}

/// Timing and top score of one engine stage of a search. BM25 searches emit
/// one `bm25` stage, semantic searches one `dense` stage; hybrid emits
/// `bm25`, `dense`, and `rrf`, in that order. Semantic and hybrid searches
/// that short-circuit on an empty corpus or `top_k == 0` emit no stages.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchStage {
    /// Stage name: `"bm25"`, `"dense"`, or `"rrf"`.
    pub name: String,
    /// Stage wall time, in milliseconds.
    pub took_ms: u64,
    /// Best score the stage produced (that stage's scale); `None` when it
    /// returned no hits.
    pub top_score: Option<f64>,
}

/// One ranked skill hit inside a [`TraceEvent::SkillSearch`] event — the
/// skill-side twin of [`SearchHitTrace`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillHitTrace {
    /// Id of the matching skill.
    pub skill_id: String,
    /// The engine score, widened to `f64` — same per-method semantics as
    /// [`crate::SkillHit::score`].
    pub score: f64,
}

/// One ranked fact hit inside a [`TraceEvent::FactSearch`] event — the
/// fact-side twin of [`SkillHitTrace`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FactHitTrace {
    /// Id of the matching fact.
    pub fact_id: String,
    /// The engine score, widened to `f64` — same per-method semantics as
    /// [`crate::FactHit::score`].
    pub score: f64,
}

/// Why a fact's body was (re-)injected into the context, carried by
/// [`TraceEvent::FactInject`]. The grounding layer decides this by scanning
/// the transcript for the fact's own body text (content presence); it is the
/// observable half of the re-injection freshness gate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactInjectReason {
    /// Not present in the transcript and never injected this session — a
    /// first injection. Wire value `never`.
    Never,
    /// Injected earlier but its body is gone from the window now (trimmed /
    /// compacted out), so it is re-injected. Wire value `evicted`.
    Evicted,
    /// The registered body changed since it was injected (the current body is
    /// absent and differs from the one last injected), so the new version is
    /// injected. Wire value `mutated`.
    Mutated,
}

/// Every event produced by any layer of Ratel. New variants are additive;
/// renames or removals are breaking — see ADR-0007.
///
/// On the wire each event is a JSON object whose `type` tag is the variant
/// name in snake_case (`IndexChurn` → `index_churn`), with the variant's
/// fields flattened beside it; sinks wrap it in a [`TraceEnvelope`]. All
/// `took_ms` fields are wall time in milliseconds.
///
/// `#[non_exhaustive]` is what makes "new variants are additive" *true* rather
/// than aspirational: it requires downstream `match`es to carry a `_ =>` arm, so
/// a future event variant lands there instead of breaking their compile. Two
/// axes, only the first mechanical:
///
/// - **New variant** → non-breaking, enforced here.
/// - **New field on an existing variant** → non-breaking only if consumers
///   destructure with a trailing `..` (as this crate always does); variant-level
///   non-exhaustiveness is intentionally *not* used, since it would also block
///   downstream from constructing events by literal.
///
/// Renames and removals are breaking on both axes.
///
/// ```
/// use ratel_ai_core::TraceEvent;
/// // A downstream matcher must include `_ =>`, and is then future-proof:
/// fn kind(e: &TraceEvent) -> &str {
///     match e {
///         TraceEvent::Search { .. } => "search",
///         _ => "other",
///     }
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TraceEvent {
    /// A [`crate::ToolRegistry`] search completed (any [`crate::SearchMethod`]).
    /// Carries the query, the requested `top_k`, the ranked `hits` with
    /// scores, the per-engine `stages` timings, and the total wall time.
    Search {
        /// The search text.
        query: String,
        /// Direct library call vs agent-synthesized.
        origin: Origin,
        /// Requested result count.
        top_k: u32,
        /// The ranked results, best-first.
        hits: Vec<SearchHitTrace>,
        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
        stages: Vec<SearchStage>,
        /// Total search wall time, in milliseconds.
        took_ms: u64,
    },
    /// The tool corpus changed: [`crate::ToolRegistry::register`] emits this
    /// with [`ChurnKind::Add`] for both a fresh registration and a
    /// replace-in-place re-register.
    IndexChurn {
        /// Whether the id was added or removed.
        kind: ChurnKind,
        /// Id of the affected tool.
        tool_id: String,
    },
    /// A [`crate::SkillRegistry`] search completed — the skill-side twin of
    /// [`TraceEvent::Search`], with the same shape.
    SkillSearch {
        /// The search text.
        query: String,
        /// Direct library call vs agent-synthesized.
        origin: Origin,
        /// Requested result count.
        top_k: u32,
        /// The ranked results, best-first.
        hits: Vec<SkillHitTrace>,
        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
        stages: Vec<SearchStage>,
        /// Total search wall time, in milliseconds.
        took_ms: u64,
    },
    /// The skill corpus changed — the skill-side twin of
    /// [`TraceEvent::IndexChurn`]. [`crate::SkillRegistry::register`] emits
    /// [`ChurnKind::Add`] only; [`crate::SkillRegistry::replace_all`] emits
    /// either kind, and is the only source of [`ChurnKind::Remove`] for skills.
    SkillChurn {
        /// Whether the id was added or removed.
        kind: ChurnKind,
        /// Id of the affected skill.
        skill_id: String,
    },
    /// A skill's body was loaded for dispatch (the `get_skill_content` path).
    /// Emitted by the SDK skill catalogs via
    /// [`crate::SkillRegistry::record_event`].
    SkillInvoke {
        /// Id of the loaded skill.
        skill_id: String,
        /// Load wall time, in milliseconds.
        took_ms: u64,
    },
    /// A [`crate::FactRegistry`] search completed — the fact-side twin of
    /// [`TraceEvent::SkillSearch`], with the same shape.
    FactSearch {
        /// The search text.
        query: String,
        /// Direct library call vs agent-synthesized.
        origin: Origin,
        /// Requested result count.
        top_k: u32,
        /// The ranked results, best-first.
        hits: Vec<FactHitTrace>,
        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
        stages: Vec<SearchStage>,
        /// Total search wall time, in milliseconds.
        took_ms: u64,
    },
    /// The fact corpus changed — the fact-side twin of
    /// [`TraceEvent::SkillChurn`], emitted by [`crate::FactRegistry::register`].
    FactChurn {
        /// Whether the id was added or removed.
        kind: ChurnKind,
        /// Id of the affected fact.
        fact_id: String,
    },
    /// A fact's body was injected into the context by the grounding layer.
    /// Emitted by the SDK via [`crate::FactRegistry::record_event`]; `reason`
    /// records why the re-injection freshness gate let it through.
    FactInject {
        /// Id of the injected fact.
        fact_id: String,
        /// Why it was (re-)injected this turn.
        reason: FactInjectReason,
    },
    /// A fact was *not* re-injected because it is still fresh in the context —
    /// the token-saving half of the freshness gate, surfaced so the saving is
    /// observable. Emitted by the SDK via
    /// [`crate::FactRegistry::record_event`].
    FactInjectSkip {
        /// Id of the fact that was already present and left alone.
        fact_id: String,
    },
    /// A fact rode along in a per-call grounding snapshot — the stateless
    /// `groundSnapshot` path: recomputed each call, nothing persisted, no
    /// freshness gate. The per-call twin of [`TraceEvent::FactInject`], emitted
    /// by the SDK via [`crate::FactRegistry::record_event`] once per fact per
    /// snapshot.
    FactSnapshot {
        /// Id of the fact included in the snapshot.
        fact_id: String,
    },
    /// A tool invocation began. Emitted by the SDK catalogs just before the
    /// tool's executor runs; paired with [`TraceEvent::InvokeEnd`] or
    /// [`TraceEvent::InvokeError`].
    InvokeStart {
        /// Id of the invoked tool.
        tool_id: String,
        /// Size of the serialized argument payload, in bytes.
        args_size_bytes: u64,
    },
    /// A tool invocation completed successfully.
    InvokeEnd {
        /// Id of the invoked tool.
        tool_id: String,
        /// Invocation wall time, in milliseconds.
        took_ms: u64,
    },
    /// A tool invocation failed; `error` carries the executor's message.
    InvokeError {
        /// Id of the invoked tool.
        tool_id: String,
        /// Wall time until the failure, in milliseconds.
        took_ms: u64,
        /// The failure message.
        error: String,
    },
    /// The agent searched the catalog through the capability tools
    /// (`search_capabilities`, or the deprecated `search_tools`). Carries only
    /// the hit *count*; the ranked list with scores is on the underlying
    /// [`TraceEvent::Search`] / [`TraceEvent::SkillSearch`] the registries
    /// emit for the same call. The `gateway_*` wire prefix is frozen
    /// (ADR-0007: renames are breaking).
    GatewaySearch {
        /// The search text.
        query: String,
        /// Direct library call vs agent-synthesized.
        origin: Origin,
        /// Requested result count.
        top_k: u32,
        /// Number of results returned.
        hits: u32,
        /// Total search wall time, in milliseconds.
        took_ms: u64,
    },
    /// The agent invoked a tool through the `invoke_tool` capability tool and
    /// it succeeded.
    GatewayInvoke {
        /// Id of the invoked tool.
        tool_id: String,
        /// Invocation wall time, in milliseconds.
        took_ms: u64,
    },
    /// A capability-tool call failed: an unknown tool/skill id, an executor
    /// error, or an upstream that needs auth.
    GatewayError {
        /// Id of the tool (or skill) the call named.
        tool_id: String,
        /// The failure message (e.g. `needs_auth`).
        error: String,
    },
    /// An upstream MCP server's tools were ingested into the catalog
    /// (the SDK's `register_mcp_server`).
    UpstreamRegister {
        /// Upstream server name.
        server: String,
        /// Transport used to reach it (e.g. `stdio` / `http` / `sse`).
        transport: String,
        /// Number of tools ingested.
        tool_count: u32,
    },
    /// A proxied call to a tool backed by an upstream MCP server completed.
    UpstreamInvoke {
        /// Upstream server name.
        server: String,
        /// Id of the invoked tool.
        tool_id: String,
        /// Invocation wall time, in milliseconds.
        took_ms: u64,
    },
    /// A proxied upstream call failed; `error` carries the upstream's message.
    UpstreamError {
        /// Upstream server name.
        server: String,
        /// Id of the invoked tool.
        tool_id: String,
        /// The failure message.
        error: String,
    },
    /// A credential refresh for an upstream MCP server was attempted.
    AuthRefresh {
        /// Upstream server name.
        upstream: String,
        /// Whether the refresh produced valid credentials.
        ok: bool,
    },
    /// An upstream MCP server challenged for auth (e.g. a 401): user
    /// interaction is required before its tools work.
    AuthNeeds {
        /// Upstream server name.
        upstream: String,
    },
    /// An interactive auth flow (e.g. OAuth) started for an upstream MCP
    /// server; paired with [`TraceEvent::AuthFlowEnd`].
    AuthFlowStart {
        /// Upstream server name.
        upstream: String,
    },
    /// The interactive auth flow ended.
    AuthFlowEnd {
        /// Upstream server name.
        upstream: String,
        /// Whether the flow produced valid credentials.
        ok: bool,
    },
    /// Emitted once, on the first (cold) load of the embedding model. `status`
    /// flags a slow load (possibly underpowered machine) or a failed one;
    /// `reason` carries the hint / error. See `embedding.rs` and ADR-0011.
    EmbedderLoad {
        /// Resolved model display name: repo id, local path, or endpoint model
        /// and URL.
        model: String,
        /// Load outcome: ok, slow, or failed.
        status: EmbedderLoadStatus,
        /// Load wall time, in milliseconds (`0` when the load failed before
        /// timing).
        took_ms: u64,
        /// The slow-load hint or the load error; `None` on a normal load.
        reason: Option<String>,
    },
    /// Emitted once when a configured embedding model is actually downloaded to
    /// the HuggingFace cache (a cold fetch), carrying the real byte size — so a
    /// multi-second first-run download is never a silent surprise. See ADR-0012.
    EmbedderDownload {
        /// The model that was downloaded.
        model: String,
        /// Real download size, in bytes.
        bytes: u64,
    },
    /// Emitted when a semantic/hybrid search runs against an embedding set built
    /// with a *different* model than the one now configured. Retrieval fails
    /// rather than mixing vector spaces; the caller must rebuild the complete
    /// embedding cache. See `dense_cache.rs` and ADR-0012.
    EmbedderModelMismatch {
        /// The model the existing embeddings were built with.
        built: String,
        /// The model now configured.
        active: String,
    },
    /// Emitted once when a semantic/hybrid search finds the attached intent
    /// graph's centroids were built with a *different* embedding model than the
    /// active one, so cosine across the two spaces would be meaningless. Unlike
    /// [`Self::EmbedderModelMismatch`] (corpus, fatal), the usage arm merely
    /// **pauses** — base ranking is unaffected — until the graph is rebuilt. See
    /// `usage.rs` and ADR-0014.
    UsageModelMismatch {
        /// The graph's model — its fingerprint, or its centroid width when the
        /// mismatch is dimensional.
        built: String,
        /// The active model, in the same units as `built`.
        active: String,
        /// `true` when the models differ in output dimension, `false` when only
        /// the model identity differs at the same width (a same-dim swap a length
        /// check cannot catch).
        dim_mismatch: bool,
    },
    /// Emitted on every search of a registry that has an intent graph attached,
    /// recording whether usage history contributed to the ranking (ADR-0014).
    /// A registry with no graph emits nothing, so this event's presence is
    /// itself the signal that adaptive ranking is switched on.
    ///
    /// `intent: None` is the **miss** case: the query matched no cluster and
    /// ranked exactly as it would have with no graph at all. A rising share of
    /// misses means the graph no longer covers what is being asked — the cue to
    /// re-derive it.
    UsageBoost {
        /// Id of the matched cluster; `None` when nothing cleared the match
        /// threshold.
        intent: Option<String>,
        /// How well the query matched the cluster — cosine on the dense tier,
        /// token-overlap share on the lexical one. `0.0` on a miss. Scales
        /// differ between tiers, so compare within one. Reported so near-misses
        /// are visible and the threshold can be judged against real traffic.
        similarity: f64,
        /// The matched cluster's observation count, which scales the arm's
        /// weight. `0` on a miss.
        support: u32,
        /// How many capability ids the arm contributed to the fusion. `0` on a
        /// miss.
        promoted: u32,
    },
    /// Emitted once when an in-process model's pooling could not be detected
    /// (no `1_Pooling/config.json`) and no override was given, so a mode was
    /// assumed. A non-silent guess: set `pooling` to correct it. See ADR-0012.
    EmbedderPoolingAssumed {
        /// The model whose pooling could not be detected.
        model: String,
        /// The pooling mode that was assumed (`"cls"` or `"mean"`).
        pooling: String,
    },
}

/// The versioned wrapper a sink writes around each [`TraceEvent`]: schema
/// version, timestamp, and session id. On the wire the event is flattened
/// (`#[serde(flatten)]`), so its `type` tag and fields sit beside `v` / `ts` /
/// `session_id` in one JSON object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TraceEnvelope {
    /// Envelope schema version; currently `1`.
    pub v: u32,
    /// Event time, in milliseconds since the Unix epoch.
    pub ts: u64,
    /// The session the event belongs to, as given to the sink — correlates
    /// all events from one agent session.
    pub session_id: String,
    /// The event itself, flattened into the envelope on the wire.
    #[serde(flatten)]
    pub event: TraceEvent,
}