sim_lib_agent_runner_core/
output.rs1use sim_kernel::{Expr, Symbol};
2use sim_shape::{GrammarDialect, GrammarGraph, Shape, shape_grammar_graph, shape_json_schema};
3
4use crate::grammar::shape_to_grammar;
5
6pub const RETURN_CODEC_EXTRA: &str = "return-codec";
8pub const RETURN_SHAPE_EXTRA: &str = "return-shape";
10pub const OUTPUT_GRAMMAR_EXTRA: &str = "output-grammar";
12pub const OUTPUT_GRAMMAR_DIALECT_EXTRA: &str = "output-grammar-dialect";
14pub const OUTPUT_GRAMMAR_REQUIRED_EXTRA: &str = "output-grammar-required";
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct OutputContract {
20 pub codec: Symbol,
22 pub shape_expr: Expr,
24 pub grammar: Option<String>,
26 pub grammar_dialect: Option<GrammarDialect>,
28 pub grammar_graph: Option<GrammarGraph>,
30 pub required: bool,
32}
33
34impl OutputContract {
35 pub fn new(codec: Symbol, shape_expr: Expr, grammar: Option<String>, required: bool) -> Self {
37 let grammar_dialect = grammar.as_ref().map(|_| GrammarDialect::JsonSchema);
38 Self {
39 codec,
40 shape_expr,
41 grammar,
42 grammar_dialect,
43 grammar_graph: None,
44 required,
45 }
46 }
47
48 pub fn for_shape(codec: Symbol, shape_expr: Expr, shape: &dyn Shape, required: bool) -> Self {
50 let grammar_graph = shape_grammar_graph(shape).ok();
51 let grammar = grammar_graph
52 .as_ref()
53 .and_then(|_| shape_json_schema(shape).ok())
54 .or_else(|| shape_to_grammar(shape).ok());
55 let grammar_dialect = grammar.as_ref().map(|_| GrammarDialect::JsonSchema);
56 Self {
57 codec,
58 shape_expr,
59 grammar,
60 grammar_dialect,
61 grammar_graph,
62 required,
63 }
64 }
65
66 pub fn into_extra_entries(self, extra: &mut Vec<(Expr, Expr)>) {
68 extra.push(entry(RETURN_CODEC_EXTRA, Expr::Symbol(self.codec)));
69 extra.push(entry(RETURN_SHAPE_EXTRA, self.shape_expr));
70 if let Some(grammar) = self.grammar {
71 extra.push(entry(OUTPUT_GRAMMAR_EXTRA, Expr::String(grammar)));
72 }
73 if let Some(dialect) = self.grammar_dialect {
74 extra.push(entry(
75 OUTPUT_GRAMMAR_DIALECT_EXTRA,
76 Expr::Symbol(grammar_dialect_symbol(dialect)),
77 ));
78 }
79 extra.push(entry(
80 OUTPUT_GRAMMAR_REQUIRED_EXTRA,
81 Expr::Bool(self.required),
82 ));
83 }
84}
85
86pub fn grammar_dialect_symbol(dialect: GrammarDialect) -> Symbol {
88 match dialect {
89 GrammarDialect::JsonSchema => Symbol::new("json-schema"),
90 GrammarDialect::Gbnf => Symbol::new("gbnf"),
91 GrammarDialect::SExpr => Symbol::new("sexpr"),
92 }
93}
94
95pub fn grammar_dialect_from_symbol(symbol: &Symbol) -> Option<GrammarDialect> {
97 match symbol.name.as_ref() {
98 "json-schema" if symbol.namespace.is_none() => Some(GrammarDialect::JsonSchema),
99 "gbnf" if symbol.namespace.is_none() => Some(GrammarDialect::Gbnf),
100 "sexpr" if symbol.namespace.is_none() => Some(GrammarDialect::SExpr),
101 _ => None,
102 }
103}
104
105fn entry(name: &str, value: Expr) -> (Expr, Expr) {
106 (Expr::Symbol(Symbol::new(name)), value)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::{
112 OUTPUT_GRAMMAR_DIALECT_EXTRA, OUTPUT_GRAMMAR_EXTRA, OUTPUT_GRAMMAR_REQUIRED_EXTRA,
113 OutputContract, RETURN_CODEC_EXTRA, RETURN_SHAPE_EXTRA,
114 };
115 use sim_kernel::{
116 Cx, Expr, MatchScore, Result, ShapeDoc, ShapeMatch, Symbol, Value, shape::Shape,
117 };
118 use sim_shape::{ExprKind, ExprKindShape};
119
120 struct NamedRecordShape;
121
122 impl Shape for NamedRecordShape {
123 fn symbol(&self) -> Option<Symbol> {
124 Some(Symbol::qualified("bridge", "NamedRecord"))
125 }
126
127 fn check_value(&self, _cx: &mut Cx, _value: Value) -> Result<ShapeMatch> {
128 Ok(ShapeMatch::accept(MatchScore::exact(1)))
129 }
130
131 fn check_expr(&self, _cx: &mut Cx, _expr: &Expr) -> Result<ShapeMatch> {
132 Ok(ShapeMatch::accept(MatchScore::exact(1)))
133 }
134
135 fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
136 Ok(ShapeDoc::new("named record"))
137 }
138 }
139
140 #[test]
141 fn supported_shape_attaches_grammar() {
142 let contract = OutputContract::for_shape(
143 Symbol::qualified("codec", "bridge"),
144 Expr::Symbol(Symbol::qualified("shape", "String")),
145 &ExprKindShape::new(ExprKind::String),
146 true,
147 );
148 let mut extra = Vec::new();
149 contract.into_extra_entries(&mut extra);
150
151 assert_eq!(
152 field(&extra, RETURN_CODEC_EXTRA),
153 Some(&Expr::Symbol(Symbol::qualified("codec", "bridge")))
154 );
155 assert_eq!(
156 field(&extra, RETURN_SHAPE_EXTRA),
157 Some(&Expr::Symbol(Symbol::qualified("shape", "String")))
158 );
159 assert_eq!(
160 field(&extra, OUTPUT_GRAMMAR_EXTRA),
161 Some(&Expr::String(r#"{"type":"string"}"#.to_owned()))
162 );
163 assert_eq!(
164 field(&extra, OUTPUT_GRAMMAR_DIALECT_EXTRA),
165 Some(&Expr::Symbol(Symbol::new("json-schema")))
166 );
167 assert_eq!(
168 field(&extra, OUTPUT_GRAMMAR_REQUIRED_EXTRA),
169 Some(&Expr::Bool(true))
170 );
171 }
172
173 #[test]
174 fn named_record_shape_omits_grammar_without_error() {
175 let contract = OutputContract::for_shape(
176 Symbol::qualified("codec", "bridge"),
177 Expr::Symbol(Symbol::qualified("bridge", "NamedRecord")),
178 &NamedRecordShape,
179 true,
180 );
181 let mut extra = Vec::new();
182 contract.into_extra_entries(&mut extra);
183
184 assert!(field(&extra, OUTPUT_GRAMMAR_EXTRA).is_none());
185 assert!(field(&extra, OUTPUT_GRAMMAR_DIALECT_EXTRA).is_none());
186 assert_eq!(
187 field(&extra, RETURN_SHAPE_EXTRA),
188 Some(&Expr::Symbol(Symbol::qualified("bridge", "NamedRecord")))
189 );
190 assert_eq!(
191 field(&extra, OUTPUT_GRAMMAR_REQUIRED_EXTRA),
192 Some(&Expr::Bool(true))
193 );
194 }
195
196 fn field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
197 entries.iter().find_map(|(key, value)| {
198 if *key == Expr::Symbol(Symbol::new(name)) {
199 Some(value)
200 } else {
201 None
202 }
203 })
204 }
205}