Skip to main content

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}