Skip to main content

warble_claude_code/
slots.rs

1//! Slot resolution — turning IR 0.7's declared slots into the text a model actually receives.
2//!
3//! The IR carries every variant and selects none; selecting is the host's job, and so is answering a
4//! slot's `present_when`. This module is the seam between the two: a host hands in a table, and every
5//! `{{ slot.<name> }}` in the prompt text is replaced before anything is emitted.
6//!
7//! **Without this the placeholder ships verbatim.** The compiler's template-syntax check cannot
8//! catch that — `{{ slot.x }}` is valid syntax that simply had no consumer downstream — so the
9//! failure was silent, which is why [`assert_no_slot_references`] exists and is not optional.
10//!
11//! The scanner mirrors the compiler's `double_brace_bodies` / `is_slot_name` rather than inventing a
12//! pattern of its own: the compiler decides what counts as a reference, and a consumer that
13//! disagreed would either substitute something the compiler never checked or leave behind something
14//! the compiler assumed was handled.
15
16use std::collections::{BTreeMap, HashMap, HashSet};
17
18use crate::error::DispatchError;
19use crate::ir::SlotDecl;
20
21/// What a host says about each slot, keyed by slot name.
22///
23/// `Some(variant)` renders that variant; `None` removes the slot because its `present_when` does not
24/// hold; a name absent from the map means the host has no opinion and the declared `default` is
25/// used.
26///
27/// A plain table rather than a callback, so what the model was told is decided before the run rather
28/// than during it: it can be recorded, compared and fingerprinted.
29pub type SlotSupply = BTreeMap<String, Option<String>>;
30
31/// Whether `name` is a well-formed slot name: `[a-z_][a-z0-9_]*`. Mirrors the compiler's
32/// `is_slot_name`.
33fn is_slot_name(name: &str) -> bool {
34    let mut chars = name.chars();
35    match chars.next() {
36        Some(c) if c.is_ascii_lowercase() || c == '_' => {}
37        _ => return false,
38    }
39    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
40}
41
42/// Byte spans of every well-formed `{{ slot.<name> }}` in `raw`, in order, with the name.
43///
44/// Mirrors the compiler's `double_brace_bodies`, including its treatment of an unterminated `{{` as
45/// yielding nothing: it cannot be read as a reference, and guessing at the author's intent is worse
46/// than leaving it alone. A `{{ … }}` whose body is not a slot reference (`{{project}}`, say) is not
47/// returned — that is somebody else's substitution and must survive this pass untouched.
48fn slot_references(raw: &str) -> Vec<(usize, usize, String)> {
49    let mut found = Vec::new();
50    let mut cursor = 0usize;
51    while let Some(open) = raw[cursor..].find("{{") {
52        let start = cursor + open;
53        let body_start = start + 2;
54        let Some(close) = raw[body_start..].find("}}") else {
55            return found;
56        };
57        let end = body_start + close + 2;
58        let body = raw[body_start..body_start + close].trim();
59        if let Some(name) = body.strip_prefix("slot.") {
60            if is_slot_name(name) {
61                found.push((start, end, name.to_string()));
62            }
63        }
64        cursor = end;
65    }
66    found
67}
68
69/// Resolve every slot in `decls` to the text that replaces its references, or to `None` when the
70/// host said it is not present.
71///
72/// A chosen variant may itself reference other slots — the compiler collects references from variant
73/// text and validates them, so this is authored-legal — hence the recursion, with a visiting set so
74/// a cycle is reported rather than spun on.
75///
76/// `decls` is every slot in scope for the text being resolved: the profile's plus the component's,
77/// together. The compiler checks the two layers separately but folds the profile's `system_prompt`
78/// into each component's `brief`, so by the time a consumer sees the IR one string carries
79/// references from both. Names are unique project-wide, which is what makes one table unambiguous.
80pub fn resolve_slots(
81    decls: &[SlotDecl],
82    supply: &SlotSupply,
83    scope: &str,
84) -> Result<HashMap<String, Option<String>>, DispatchError> {
85    let by_name: HashMap<&str, &SlotDecl> = decls.iter().map(|d| (d.name.as_str(), d)).collect();
86    let mut resolved: HashMap<String, Option<String>> = HashMap::new();
87    let mut visiting: Vec<String> = Vec::new();
88    for decl in decls {
89        resolve_one(
90            &decl.name,
91            &by_name,
92            supply,
93            scope,
94            &mut resolved,
95            &mut visiting,
96        )?;
97    }
98    Ok(resolved)
99}
100
101fn resolve_one(
102    name: &str,
103    by_name: &HashMap<&str, &SlotDecl>,
104    supply: &SlotSupply,
105    scope: &str,
106    resolved: &mut HashMap<String, Option<String>>,
107    visiting: &mut Vec<String>,
108) -> Result<Option<String>, DispatchError> {
109    if let Some(done) = resolved.get(name) {
110        return Ok(done.clone());
111    }
112    if visiting.iter().any(|v| v == name) {
113        let mut chain = visiting.clone();
114        chain.push(name.to_string());
115        return Err(DispatchError(format!(
116            "slot '{name}' in {scope} is defined in terms of itself: a variant's text references a \
117             slot whose own variant references it back (chain: {}).",
118            chain.join(" -> ")
119        )));
120    }
121    let decl = by_name
122        .get(name)
123        .expect("caller resolves declared names only");
124    let choice = supply.get(name);
125
126    if let Some(None) = choice {
127        resolved.insert(name.to_string(), None);
128        return Ok(None);
129    }
130    if choice.is_none() && decl.present_when.is_some() {
131        // `default` covers "the host has no opinion on the wording". It cannot cover "the host has
132        // no opinion on whether this should exist" — a slot is conditional precisely because its
133        // text describes something that may have been withheld, and instructions for a withheld
134        // capability are worse than no instructions. So an unanswered condition is a loud failure.
135        return Err(DispatchError(format!(
136            "slot '{name}' in {scope} declares a present_when condition, and nothing answered it. \
137             Supply the slot as a variant name to include it, or as absent-with-null to remove it; \
138             falling back to the default would ship wording for something that may not be there."
139        )));
140    }
141    let key = match choice {
142        Some(Some(variant)) => variant.as_str(),
143        _ => decl.default.as_str(),
144    };
145    let Some(text) = decl.variants.get(key) else {
146        return Err(DispatchError(format!(
147            "slot '{name}' in {scope} was given variant '{key}', which it does not declare \
148             (declared: {}).",
149            decl.variants.keys().cloned().collect::<Vec<_>>().join(", ")
150        )));
151    };
152
153    visiting.push(name.to_string());
154    let expanded = substitute(text, by_name, supply, scope, resolved, visiting)?;
155    visiting.pop();
156    resolved.insert(name.to_string(), Some(expanded.clone()));
157    Ok(Some(expanded))
158}
159
160fn substitute(
161    raw: &str,
162    by_name: &HashMap<&str, &SlotDecl>,
163    supply: &SlotSupply,
164    scope: &str,
165    resolved: &mut HashMap<String, Option<String>>,
166    visiting: &mut Vec<String>,
167) -> Result<String, DispatchError> {
168    let refs = slot_references(raw);
169    if refs.is_empty() {
170        return Ok(raw.to_string());
171    }
172    let mut out = String::new();
173    let mut cursor = 0usize;
174    for (start, end, name) in refs {
175        if !by_name.contains_key(name.as_str()) {
176            return Err(DispatchError(format!(
177                "prompt text in {scope} references slot '{name}', which is neither one of the \
178                 component's slots nor one of the profile's."
179            )));
180        }
181        out.push_str(&raw[cursor..start]);
182        if let Some(text) = resolve_one(&name, by_name, supply, scope, resolved, visiting)? {
183            out.push_str(&text);
184        }
185        cursor = end;
186    }
187    out.push_str(&raw[cursor..]);
188    Ok(out)
189}
190
191/// Apply an already-resolved scope to one piece of prompt text.
192///
193/// A slot resolved to `None` leaves nothing behind — deliberately the empty string rather than any
194/// variant, since the point of a condition that does not hold is that the wording must not appear.
195pub fn apply_slots(
196    raw: &str,
197    resolved: &HashMap<String, Option<String>>,
198    scope: &str,
199) -> Result<String, DispatchError> {
200    let refs = slot_references(raw);
201    if refs.is_empty() {
202        return Ok(raw.to_string());
203    }
204    let mut out = String::new();
205    let mut cursor = 0usize;
206    for (start, end, name) in refs {
207        let Some(text) = resolved.get(&name) else {
208            return Err(DispatchError(format!(
209                "prompt text in {scope} references slot '{name}', which is not declared there."
210            )));
211        };
212        out.push_str(&raw[cursor..start]);
213        if let Some(text) = text {
214            out.push_str(text);
215        }
216        cursor = end;
217    }
218    out.push_str(&raw[cursor..]);
219    Ok(out)
220}
221
222/// Refuse prompt text that still carries a slot reference.
223///
224/// The guard, not a formality. Every other failure here is loud on its own; this one catches the
225/// case where a surface was never routed through [`apply_slots`] at all — a newly added
226/// prompt-carrying field, text assembled somewhere unexpected. That is exactly how the original
227/// defect arrived: the IR grew slots and nothing downstream was taught to consume them.
228pub fn assert_no_slot_references(raw: &str, owner: &str) -> Result<(), DispatchError> {
229    let refs = slot_references(raw);
230    if refs.is_empty() {
231        return Ok(());
232    }
233    let mut names: Vec<String> = refs.into_iter().map(|(_, _, name)| name).collect();
234    let seen: HashSet<String> = names.drain(..).collect();
235    let mut names: Vec<String> = seen.into_iter().collect();
236    names.sort();
237    Err(DispatchError(format!(
238        "{owner} still contains unresolved slot reference(s) ({}) at dispatch. Prompt text must go \
239         through slot resolution before it is emitted; shipping the placeholder would put literal \
240         '{{{{ slot.… }}}}' in front of the model.",
241        names.join(", ")
242    )))
243}
244
245/// Resolve every slot in an IR document, in place, before anything deserializes it into a
246/// target-specific type.
247///
248/// **Why at the JSON level rather than on a typed IR.** The two Rust back-ends deserialize the same
249/// document into two distinct `WarbleIr` types, so a typed pass would have to exist twice and the
250/// two copies would eventually disagree — the same trap the scanner avoids by mirroring the
251/// compiler's. One pass here means every target the CLI dispatches gets identical resolution, and
252/// neither back-end's types need to know slots exist at all.
253///
254/// The `slots` declarations are removed once applied: the text is resolved, so anything downstream
255/// that still found a declaration could only use it to resolve a second time, against text that no
256/// longer holds references. What was chosen is a property of the supply, which the caller has.
257///
258/// A document declaring no slots is returned unchanged, so a pre-0.7-shaped IR is untouched.
259pub fn resolve_ir_json(raw: &str, supply: &SlotSupply) -> Result<String, DispatchError> {
260    let mut doc: serde_json::Value = serde_json::from_str(raw)
261        .map_err(|e| DispatchError(format!("failed to parse IR while resolving slots: {e}")))?;
262
263    let profile_slots = take_slot_decls(&mut doc, "the profile")?;
264    let Some(components) = doc.get_mut("components").and_then(|c| c.as_array_mut()) else {
265        return Ok(raw.to_string());
266    };
267    let mut touched = !profile_slots.is_empty();
268
269    for node in components.iter_mut() {
270        let id = node
271            .get("id")
272            .and_then(|v| v.as_str())
273            .unwrap_or("<unnamed>")
274            .to_string();
275        let scope = format!("component '{id}'");
276        let own = take_slot_decls(node, &scope)?;
277        if own.is_empty() && profile_slots.is_empty() {
278            continue;
279        }
280        touched = true;
281        let decls: Vec<SlotDecl> = profile_slots.iter().chain(own.iter()).cloned().collect();
282        let resolved = resolve_slots(&decls, supply, &scope)?;
283        apply_to_node(node, &resolved, &scope)?;
284    }
285
286    if !touched {
287        return Ok(raw.to_string());
288    }
289    serde_json::to_string_pretty(&doc).map_err(|e| {
290        DispatchError(format!(
291            "failed to re-serialize IR after resolving slots: {e}"
292        ))
293    })
294}
295
296/// Remove and parse an object's `slots` array, if it has one.
297fn take_slot_decls(
298    value: &mut serde_json::Value,
299    scope: &str,
300) -> Result<Vec<SlotDecl>, DispatchError> {
301    let Some(object) = value.as_object_mut() else {
302        return Ok(Vec::new());
303    };
304    let Some(raw) = object.remove("slots") else {
305        return Ok(Vec::new());
306    };
307    serde_json::from_value(raw)
308        .map_err(|e| DispatchError(format!("malformed slots declaration on {scope}: {e}")))
309}
310
311/// Rewrite a component node's prompt-carrying fields with the resolved text.
312///
313/// The field list is the same one the typed path rewrites. A prompt surface added later and missed
314/// here is caught by [`assert_no_slot_references`] rather than shipping — that is the whole point of
315/// having a guard as well as a rewrite.
316fn apply_to_node(
317    node: &mut serde_json::Value,
318    resolved: &HashMap<String, Option<String>>,
319    scope: &str,
320) -> Result<(), DispatchError> {
321    for field in ["brief", "prompt_fragment"] {
322        if let Some(text) = node.get(field).and_then(|v| v.as_str()) {
323            let next = apply_slots(text, resolved, scope)?;
324            node[field] = serde_json::Value::String(next);
325        }
326    }
327    if let Some(calls) = node.get_mut("llm_calls").and_then(|c| c.as_array_mut()) {
328        for call in calls.iter_mut() {
329            if let Some(text) = call.get("prompt").and_then(|v| v.as_str()) {
330                let next = apply_slots(text, resolved, scope)?;
331                call["prompt"] = serde_json::Value::String(next);
332            }
333        }
334    }
335    Ok(())
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn decl(name: &str, default: &str, variants: &[(&str, &str)], conditional: bool) -> SlotDecl {
343        SlotDecl {
344            name: name.to_string(),
345            default: default.to_string(),
346            variants: variants
347                .iter()
348                .map(|(k, v)| (k.to_string(), v.to_string()))
349                .collect(),
350            present_when: conditional.then(|| serde_json::json!({"flag": "x"})),
351        }
352    }
353
354    fn supply(pairs: &[(&str, Option<&str>)]) -> SlotSupply {
355        pairs
356            .iter()
357            .map(|(k, v)| (k.to_string(), v.map(str::to_string)))
358            .collect()
359    }
360
361    const SCOPE: &str = "component 'c'";
362
363    #[test]
364    fn recognises_a_reference_with_and_without_inner_spacing() {
365        let decls = [decl("policy", "base", &[("base", "BASE")], false)];
366        let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
367        for raw in [
368            "{{slot.policy}}",
369            "{{ slot.policy }}",
370            "{{   slot.policy   }}",
371        ] {
372            assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), "BASE");
373        }
374    }
375
376    #[test]
377    fn leaves_an_unterminated_brace_alone_like_the_compiler_does() {
378        let decls = [decl("policy", "base", &[("base", "BASE")], false)];
379        let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
380        // The name must run to the end with nothing after it: a body containing spaces fails the
381        // name check anyway, so a longer string would pass whether or not the scanner honoured the
382        // missing `}}` — a test that cannot fail.
383        let raw = "keep {{ slot.policy";
384        assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
385    }
386
387    #[test]
388    fn leaves_other_placeholders_untouched() {
389        let decls = [decl("policy", "base", &[("base", "BASE")], false)];
390        let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
391        let raw = "{{project}} / {{project_name}}";
392        assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
393    }
394
395    #[test]
396    fn a_name_the_compiler_would_reject_is_not_a_reference() {
397        let decls = [decl("policy", "base", &[("base", "BASE")], false)];
398        let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
399        let raw = "{{ slot.Policy }}";
400        assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
401    }
402
403    #[test]
404    fn no_answer_renders_the_default_and_a_named_variant_wins() {
405        let decls = [decl(
406            "policy",
407            "base",
408            &[("base", "BASE"), ("terse", "T")],
409            false,
410        )];
411        let default = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
412        assert_eq!(
413            apply_slots("[{{ slot.policy }}]", &default, SCOPE).expect("applies"),
414            "[BASE]"
415        );
416        let chosen =
417            resolve_slots(&decls, &supply(&[("policy", Some("terse"))]), SCOPE).expect("resolves");
418        assert_eq!(
419            apply_slots("[{{ slot.policy }}]", &chosen, SCOPE).expect("applies"),
420            "[T]"
421        );
422    }
423
424    #[test]
425    fn none_removes_the_slot_rather_than_falling_back() {
426        let decls = [decl("policy", "base", &[("base", "BASE")], false)];
427        let resolved =
428            resolve_slots(&decls, &supply(&[("policy", None)]), SCOPE).expect("resolves");
429        assert_eq!(
430            apply_slots("[{{ slot.policy }}]", &resolved, SCOPE).expect("applies"),
431            "[]"
432        );
433    }
434
435    #[test]
436    fn an_unanswered_condition_is_a_loud_failure() {
437        let decls = [decl("policy", "base", &[("base", "BASE")], true)];
438        let err = resolve_slots(&decls, &supply(&[]), SCOPE).expect_err("must refuse");
439        assert!(
440            err.0
441                .contains("declares a present_when condition, and nothing answered it"),
442            "unexpected message: {}",
443            err.0
444        );
445    }
446
447    #[test]
448    fn an_undeclared_variant_is_refused_naming_the_declared_ones() {
449        let decls = [decl(
450            "policy",
451            "base",
452            &[("base", "BASE"), ("terse", "T")],
453            false,
454        )];
455        let err = resolve_slots(&decls, &supply(&[("policy", Some("nope"))]), SCOPE)
456            .expect_err("must refuse");
457        assert!(
458            err.0.contains("does not declare (declared: base, terse)"),
459            "unexpected: {}",
460            err.0
461        );
462    }
463
464    #[test]
465    fn a_variant_may_reference_another_slot() {
466        let decls = [
467            decl("outer", "only", &[("only", "<{{ slot.inner }}>")], false),
468            decl("inner", "only", &[("only", "IN")], false),
469        ];
470        let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
471        assert_eq!(
472            apply_slots("[{{ slot.outer }}]", &resolved, SCOPE).expect("applies"),
473            "[<IN>]"
474        );
475    }
476
477    #[test]
478    fn a_cycle_between_variants_is_reported_not_spun_on() {
479        let decls = [
480            decl("a", "only", &[("only", "{{ slot.b }}")], false),
481            decl("b", "only", &[("only", "{{ slot.a }}")], false),
482        ];
483        let err = resolve_slots(&decls, &supply(&[]), SCOPE).expect_err("must refuse");
484        assert!(
485            err.0.contains("is defined in terms of itself"),
486            "unexpected: {}",
487            err.0
488        );
489    }
490
491    #[test]
492    fn the_guard_refuses_text_that_still_carries_a_reference() {
493        let err = assert_no_slot_references("a {{ slot.policy }} b", "the agent file")
494            .expect_err("must refuse");
495        assert!(
496            err.0.contains("unresolved slot reference(s) (policy)"),
497            "unexpected: {}",
498            err.0
499        );
500        assert_no_slot_references("{{project}} and a lone {{", "x").expect("not references");
501    }
502
503    const IR: &str = r#"{
504      "warble_ir_version": "0.7",
505      "profile": "p",
506      "slots": [{"name": "charter", "default": "base", "variants": {"base": "CHARTER", "alt": "ALT"}}],
507      "components": [{
508        "id": "c1",
509        "brief": "framing: {{ slot.charter }} / {{ slot.local }}",
510        "prompt_fragment": "frag {{ slot.local }}",
511        "llm_calls": [{"name": "s1", "prompt": "step {{ slot.charter }}"}],
512        "slots": [{"name": "local", "default": "d", "variants": {"d": "LOCAL"}}]
513      }]
514    }"#;
515
516    #[test]
517    fn resolves_both_layers_of_an_ir_document_and_drops_the_declarations() {
518        let out = resolve_ir_json(IR, &supply(&[("charter", Some("alt"))])).expect("resolves");
519        let doc: serde_json::Value = serde_json::from_str(&out).expect("valid json");
520        let node = &doc["components"][0];
521        assert_eq!(node["brief"], "framing: ALT / LOCAL");
522        assert_eq!(node["prompt_fragment"], "frag LOCAL");
523        assert_eq!(node["llm_calls"][0]["prompt"], "step ALT");
524        assert!(
525            doc.get("slots").is_none(),
526            "profile declarations are consumed, not carried"
527        );
528        assert!(
529            node.get("slots").is_none(),
530            "component declarations are consumed, not carried"
531        );
532    }
533
534    #[test]
535    fn an_ir_declaring_no_slots_is_returned_byte_identical() {
536        let raw = r#"{"warble_ir_version":"0.7","profile":"p","components":[{"id":"c1","brief":"plain"}]}"#;
537        assert_eq!(resolve_ir_json(raw, &supply(&[])).expect("resolves"), raw);
538    }
539}
540
541#[cfg(test)]
542mod emit_guard_tests {
543    use crate::ir::WarbleIr;
544    use std::path::Path;
545
546    /// A library caller that skips the CLI's resolution pass must be refused, not served an agent
547    /// file containing the literal placeholder. Before the guard was wired into `emit`, this
548    /// function existed and nothing called it.
549    #[test]
550    fn emit_refuses_an_ir_whose_prompt_text_still_holds_a_slot_reference() {
551        let raw = include_str!("../../../examples/analysis-agent/ir.golden.json");
552        let mut doc: serde_json::Value = serde_json::from_str(raw).expect("golden parses");
553        let node = &mut doc["components"][0];
554        let brief = node["brief"].as_str().unwrap_or("").to_string();
555        node["brief"] = serde_json::Value::String(format!("{brief}\n{{{{ slot.charter }}}}"));
556        let ir: WarbleIr = serde_json::from_value(doc).expect("still an IR");
557
558        let dir = tempfile::tempdir().expect("tempdir");
559        let err = crate::emit_claude_code(
560            &ir,
561            Path::new(dir.path()),
562            "claude-code:headless",
563            crate::DEFAULT_RENDER_FLAVOR,
564        )
565        .expect_err("must refuse an unresolved slot reference");
566        assert!(
567            err.0
568                .contains("still contains unresolved slot reference(s) (charter)"),
569            "unexpected message: {}",
570            err.0
571        );
572    }
573}