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
//! The production activity classifier, backed by the unified inference adapter.
//!
//! Why (#4427, epic #4429): the activity classifier was the last per-session LLM
//! caller in this crate still driving the legacy `trusty_common::chat::ChatProvider`
//! SSE pump. Every other trusty-mpm LLM path (the Layer-3 manager, see
//! `daemon::manager::inference`) already resolves providers through the unified
//! `trusty_common::inference` adapter + `Configurator` credential ladder, so the
//! classifier's bespoke `OpenRouterProvider` + hand-accumulated stream was the odd
//! one out: two credential policies, two error taxonomies, and a streaming
//! transport whose partial output the classifier immediately discarded. This
//! module is the migrated seam — one blocking `InferenceAdapter::chat` call per
//! classification.
//! What: [`OpenRouterClassifier`] implements [`LlmClassifier`] by resolving an
//! `Arc<dyn InferenceAdapter>` (credential-backed in production, injected in
//! tests) and issuing exactly ONE [`ChatRequest`], then parsing the model's JSON
//! verdict with [`extract_json`]/[`parse_state`]. It is deliberately split out of
//! `monitor.rs` so both files stay under the 500-SLOC production cap.
//! Test: `crates/trusty-mpm/src/activity/classifier_tests.rs` — the whole file,
//! notably `classify_parses_verdict_and_usage`,
//! `classify_missing_credential_maps_to_missing_api_key`,
//! `classify_adapter_error_maps_to_llm`,
//! `classify_unparseable_output_maps_to_serialization`.
use Arc;
use ;
use ;
use ;
use ;
/// Environment key selecting the classifier's model slug.
///
/// Why: operators switch between a cheap and a capable model without
/// recompiling; naming the key as a constant keeps the classifier, the docs
/// (`docs/reference/environment-variables.md`), and the tests in agreement.
/// What: read by [`OpenRouterClassifier::new`]; shared fleet-wide with the
/// Layer-3 manager's fallback key.
/// Test: `model_defaults_when_env_unset`.
pub const CLASSIFIER_MODEL_ENV: &str = "TRUSTY_LLM_MODEL";
/// The model slug used when [`CLASSIFIER_MODEL_ENV`] is unset.
///
/// Why: a cheap, fast model is the right default for a one-shot six-way
/// classification that runs on every session poll. The bare `openai/…` slug is
/// unchanged from the pre-#4427 classifier, so no operator config moves.
/// What: the fallback slug handed to `Configurator::build`.
/// Test: `model_defaults_when_env_unset`.
pub const DEFAULT_CLASSIFIER_MODEL: &str = "openai/gpt-4o-mini";
/// How the classifier obtains an [`InferenceAdapter`].
///
/// Why: production must resolve a provider from the operator's credential store
/// on EVERY call — a daemon started before the key was configured has to start
/// working the moment it is, exactly as the old per-call `std::env::var` read
/// did. Tests need a deterministic adapter with no network or credential. One
/// enum keeps [`OpenRouterClassifier::adapter`] a single path. Mirrors
/// `daemon::manager::inference::Source`.
/// What: [`Self::Credentialed`] holds the factory registry and the resolved key
/// store; [`Self::Fixed`] holds a ready adapter.
/// Test: `classify_missing_credential_maps_to_missing_api_key` (credentialed),
/// `classify_parses_verdict_and_usage` (fixed).
/// The [`LlmClassifier`] that asks a real model what a session is doing.
///
/// Why: the `/activity` route, the TUI per-session summary, and the circuit
/// breaker all need a state verdict for a pane of terminal text; this is the one
/// production implementation behind that trait.
/// What: owns the model slug plus a [`Source`]. Named `OpenRouterClassifier` for
/// source compatibility with its callers — post-#4427 the concrete provider is
/// whatever the shared credential ladder resolves for the slug, which is
/// OpenRouter for every slug whose own provider family has no key configured.
/// Test: the `classify_*` cases in `classifier_tests.rs`.
/// Resolve the classifier's model slug from the environment.
///
/// Why: keeps the documented precedence in one place so construction and any
/// diagnostic read agree.
/// What: [`CLASSIFIER_MODEL_ENV`] when set and non-blank, else
/// [`DEFAULT_CLASSIFIER_MODEL`].
/// Test: `model_defaults_when_env_unset`, `model_reads_env_override`.
/// Map an [`InferenceError`] onto the activity subsystem's error taxonomy.
///
/// Why: callers (and `ActivityMonitor::check`'s degrade arm) branch on
/// [`ActivityError::MissingApiKey`] to return an `Unknown` verdict instead of
/// propagating a failure; that contract must survive the adapter migration
/// unchanged. Every other inference failure is a call failure.
/// What: [`InferenceError::MissingCredential`] → [`ActivityError::MissingApiKey`];
/// everything else → [`ActivityError::Llm`] carrying the error's display text
/// (which never contains a credential — see `InferenceError`'s own contract).
/// Test: `classify_missing_credential_maps_to_missing_api_key`,
/// `classify_adapter_error_maps_to_llm`.
/// Build the classification prompt for a pane of terminal text.
///
/// Why: extracted so the prompt is assertable in a test without issuing a call,
/// and so `classify` reads as transport rather than prompt-smithing. The wording
/// is byte-identical to the pre-#4427 prompt — the migration must not move the
/// model's behaviour.
/// What: instructs a JSON-only reply over the six valid states and embeds
/// `pane_text` in a fenced block.
/// Test: `classify_sends_one_user_turn`.
/// Extract the first `{…}` block from a response that may have prose around it.
///
/// Why: models routinely wrap JSON in markdown fences or prepend an apology;
/// slicing between the outermost braces recovers the payload instead of failing
/// the whole check.
/// What: returns the span from the first `{` to the LAST `}` (inclusive), or
/// `None` when either brace is absent or they appear out of order.
/// Test: `extract_json_plain`, `extract_json_from_fenced_prose`,
/// `extract_json_spans_to_last_brace`, `extract_json_none_without_braces`,
/// `extract_json_none_when_reversed`.
/// Parse a state string into an [`ActivityState`] variant.
///
/// Why: the model returns a snake_case string and may invent one; a total
/// function with an `Unknown` fallback keeps an off-script reply from failing the
/// check. Serde would reject the unexpected variant instead.
/// What: case-insensitive exact match over the six documented states; anything
/// else (including empty) is [`ActivityState::Unknown`].
/// Test: `parse_state_maps_every_documented_state`, `parse_state_is_case_insensitive`,
/// `parse_state_unknown_for_garbage`.