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
//! Decision drivers: the vendor seam behind [`DecisionsService`].
//!
//! A decision driver is to the decisions service what a chat driver is to a
//! model provider: one vendor's transport, answering the provider-neutral
//! [`DecisionRequest`]. Callers keep talking to a [`DecisionsService`]; the
//! host puts a router in front of a registry of drivers (see
//! `everruns_host::DecisionDriverRegistry`), so a deployment switches vendors
//! without touching a call site.
//!
//! Decisions recorded here:
//!
//! - A driver answers all three primitives. One whose vendor lacks a primitive
//! translates it (a `Noul` as a two-option choice, a `Score` as a choice over
//! the levels) and says so through [`DecisionDriverCapabilities::native`].
//! Callers never see a primitive rejected for being foreign to a vendor.
//! - A driver whose vendor returns a label, not a distribution, reports
//! `DecisionOutcome::calibrated = false` and encodes the label one-hot with
//! the `DecisionAnswer::*_label` constructors. It never invents the numbers
//! in between.
//! - Limits are declared, not discovered: the router checks a request against
//! them before any round trip, so an oversized request is a clear local
//! error rather than a vendor 400.
use async_trait::async_trait;
use crate::decisions::{DecisionOutcome, DecisionQuestion, DecisionRequest, DecisionsService};
use crate::{AgentLoopError, Result};
/// Which primitives a vendor answers without translation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NativePrimitives {
/// Yes/no as a probability.
pub noul: bool,
/// One option from a set.
pub choice: bool,
/// A position along ordered levels.
pub score: bool,
}
impl NativePrimitives {
/// All three primitives are native.
pub const ALL: Self = Self {
noul: true,
choice: true,
score: true,
};
/// Only `choice` is native; the driver translates the other two.
pub const CHOICE_ONLY: Self = Self {
noul: false,
choice: true,
score: false,
};
/// Whether `question` is answered without translation.
pub fn covers(&self, question: &DecisionQuestion) -> bool {
match question {
DecisionQuestion::Noul { .. } => self.noul,
DecisionQuestion::Choice { .. } => self.choice,
DecisionQuestion::Score { .. } => self.score,
}
}
}
/// What a driver can do, declared up front.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DecisionDriverCapabilities {
/// Primitives the vendor answers natively. The rest are translated.
pub native: NativePrimitives,
/// Whether answers are measured distributions rather than labels.
pub calibrated: bool,
/// Whether the vendor accepts image content in the state.
pub image_state: bool,
/// Most questions one request may carry, when the vendor caps it.
pub max_questions: Option<usize>,
/// Most options (or levels) one question may carry.
pub max_options: Option<usize>,
/// Most bytes of serialized state one request may carry.
pub max_state_bytes: Option<usize>,
}
impl DecisionDriverCapabilities {
/// Capabilities with the given native primitives and calibration, no
/// image input, and no declared limits.
pub fn new(native: NativePrimitives, calibrated: bool) -> Self {
Self {
native,
calibrated,
..Self::default()
}
}
/// Declare image support.
pub fn with_image_state(mut self, image_state: bool) -> Self {
self.image_state = image_state;
self
}
/// Declare the per-request question cap.
pub fn with_max_questions(mut self, max: usize) -> Self {
self.max_questions = Some(max);
self
}
/// Declare the per-question option (or level) cap.
pub fn with_max_options(mut self, max: usize) -> Self {
self.max_options = Some(max);
self
}
/// Declare the per-request state size cap.
pub fn with_max_state_bytes(mut self, max: usize) -> Self {
self.max_state_bytes = Some(max);
self
}
/// Reject `request` locally when it exceeds a declared limit.
pub fn check(&self, driver: &str, request: &DecisionRequest) -> Result<()> {
if request.is_empty() {
return Err(AgentLoopError::llm(
"decision request must carry at least one question",
));
}
if let Some(max) = self.max_questions
&& request.len() > max
{
return Err(AgentLoopError::llm(format!(
"decision driver '{driver}' accepts at most {max} questions per request, got {}",
request.len()
)));
}
if let Some(max) = self.max_options {
for (id, question) in &request.questions {
let count = match question {
DecisionQuestion::Noul { .. } => 2,
DecisionQuestion::Choice { options, .. } => options.len(),
DecisionQuestion::Score { levels, .. } => levels.len(),
};
if count > max {
return Err(AgentLoopError::llm(format!(
"decision driver '{driver}' accepts at most {max} options per question; \
'{id}' has {count}"
)));
}
}
}
if let Some(max) = self.max_state_bytes {
let bytes = match &request.state {
serde_json::Value::String(text) => text.len(),
other => other.to_string().len(),
};
if bytes > max {
return Err(AgentLoopError::llm(format!(
"decision driver '{driver}' accepts at most {max} bytes of state, got {bytes}"
)));
}
}
Ok(())
}
}
/// One vendor's transport for typed decisions.
///
/// Credentials belong to the driver and never appear in a request: a driver is
/// composed by the deployment or the embedding application
/// (THREAT[TM-LLM-037]).
#[async_trait]
pub trait DecisionDriver: Send + Sync {
/// Stable driver id, as used in `driver/model` routing and in
/// `DECISIONS_DRIVER`: `typesafe`, `openai`, `llm`.
fn id(&self) -> &str;
/// What this driver answers and within which limits.
fn capabilities(&self) -> DecisionDriverCapabilities;
/// Model-id prefixes this driver owns without an explicit `driver/` form,
/// such as `jev-` for TypeSafe. Empty means explicit routing only.
fn model_prefixes(&self) -> &[&str] {
&[]
}
/// Answer every question in `request`.
///
/// `request.model` is the vendor's own model id with any `driver/` prefix
/// already stripped, or `None` for the driver's default.
async fn evaluate(&self, request: DecisionRequest) -> Result<DecisionOutcome>;
}
/// Serve a single driver as a [`DecisionsService`].
///
/// For embedders that hold one vendor and want no registry: the Framework's
/// `Decisions::new(model, driver)` takes any service, and this makes a driver
/// one.
pub struct SingleDriverService<D>(pub D);
#[async_trait]
impl<D: DecisionDriver> DecisionsService for SingleDriverService<D> {
fn is_configured(&self) -> bool {
true
}
async fn evaluate(&self, request: DecisionRequest) -> Result<DecisionOutcome> {
self.0.capabilities().check(self.0.id(), &request)?;
self.0.evaluate(request).await
}
fn name(&self) -> &'static str {
"SingleDriverService"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request_with(questions: usize) -> DecisionRequest {
(0..questions).fold(DecisionRequest::new("state"), |request, index| {
request.ask(format!("q{index}"), DecisionQuestion::noul("Yes?"))
})
}
#[test]
fn limits_reject_oversized_requests_before_any_round_trip() {
let caps = DecisionDriverCapabilities::new(NativePrimitives::ALL, true)
.with_max_questions(2)
.with_max_options(3)
.with_max_state_bytes(10);
assert!(caps.check("x", &request_with(2)).is_ok());
let error = caps.check("x", &request_with(3)).unwrap_err();
assert!(error.to_string().contains("at most 2 questions"), "{error}");
let wide = DecisionRequest::new("s")
.ask("q", DecisionQuestion::score("How?", ["a", "b", "c", "d"]));
let error = caps.check("x", &wide).unwrap_err();
assert!(error.to_string().contains("'q' has 4"), "{error}");
let long = DecisionRequest::new("far more than ten bytes")
.ask("q", DecisionQuestion::noul("Yes?"));
let error = caps.check("x", &long).unwrap_err();
assert!(error.to_string().contains("bytes of state"), "{error}");
let error = caps.check("x", &DecisionRequest::new("s")).unwrap_err();
assert!(error.to_string().contains("at least one question"));
}
#[test]
fn native_primitives_cover_by_kind() {
let noul = DecisionQuestion::noul("Yes?");
let score = DecisionQuestion::score("How?", ["a", "b"]);
assert!(NativePrimitives::ALL.covers(&noul));
assert!(!NativePrimitives::CHOICE_ONLY.covers(&noul));
assert!(!NativePrimitives::CHOICE_ONLY.covers(&score));
}
}