domarinn_protocol/lib.rs
1//! The exec JSON protocol shared by providers, asserts, and generators.
2//!
3//! One protocol, three kinds. v1 is one-shot: domarinn writes exactly one JSON
4//! request to the child's stdin, closes it, and reads one JSON document from
5//! stdout. The `domarinn.protocol` field makes the envelope evolvable.
6//!
7//! # Compatibility
8//!
9//! Every optional field is `skip_serializing_if`, so a program written against
10//! an earlier build of this crate emits a byte-identical document and is parsed
11//! identically. New fields are added the same way and the wire version stays
12//! `1`. Both sides ignore unknown fields; that is the whole forward-compat
13//! story, and it is why none of these types use `deny_unknown_fields`.
14//!
15//! # Scope
16//!
17//! Serde shapes only — no I/O, no engine, no schema generation. See this
18//! crate's README for why it is separate from `domarinn-types`, and
19//! `docs/protocol.md` in the repository for the normative field tables.
20
21use serde::{Deserialize, Serialize};
22use serde_json::Value as Json;
23
24pub const PROTOCOL_VERSION: u32 = 1;
25pub const PROTOCOL_ENV: &str = "DOMARINN_PROTOCOL";
26
27/// The envelope every request carries.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Envelope {
30 pub protocol: u32,
31 pub kind: Kind,
32}
33
34impl Envelope {
35 pub fn new(kind: Kind) -> Self {
36 Envelope {
37 protocol: PROTOCOL_VERSION,
38 kind,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Kind {
46 Provider,
47 Assert,
48 GenerateTests,
49}
50
51/// A provider request written to the child's stdin.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ProviderReq {
54 #[serde(rename = "domarinn")]
55 pub envelope: Envelope,
56 /// `None` when the suite has no prompts (self-input case).
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub prompt: Option<Json>,
59 #[serde(default)]
60 pub vars: Json,
61 #[serde(default)]
62 pub params: Json,
63 pub test: TestRef,
64 /// Tools the suite declared for this call, or empty when it declared none.
65 ///
66 /// Offering them is the child's job; domarinn does not run an agent loop
67 /// and never executes a tool. What it wants back is `tool_calls` — the
68 /// *decision* the model made, which is the thing an evaluation grades.
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub tools: Vec<ToolDef>,
71}
72
73/// A tool the model may call, as declared by the suite.
74///
75/// Anthropic's field names, because `input_schema` says what the value is (a
76/// JSON Schema) where OpenAI's `parameters` does not, and one of the two shapes
77/// had to win. Mapping to the other vendor is mechanical.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ToolDef {
80 pub name: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub description: Option<String>,
83 /// JSON Schema for the tool's arguments.
84 #[serde(default)]
85 pub input_schema: Json,
86}
87
88/// One tool call the model decided to make.
89///
90/// `arguments` is the decoded object, not the raw JSON string some vendors put
91/// on the wire — a child that forwards the string verbatim is handing every
92/// assertion a parsing problem instead of an argument.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ToolCall {
95 /// The vendor's call id, when there is one. Carried so a multi-call
96 /// response stays attributable; never interpreted here.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub id: Option<String>,
99 pub name: String,
100 #[serde(default)]
101 pub arguments: Json,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct TestRef {
106 pub id: String,
107 #[serde(default)]
108 pub tags: Vec<String>,
109}
110
111/// A provider response read from the child's stdout. Only `output` is required.
112///
113/// `Default` is derived so a child can write `ProviderResp { output, ..Default::default() }`
114/// and stay correct as optional fields are added — the shape a provider author
115/// reaches for first should not have to enumerate fields it does not use.
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct ProviderResp {
118 pub output: Json,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub usage: Option<Usage>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub cost_usd: Option<f64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub error: Option<ProtocolError>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub metadata: Option<Json>,
127
128 /// The vendor's finish reason, verbatim (`end_turn`, `length`, `refusal`,
129 /// …). Free-form: this crate never checks it against a list, because the
130 /// list grows at model-release cadence with no domarinn release in the loop.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub stop_reason: Option<String>,
133
134 /// Why `output` has nothing gradeable in it, when it does not.
135 ///
136 /// Set this when you know — a refusal, a truncation, a tool call with no
137 /// prose — rather than returning an empty string and letting every
138 /// assertion score zero for a reason that has nothing to do with the
139 /// prompt. See `docs/protocol.md` for the known values; an unrecognized one
140 /// is carried through verbatim and is never an error.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub empty_reason: Option<String>,
143
144 /// The model's reasoning or thinking text, when the child can expose it.
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub reasoning: Option<String>,
147
148 /// The model actually used, as opposed to the one requested — an alias that
149 /// silently repoints to a new snapshot has no other signal.
150 ///
151 /// Response metadata, not request identity: it never enters a cache key,
152 /// because a key cannot depend on something learned after the call.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub model: Option<String>,
155
156 /// The tool calls the model made, in the order it made them.
157 ///
158 /// Report these whenever the model called a tool, *including* when it also
159 /// produced text. A case whose right answer is a tool call has no gradeable
160 /// prose, and returning `""` scores it zero for a reason unrelated to the
161 /// prompt — set `empty_reason: "tool_use_only"` alongside these when that
162 /// is what happened.
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub tool_calls: Vec<ToolCall>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct Usage {
169 #[serde(default)]
170 pub input_tokens: u64,
171 #[serde(default)]
172 pub output_tokens: u64,
173 /// Input tokens served from a provider-side prompt cache.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub cache_read_tokens: Option<u64>,
176 /// Input tokens written *into* a provider-side prompt cache. Billed at a
177 /// premium over an ordinary input token, so a harness that cannot see this
178 /// under-reports cost on exactly the calls that populate the cache.
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub cache_write_tokens: Option<u64>,
181 /// The subset of `cache_write_tokens` written at a longer-lived TTL, when
182 /// the provider reports the split. Absent means "all at the default TTL".
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub cache_write_1h_tokens: Option<u64>,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188pub struct ProtocolError {
189 pub message: String,
190 #[serde(default)]
191 pub retriable: bool,
192
193 /// Structured diagnostics for the failure — the machine-readable half of
194 /// `message`.
195 ///
196 /// Worth its own field because `message` is prose: without this, a child
197 /// with anything structured to say has to format JSON into a sentence and
198 /// hope the reader parses it back out.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub details: Option<Json>,
201
202 /// What kind of failure this was, using domarinn's error-class vocabulary
203 /// (`provider_auth`, `provider_rate_limit`, `provider_timeout`, …).
204 ///
205 /// Without it every exec failure is indistinguishable from every other, so
206 /// a child that knows perfectly well its credential was rejected cannot say
207 /// so. Unrecognized values are kept verbatim, same as `empty_reason`.
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub class: Option<String>,
210
211 /// How long to wait before retrying, in milliseconds — a `Retry-After` the
212 /// child received and would otherwise have to swallow. Only meaningful
213 /// alongside `retriable: true`.
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub retry_after_ms: Option<u64>,
216}
217
218/// An assert request (GradingResult-shaped response).
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct AssertReq {
221 #[serde(rename = "domarinn")]
222 pub envelope: Envelope,
223 pub output: Json,
224 pub test: TestRef,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub prompt: Option<Json>,
227 pub provider: ProviderRef,
228 #[serde(default)]
229 pub config: Json,
230 /// The case's rendered variables.
231 ///
232 /// An assertion frequently needs the inputs to judge the output — an
233 /// expected value, a scope, the id of whatever was being asked about — and
234 /// without them a child can only grade the text in isolation.
235 #[serde(default)]
236 pub vars: Json,
237
238 /// The tool calls the model made, in the order it made them; omitted when
239 /// there were none.
240 ///
241 /// The first time [`ToolCall`] travels domarinn → child rather than the
242 /// other way round. An assertion about *behaviour* — did it call `search`
243 /// before answering, with the scope it was given? — is unanswerable from
244 /// `output` alone, because a cell whose right answer is a tool call has no
245 /// prose to read.
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub tool_calls: Vec<ToolCall>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct ProviderRef {
252 pub id: String,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct AssertResp {
257 pub pass: bool,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub score: Option<f64>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub reason: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub details: Option<Json>,
264}
265
266/// A generate-tests request.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct GenerateReq {
269 #[serde(rename = "domarinn")]
270 pub envelope: Envelope,
271 #[serde(default)]
272 pub config: Json,
273}
274
275/// A generate-tests response (JSON object form; JSONL is also accepted).
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct GenerateResp {
278 pub tests: Vec<Json>,
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn provider_request_round_trips() {
287 let req = ProviderReq {
288 envelope: Envelope::new(Kind::Provider),
289 prompt: None,
290 vars: serde_json::json!({"x": 1}),
291 params: serde_json::json!({}),
292 test: TestRef {
293 id: "t".into(),
294 tags: vec!["a".into()],
295 },
296 tools: vec![],
297 };
298 let s = serde_json::to_string(&req).unwrap();
299 assert!(s.contains("\"domarinn\""));
300 assert!(s.contains("\"protocol\":1"));
301 let back: ProviderReq = serde_json::from_str(&s).unwrap();
302 assert_eq!(back.test.id, "t");
303 }
304
305 /// The byte-shape guard behind the compatibility promise in the module doc:
306 /// a response that sets nothing but `output` must not grow keys on the wire.
307 /// Every future optional field has to keep this passing.
308 #[test]
309 fn a_minimal_response_serializes_to_exactly_output() {
310 let resp = ProviderResp {
311 output: Json::String("hi".into()),
312 ..Default::default()
313 };
314 assert_eq!(serde_json::to_string(&resp).unwrap(), r#"{"output":"hi"}"#);
315 }
316
317 /// The other half of forward compatibility: a document from a *newer*
318 /// domarinn must parse here rather than erroring, or an upgrade on one side
319 /// breaks every provider built against the other.
320 #[test]
321 fn unknown_response_fields_are_ignored() {
322 let resp: ProviderResp =
323 serde_json::from_str(r#"{"output":"hi","invented_later":{"a":1}}"#).unwrap();
324 assert_eq!(resp.output, Json::String("hi".into()));
325 }
326
327 fn minimal_assert_req() -> AssertReq {
328 AssertReq {
329 envelope: Envelope::new(Kind::Assert),
330 output: Json::String("hi".into()),
331 test: TestRef {
332 id: "t".into(),
333 tags: vec![],
334 },
335 prompt: None,
336 provider: ProviderRef { id: "p".into() },
337 config: Json::Null,
338 vars: serde_json::json!({}),
339 tool_calls: vec![],
340 }
341 }
342
343 /// The same byte-shape guard as `a_minimal_response_serializes_to_exactly_output`,
344 /// applied to the request side: a cell whose model called nothing must send
345 /// no `tool_calls` member. The exec-assert cache key is built from this
346 /// document, so a member that appeared unconditionally would re-key every
347 /// entry in every store for a field the child never reads.
348 #[test]
349 fn an_assert_request_without_tool_calls_does_not_mention_them() {
350 let s = serde_json::to_string(&minimal_assert_req()).unwrap();
351 assert!(!s.contains("tool_calls"), "{s}");
352 }
353
354 #[test]
355 fn an_assert_request_round_trips_its_tool_calls() {
356 let req = AssertReq {
357 tool_calls: vec![ToolCall {
358 id: Some("toolu_1".into()),
359 name: "get_weather".into(),
360 arguments: serde_json::json!({"city": "Reykjavik"}),
361 }],
362 ..minimal_assert_req()
363 };
364 let s = serde_json::to_string(&req).unwrap();
365 let back: AssertReq = serde_json::from_str(&s).unwrap();
366 assert_eq!(back.tool_calls.len(), 1);
367 assert_eq!(back.tool_calls[0].name, "get_weather");
368 assert_eq!(back.tool_calls[0].arguments["city"], "Reykjavik");
369 assert_eq!(back.tool_calls[0].id.as_deref(), Some("toolu_1"));
370 }
371
372 #[test]
373 fn usage_defaults_both_counts_to_zero() {
374 let usage: Usage = serde_json::from_str("{}").unwrap();
375 assert_eq!(usage.input_tokens, 0);
376 assert_eq!(usage.output_tokens, 0);
377 }
378}