Skip to main content

sim_lib_agent_runner_core/
expr.rs

1use crate::{ModelBid, ModelCard, ModelEvent, ModelRequest, ModelResponse, ModelUsage};
2use sim_codec_chat::{
3    is_model_request_expr, model_card_expr, model_request_messages_expr, model_response_expr,
4    validate_chat_transcript,
5};
6use sim_kernel::{Error, Expr, Result, Symbol};
7
8impl From<ModelRequest> for Expr {
9    fn from(value: ModelRequest) -> Self {
10        let mut entries = vec![
11            key_bool("model-request", true),
12            key_expr("task", value.task),
13            key_expr("messages", Expr::List(value.messages)),
14        ];
15        entries.extend(value.extra);
16        Expr::Map(entries)
17    }
18}
19
20impl TryFrom<Expr> for ModelRequest {
21    type Error = Error;
22
23    fn try_from(value: Expr) -> Result<Self> {
24        if !is_model_request_expr(&value) {
25            return Err(Error::Eval(
26                "model request transcript must set model-request true".to_owned(),
27            ));
28        }
29        validate_chat_transcript(&value)?;
30        Ok(Self {
31            task: require_field(&value, "task")?.clone(),
32            messages: model_request_messages_expr(&value)?.to_vec(),
33            extra: extra_fields(&value, &["model-request", "task", "messages"]),
34        })
35    }
36}
37
38impl From<ModelResponse> for Expr {
39    fn from(value: ModelResponse) -> Self {
40        let mut expr = match model_response_expr(
41            value.runner,
42            value.model,
43            value.content,
44            value.stop_reason,
45        ) {
46            Expr::Map(entries) => entries,
47            _ => unreachable!("model_response_expr always returns a map"),
48        };
49        if let Some(usage) = value.usage {
50            expr.push(key_expr("usage", usage.into()));
51        }
52        expr.extend(value.extra);
53        Expr::Map(expr)
54    }
55}
56
57impl TryFrom<Expr> for ModelResponse {
58    type Error = Error;
59
60    fn try_from(value: Expr) -> Result<Self> {
61        validate_chat_transcript(&value)?;
62        expect_marker(&value, "model-response")?;
63        Ok(Self {
64            runner: require_symbol_field(&value, "runner")?.clone(),
65            model: require_string_field(&value, "model")?.to_owned(),
66            content: require_list_field(&value, "content")?.to_vec(),
67            stop_reason: require_symbol_field(&value, "stop-reason")?.clone(),
68            usage: field(&value, "usage")
69                .map(ModelUsage::from_expr)
70                .transpose()?,
71            extra: extra_fields(
72                &value,
73                &[
74                    "model-response",
75                    "runner",
76                    "model",
77                    "content",
78                    "stop-reason",
79                    "usage",
80                ],
81            ),
82        })
83    }
84}
85
86impl From<ModelUsage> for Expr {
87    fn from(value: ModelUsage) -> Self {
88        let mut entries = Vec::new();
89        if let Some(tokens) = value.input_tokens {
90            entries.push(key_expr("input-tokens", number_expr(tokens)));
91        }
92        if let Some(tokens) = value.output_tokens {
93            entries.push(key_expr("output-tokens", number_expr(tokens)));
94        }
95        if let Some(latency) = value.latency_ms {
96            entries.push(key_expr("latency-ms", number_expr(latency)));
97        }
98        if let Some(cost) = value.cost_usd {
99            entries.push(key_expr("cost-usd", float_expr(cost)));
100        }
101        entries.extend(value.extra);
102        Expr::Map(entries)
103    }
104}
105
106impl ModelUsage {
107    fn from_expr(value: &Expr) -> Result<Self> {
108        let Expr::Map(_) = value else {
109            return Err(Error::Eval("model usage must be a map".to_owned()));
110        };
111        Ok(Self {
112            input_tokens: field(value, "input-tokens").map(parse_u64).transpose()?,
113            output_tokens: field(value, "output-tokens").map(parse_u64).transpose()?,
114            latency_ms: field(value, "latency-ms").map(parse_u64).transpose()?,
115            cost_usd: field(value, "cost-usd").map(parse_f64).transpose()?,
116            extra: extra_fields(
117                value,
118                &["input-tokens", "output-tokens", "latency-ms", "cost-usd"],
119            ),
120        })
121    }
122}
123
124impl From<ModelEvent> for Expr {
125    fn from(value: ModelEvent) -> Self {
126        let mut entries = vec![
127            key_bool("model-event", true),
128            key_expr("event", Expr::Symbol(value.event)),
129            key_expr("runner", Expr::Symbol(value.runner)),
130            key_expr("model", Expr::String(value.model)),
131            key_expr("span-id", value.span_id),
132        ];
133        if let Some(response) = value.response {
134            entries.push(key_expr("response", response.into()));
135        }
136        entries.extend(value.extra);
137        Expr::Map(entries)
138    }
139}
140
141impl TryFrom<Expr> for ModelEvent {
142    type Error = Error;
143
144    fn try_from(value: Expr) -> Result<Self> {
145        validate_chat_transcript(&value)?;
146        expect_marker(&value, "model-event")?;
147        Ok(Self {
148            event: require_symbol_field(&value, "event")?.clone(),
149            runner: require_symbol_field(&value, "runner")?.clone(),
150            model: require_string_field(&value, "model")?.to_owned(),
151            span_id: require_field(&value, "span-id")?.clone(),
152            response: field(&value, "response")
153                .cloned()
154                .map(ModelResponse::try_from)
155                .transpose()?,
156            extra: extra_fields(
157                &value,
158                &[
159                    "model-event",
160                    "event",
161                    "runner",
162                    "model",
163                    "span-id",
164                    "response",
165                ],
166            ),
167        })
168    }
169}
170
171impl From<ModelCard> for Expr {
172    fn from(value: ModelCard) -> Self {
173        let mut expr =
174            match model_card_expr(value.runner, value.model, value.provider, value.locality) {
175                Expr::Map(entries) => entries,
176                _ => unreachable!("model_card_expr always returns a map"),
177            };
178        expr.extend(value.extra);
179        Expr::Map(expr)
180    }
181}
182
183impl TryFrom<Expr> for ModelCard {
184    type Error = Error;
185
186    fn try_from(value: Expr) -> Result<Self> {
187        validate_chat_transcript(&value)?;
188        expect_marker(&value, "model-card")?;
189        Ok(Self {
190            runner: require_symbol_field(&value, "runner")?.clone(),
191            model: require_string_field(&value, "model")?.to_owned(),
192            provider: require_symbol_field(&value, "provider")?.clone(),
193            locality: require_symbol_field(&value, "locality")?.clone(),
194            extra: extra_fields(
195                &value,
196                &["model-card", "runner", "model", "provider", "locality"],
197            ),
198        })
199    }
200}
201
202impl From<ModelBid> for Expr {
203    fn from(value: ModelBid) -> Self {
204        let mut entries = vec![key_bool("model-bid", value.available)];
205        if let Some(reason) = value.reason {
206            entries.push(key_expr("reason", Expr::String(reason)));
207        }
208        if let Some(score) = value.score {
209            entries.push(key_expr("score", float_expr(score)));
210        }
211        if let Some(model) = value.model {
212            entries.push(key_expr("model", Expr::String(model)));
213        }
214        entries.extend(value.extra);
215        Expr::Map(entries)
216    }
217}
218
219impl TryFrom<Expr> for ModelBid {
220    type Error = Error;
221
222    fn try_from(value: Expr) -> Result<Self> {
223        Ok(Self {
224            available: match field(&value, "model-bid") {
225                Some(Expr::Bool(flag)) => *flag,
226                _ => {
227                    return Err(Error::Eval(
228                        "chat transcript must set model-bid to a bool".to_owned(),
229                    ));
230                }
231            },
232            reason: field(&value, "reason")
233                .map(require_string)
234                .transpose()?
235                .map(str::to_owned),
236            score: field(&value, "score").map(parse_f64).transpose()?,
237            model: field(&value, "model")
238                .map(require_string)
239                .transpose()?
240                .map(str::to_owned),
241            extra: extra_fields(&value, &["model-bid", "reason", "score", "model"]),
242        })
243    }
244}
245
246use sim_value::access::field;
247
248fn require_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Expr> {
249    field(expr, name).ok_or_else(|| Error::Eval(format!("chat transcript missing {name} field")))
250}
251
252fn require_list_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a [Expr]> {
253    match require_field(expr, name)? {
254        Expr::List(items) => Ok(items),
255        _ => Err(Error::Eval(format!(
256            "chat transcript {name} field must be a list"
257        ))),
258    }
259}
260
261fn require_symbol_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Symbol> {
262    match require_field(expr, name)? {
263        Expr::Symbol(symbol) => Ok(symbol),
264        _ => Err(Error::Eval(format!(
265            "chat transcript {name} field must be a symbol"
266        ))),
267    }
268}
269
270fn require_string_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a str> {
271    require_string(require_field(expr, name)?)
272}
273
274fn require_string(expr: &Expr) -> Result<&str> {
275    match expr {
276        Expr::String(text) => Ok(text),
277        _ => Err(Error::Eval(
278            "chat transcript field must be a string".to_owned(),
279        )),
280    }
281}
282
283fn expect_marker(expr: &Expr, name: &str) -> Result<()> {
284    if matches!(field(expr, name), Some(Expr::Bool(true))) {
285        Ok(())
286    } else {
287        Err(Error::Eval(format!("chat transcript must set {name} true")))
288    }
289}
290
291fn extra_fields(expr: &Expr, known: &[&str]) -> Vec<(Expr, Expr)> {
292    let Expr::Map(entries) = expr else {
293        return Vec::new();
294    };
295    entries
296        .iter()
297        .filter(|(key, _)| !is_known_field(key, known))
298        .cloned()
299        .collect()
300}
301
302fn is_known_field(key: &Expr, known: &[&str]) -> bool {
303    match key {
304        Expr::Symbol(symbol) if symbol.namespace.is_none() => {
305            known.iter().any(|name| symbol.name.as_ref() == *name)
306        }
307        _ => false,
308    }
309}
310
311fn key_bool(name: &str, value: bool) -> (Expr, Expr) {
312    key_expr(name, Expr::Bool(value))
313}
314
315use sim_value::build::entry as key_expr;
316
317fn number_expr(value: u64) -> Expr {
318    sim_value::build::num_q(Some("numbers"), "f64", &value.to_string())
319}
320
321fn float_expr(value: f64) -> Expr {
322    sim_value::build::num_q(Some("numbers"), "f64", &value.to_string())
323}
324
325fn parse_u64(expr: &Expr) -> Result<u64> {
326    match expr {
327        Expr::Number(number) => number
328            .canonical
329            .parse()
330            .map_err(|_| Error::Eval("numeric transcript field must be an integer".to_owned())),
331        _ => Err(Error::Eval(
332            "numeric transcript field must be a number literal".to_owned(),
333        )),
334    }
335}
336
337fn parse_f64(expr: &Expr) -> Result<f64> {
338    match expr {
339        Expr::Number(number) => number
340            .canonical
341            .parse()
342            .map_err(|_| Error::Eval("numeric transcript field must be numeric".to_owned())),
343        _ => Err(Error::Eval(
344            "numeric transcript field must be a number literal".to_owned(),
345        )),
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use crate::{ModelBid, ModelCard, ModelEvent, ModelRequest, ModelResponse, ModelUsage};
352    use sim_codec_chat::validate_chat_transcript;
353    use sim_kernel::{Expr, Symbol};
354
355    #[test]
356    fn model_request_round_trips() {
357        let mut request = ModelRequest::new(
358            Expr::String("summarize".to_owned()),
359            vec![Expr::Map(vec![
360                (
361                    Expr::Symbol(Symbol::new("role")),
362                    Expr::Symbol(Symbol::new("user")),
363                ),
364                (
365                    Expr::Symbol(Symbol::new("content")),
366                    Expr::List(vec![Expr::Map(vec![
367                        (
368                            Expr::Symbol(Symbol::new("type")),
369                            Expr::Symbol(Symbol::new("text")),
370                        ),
371                        (
372                            Expr::Symbol(Symbol::new("text")),
373                            Expr::String("hello".to_owned()),
374                        ),
375                    ])]),
376                ),
377            ])],
378        );
379        request.extra.push((
380            Expr::Symbol(Symbol::new("metadata")),
381            Expr::Map(vec![(
382                Expr::Symbol(Symbol::new("ticket")),
383                Expr::String("A5".to_owned()),
384            )]),
385        ));
386
387        let expr: Expr = request.clone().into();
388        validate_chat_transcript(&expr).unwrap();
389        let decoded = ModelRequest::try_from(expr).unwrap();
390        assert_eq!(decoded, request);
391    }
392
393    #[test]
394    fn model_response_usage_round_trips() {
395        let mut response = ModelResponse::new(
396            Symbol::new("runner"),
397            "fake/model",
398            vec![Expr::Map(vec![
399                (
400                    Expr::Symbol(Symbol::new("type")),
401                    Expr::Symbol(Symbol::new("text")),
402                ),
403                (
404                    Expr::Symbol(Symbol::new("text")),
405                    Expr::String("done".to_owned()),
406                ),
407            ])],
408            Symbol::new("stop"),
409        );
410        response.usage = Some(ModelUsage {
411            input_tokens: Some(10),
412            output_tokens: Some(2),
413            latency_ms: Some(5),
414            cost_usd: Some(0.0),
415            extra: Vec::new(),
416        });
417        response
418            .extra
419            .push((Expr::Symbol(Symbol::new("shape-ok")), Expr::Bool(true)));
420
421        let expr: Expr = response.clone().into();
422        validate_chat_transcript(&expr).unwrap();
423        let decoded = ModelResponse::try_from(expr).unwrap();
424        assert_eq!(decoded, response);
425    }
426
427    #[test]
428    fn model_event_card_and_bid_round_trip() {
429        let mut event = ModelEvent::new(
430            Symbol::new("usage"),
431            Symbol::new("runner"),
432            "fake/model",
433            Expr::String("span-1".to_owned()),
434        );
435        event.extra.push((
436            Expr::Symbol(Symbol::new("detail")),
437            Expr::String("tracked".to_owned()),
438        ));
439        let expr: Expr = event.clone().into();
440        validate_chat_transcript(&expr).unwrap();
441        assert_eq!(ModelEvent::try_from(expr).unwrap(), event);
442
443        let mut card = ModelCard::new(
444            Symbol::new("runner"),
445            "fake/model",
446            Symbol::new("fake"),
447            Symbol::new("local"),
448        );
449        card.extra.push((
450            Expr::Symbol(Symbol::new("supports-shape")),
451            Expr::Bool(true),
452        ));
453        let expr: Expr = card.into();
454        validate_chat_transcript(&expr).unwrap();
455        let decoded = ModelCard::try_from(expr).unwrap();
456        assert_eq!(decoded.runner, Symbol::new("runner"));
457        assert_eq!(decoded.model, "fake/model");
458        assert!(decoded.extra.iter().any(|(key, value)| {
459            *key == Expr::Symbol(Symbol::new("supports-shape")) && *value == Expr::Bool(true)
460        }));
461
462        let bid = ModelBid::unavailable("busy");
463        let expr: Expr = bid.clone().into();
464        assert_eq!(ModelBid::try_from(expr).unwrap().reason, bid.reason);
465    }
466}