Skip to main content

gemini_adk_fluent_rs/spec/
authoring.rs

1//! The authoring interface a coding harness uses to build a session spec.
2//!
3//! A harness (a coding agent, an IDE, a CLI wizard) edits `agent.json` on the
4//! user's behalf. These operations tell it what can be written, what is wrong
5//! with the document, and which decisions are still the user's to make:
6//!
7//! - [`catalog`] lists the vocabulary: voices, guard atoms, policies, tool
8//!   bindings, resume policies, question rules and diagnostic codes.
9//! - [`check`] validates a document and reports structured [`Diagnostic`]s.
10//!   Each has a JSON pointer and, where the repair is mechanical, a [`Fix`]
11//!   expressed as JSON-patch operations.
12//! - [`plan`] lists the open decisions as [`Question`]s. Every option carries
13//!   the patch that records it.
14//! - [`answer`] applies chosen options. It plans again before each answer,
15//!   so every patch is computed against the current document.
16//! - [`apply_patch`] applies JSON-patch operations (RFC 6902 `add`,
17//!   `replace`, `remove`) atomically.
18//!
19//! All of them take the document as a JSON value, so a document that does
20//! not deserialize still gets diagnostics.
21//!
22//! Fixes in one [`CheckReport`] are each computed against the checked
23//! document. Apply one, then check again: two fixes from the same report can
24//! touch the same array.
25//!
26//! ```
27//! use gemini_adk_fluent_rs::spec::authoring::{self, Answer, Decisions};
28//! use serde_json::json;
29//!
30//! let doc = json!({ "name": "", "instruction": "Answer questions about opening hours." });
31//! let plan = authoring::plan(&doc, &Decisions::new());
32//! assert_eq!(plan.questions[0].id, "name");
33//!
34//! let answered = authoring::answer(
35//!     &doc,
36//!     &[Answer::with_value("name", json!("hours-line"))],
37//!     &Decisions::new(),
38//! )
39//! .unwrap();
40//! assert_eq!(answered.spec["name"], "hours-line");
41//! ```
42
43use std::collections::{BTreeMap, BTreeSet};
44
45use serde::{Deserialize, Serialize};
46use serde_json::{Value, json};
47
48use super::{MEMORY_TOOL_NAMES, RUNTIME_WRITTEN, SessionSpec, levenshtein};
49use crate::conversation::StageSpec;
50use crate::policy::Policy;
51
52/// Version of the authoring interface. Bumped when an operation's output
53/// changes incompatibly.
54pub const AUTHORING_VERSION: u32 = 1;
55
56/// The placeholder an option's patch uses for the user's value. A string
57/// equal to it is replaced by [`Answer::value`].
58pub const ANSWER_PLACEHOLDER: &str = "$answer";
59
60/// Name of the extractor that question and fix patches add boolean caller
61/// signals to (confirmations, intents).
62pub const SIGNALS_EXTRACTOR: &str = "caller_signals";
63
64// ---------------------------------------------------------------------------
65// JSON patch
66// ---------------------------------------------------------------------------
67
68/// One JSON-patch operation (RFC 6902 subset). `path` is a JSON pointer.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
70#[serde(tag = "op", rename_all = "snake_case")]
71pub enum PatchOp {
72    /// Insert into an array (`/-` appends) or set an object member.
73    Add {
74        /// Target location.
75        path: String,
76        /// Value to add.
77        value: Value,
78    },
79    /// Replace an existing value.
80    Replace {
81        /// Target location; must exist.
82        path: String,
83        /// Replacement value.
84        value: Value,
85    },
86    /// Remove an existing value.
87    Remove {
88        /// Target location; must exist.
89        path: String,
90    },
91}
92
93impl PatchOp {
94    /// The operation's target pointer.
95    pub fn path(&self) -> &str {
96        match self {
97            PatchOp::Add { path, .. }
98            | PatchOp::Replace { path, .. }
99            | PatchOp::Remove { path } => path,
100        }
101    }
102}
103
104/// Why a patch could not be applied.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PatchError {
107    /// Index of the failing operation.
108    pub index: usize,
109    /// Its target pointer.
110    pub path: String,
111    /// What went wrong.
112    pub reason: String,
113}
114
115impl std::fmt::Display for PatchError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        write!(
118            f,
119            "patch operation {} at '{}': {}",
120            self.index, self.path, self.reason
121        )
122    }
123}
124
125impl std::error::Error for PatchError {}
126
127/// Apply `ops` in order to a copy of `doc`. Either every operation applies
128/// or the document is returned unchanged inside the error.
129pub fn apply_patch(doc: &Value, ops: &[PatchOp]) -> Result<Value, PatchError> {
130    let mut out = doc.clone();
131    for (index, op) in ops.iter().enumerate() {
132        apply_op(&mut out, op).map_err(|reason| PatchError {
133            index,
134            path: op.path().to_string(),
135            reason,
136        })?;
137    }
138    Ok(out)
139}
140
141fn apply_op(doc: &mut Value, op: &PatchOp) -> Result<(), String> {
142    let path = op.path();
143    if !path.is_empty() && !path.starts_with('/') {
144        return Err("a JSON pointer must be empty or start with '/'".into());
145    }
146    match op {
147        PatchOp::Replace { value, .. } => {
148            let target = doc
149                .pointer_mut(path)
150                .ok_or_else(|| "no value at this path".to_string())?;
151            *target = value.clone();
152            Ok(())
153        }
154        PatchOp::Add { value, .. } => {
155            if path.is_empty() {
156                *doc = value.clone();
157                return Ok(());
158            }
159            let (parent, last) = split_pointer(path);
160            match doc.pointer_mut(parent) {
161                Some(Value::Object(map)) => {
162                    map.insert(last, value.clone());
163                    Ok(())
164                }
165                Some(Value::Array(items)) => {
166                    if last == "-" {
167                        items.push(value.clone());
168                        return Ok(());
169                    }
170                    let index = array_index(&last)?;
171                    if index > items.len() {
172                        return Err(format!("index {index} is past the end of the array"));
173                    }
174                    items.insert(index, value.clone());
175                    Ok(())
176                }
177                Some(_) => Err("the parent is not an object or array".into()),
178                None => Err("the parent does not exist".into()),
179            }
180        }
181        PatchOp::Remove { .. } => {
182            if path.is_empty() {
183                return Err("cannot remove the whole document".into());
184            }
185            let (parent, last) = split_pointer(path);
186            match doc.pointer_mut(parent) {
187                Some(Value::Object(map)) => map
188                    .remove(&last)
189                    .map(|_| ())
190                    .ok_or_else(|| "no value at this path".to_string()),
191                Some(Value::Array(items)) => {
192                    let index = array_index(&last)?;
193                    if index >= items.len() {
194                        return Err("no value at this path".into());
195                    }
196                    items.remove(index);
197                    Ok(())
198                }
199                _ => Err("no value at this path".into()),
200            }
201        }
202    }
203}
204
205/// Split a non-empty pointer into its (still escaped) parent and its
206/// unescaped last token.
207fn split_pointer(path: &str) -> (&str, String) {
208    let cut = path.rfind('/').unwrap_or(0);
209    let last = path[cut + 1..].replace("~1", "/").replace("~0", "~");
210    (&path[..cut], last)
211}
212
213fn array_index(token: &str) -> Result<usize, String> {
214    if token.len() > 1 && token.starts_with('0') {
215        return Err(format!("'{token}' is not a valid array index"));
216    }
217    token
218        .parse()
219        .map_err(|_| format!("'{token}' is not a valid array index"))
220}
221
222/// Escape one reference token for a JSON pointer.
223fn escape(token: &str) -> String {
224    token.replace('~', "~0").replace('/', "~1")
225}
226
227/// An `add` that appends `value` to the array at `array`, creating the array
228/// when it does not exist yet.
229fn append(doc: &Value, array: &str, value: Value) -> PatchOp {
230    if doc.pointer(array).is_some_and(Value::is_array) {
231        PatchOp::Add {
232            path: format!("{array}/-"),
233            value,
234        }
235    } else {
236        PatchOp::Add {
237            path: array.to_string(),
238            value: json!([value]),
239        }
240    }
241}
242
243fn add(path: impl Into<String>, value: Value) -> PatchOp {
244    PatchOp::Add {
245        path: path.into(),
246        value,
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Catalog
252// ---------------------------------------------------------------------------
253
254/// One authorable item: its name, what it means and an example.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
256pub struct CatalogEntry {
257    /// Name as written in the spec (or the id pattern, for questions).
258    pub name: String,
259    /// What it does.
260    pub meaning: String,
261    /// A valid example, when one applies.
262    #[serde(default, skip_serializing_if = "Value::is_null")]
263    pub example: Value,
264}
265
266/// The vocabulary a harness may write. The full document shape is the spec's
267/// JSON Schema (`SessionSpec::json_schema`, `adk spec schema`).
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
269pub struct Catalog {
270    /// [`AUTHORING_VERSION`].
271    pub version: u32,
272    /// Output modalities.
273    pub modalities: Vec<String>,
274    /// Prebuilt voice names. Other names are passed through to the provider.
275    pub voices: Vec<String>,
276    /// Guard atoms and combinators.
277    pub guards: Vec<CatalogEntry>,
278    /// Conversation policies.
279    pub policies: Vec<CatalogEntry>,
280    /// Ways to implement a declared tool.
281    pub tool_bindings: Vec<CatalogEntry>,
282    /// What happens when a digression closes.
283    pub resume: Vec<CatalogEntry>,
284    /// Question rules [`plan`] can raise, by id pattern.
285    pub questions: Vec<CatalogEntry>,
286    /// Diagnostic codes [`check`] can report.
287    pub diagnostics: Vec<CatalogEntry>,
288}
289
290fn entry(name: &str, meaning: &str, example: Value) -> CatalogEntry {
291    CatalogEntry {
292        name: name.into(),
293        meaning: meaning.into(),
294        example,
295    }
296}
297
298const VOICES: [&str; 5] = ["Aoede", "Charon", "Fenrir", "Kore", "Puck"];
299
300/// The authoring vocabulary.
301pub fn catalog() -> Catalog {
302    Catalog {
303        version: AUTHORING_VERSION,
304        modalities: vec!["text".into(), "audio".into()],
305        voices: VOICES.iter().map(|v| (*v).to_string()).collect(),
306        guards: vec![
307            entry(
308                "always",
309                "Always holds. Rejected as a commit guard.",
310                json!("always"),
311            ),
312            entry(
313                "is_true",
314                "The state key is exactly true.",
315                json!({ "is_true": "user_confirmed" }),
316            ),
317            entry(
318                "is_set",
319                "The state key has a value.",
320                json!({ "is_set": "party_size" }),
321            ),
322            entry(
323                "eq",
324                "The state key equals the value.",
325                json!({ "eq": ["tier", "gold"] }),
326            ),
327            entry(
328                "captured",
329                "Every listed state key has a value.",
330                json!({ "captured": ["party_size", "slot"] }),
331            ),
332            entry(
333                "called_ok",
334                "The tool has run successfully.",
335                json!({ "called_ok": "book_table" }),
336            ),
337            entry(
338                "done",
339                "The stage is complete.",
340                json!({ "done": "collect" }),
341            ),
342            entry(
343                "decided",
344                "A decision model's answer to a question in `decisions`, about the \
345                 caller's latest words: a question id (yes, picked, or scored), or \
346                 {question: true | false | option | {\"at_least\": n} | {\"at_most\": n}}. \
347                 Asked by the runtime when the flow can act on it; an earlier answer \
348                 does not count.",
349                json!({ "decided": { "next_step": "book" } }),
350            ),
351            entry(
352                "all",
353                "Every guard holds.",
354                json!({ "all": [{ "is_set": "slot" }, { "is_true": "user_confirmed" }] }),
355            ),
356            entry(
357                "any",
358                "At least one guard holds.",
359                json!({ "any": [{ "is_true": "intent:cancel" }, { "is_true": "intent:human_agent" }] }),
360            ),
361            entry(
362                "not",
363                "The guard does not hold.",
364                json!({ "not": { "is_true": "large_party" } }),
365            ),
366        ],
367        policies: vec![
368            entry(
369                "redact",
370                "Redact these state keys in the journal, persistence snapshots and \
371                 extraction events. Transcript text is not covered.",
372                json!({ "kind": "redact", "keys": ["card_number"] }),
373            ),
374            entry(
375                "safety_handoff",
376                "End the conversation when any `intent:{name}` flag becomes true. \
377                 Something must write the flag, such as a signal extractor. Its \
378                 digression restricts no tools on the turn it is entered; a handoff \
379                 digression whose stage admits only a transfer tool does not have that gap.",
380                json!({ "kind": "safety_handoff", "intents": ["human_agent"] }),
381            ),
382            entry(
383                "commit",
384                "Idempotency and compensation for a committing tool.",
385                json!({
386                    "kind": "commit",
387                    "tool": "book_table",
388                    "idempotency_key": "{booking_ref}",
389                    "compensate_with": "cancel_booking"
390                }),
391            ),
392        ],
393        tool_bindings: vec![
394            entry(
395                "stub",
396                "No binding. Codegen writes a typed stub that you implement.",
397                json!({
398                    "name": "check_availability",
399                    "description": "Check table availability for a date and time.",
400                    "parameters": {
401                        "type": "object",
402                        "properties": { "datetime": { "type": "string" } },
403                        "required": ["datetime"]
404                    }
405                }),
406            ),
407            entry(
408                "mock",
409                "A canned response and state writes, for offline runs and demos.",
410                json!({
411                    "name": "check_availability",
412                    "response": { "available": true },
413                    "set_state": { "availability_checked": true }
414                }),
415            ),
416            entry(
417                "http",
418                "Call an HTTP endpoint with the arguments.",
419                json!({
420                    "name": "book_table",
421                    "http": { "method": "POST", "url": "https://bookings.example.com/v1/book" }
422                }),
423            ),
424            entry(
425                "mcp",
426                "Call the tool of the same name on an MCP server (a command line or an \
427                 http(s) URL).",
428                json!({ "name": "book_table", "mcp": "python tools/server.py" }),
429            ),
430        ],
431        resume: vec![
432            entry(
433                "previous",
434                "The layer beneath continues where it was suspended (default).",
435                json!("previous"),
436            ),
437            entry(
438                "restart",
439                "The layer beneath restarts its stages. Filled slots stay filled.",
440                json!("restart"),
441            ),
442            entry(
443                "terminate",
444                "The conversation ends and `flow:terminated` becomes true.",
445                json!("terminate"),
446            ),
447        ],
448        questions: QUESTION_RULES
449            .iter()
450            .map(|(name, meaning)| entry(name, meaning, Value::Null))
451            .collect(),
452        diagnostics: DIAGNOSTIC_CODES
453            .iter()
454            .map(|(name, meaning)| entry(name, meaning, Value::Null))
455            .collect(),
456    }
457}
458
459const QUESTION_RULES: [(&str, &str); 10] = [
460    ("name", "The app has no name."),
461    ("instruction", "The app has no base instruction."),
462    (
463        "tool_description:<tool>",
464        "A declared tool has no description, so the model cannot tell when to call it.",
465    ),
466    (
467        "commit_gate:<tool>",
468        "A conversation allows a tool that looks like it changes something, and no stage \
469         commits it behind a guard.",
470    ),
471    (
472        "redact:<slot>",
473        "A collected slot looks sensitive and no redact policy covers it.",
474    ),
475    ("voice", "An audio app has no voice."),
476    (
477        "greeting",
478        "An audio app has no greeting, so it waits for the caller to speak first.",
479    ),
480    (
481        "escalation",
482        "The conversation has no way out: no digression, safety handoff or repair escalation.",
483    ),
484    (
485        "disclosure",
486        "An audio conversation reads no fixed text, such as a recording or AI disclosure.",
487    ),
488    (
489        "tool_binding:<tool>",
490        "A declared tool has no implementation: no HTTP or MCP binding and no canned response.",
491    ),
492];
493
494const DIAGNOSTIC_CODES: [(&str, &str); 8] = [
495    ("invalid_json", "The text is not JSON."),
496    ("not_an_object", "The document is not a JSON object."),
497    (
498        "invalid_spec",
499        "The document does not deserialize as a session spec.",
500    ),
501    (
502        "unknown_field",
503        "A field is not part of the spec and is ignored. Usually a misspelling.",
504    ),
505    (
506        "unknown_tool",
507        "The conversation references a tool that is not declared.",
508    ),
509    (
510        "unwritten_key",
511        "A guard reads a state key that nothing writes, so it can never become true.",
512    ),
513    (
514        "unpinned_extraction_model",
515        "Extraction names no model, so it runs on the host default, a rolling alias whose model and latency change; every turn waits for it.",
516    ),
517    (
518        "validation",
519        "A problem reported by spec validation, passed through as text.",
520    ),
521];
522
523// ---------------------------------------------------------------------------
524// Check
525// ---------------------------------------------------------------------------
526
527/// How serious a diagnostic is.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
529#[serde(rename_all = "snake_case")]
530pub enum Severity {
531    /// The spec cannot run as written.
532    Error,
533    /// The spec runs but probably not as intended.
534    Warning,
535}
536
537/// A mechanical repair for a diagnostic.
538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
539pub struct Fix {
540    /// What the patch does.
541    pub description: String,
542    /// The patch, computed against the checked document.
543    pub patch: Vec<PatchOp>,
544}
545
546/// One problem found by [`check`].
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
548pub struct Diagnostic {
549    /// Severity.
550    pub severity: Severity,
551    /// Stable code; see [`Catalog::diagnostics`].
552    pub code: String,
553    /// JSON pointer to the offending value, when known.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub path: Option<String>,
556    /// What is wrong.
557    pub message: String,
558    /// A mechanical repair, when one exists.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub fix: Option<Fix>,
561}
562
563/// The result of [`check`].
564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct CheckReport {
566    /// No error-severity diagnostics.
567    pub valid: bool,
568    /// Errors first, then warnings.
569    pub diagnostics: Vec<Diagnostic>,
570}
571
572impl CheckReport {
573    fn new(mut diagnostics: Vec<Diagnostic>) -> Self {
574        diagnostics.sort_by_key(|d| d.severity != Severity::Error);
575        let valid = diagnostics.iter().all(|d| d.severity != Severity::Error);
576        Self { valid, diagnostics }
577    }
578}
579
580fn diagnostic(
581    severity: Severity,
582    code: &str,
583    path: Option<String>,
584    message: impl Into<String>,
585    fix: Option<Fix>,
586) -> Diagnostic {
587    Diagnostic {
588        severity,
589        code: code.into(),
590        path,
591        message: message.into(),
592        fix,
593    }
594}
595
596/// Validate `doc` and report structured diagnostics.
597///
598/// Conversation specs get pointer-level diagnostics for unknown tools and
599/// unwritten guard keys, including digression triggers and safety-handoff
600/// intents. Everything else that [`SessionSpec::validate`] reports is passed
601/// through with code `validation`.
602pub fn check(doc: &Value) -> CheckReport {
603    let Some(object) = doc.as_object() else {
604        return CheckReport::new(vec![diagnostic(
605            Severity::Error,
606            "not_an_object",
607            Some(String::new()),
608            "a spec must be a JSON object",
609            None,
610        )]);
611    };
612    let bare_flow = !object.contains_key("flow") && object.contains_key("steps");
613    let mut diagnostics = Vec::new();
614    if !bare_flow {
615        unknown_fields(doc, &mut diagnostics);
616    }
617    let spec = match SessionSpec::from_value(doc.clone()) {
618        Ok(spec) => spec,
619        Err(message) => {
620            diagnostics.push(diagnostic(
621                Severity::Error,
622                "invalid_spec",
623                None,
624                message,
625                None,
626            ));
627            return CheckReport::new(diagnostics);
628        }
629    };
630
631    // Messages the structured checks already report with a path.
632    let mut covered = Vec::new();
633    unpinned_extraction_model(doc, &spec, &mut diagnostics, &mut covered);
634    if spec.conversation.is_some() {
635        unknown_tools(doc, &spec, &mut diagnostics, &mut covered);
636        unwritten_keys(doc, &spec, &mut diagnostics, &mut covered);
637    }
638    let validation = spec.validate();
639    let passes = |message: &String| !covered.iter().any(|c| message.contains(c.as_str()));
640    for message in validation.errors.into_iter().filter(passes) {
641        diagnostics.push(diagnostic(
642            Severity::Error,
643            "validation",
644            None,
645            message,
646            None,
647        ));
648    }
649    for message in validation.warnings.into_iter().filter(passes) {
650        diagnostics.push(diagnostic(
651            Severity::Warning,
652            "validation",
653            None,
654            message,
655            None,
656        ));
657    }
658    CheckReport::new(diagnostics)
659}
660
661/// Extraction that names no model runs on the host's rolling default; the
662/// fix pins `models.extraction`.
663fn unpinned_extraction_model(
664    doc: &Value,
665    spec: &SessionSpec,
666    out: &mut Vec<Diagnostic>,
667    covered: &mut Vec<String>,
668) {
669    if !spec.uses_default_extraction_model() {
670        return;
671    }
672    let patch = pin_extraction_model(doc);
673    out.push(diagnostic(
674        Severity::Warning,
675        "unpinned_extraction_model",
676        Some("/models/extraction".into()),
677        "extraction names no model, so it runs on the host default (gemini-flash-latest, a \
678         rolling alias whose model and latency change under you, unless GEMINI_TEXT_MODEL is \
679         set); every turn waits for it",
680        Some(Fix {
681            description: format!("Run extraction on {}", super::RECOMMENDED_EXTRACTION_MODEL),
682            patch,
683        }),
684    ));
685    covered.push("extraction names no model".into());
686}
687
688/// Ops that pin the recommended extraction model and its thinking budget,
689/// keeping whatever `models` already sets.
690fn pin_extraction_model(doc: &Value) -> Vec<PatchOp> {
691    let model = json!(super::RECOMMENDED_EXTRACTION_MODEL);
692    let budget = json!(super::RECOMMENDED_EXTRACTION_THINKING_BUDGET);
693    if !doc.get("models").is_some_and(Value::is_object) {
694        return vec![add(
695            "/models",
696            json!({ "extraction": model, "extraction_thinking_budget": budget }),
697        )];
698    }
699    let mut ops = Vec::new();
700    if doc.pointer("/models/extraction").is_none() {
701        ops.push(add("/models/extraction", model));
702        if doc.pointer("/models/extraction_thinking_budget").is_none() {
703            ops.push(add("/models/extraction_thinking_budget", budget));
704        }
705    }
706    ops
707}
708
709/// [`check`] the text of a spec file. Text that is not JSON gets an
710/// `invalid_json` diagnostic with its line and column.
711pub fn check_str(text: &str) -> CheckReport {
712    match serde_json::from_str::<Value>(text) {
713        Ok(doc) => check(&doc),
714        Err(e) => CheckReport::new(vec![diagnostic(
715            Severity::Error,
716            "invalid_json",
717            None,
718            e.to_string(),
719            None,
720        )]),
721    }
722}
723
724/// Field names of a definition in the spec's JSON Schema.
725fn schema_fields(schema: &Value, definition: Option<&str>) -> BTreeSet<String> {
726    let node = match definition {
727        Some(name) => &schema["definitions"][name],
728        None => schema,
729    };
730    node["properties"]
731        .as_object()
732        .map(|props| props.keys().cloned().collect())
733        .unwrap_or_default()
734}
735
736fn unknown_fields(doc: &Value, out: &mut Vec<Diagnostic>) {
737    let schema = SessionSpec::json_schema();
738    let top = schema_fields(&schema, None);
739    let mut stage = schema_fields(&schema, Some("StageSpec"));
740    // `say` also deserializes from `instruction`.
741    stage.insert("instruction".into());
742    let conversation = schema_fields(&schema, Some("ConversationSpec"));
743    let overlay = schema_fields(&schema, Some("OverlaySpec"));
744    let tool = schema_fields(&schema, Some("ToolSpec"));
745
746    unknown_in(doc, "", &top, out);
747    for (i, t) in array(doc, "/tools") {
748        unknown_in(t, &format!("/tools/{i}"), &tool, out);
749    }
750    let mut decision = schema_fields(&schema, Some("DecisionSpec"));
751    // The question type's own fields are flattened in.
752    decision.extend(["type", "instructions", "criteria"].map(String::from));
753    for (id, d) in doc
754        .get("decisions")
755        .and_then(Value::as_object)
756        .into_iter()
757        .flatten()
758    {
759        unknown_in(d, &format!("/decisions/{}", escape(id)), &decision, out);
760    }
761    if let Some(code) = doc.get("code").filter(|c| c.is_object()) {
762        unknown_in(
763            code,
764            "/code",
765            &schema_fields(&schema, Some("CodeSpec")),
766            out,
767        );
768        if let Some(limits) = code.get("limits").filter(|l| l.is_object()) {
769            let fields = schema_fields(&schema, Some("CodeLimitsSpec"));
770            unknown_in(limits, "/code/limits", &fields, out);
771        }
772    }
773    let Some(conv) = doc.get("conversation").filter(|c| c.is_object()) else {
774        return;
775    };
776    unknown_in(conv, "/conversation", &conversation, out);
777    for (i, s) in array(doc, "/conversation/stages") {
778        unknown_in(s, &format!("/conversation/stages/{i}"), &stage, out);
779    }
780    for (o, ov) in array(doc, "/conversation/overlays") {
781        let base = format!("/conversation/overlays/{o}");
782        unknown_in(ov, &base, &overlay, out);
783        for (i, s) in array(ov, "/stages") {
784            unknown_in(s, &format!("{base}/stages/{i}"), &stage, out);
785        }
786    }
787}
788
789fn array<'a>(doc: &'a Value, pointer: &str) -> impl Iterator<Item = (usize, &'a Value)> {
790    doc.pointer(pointer)
791        .and_then(Value::as_array)
792        .into_iter()
793        .flatten()
794        .enumerate()
795}
796
797fn unknown_in(value: &Value, base: &str, known: &BTreeSet<String>, out: &mut Vec<Diagnostic>) {
798    let Some(object) = value.as_object() else {
799        return;
800    };
801    for (key, field) in object {
802        // `$schema` and similar editor keys are allowed.
803        if known.contains(key) || key.starts_with('$') {
804            continue;
805        }
806        let path = format!("{base}/{}", escape(key));
807        let suggestion = closest(key, known.iter().map(String::as_str));
808        let message = match suggestion {
809            Some(s) => format!("'{key}' is not a field here and is ignored — did you mean '{s}'?"),
810            None => format!("'{key}' is not a field here and is ignored"),
811        };
812        let fix = suggestion
813            .filter(|s| !object.contains_key(*s))
814            .map(|s| Fix {
815                description: format!("rename '{key}' to '{s}'"),
816                patch: vec![
817                    PatchOp::Remove { path: path.clone() },
818                    add(format!("{base}/{}", escape(s)), field.clone()),
819                ],
820            });
821        out.push(diagnostic(
822            Severity::Warning,
823            "unknown_field",
824            Some(path),
825            message,
826            fix,
827        ));
828    }
829}
830
831/// The unique closest candidate within edit distance 2.
832fn closest<'a>(word: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
833    let mut best: Option<(usize, &str)> = None;
834    let mut tied = false;
835    for candidate in candidates {
836        let distance = levenshtein(word, candidate);
837        if distance == 0 || distance > 2 {
838            continue;
839        }
840        match best {
841            Some((d, _)) if distance > d => {}
842            Some((d, _)) if distance == d => tied = true,
843            _ => {
844                best = Some((distance, candidate));
845                tied = false;
846            }
847        }
848    }
849    best.filter(|_| !tied).map(|(_, c)| c)
850}
851
852/// A stage and the pointer to it, main flow first, then digressions.
853fn stage_pointers(spec: &SessionSpec) -> Vec<(String, &StageSpec)> {
854    let Some(conv) = &spec.conversation else {
855        return Vec::new();
856    };
857    let main = conv
858        .stages
859        .iter()
860        .enumerate()
861        .map(|(i, s)| (format!("/conversation/stages/{i}"), s));
862    let overlays = conv.overlays.iter().enumerate().flat_map(|(o, ov)| {
863        ov.stages
864            .iter()
865            .enumerate()
866            .map(move |(i, s)| (format!("/conversation/overlays/{o}/stages/{i}"), s))
867    });
868    main.chain(overlays).collect()
869}
870
871/// Where a guard sits, for describing what its keys mean.
872#[derive(Debug, Clone)]
873enum Site {
874    Commit(String),
875    Trigger,
876    Other,
877}
878
879/// A guard atom that reads a name: its kind, the name, and the pointer to the
880/// string holding it.
881struct Atom {
882    kind: &'static str,
883    name: String,
884    path: String,
885}
886
887fn guard_atoms(guard: &Value, path: &str, out: &mut Vec<Atom>) {
888    let Some(object) = guard.as_object() else {
889        return;
890    };
891    for (kind, inner) in object {
892        let here = format!("{path}/{}", escape(kind));
893        match kind.as_str() {
894            "is_true" | "is_set" | "called_ok" | "done" => {
895                if let Some(name) = inner.as_str() {
896                    out.push(Atom {
897                        kind: atom_kind(kind),
898                        name: name.to_string(),
899                        path: here,
900                    });
901                }
902            }
903            "eq" => {
904                if let Some(name) = inner.get(0).and_then(Value::as_str) {
905                    out.push(Atom {
906                        kind: "eq",
907                        name: name.to_string(),
908                        path: format!("{here}/0"),
909                    });
910                }
911            }
912            "captured" => {
913                for (i, name) in inner.as_array().into_iter().flatten().enumerate() {
914                    if let Some(name) = name.as_str() {
915                        out.push(Atom {
916                            kind: "captured",
917                            name: name.to_string(),
918                            path: format!("{here}/{i}"),
919                        });
920                    }
921                }
922            }
923            "all" | "any" => {
924                for (i, g) in inner.as_array().into_iter().flatten().enumerate() {
925                    guard_atoms(g, &format!("{here}/{i}"), out);
926                }
927            }
928            "not" => guard_atoms(inner, &here, out),
929            _ => {}
930        }
931    }
932}
933
934fn atom_kind(kind: &str) -> &'static str {
935    match kind {
936        "is_true" => "is_true",
937        "is_set" => "is_set",
938        "called_ok" => "called_ok",
939        _ => "done",
940    }
941}
942
943/// Every guard in the conversation, with its pointer and site.
944fn conversation_guards(doc: &Value, spec: &SessionSpec) -> Vec<(String, Site)> {
945    let mut guards = Vec::new();
946    for (base, stage) in stage_pointers(spec) {
947        if stage.done.is_some() {
948            guards.push((format!("{base}/done"), Site::Other));
949        }
950        if let Some(commit) = &stage.commit {
951            guards.push((
952                format!("{base}/commit/when"),
953                Site::Commit(commit.tool.clone()),
954            ));
955        }
956        for j in 0..stage.next.len() {
957            guards.push((format!("{base}/next/{j}/when"), Site::Other));
958        }
959    }
960    for (o, _) in array(doc, "/conversation/overlays") {
961        guards.push((format!("/conversation/overlays/{o}/trigger"), Site::Trigger));
962    }
963    guards
964}
965
966fn unknown_tools(
967    doc: &Value,
968    spec: &SessionSpec,
969    out: &mut Vec<Diagnostic>,
970    covered: &mut Vec<String>,
971) {
972    // MCP toolsets and skills resolve their tool names elsewhere.
973    if !spec.mcp.is_empty() || !spec.skills.is_empty() {
974        return;
975    }
976    let mut known: BTreeSet<&str> = spec.tools.iter().map(|t| t.name.as_str()).collect();
977    if spec.memory.is_some() {
978        known.extend(MEMORY_TOOL_NAMES);
979    }
980    // Validation only checks names against a non-empty registry.
981    let severity = if spec.tools.is_empty() {
982        Severity::Warning
983    } else {
984        Severity::Error
985    };
986
987    let mut refs: Vec<(String, String)> = Vec::new();
988    for (base, stage) in stage_pointers(spec) {
989        for (k, tool) in stage.allow.iter().enumerate() {
990            refs.push((tool.clone(), format!("{base}/allow/{k}")));
991        }
992        if let Some(commit) = &stage.commit {
993            refs.push((commit.tool.clone(), format!("{base}/commit/tool")));
994        }
995    }
996    for (path, _) in conversation_guards(doc, spec) {
997        let mut atoms = Vec::new();
998        if let Some(guard) = doc.pointer(&path) {
999            guard_atoms(guard, &path, &mut atoms);
1000        }
1001        refs.extend(
1002            atoms
1003                .into_iter()
1004                .filter(|a| a.kind == "called_ok")
1005                .map(|a| (a.name, a.path)),
1006        );
1007    }
1008    if let Some(conv) = &spec.conversation {
1009        for (p, policy) in conv.policies.iter().enumerate() {
1010            if let Policy::Commit {
1011                tool,
1012                compensate_with,
1013                ..
1014            } = policy
1015            {
1016                let base = format!("/conversation/policies/{p}");
1017                refs.push((tool.clone(), format!("{base}/tool")));
1018                if let Some(c) = compensate_with {
1019                    refs.push((c.clone(), format!("{base}/compensate_with")));
1020                }
1021            }
1022        }
1023    }
1024
1025    for (name, path) in refs {
1026        if known.contains(name.as_str()) {
1027            continue;
1028        }
1029        covered.push(format!(
1030            "tool '{name}' which is not in the provided tool registry"
1031        ));
1032        let suggestion = closest(&name, known.iter().copied());
1033        let (message, fix) = match suggestion {
1034            Some(s) => (
1035                format!("tool '{name}' is not declared — did you mean '{s}'?"),
1036                Fix {
1037                    description: format!("replace '{name}' with '{s}'"),
1038                    patch: vec![PatchOp::Replace {
1039                        path: path.clone(),
1040                        value: json!(s),
1041                    }],
1042                },
1043            ),
1044            None => (
1045                format!("tool '{name}' is not declared"),
1046                Fix {
1047                    description: format!(
1048                        "declare '{name}' with no binding (codegen writes a stub for it)"
1049                    ),
1050                    patch: vec![append(doc, "/tools", json!({ "name": name }))],
1051                },
1052            ),
1053        };
1054        out.push(diagnostic(
1055            severity,
1056            "unknown_tool",
1057            Some(path),
1058            message,
1059            Some(fix),
1060        ));
1061    }
1062}
1063
1064/// Every key the spec writes, plus the prefixes and suffixes the runtime
1065/// writes on its own.
1066fn is_written(key: &str, written: &BTreeSet<String>) -> bool {
1067    written.contains(key)
1068        || RUNTIME_WRITTEN.iter().any(|p| key.starts_with(p))
1069        || key.ends_with(":result")
1070}
1071
1072/// One guard read of a state key.
1073struct KeyRead {
1074    kind: &'static str,
1075    path: String,
1076    /// A safety-handoff intent name: the key without its `intent:` prefix.
1077    intent_name: bool,
1078}
1079
1080fn unwritten_keys(
1081    doc: &Value,
1082    spec: &SessionSpec,
1083    out: &mut Vec<Diagnostic>,
1084    covered: &mut Vec<String>,
1085) {
1086    let written = spec.state_keys_written();
1087    // Each key's most specific site (a commit or trigger beats any other
1088    // guard) and every read of it.
1089    let mut reads: BTreeMap<String, (Site, Vec<KeyRead>)> = BTreeMap::new();
1090    for (path, site) in conversation_guards(doc, spec) {
1091        let mut atoms = Vec::new();
1092        if let Some(guard) = doc.pointer(&path) {
1093            guard_atoms(guard, &path, &mut atoms);
1094        }
1095        for atom in atoms {
1096            if matches!(atom.kind, "called_ok" | "done") {
1097                continue;
1098            }
1099            let read = reads
1100                .entry(atom.name)
1101                .or_insert_with(|| (site.clone(), Vec::new()));
1102            if matches!(read.0, Site::Other) {
1103                read.0 = site.clone();
1104            }
1105            read.1.push(KeyRead {
1106                kind: atom.kind,
1107                path: atom.path,
1108                intent_name: false,
1109            });
1110        }
1111    }
1112    if let Some(conv) = &spec.conversation {
1113        for (p, policy) in conv.policies.iter().enumerate() {
1114            if let Policy::SafetyHandoff { intents } = policy {
1115                for (k, intent) in intents.iter().enumerate() {
1116                    let read = reads
1117                        .entry(format!("intent:{intent}"))
1118                        .or_insert_with(|| (Site::Trigger, Vec::new()));
1119                    read.0 = Site::Trigger;
1120                    read.1.push(KeyRead {
1121                        kind: "is_true",
1122                        path: format!("/conversation/policies/{p}/intents/{k}"),
1123                        intent_name: true,
1124                    });
1125                }
1126            }
1127        }
1128    }
1129
1130    for (key, (site, reads)) in reads {
1131        if is_written(&key, &written) {
1132            continue;
1133        }
1134        covered.push(format!("state key '{key}'"));
1135        let path = reads[0].path.clone();
1136        // A handoff policy names the intent without its prefix, so only an
1137        // intent flag can replace it.
1138        let intent_only = reads.iter().any(|r| r.intent_name);
1139        let suggestion = closest(
1140            &key,
1141            written
1142                .iter()
1143                .map(String::as_str)
1144                .filter(|w| !intent_only || w.starts_with("intent:")),
1145        );
1146        let mut message = format!(
1147            "a guard reads state key '{key}' but nothing writes it, so it can never become true"
1148        );
1149        let fix = if let Some(s) = suggestion {
1150            message.push_str(&format!(" — did you mean '{s}'?"));
1151            let patch = reads
1152                .iter()
1153                .map(|r| {
1154                    let value = if r.intent_name {
1155                        s.strip_prefix("intent:").unwrap_or(s)
1156                    } else {
1157                        s
1158                    };
1159                    PatchOp::Replace {
1160                        path: r.path.clone(),
1161                        value: json!(value),
1162                    }
1163                })
1164                .collect();
1165            Some(Fix {
1166                description: format!("read '{s}' instead"),
1167                patch,
1168            })
1169        } else if reads.iter().all(|r| r.kind == "is_true") {
1170            Some(Fix {
1171                description: format!(
1172                    "add a '{SIGNALS_EXTRACTOR}' extractor field that sets '{key}' when the \
1173                     caller says so (needs an extraction model at run time)"
1174                ),
1175                patch: signal_ops(doc, &key, &signal_description(&key, &site)),
1176            })
1177        } else {
1178            None
1179        };
1180        out.push(diagnostic(
1181            Severity::Warning,
1182            "unwritten_key",
1183            Some(path),
1184            message,
1185            fix,
1186        ));
1187    }
1188}
1189
1190fn humanize(key: &str) -> String {
1191    key.replace(['_', '-', ':'], " ")
1192}
1193
1194fn signal_description(key: &str, site: &Site) -> String {
1195    match (site, key.strip_prefix("intent:")) {
1196        (_, Some(intent)) => format!("What the caller said matches the intent '{intent}'."),
1197        (Site::Commit(tool), None) => format!(
1198            "The caller explicitly agreed to go ahead with {} after hearing the details.",
1199            humanize(tool)
1200        ),
1201        _ => format!("The conversation shows that '{key}' is true."),
1202    }
1203}
1204
1205/// The JSON field a signal extractor fills for `key`.
1206fn signal_field(key: &str) -> String {
1207    key.chars()
1208        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1209        .collect()
1210}
1211
1212/// Ops that make the signal extractor write `key` as a boolean.
1213fn signal_ops(doc: &Value, key: &str, description: &str) -> Vec<PatchOp> {
1214    let field = signal_field(key);
1215    let promote = if field == key {
1216        json!({ "field": field, "policy": "true_only" })
1217    } else {
1218        json!({ "field": field, "to": key, "policy": "true_only" })
1219    };
1220    let property = json!({ "type": "boolean", "description": description });
1221    let existing = array(doc, "/extract").find(|(_, e)| e["name"] == SIGNALS_EXTRACTOR);
1222    match existing {
1223        Some((i, extractor)) => {
1224            let base = format!("/extract/{i}");
1225            let schema_op = if extractor["schema"]["properties"].is_object() {
1226                add(
1227                    format!("{base}/schema/properties/{}", escape(&field)),
1228                    property,
1229                )
1230            } else {
1231                add(
1232                    format!("{base}/schema"),
1233                    json!({ "type": "object", "properties": { field.clone(): property } }),
1234                )
1235            };
1236            vec![schema_op, append(doc, &format!("{base}/promote"), promote)]
1237        }
1238        None => {
1239            let mut ops = vec![append(
1240                doc,
1241                "/extract",
1242                json!({
1243                    "name": SIGNALS_EXTRACTOR,
1244                    "instruction": "Read the latest turns of the conversation. Set a field to true \
1245                                    only when the caller clearly said so in their own words. \
1246                                    Otherwise leave it out.",
1247                    "schema": { "type": "object", "properties": { field: property } },
1248                    "promote": [promote]
1249                }),
1250            )];
1251            // New extraction names its model.
1252            ops.extend(pin_extraction_model(doc));
1253            ops
1254        }
1255    }
1256}
1257
1258// ---------------------------------------------------------------------------
1259// Plan
1260// ---------------------------------------------------------------------------
1261
1262/// Answered question ids, mapped to the chosen option value. A harness
1263/// stores this next to the spec and passes it back so answered questions
1264/// are not asked again.
1265pub type Decisions = BTreeMap<String, String>;
1266
1267/// How a question is answered.
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1269#[serde(rename_all = "snake_case")]
1270pub enum QuestionKind {
1271    /// Pick one option.
1272    Choice,
1273    /// Supply text; the question has one option, which needs a value.
1274    Text,
1275}
1276
1277/// One way to answer a question.
1278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1279pub struct AnswerOption {
1280    /// The value to send in [`Answer::choice`].
1281    pub value: String,
1282    /// What choosing it means.
1283    pub label: String,
1284    /// The patch that records it. Empty when the choice changes nothing in
1285    /// the spec and is only recorded in [`Decisions`].
1286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1287    pub patch: Vec<PatchOp>,
1288    /// The option needs [`Answer::value`]; it replaces the
1289    /// [`ANSWER_PLACEHOLDER`] strings in `patch`.
1290    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1291    pub needs_value: bool,
1292}
1293
1294/// A decision the document leaves open.
1295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1296pub struct Question {
1297    /// Stable id; see [`Catalog::questions`].
1298    pub id: String,
1299    /// The question, phrased for the user.
1300    pub ask: String,
1301    /// Why it matters.
1302    pub why: String,
1303    /// Choice or text.
1304    pub kind: QuestionKind,
1305    /// The ways to answer.
1306    pub options: Vec<AnswerOption>,
1307    /// The option to take when the user has no preference. Never one that
1308    /// needs a value.
1309    #[serde(default, skip_serializing_if = "Option::is_none")]
1310    pub default: Option<String>,
1311    /// The app should not be generated until this is answered.
1312    pub blocking: bool,
1313    /// Pointers the options write.
1314    pub affects: Vec<String>,
1315}
1316
1317/// The result of [`plan`].
1318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1319pub struct Plan {
1320    /// No blocking questions and no error diagnostics: generate now.
1321    pub ready: bool,
1322    /// Number of blocking questions.
1323    pub blocking: usize,
1324    /// Open questions, blocking first.
1325    pub questions: Vec<Question>,
1326}
1327
1328/// The open decisions in `doc`, skipping question ids in `decisions`.
1329///
1330/// Rules apply to full specs. A bare flow or a document that does not
1331/// deserialize has no questions; [`check`] reports why it does not parse.
1332pub fn plan(doc: &Value, decisions: &Decisions) -> Plan {
1333    let questions = questions(doc, decisions);
1334    let blocking = questions.iter().filter(|q| q.blocking).count();
1335    Plan {
1336        ready: blocking == 0 && check(doc).valid,
1337        blocking,
1338        questions,
1339    }
1340}
1341
1342fn questions(doc: &Value, decisions: &Decisions) -> Vec<Question> {
1343    let bare_flow = doc.get("flow").is_none() && doc.get("steps").is_some();
1344    if bare_flow {
1345        return Vec::new();
1346    }
1347    let Ok(spec) = SessionSpec::from_value(doc.clone()) else {
1348        return Vec::new();
1349    };
1350    let mut out = Vec::new();
1351    ask_name_and_instruction(&spec, &mut out);
1352    ask_tool_descriptions(&spec, &mut out);
1353    ask_commit_gates(doc, &spec, &mut out);
1354    ask_redactions(doc, &spec, &mut out);
1355    ask_voice(&spec, &mut out);
1356    ask_greeting(&spec, &mut out);
1357    ask_escalation(doc, &spec, &mut out);
1358    ask_disclosure(&spec, &mut out);
1359    ask_tool_bindings(&spec, &mut out);
1360    out.retain(|q| !decisions.contains_key(&q.id));
1361    out.sort_by_key(|q| !q.blocking);
1362    out
1363}
1364
1365fn text_question(id: String, ask: String, why: &str, path: String, blocking: bool) -> Question {
1366    Question {
1367        id,
1368        ask,
1369        why: why.into(),
1370        kind: QuestionKind::Text,
1371        options: vec![AnswerOption {
1372            value: "text".into(),
1373            label: "Your answer".into(),
1374            patch: vec![add(path.clone(), json!(ANSWER_PLACEHOLDER))],
1375            needs_value: true,
1376        }],
1377        default: None,
1378        blocking,
1379        affects: vec![path],
1380    }
1381}
1382
1383fn option(value: &str, label: impl Into<String>, patch: Vec<PatchOp>) -> AnswerOption {
1384    AnswerOption {
1385        value: value.into(),
1386        label: label.into(),
1387        patch,
1388        needs_value: false,
1389    }
1390}
1391
1392fn ask_name_and_instruction(spec: &SessionSpec, out: &mut Vec<Question>) {
1393    if spec.name.trim().is_empty() {
1394        out.push(text_question(
1395            "name".into(),
1396            "What is the app called? Use a short slug, such as `trattoria-booking`.".into(),
1397            "The name identifies the app in bundles, traces and generated projects.",
1398            "/name".into(),
1399            true,
1400        ));
1401    }
1402    if spec.instruction.trim().is_empty() {
1403        out.push(text_question(
1404            "instruction".into(),
1405            "What should the agent do, and for whom? One or two sentences.".into(),
1406            "The base instruction is the model's standing brief for every turn.",
1407            "/instruction".into(),
1408            true,
1409        ));
1410    }
1411}
1412
1413fn ask_tool_descriptions(spec: &SessionSpec, out: &mut Vec<Question>) {
1414    for (i, tool) in spec.tools.iter().enumerate() {
1415        if tool.description.trim().is_empty() {
1416            out.push(text_question(
1417                format!("tool_description:{}", tool.name),
1418                format!("What does the tool `{}` do?", tool.name),
1419                "The model reads the description to decide when to call the tool.",
1420                format!("/tools/{i}/description"),
1421                true,
1422            ));
1423        }
1424    }
1425}
1426
1427/// Verbs that suggest a tool changes something outside the conversation.
1428const COMMITTING_VERBS: [&str; 30] = [
1429    "add",
1430    "apply",
1431    "approve",
1432    "book",
1433    "buy",
1434    "cancel",
1435    "charge",
1436    "close",
1437    "create",
1438    "delete",
1439    "deposit",
1440    "enroll",
1441    "issue",
1442    "order",
1443    "pay",
1444    "place",
1445    "purchase",
1446    "refund",
1447    "register",
1448    "remove",
1449    "reschedule",
1450    "reserve",
1451    "schedule",
1452    "send",
1453    "sign",
1454    "submit",
1455    "transfer",
1456    "update",
1457    "withdraw",
1458    "write",
1459];
1460
1461/// Lowercase words of a snake, kebab or camel case name.
1462fn name_parts(name: &str) -> Vec<String> {
1463    let mut parts = vec![String::new()];
1464    let mut previous_lower = false;
1465    for c in name.chars() {
1466        if !c.is_ascii_alphanumeric() {
1467            parts.push(String::new());
1468            previous_lower = false;
1469            continue;
1470        }
1471        if c.is_ascii_uppercase() && previous_lower {
1472            parts.push(String::new());
1473        }
1474        previous_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
1475        if let Some(part) = parts.last_mut() {
1476            part.push(c.to_ascii_lowercase());
1477        }
1478    }
1479    parts.retain(|p| !p.is_empty());
1480    parts
1481}
1482
1483fn looks_committing(spec: &SessionSpec, tool: &str) -> bool {
1484    let verb = name_parts(tool).into_iter().next().unwrap_or_default();
1485    let mutating_http = spec
1486        .tools
1487        .iter()
1488        .find(|t| t.name == tool)
1489        .and_then(|t| t.http.as_ref())
1490        .is_some_and(|h| !h.method.eq_ignore_ascii_case("GET"));
1491    COMMITTING_VERBS.contains(&verb.as_str()) || mutating_http
1492}
1493
1494fn ask_commit_gates(doc: &Value, spec: &SessionSpec, out: &mut Vec<Question>) {
1495    if spec.conversation.is_none() {
1496        return;
1497    }
1498    let stages = stage_pointers(spec);
1499    let committed: BTreeSet<&str> = stages
1500        .iter()
1501        .filter_map(|(_, s)| s.commit.as_ref().map(|c| c.tool.as_str()))
1502        .collect();
1503    let written = spec.state_keys_written();
1504    let mut seen = BTreeSet::new();
1505    for (_, stage) in &stages {
1506        for tool in &stage.allow {
1507            if committed.contains(tool.as_str())
1508                || !seen.insert(tool.as_str())
1509                || !looks_committing(spec, tool)
1510            {
1511                continue;
1512            }
1513            // The first stage allowing the tool that has no commit yet.
1514            let Some((base, _)) = stages
1515                .iter()
1516                .find(|(_, s)| s.commit.is_none() && s.allow.contains(tool))
1517            else {
1518                continue;
1519            };
1520            let key = format!("{tool}_confirmed");
1521            let mut patch = vec![add(
1522                format!("{base}/commit"),
1523                json!({ "tool": tool, "when": { "is_true": key } }),
1524            )];
1525            if !is_written(&key, &written) {
1526                patch.extend(signal_ops(
1527                    doc,
1528                    &key,
1529                    &signal_description(&key, &Site::Commit(tool.clone())),
1530                ));
1531            }
1532            out.push(Question {
1533                id: format!("commit_gate:{tool}"),
1534                ask: format!(
1535                    "Should `{tool}` run only after the caller confirms the details read back \
1536                     to them?"
1537                ),
1538                why: "A stage that allows a tool without a commit guard lets the model call it \
1539                      as soon as the stage is active, before the caller has agreed."
1540                    .into(),
1541                kind: QuestionKind::Choice,
1542                options: vec![
1543                    option(
1544                        "confirm",
1545                        format!(
1546                            "Gate it on '{key}', which an extractor sets when the caller \
1547                             agrees"
1548                        ),
1549                        patch,
1550                    ),
1551                    option(
1552                        "no_confirmation",
1553                        "It changes nothing the caller has to approve",
1554                        Vec::new(),
1555                    ),
1556                ],
1557                default: Some("confirm".into()),
1558                blocking: true,
1559                affects: vec![format!("{base}/commit"), "/extract".into()],
1560            });
1561        }
1562    }
1563}
1564
1565/// Name parts that suggest a slot holds personal or payment data.
1566const SENSITIVE_PARTS: [&str; 19] = [
1567    "account",
1568    "birth",
1569    "card",
1570    "cvc",
1571    "cvv",
1572    "diagnosis",
1573    "dob",
1574    "iban",
1575    "insurance",
1576    "licence",
1577    "license",
1578    "medical",
1579    "passcode",
1580    "passport",
1581    "password",
1582    "pin",
1583    "routing",
1584    "ssn",
1585    "tax",
1586];
1587
1588fn looks_sensitive(slot: &str) -> bool {
1589    name_parts(slot)
1590        .iter()
1591        .any(|part| SENSITIVE_PARTS.contains(&part.as_str()))
1592}
1593
1594fn ask_redactions(doc: &Value, spec: &SessionSpec, out: &mut Vec<Question>) {
1595    let Some(conv) = &spec.conversation else {
1596        return;
1597    };
1598    let mut redacted = BTreeSet::new();
1599    let mut redact_policy = None;
1600    for (p, policy) in conv.policies.iter().enumerate() {
1601        if let Policy::Redact { keys } = policy {
1602            redacted.extend(keys.iter().map(String::as_str));
1603            redact_policy.get_or_insert(p);
1604        }
1605    }
1606    let mut slots = BTreeSet::new();
1607    for (_, stage) in stage_pointers(spec) {
1608        slots.extend(stage.collect.iter().map(String::as_str));
1609        slots.extend(stage.resolve.iter().map(|r| r.slot.as_str()));
1610    }
1611    for slot in slots {
1612        if redacted.contains(slot) || !looks_sensitive(slot) {
1613            continue;
1614        }
1615        let patch = match redact_policy {
1616            Some(p) => vec![add(
1617                format!("/conversation/policies/{p}/keys/-"),
1618                json!(slot),
1619            )],
1620            None => vec![append(
1621                doc,
1622                "/conversation/policies",
1623                json!({ "kind": "redact", "keys": [slot] }),
1624            )],
1625        };
1626        out.push(Question {
1627            id: format!("redact:{slot}"),
1628            ask: format!("`{slot}` looks sensitive. Should it be redacted?"),
1629            why: "A redacted key is written as `[redacted]` to the journal, persistence \
1630                  snapshots and extraction events. In-process reads still see the value, and \
1631                  transcript text is not covered: see the hardening guide."
1632                .into(),
1633            kind: QuestionKind::Choice,
1634            options: vec![
1635                option("redact", "Redact it", patch),
1636                option(
1637                    "keep",
1638                    "Keep it in the clear (it is not sensitive, or a later system needs it)",
1639                    Vec::new(),
1640                ),
1641            ],
1642            default: Some("redact".into()),
1643            blocking: true,
1644            affects: vec!["/conversation/policies".into()],
1645        });
1646    }
1647}
1648
1649fn ask_voice(spec: &SessionSpec, out: &mut Vec<Question>) {
1650    if spec.modality != super::SpecModality::Audio || spec.voice.is_some() {
1651        return;
1652    }
1653    out.push(Question {
1654        id: "voice".into(),
1655        ask: "Which voice should the agent speak with?".into(),
1656        why: "Without one the provider default (Puck) is used.".into(),
1657        kind: QuestionKind::Choice,
1658        options: VOICES
1659            .iter()
1660            .map(|v| option(v, *v, vec![add("/voice", json!(v))]))
1661            .collect(),
1662        default: Some("Puck".into()),
1663        blocking: false,
1664        affects: vec!["/voice".into()],
1665    });
1666}
1667
1668fn ask_greeting(spec: &SessionSpec, out: &mut Vec<Question>) {
1669    if spec.modality != super::SpecModality::Audio || spec.greeting.is_some() {
1670        return;
1671    }
1672    let greeting = if spec.name.trim().is_empty() {
1673        "Greet the caller and ask how you can help.".to_string()
1674    } else {
1675        format!(
1676            "Greet the caller as the assistant for {} and ask how you can help.",
1677            spec.name
1678        )
1679    };
1680    out.push(Question {
1681        id: "greeting".into(),
1682        ask: "Should the agent speak first when the call connects?".into(),
1683        why: "On a phone call the caller usually expects to hear the agent first. Without a \
1684              greeting the agent waits for the caller."
1685            .into(),
1686        kind: QuestionKind::Choice,
1687        options: vec![
1688            option(
1689                "speak_first",
1690                format!("Speak first: \"{greeting}\""),
1691                vec![add("/greeting", json!(greeting))],
1692            ),
1693            AnswerOption {
1694                value: "custom".into(),
1695                label: "Speak first, with your own greeting instruction".into(),
1696                patch: vec![add("/greeting", json!(ANSWER_PLACEHOLDER))],
1697                needs_value: true,
1698            },
1699            option("wait", "Wait for the caller to speak", Vec::new()),
1700        ],
1701        default: Some("speak_first".into()),
1702        blocking: false,
1703        affects: vec!["/greeting".into()],
1704    });
1705}
1706
1707fn ask_escalation(doc: &Value, spec: &SessionSpec, out: &mut Vec<Question>) {
1708    let Some(conv) = &spec.conversation else {
1709        return;
1710    };
1711    let handoff = conv
1712        .policies
1713        .iter()
1714        .any(|p| matches!(p, Policy::SafetyHandoff { .. }));
1715    let escalates = conv
1716        .stages
1717        .iter()
1718        .any(|s| s.repair.as_ref().is_some_and(|r| r.escalate_to.is_some()));
1719    if handoff || escalates || !conv.overlays.is_empty() {
1720        return;
1721    }
1722    // A digression whose stage admits only the transfer tool and waits for
1723    // it. A terminal digression stage, which `safety_handoff` lowers to,
1724    // restricts no tools on the turn it is entered.
1725    let key = "intent:human_agent";
1726    let transfer = "handoff_to_staff";
1727    let mut patch = Vec::new();
1728    if !spec.tools.iter().any(|t| t.name == transfer) {
1729        patch.push(append(
1730            doc,
1731            "/tools",
1732            json!({
1733                "name": transfer,
1734                "description": "Transfer the caller to a member of staff."
1735            }),
1736        ));
1737    }
1738    patch.push(append(
1739        doc,
1740        "/conversation/overlays",
1741        json!({
1742            "name": "handoff",
1743            "trigger": { "is_true": key },
1744            "stages": [{
1745                "id": "transfer",
1746                "say": "Tell the caller you are passing them to a member of staff, then call handoff_to_staff.",
1747                "allow": [transfer],
1748                "done": { "called_ok": transfer }
1749            }],
1750            "resume": "terminate"
1751        }),
1752    ));
1753    if !spec.state_keys_written().contains(key) {
1754        patch.extend(signal_ops(
1755            doc,
1756            key,
1757            "The caller asked to speak to a person instead of the assistant.",
1758        ));
1759    }
1760    out.push(Question {
1761        id: "escalation".into(),
1762        ask: "What should happen when the caller asks for a person?".into(),
1763        why: "The conversation has no digression, safety handoff or repair escalation, so a \
1764              caller who wants out has no path."
1765            .into(),
1766        kind: QuestionKind::Choice,
1767        options: vec![
1768            option(
1769                "handoff",
1770                "Hand off through a `handoff_to_staff` tool your app implements; the \
1771                 conversation then ends and `flow:terminated` becomes true",
1772                patch,
1773            ),
1774            option("none", "Keep the agent in the conversation", Vec::new()),
1775        ],
1776        default: Some("handoff".into()),
1777        blocking: false,
1778        affects: vec![
1779            "/tools".into(),
1780            "/conversation/overlays".into(),
1781            "/extract".into(),
1782        ],
1783    });
1784}
1785
1786fn ask_disclosure(spec: &SessionSpec, out: &mut Vec<Question>) {
1787    let Some(conv) = &spec.conversation else {
1788        return;
1789    };
1790    if spec.modality != super::SpecModality::Audio
1791        || conv.stages.is_empty()
1792        || conv.stages.iter().any(|s| s.verbatim.is_some())
1793    {
1794        return;
1795    }
1796    let path = "/conversation/stages/0/verbatim".to_string();
1797    out.push(Question {
1798        id: "disclosure".into(),
1799        ask: format!(
1800            "Should the agent read a fixed disclosure in the `{}` stage, such as a \
1801             recording or AI notice?",
1802            conv.stages[0].id
1803        ),
1804        why: "Text that must be said word for word belongs in a verbatim stage, which does \
1805              not complete until it has been said."
1806            .into(),
1807        kind: QuestionKind::Choice,
1808        options: vec![
1809            AnswerOption {
1810                value: "read".into(),
1811                label: "Read this text word for word".into(),
1812                patch: vec![add(path.clone(), json!(ANSWER_PLACEHOLDER))],
1813                needs_value: true,
1814            },
1815            option("none", "No fixed text", Vec::new()),
1816        ],
1817        default: Some("none".into()),
1818        blocking: false,
1819        affects: vec![path],
1820    });
1821}
1822
1823fn ask_tool_bindings(spec: &SessionSpec, out: &mut Vec<Question>) {
1824    for (i, tool) in spec.tools.iter().enumerate() {
1825        if tool.http.is_some() || tool.mcp.is_some() || tool.response.is_some() {
1826            continue;
1827        }
1828        let method = if looks_committing(spec, &tool.name) {
1829            "POST"
1830        } else {
1831            "GET"
1832        };
1833        out.push(Question {
1834            id: format!("tool_binding:{}", tool.name),
1835            ask: format!("How is `{}` implemented?", tool.name),
1836            why: "A tool with no binding returns `{\"ok\": true}` until it is implemented.".into(),
1837            kind: QuestionKind::Choice,
1838            options: vec![
1839                option(
1840                    "stub",
1841                    "In the generated project: codegen writes a typed stub to fill in",
1842                    Vec::new(),
1843                ),
1844                AnswerOption {
1845                    value: "http".into(),
1846                    label: format!("An HTTP endpoint ({method}); give its URL"),
1847                    patch: vec![add(
1848                        format!("/tools/{i}/http"),
1849                        json!({ "method": method, "url": ANSWER_PLACEHOLDER }),
1850                    )],
1851                    needs_value: true,
1852                },
1853                AnswerOption {
1854                    value: "mcp".into(),
1855                    label: "An MCP server; give its command line or http(s) URL".into(),
1856                    patch: vec![add(format!("/tools/{i}/mcp"), json!(ANSWER_PLACEHOLDER))],
1857                    needs_value: true,
1858                },
1859            ],
1860            default: Some("stub".into()),
1861            blocking: false,
1862            affects: vec![format!("/tools/{i}")],
1863        });
1864    }
1865}
1866
1867// ---------------------------------------------------------------------------
1868// Answer
1869// ---------------------------------------------------------------------------
1870
1871/// One answer to a [`Question`].
1872#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1873pub struct Answer {
1874    /// The question id.
1875    pub id: String,
1876    /// The chosen option. Omitted: the only option of a text question, or
1877    /// the question's default.
1878    #[serde(default, skip_serializing_if = "Option::is_none")]
1879    pub choice: Option<String>,
1880    /// The value for an option that needs one.
1881    #[serde(default, skip_serializing_if = "Option::is_none")]
1882    pub value: Option<Value>,
1883}
1884
1885impl Answer {
1886    /// Choose an option.
1887    pub fn choose(id: impl Into<String>, choice: impl Into<String>) -> Self {
1888        Self {
1889            id: id.into(),
1890            choice: Some(choice.into()),
1891            value: None,
1892        }
1893    }
1894
1895    /// Answer a text question.
1896    pub fn with_value(id: impl Into<String>, value: Value) -> Self {
1897        Self {
1898            id: id.into(),
1899            choice: None,
1900            value: Some(value),
1901        }
1902    }
1903}
1904
1905/// The result of [`answer`].
1906#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1907pub struct Answered {
1908    /// The updated document.
1909    pub spec: Value,
1910    /// Question ids applied, in order.
1911    pub applied: Vec<String>,
1912    /// The input decisions plus the new ones.
1913    pub decisions: Decisions,
1914}
1915
1916/// Why answers could not be applied. Nothing is applied when any answer
1917/// fails.
1918#[derive(Debug, Clone, PartialEq)]
1919pub enum AnswerError {
1920    /// The question is not open: answered already, decided, or no longer
1921    /// applies after earlier answers.
1922    NotOpen(String),
1923    /// No choice was given and the question has no default.
1924    MissingChoice(String),
1925    /// The choice is not one of the question's options.
1926    UnknownChoice {
1927        /// Question id.
1928        id: String,
1929        /// The choice given.
1930        choice: String,
1931        /// The valid option values.
1932        options: Vec<String>,
1933    },
1934    /// The option needs a non-empty value.
1935    MissingValue {
1936        /// Question id.
1937        id: String,
1938        /// The option chosen.
1939        choice: String,
1940    },
1941    /// The option's patch did not apply.
1942    Patch {
1943        /// Question id.
1944        id: String,
1945        /// The patch failure.
1946        error: PatchError,
1947    },
1948    /// The answer left a document that no longer deserializes.
1949    Invalid {
1950        /// Question id.
1951        id: String,
1952        /// The deserialization error.
1953        message: String,
1954    },
1955}
1956
1957impl std::fmt::Display for AnswerError {
1958    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1959        match self {
1960            AnswerError::NotOpen(id) => write!(
1961                f,
1962                "question '{id}' is not open: no such question, already decided, or it no \
1963                 longer applies"
1964            ),
1965            AnswerError::MissingChoice(id) => {
1966                write!(f, "question '{id}' needs a choice: it has no default")
1967            }
1968            AnswerError::UnknownChoice {
1969                id,
1970                choice,
1971                options,
1972            } => write!(
1973                f,
1974                "question '{id}' has no option '{choice}' (options: {})",
1975                options.join(", ")
1976            ),
1977            AnswerError::MissingValue { id, choice } => {
1978                write!(f, "question '{id}', option '{choice}' needs a value")
1979            }
1980            AnswerError::Patch { id, error } => write!(f, "question '{id}': {error}"),
1981            AnswerError::Invalid { id, message } => {
1982                write!(f, "question '{id}' left an invalid spec: {message}")
1983            }
1984        }
1985    }
1986}
1987
1988impl std::error::Error for AnswerError {}
1989
1990/// Apply `answers` in order. Before each one the document is planned again,
1991/// so a patch never comes from a stale plan.
1992pub fn answer(
1993    doc: &Value,
1994    answers: &[Answer],
1995    decisions: &Decisions,
1996) -> Result<Answered, AnswerError> {
1997    let mut spec = doc.clone();
1998    let mut decisions = decisions.clone();
1999    let mut applied = Vec::new();
2000    for a in answers {
2001        let question = questions(&spec, &decisions)
2002            .into_iter()
2003            .find(|q| q.id == a.id)
2004            .ok_or_else(|| AnswerError::NotOpen(a.id.clone()))?;
2005        let choice = match (&a.choice, question.options.as_slice()) {
2006            (Some(choice), _) => choice.clone(),
2007            (None, [only]) => only.value.clone(),
2008            (None, _) => question
2009                .default
2010                .clone()
2011                .ok_or_else(|| AnswerError::MissingChoice(a.id.clone()))?,
2012        };
2013        let chosen = question
2014            .options
2015            .iter()
2016            .find(|o| o.value == choice)
2017            .ok_or_else(|| AnswerError::UnknownChoice {
2018                id: a.id.clone(),
2019                choice: choice.clone(),
2020                options: question.options.iter().map(|o| o.value.clone()).collect(),
2021            })?;
2022        let patch = if chosen.needs_value {
2023            let value = a
2024                .value
2025                .clone()
2026                .filter(|v| v.as_str().is_none_or(|s| !s.trim().is_empty()) && !v.is_null())
2027                .ok_or_else(|| AnswerError::MissingValue {
2028                    id: a.id.clone(),
2029                    choice: choice.clone(),
2030                })?;
2031            chosen
2032                .patch
2033                .iter()
2034                .map(|op| substitute(op, &value))
2035                .collect()
2036        } else {
2037            chosen.patch.clone()
2038        };
2039        spec = apply_patch(&spec, &patch).map_err(|error| AnswerError::Patch {
2040            id: a.id.clone(),
2041            error,
2042        })?;
2043        SessionSpec::from_value(spec.clone()).map_err(|message| AnswerError::Invalid {
2044            id: a.id.clone(),
2045            message,
2046        })?;
2047        decisions.insert(question.id.clone(), choice);
2048        applied.push(question.id);
2049    }
2050    Ok(Answered {
2051        spec,
2052        applied,
2053        decisions,
2054    })
2055}
2056
2057fn substitute(op: &PatchOp, answer: &Value) -> PatchOp {
2058    fn fill(value: &Value, answer: &Value) -> Value {
2059        match value {
2060            Value::String(s) if s == ANSWER_PLACEHOLDER => answer.clone(),
2061            Value::Array(items) => Value::Array(items.iter().map(|v| fill(v, answer)).collect()),
2062            Value::Object(map) => Value::Object(
2063                map.iter()
2064                    .map(|(k, v)| (k.clone(), fill(v, answer)))
2065                    .collect(),
2066            ),
2067            other => other.clone(),
2068        }
2069    }
2070    match op {
2071        PatchOp::Add { path, value } => PatchOp::Add {
2072            path: path.clone(),
2073            value: fill(value, answer),
2074        },
2075        PatchOp::Replace { path, value } => PatchOp::Replace {
2076            path: path.clone(),
2077            value: fill(value, answer),
2078        },
2079        PatchOp::Remove { path } => PatchOp::Remove { path: path.clone() },
2080    }
2081}
2082
2083#[cfg(test)]
2084mod tests {
2085    use super::*;
2086
2087    fn booking() -> Value {
2088        json!({
2089            "name": "trattoria",
2090            "instruction": "Book tables for Trattoria Rustica.",
2091            "modality": "audio",
2092            "voice": "Charon",
2093            "greeting": "Welcome the caller.",
2094            "tools": [
2095                { "name": "check_availability", "description": "Check a time.", "response": { "available": true } },
2096                { "name": "book_table", "description": "Book the table.", "response": { "booked": true } }
2097            ],
2098            "conversation": {
2099                "name": "booking",
2100                "stages": [
2101                    {
2102                        "id": "collect",
2103                        "say": "Find out the party size and time.",
2104                        "verbatim": "This call may be recorded.",
2105                        "collect": ["party_size", "slot"],
2106                        "allow": ["check_availability"],
2107                        "next": [{ "to": "confirm", "when": { "captured": ["party_size", "slot"] } }]
2108                    },
2109                    {
2110                        "id": "confirm",
2111                        "say": "Read the booking back and book it once the caller agrees.",
2112                        "allow": ["book_table"],
2113                        "next": [{ "to": "done", "when": { "called_ok": "book_table" } }]
2114                    },
2115                    { "id": "done", "terminal": true }
2116                ],
2117                "require": ["done"],
2118                "policies": [{ "kind": "safety_handoff", "intents": ["human_agent"] }]
2119            },
2120            "models": { "extraction": "gemini-3.5-flash-lite", "extraction_thinking_budget": 64 },
2121            "extract": [{
2122                "name": "caller_signals",
2123                "instruction": "Signals.",
2124                "schema": { "type": "object", "properties": { "intent_human_agent": { "type": "boolean" } } },
2125                "promote": [{ "field": "intent_human_agent", "to": "intent:human_agent", "policy": "true_only" }]
2126            }]
2127        })
2128    }
2129
2130    fn codes(report: &CheckReport) -> Vec<&str> {
2131        report.diagnostics.iter().map(|d| d.code.as_str()).collect()
2132    }
2133
2134    fn fix(report: &CheckReport, code: &str) -> Vec<PatchOp> {
2135        report
2136            .diagnostics
2137            .iter()
2138            .find(|d| d.code == code)
2139            .and_then(|d| d.fix.clone())
2140            .unwrap_or_else(|| panic!("no fix for {code}: {report:#?}"))
2141            .patch
2142    }
2143
2144    #[test]
2145    fn patch_add_replace_remove() {
2146        let doc = json!({ "a": [1, 3], "b": { "c": 1 } });
2147        let out = apply_patch(
2148            &doc,
2149            &[
2150                add("/a/1", json!(2)),
2151                add("/a/-", json!(4)),
2152                PatchOp::Replace {
2153                    path: "/b/c".into(),
2154                    value: json!(2),
2155                },
2156                add("/b/d~1e", json!(true)),
2157                PatchOp::Remove {
2158                    path: "/a/0".into(),
2159                },
2160            ],
2161        )
2162        .unwrap();
2163        assert_eq!(out, json!({ "a": [2, 3, 4], "b": { "c": 2, "d/e": true } }));
2164    }
2165
2166    #[test]
2167    fn patch_is_atomic_and_reports_the_failing_op() {
2168        let doc = json!({ "a": 1 });
2169        let err = apply_patch(
2170            &doc,
2171            &[
2172                add("/b", json!(2)),
2173                PatchOp::Replace {
2174                    path: "/missing".into(),
2175                    value: json!(0),
2176                },
2177            ],
2178        )
2179        .unwrap_err();
2180        assert_eq!(err.index, 1);
2181        assert_eq!(err.path, "/missing");
2182        assert!(apply_patch(&doc, &[add("/x/y", json!(1))]).is_err());
2183        assert!(apply_patch(&json!([1]), &[add("/01", json!(1))]).is_err());
2184        assert!(apply_patch(&json!([1]), &[add("/2", json!(1))]).is_err());
2185        assert!(apply_patch(&doc, &[PatchOp::Remove { path: "".into() }]).is_err());
2186    }
2187
2188    #[test]
2189    fn patch_ops_use_rfc6902_json() {
2190        let op: PatchOp =
2191            serde_json::from_value(json!({ "op": "add", "path": "/a", "value": 1 })).unwrap();
2192        assert_eq!(op, add("/a", json!(1)));
2193        assert_eq!(
2194            serde_json::to_value(PatchOp::Remove { path: "/a".into() }).unwrap(),
2195            json!({ "op": "remove", "path": "/a" })
2196        );
2197    }
2198
2199    #[test]
2200    fn catalog_examples_deserialize() {
2201        let catalog = catalog();
2202        for g in &catalog.guards {
2203            serde_json::from_value::<gemini_adk_rs::flow::Guard>(g.example.clone())
2204                .unwrap_or_else(|e| panic!("guard {}: {e}", g.name));
2205        }
2206        for p in &catalog.policies {
2207            let policy: Policy = serde_json::from_value(p.example.clone())
2208                .unwrap_or_else(|e| panic!("policy {}: {e}", p.name));
2209            assert_eq!(serde_json::to_value(&policy).unwrap()["kind"], p.name);
2210        }
2211        for b in &catalog.tool_bindings {
2212            serde_json::from_value::<super::super::ToolSpec>(b.example.clone())
2213                .unwrap_or_else(|e| panic!("binding {}: {e}", b.name));
2214        }
2215        for r in &catalog.resume {
2216            serde_json::from_value::<crate::conversation::Resume>(r.example.clone())
2217                .unwrap_or_else(|e| panic!("resume {}: {e}", r.name));
2218        }
2219        for m in &catalog.modalities {
2220            serde_json::from_value::<super::super::SpecModality>(json!(m)).unwrap();
2221        }
2222        for v in &catalog.voices {
2223            let voice: gemini_genai_rs::prelude::Voice = serde_json::from_value(json!(v)).unwrap();
2224            assert_eq!(&voice.to_string(), v);
2225            assert!(!matches!(voice, gemini_genai_rs::prelude::Voice::Custom(_)));
2226        }
2227    }
2228
2229    #[test]
2230    fn catalog_lists_every_question_rule() {
2231        // Every question id the rules can produce has a catalog entry.
2232        let doc = json!({
2233            "modality": "audio",
2234            "tools": [{ "name": "book_table" }],
2235            "conversation": {
2236                "name": "c",
2237                "stages": [
2238                    { "id": "collect", "say": "s", "collect": ["card_number"], "allow": ["book_table"],
2239                      "next": [{ "to": "done", "when": { "called_ok": "book_table" } }] },
2240                    { "id": "done", "terminal": true }
2241                ]
2242            }
2243        });
2244        let ids: BTreeSet<String> = plan(&doc, &Decisions::new())
2245            .questions
2246            .into_iter()
2247            .map(|q| q.id.split(':').next().unwrap().to_string())
2248            .collect();
2249        let catalog: BTreeSet<String> = catalog()
2250            .questions
2251            .into_iter()
2252            .map(|q| q.name.split(':').next().unwrap().to_string())
2253            .collect();
2254        assert_eq!(ids, catalog);
2255    }
2256
2257    #[test]
2258    fn a_complete_spec_checks_clean_and_has_one_question() {
2259        let doc = booking();
2260        let report = check(&doc);
2261        assert!(report.valid, "{report:#?}");
2262        assert!(report.diagnostics.is_empty(), "{report:#?}");
2263        let plan = plan(&doc, &Decisions::new());
2264        let ids: Vec<&str> = plan.questions.iter().map(|q| q.id.as_str()).collect();
2265        assert_eq!(ids, ["commit_gate:book_table"]);
2266        assert!(!plan.ready);
2267    }
2268
2269    #[test]
2270    fn misspelled_fields_are_reported_with_a_rename() {
2271        let mut doc = booking();
2272        let instruction = doc["instruction"].take();
2273        doc.as_object_mut().unwrap().remove("instruction");
2274        doc["instructions"] = instruction;
2275        doc["conversation"]["stages"][0]["colect"] = json!(["x"]);
2276        doc["$schema"] = json!("ignored");
2277        let report = check(&doc);
2278        let unknown: Vec<_> = report
2279            .diagnostics
2280            .iter()
2281            .filter(|d| d.code == "unknown_field")
2282            .map(|d| d.path.clone().unwrap())
2283            .collect();
2284        assert_eq!(unknown, ["/instructions", "/conversation/stages/0/colect"]);
2285        let fixed = apply_patch(&doc, &fix(&report, "unknown_field")).unwrap();
2286        assert_eq!(fixed["instruction"], "Book tables for Trattoria Rustica.");
2287        assert!(fixed.get("instructions").is_none());
2288    }
2289
2290    #[test]
2291    fn a_misspelled_code_field_is_reported_with_a_rename() {
2292        let mut doc = booking();
2293        doc["code"] = json!({ "fucntions": [], "limits": { "call": 5 } });
2294        let report = check(&doc);
2295        let unknown: Vec<_> = report
2296            .diagnostics
2297            .iter()
2298            .filter(|d| d.code == "unknown_field")
2299            .map(|d| d.path.clone().unwrap())
2300            .collect();
2301        assert_eq!(unknown, ["/code/fucntions", "/code/limits/call"]);
2302        let renames: Vec<_> = report
2303            .diagnostics
2304            .iter()
2305            .filter_map(|d| d.fix.as_ref().map(|f| f.description.clone()))
2306            .collect();
2307        assert!(renames.contains(&"rename 'fucntions' to 'functions'".to_string()));
2308        assert!(renames.contains(&"rename 'call' to 'calls'".to_string()));
2309    }
2310
2311    #[test]
2312    fn unknown_tools_get_a_path_and_a_fix() {
2313        let mut doc = booking();
2314        doc["conversation"]["stages"][1]["allow"] = json!(["book_tabel"]);
2315        let report = check(&doc);
2316        assert!(!report.valid);
2317        let d = &report.diagnostics[0];
2318        assert_eq!(d.code, "unknown_tool");
2319        assert_eq!(d.path.as_deref(), Some("/conversation/stages/1/allow/0"));
2320        // The validator's own message is not repeated.
2321        assert!(!codes(&report).contains(&"validation"), "{report:#?}");
2322        let fixed = apply_patch(&doc, &fix(&report, "unknown_tool")).unwrap();
2323        assert!(check(&fixed).valid);
2324
2325        // No close match: declare it.
2326        let mut doc = booking();
2327        doc["conversation"]["stages"][1]["allow"] = json!(["refund"]);
2328        let report = check(&doc);
2329        let fixed = apply_patch(&doc, &fix(&report, "unknown_tool")).unwrap();
2330        assert_eq!(fixed["tools"][2], json!({ "name": "refund" }));
2331        assert!(check(&fixed).valid, "{:#?}", check(&fixed));
2332    }
2333
2334    #[test]
2335    fn unwritten_commit_key_gets_a_signal_extractor() {
2336        let mut doc = booking();
2337        doc["conversation"]["stages"][1]["commit"] =
2338            json!({ "tool": "book_table", "when": { "is_true": "user_confirmed" } });
2339        let report = check(&doc);
2340        assert_eq!(codes(&report), ["unwritten_key"], "{report:#?}");
2341        assert_eq!(
2342            report.diagnostics[0].path.as_deref(),
2343            Some("/conversation/stages/1/commit/when/is_true")
2344        );
2345        let fixed = apply_patch(&doc, &fix(&report, "unwritten_key")).unwrap();
2346        let signals = &fixed["extract"][0];
2347        assert_eq!(
2348            signals["schema"]["properties"]["user_confirmed"]["type"],
2349            "boolean"
2350        );
2351        assert_eq!(
2352            signals["promote"][1],
2353            json!({ "field": "user_confirmed", "policy": "true_only" })
2354        );
2355        assert!(check(&fixed).diagnostics.is_empty());
2356    }
2357
2358    #[test]
2359    fn unwritten_key_prefers_a_close_written_key() {
2360        let mut doc = booking();
2361        doc["conversation"]["stages"][0]["next"][0]["when"] =
2362            json!({ "all": [{ "captured": ["party_sise"] }, { "is_set": "slot" }] });
2363        let report = check(&doc);
2364        let d = report
2365            .diagnostics
2366            .iter()
2367            .find(|d| d.code == "unwritten_key")
2368            .unwrap();
2369        assert_eq!(
2370            d.path.as_deref(),
2371            Some("/conversation/stages/0/next/0/when/all/0/captured/0")
2372        );
2373        let fixed = apply_patch(&doc, &fix(&report, "unwritten_key")).unwrap();
2374        assert!(check(&fixed).diagnostics.is_empty());
2375    }
2376
2377    #[test]
2378    fn digression_triggers_and_handoff_intents_need_a_writer() {
2379        // Validation does not see digression triggers; check does.
2380        let mut doc = booking();
2381        doc.as_object_mut().unwrap().remove("extract");
2382        doc["conversation"]["overlays"] = json!([{
2383            "name": "cancel",
2384            "trigger": { "is_true": "intent:cancel" },
2385            "stages": [{ "id": "bye", "say": "Say goodbye.", "terminal": true }],
2386            "resume": "terminate"
2387        }]);
2388        let report = check(&doc);
2389        let paths: Vec<_> = report
2390            .diagnostics
2391            .iter()
2392            .filter(|d| d.code == "unwritten_key")
2393            .map(|d| d.path.clone().unwrap())
2394            .collect();
2395        assert_eq!(
2396            paths,
2397            [
2398                "/conversation/overlays/0/trigger/is_true",
2399                "/conversation/policies/0/intents/0"
2400            ]
2401        );
2402        // One fix at a time, checking again in between.
2403        for _ in 0..2 {
2404            let report = check(&doc);
2405            doc = apply_patch(&doc, &fix(&report, "unwritten_key")).unwrap();
2406        }
2407        assert!(check(&doc).diagnostics.is_empty(), "{:#?}", check(&doc));
2408        let promote = &doc["extract"][0]["promote"];
2409        assert_eq!(promote[0]["to"], "intent:cancel");
2410        assert_eq!(promote[1]["to"], "intent:human_agent");
2411    }
2412
2413    #[test]
2414    fn invalid_documents_are_reported_not_planned() {
2415        let report = check_str("{\"name\": ");
2416        assert_eq!(codes(&report), ["invalid_json"]);
2417        assert!(report.diagnostics[0].message.contains("line 1"));
2418        assert_eq!(codes(&check(&json!([]))), ["not_an_object"]);
2419        let doc = json!({ "name": 3 });
2420        assert_eq!(codes(&check(&doc)), ["invalid_spec"]);
2421        let plan = plan(&doc, &Decisions::new());
2422        assert!(plan.questions.is_empty() && !plan.ready);
2423    }
2424
2425    #[test]
2426    fn validation_messages_pass_through() {
2427        let mut doc = booking();
2428        doc["conversation"]["stages"][1]["commit"] =
2429            json!({ "tool": "book_table", "when": "always" });
2430        let report = check(&doc);
2431        assert!(!report.valid);
2432        assert!(
2433            report
2434                .diagnostics
2435                .iter()
2436                .any(|d| d.code == "validation" && d.message.contains("always-true"))
2437        );
2438    }
2439
2440    #[test]
2441    fn plan_asks_the_open_questions_blocking_first() {
2442        let doc = json!({
2443            "modality": "audio",
2444            "tools": [{ "name": "book_table" }],
2445            "conversation": {
2446                "name": "booking",
2447                "stages": [
2448                    { "id": "collect", "say": "s", "collect": ["party_size", "card_number"],
2449                      "next": [{ "to": "confirm", "when": { "captured": ["party_size"] } }] },
2450                    { "id": "confirm", "say": "c", "allow": ["book_table"],
2451                      "next": [{ "to": "done", "when": { "called_ok": "book_table" } }] },
2452                    { "id": "done", "terminal": true }
2453                ]
2454            }
2455        });
2456        let plan = plan(&doc, &Decisions::new());
2457        let ids: Vec<&str> = plan.questions.iter().map(|q| q.id.as_str()).collect();
2458        assert_eq!(
2459            ids,
2460            [
2461                "name",
2462                "instruction",
2463                "tool_description:book_table",
2464                "commit_gate:book_table",
2465                "redact:card_number",
2466                "voice",
2467                "greeting",
2468                "escalation",
2469                "disclosure",
2470                "tool_binding:book_table",
2471            ]
2472        );
2473        assert_eq!(plan.blocking, 5);
2474        // A committing tool is bound with POST.
2475        let binding = plan.questions.last().unwrap();
2476        let http = binding.options.iter().find(|o| o.value == "http").unwrap();
2477        assert_eq!(
2478            http.patch,
2479            [add(
2480                "/tools/0/http",
2481                json!({ "method": "POST", "url": ANSWER_PLACEHOLDER })
2482            )]
2483        );
2484        assert!(!plan.ready);
2485        for q in &plan.questions {
2486            if let Some(default) = &q.default {
2487                let option = q.options.iter().find(|o| &o.value == default).unwrap();
2488                assert!(!option.needs_value, "{} default needs a value", q.id);
2489            }
2490        }
2491    }
2492
2493    #[test]
2494    fn answering_everything_makes_the_spec_ready() {
2495        let doc = json!({
2496            "modality": "audio",
2497            "tools": [{ "name": "book_table" }],
2498            "conversation": {
2499                "name": "booking",
2500                "stages": [
2501                    { "id": "collect", "say": "s", "collect": ["party_size", "card_number"],
2502                      "next": [{ "to": "confirm", "when": { "captured": ["party_size"] } }] },
2503                    { "id": "confirm", "say": "c", "allow": ["book_table"],
2504                      "next": [{ "to": "done", "when": { "called_ok": "book_table" } }] },
2505                    { "id": "done", "terminal": true }
2506                ],
2507                "require": ["done"]
2508            }
2509        });
2510        let answers = [
2511            Answer::with_value("name", json!("trattoria")),
2512            Answer::with_value("instruction", json!("Book tables.")),
2513            Answer::with_value("tool_description:book_table", json!("Book a table.")),
2514            Answer::choose("commit_gate:book_table", "confirm"),
2515            Answer::choose("redact:card_number", "redact"),
2516            Answer::choose("voice", "Kore"),
2517            Answer {
2518                id: "greeting".into(),
2519                choice: None,
2520                value: None,
2521            },
2522            Answer::choose("escalation", "handoff"),
2523            Answer {
2524                id: "disclosure".into(),
2525                choice: Some("read".into()),
2526                value: Some(json!("This call is with an automated assistant.")),
2527            },
2528            Answer {
2529                id: "tool_binding:book_table".into(),
2530                choice: Some("mcp".into()),
2531                value: Some(json!("python tools/server.py")),
2532            },
2533        ];
2534        let mut answers = answers.to_vec();
2535        // Answering `escalation` declares the transfer tool, which then has
2536        // its own binding question.
2537        answers.push(Answer::choose("tool_binding:handoff_to_staff", "stub"));
2538        let out = answer(&doc, &answers, &Decisions::new()).unwrap();
2539        assert_eq!(out.applied.len(), answers.len());
2540        assert_eq!(out.decisions["greeting"], "speak_first");
2541        let spec = &out.spec;
2542        assert_eq!(spec["voice"], "Kore");
2543        assert_eq!(
2544            spec["conversation"]["stages"][1]["commit"],
2545            json!({ "tool": "book_table", "when": { "is_true": "book_table_confirmed" } })
2546        );
2547        assert_eq!(
2548            spec["conversation"]["policies"],
2549            json!([{ "kind": "redact", "keys": ["card_number"] }])
2550        );
2551        assert_eq!(spec["conversation"]["overlays"][0]["name"], "handoff");
2552        assert_eq!(spec["tools"][1]["name"], "handoff_to_staff");
2553        // Both signals share one extractor.
2554        assert_eq!(spec["extract"].as_array().unwrap().len(), 1);
2555        assert_eq!(spec["extract"][0]["promote"].as_array().unwrap().len(), 2);
2556        assert_eq!(spec["tools"][0]["mcp"], "python tools/server.py");
2557
2558        let report = check(spec);
2559        assert!(report.diagnostics.is_empty(), "{report:#?}");
2560        let plan = plan(spec, &out.decisions);
2561        assert!(plan.questions.is_empty() && plan.ready, "{plan:#?}");
2562    }
2563
2564    #[tokio::test]
2565    async fn the_generated_handoff_admits_only_the_transfer() {
2566        // A terminal digression stage restricts nothing on the turn it is
2567        // entered; the generated one admits only the transfer until it runs.
2568        let doc = booking();
2569        let mut doc = doc;
2570        doc["conversation"]
2571            .as_object_mut()
2572            .unwrap()
2573            .remove("policies");
2574        doc.as_object_mut().unwrap().remove("extract");
2575        let out = answer(
2576            &doc,
2577            &[Answer::choose("escalation", "handoff")],
2578            &Decisions::new(),
2579        )
2580        .unwrap();
2581        let mut spec = out.spec;
2582        spec["scenarios"] = json!([{
2583            "name": "asking for a person admits only the transfer",
2584            "steps": [
2585                { "set": { "key": "intent:human_agent", "value": true } },
2586                "turn",
2587                { "expect_denied": "book_table" },
2588                { "expect_denied": "check_availability" },
2589                { "expect_allowed": "handoff_to_staff" },
2590                { "tool_ok": "handoff_to_staff" },
2591                "turn",
2592                { "expect_denied": "handoff_to_staff" },
2593                { "expect_denied": "book_table" }
2594            ]
2595        }]);
2596        assert!(check(&spec).valid, "{:#?}", check(&spec));
2597        let reports = SessionSpec::from_value(spec).unwrap().run_scenarios().await;
2598        assert!(reports.iter().all(|r| r.passed), "{reports:#?}");
2599    }
2600
2601    #[test]
2602    fn decisions_suppress_questions_that_leave_the_spec_unchanged() {
2603        let doc = json!({
2604            "name": "a", "instruction": "b", "modality": "audio", "voice": "Puck",
2605            "greeting": "Hi."
2606        });
2607        assert!(plan(&doc, &Decisions::new()).questions.is_empty());
2608        let mut doc = doc;
2609        doc.as_object_mut().unwrap().remove("greeting");
2610        let out = answer(
2611            &doc,
2612            &[Answer::choose("greeting", "wait")],
2613            &Decisions::new(),
2614        )
2615        .unwrap();
2616        assert_eq!(out.spec, doc);
2617        assert!(plan(&out.spec, &out.decisions).questions.is_empty());
2618        assert_eq!(
2619            answer(
2620                &out.spec,
2621                &[Answer::choose("greeting", "wait")],
2622                &out.decisions
2623            ),
2624            Err(AnswerError::NotOpen("greeting".into()))
2625        );
2626    }
2627
2628    #[test]
2629    fn bad_answers_change_nothing() {
2630        let doc = json!({ "modality": "audio", "instruction": "x" });
2631        let none = Decisions::new();
2632        assert_eq!(
2633            answer(&doc, &[Answer::choose("voice", "Nope")], &none).unwrap_err(),
2634            AnswerError::UnknownChoice {
2635                id: "voice".into(),
2636                choice: "Nope".into(),
2637                options: VOICES.iter().map(|v| (*v).to_string()).collect(),
2638            }
2639        );
2640        assert!(matches!(
2641            answer(&doc, &[Answer::with_value("name", json!(""))], &none),
2642            Err(AnswerError::MissingValue { .. })
2643        ));
2644        assert!(matches!(
2645            answer(&doc, &[Answer::with_value("name", json!(7))], &none),
2646            Err(AnswerError::Invalid { .. })
2647        ));
2648        // A later failure discards earlier answers.
2649        assert!(
2650            answer(
2651                &doc,
2652                &[
2653                    Answer::with_value("name", json!("a")),
2654                    Answer::choose("nope", "x")
2655                ],
2656                &none
2657            )
2658            .is_err()
2659        );
2660    }
2661
2662    #[test]
2663    fn commit_gate_skips_read_only_tools() {
2664        let mut doc = booking();
2665        doc["conversation"]["stages"][1]["allow"] = json!(["book_table", "check_availability"]);
2666        let ids: Vec<String> = plan(&doc, &Decisions::new())
2667            .questions
2668            .into_iter()
2669            .map(|q| q.id)
2670            .collect();
2671        assert_eq!(ids, ["commit_gate:book_table"]);
2672        assert!(looks_committing(&SessionSpec::default(), "cancel-order"));
2673        assert!(!looks_committing(&SessionSpec::default(), "lookup_order"));
2674        assert!(looks_committing(&SessionSpec::default(), "bookTable"));
2675        assert!(looks_sensitive("card_number") && looks_sensitive("cardNumber"));
2676        assert!(looks_sensitive("dateOfBirth") && looks_sensitive("SSN"));
2677        assert!(!looks_sensitive("shipping_address") && !looks_sensitive("spinach"));
2678        assert_eq!(name_parts("bookTable-v2"), ["book", "table", "v2"]);
2679    }
2680}