1use 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
52pub const AUTHORING_VERSION: u32 = 1;
55
56pub const ANSWER_PLACEHOLDER: &str = "$answer";
59
60pub const SIGNALS_EXTRACTOR: &str = "caller_signals";
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
70#[serde(tag = "op", rename_all = "snake_case")]
71pub enum PatchOp {
72 Add {
74 path: String,
76 value: Value,
78 },
79 Replace {
81 path: String,
83 value: Value,
85 },
86 Remove {
88 path: String,
90 },
91}
92
93impl PatchOp {
94 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#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PatchError {
107 pub index: usize,
109 pub path: String,
111 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
127pub 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
205fn 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
222fn escape(token: &str) -> String {
224 token.replace('~', "~0").replace('/', "~1")
225}
226
227fn 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
256pub struct CatalogEntry {
257 pub name: String,
259 pub meaning: String,
261 #[serde(default, skip_serializing_if = "Value::is_null")]
263 pub example: Value,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
269pub struct Catalog {
270 pub version: u32,
272 pub modalities: Vec<String>,
274 pub voices: Vec<String>,
276 pub guards: Vec<CatalogEntry>,
278 pub policies: Vec<CatalogEntry>,
280 pub tool_bindings: Vec<CatalogEntry>,
282 pub resume: Vec<CatalogEntry>,
284 pub questions: Vec<CatalogEntry>,
286 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
300pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
529#[serde(rename_all = "snake_case")]
530pub enum Severity {
531 Error,
533 Warning,
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
539pub struct Fix {
540 pub description: String,
542 pub patch: Vec<PatchOp>,
544}
545
546#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
548pub struct Diagnostic {
549 pub severity: Severity,
551 pub code: String,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub path: Option<String>,
556 pub message: String,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub fix: Option<Fix>,
561}
562
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct CheckReport {
566 pub valid: bool,
568 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
596pub 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 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
661fn 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
688fn 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
709pub 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
724fn 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 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 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 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
831fn 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
852fn 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#[derive(Debug, Clone)]
873enum Site {
874 Commit(String),
875 Trigger,
876 Other,
877}
878
879struct 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
943fn 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 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 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
1064fn 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
1072struct KeyRead {
1074 kind: &'static str,
1075 path: String,
1076 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 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 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
1205fn signal_field(key: &str) -> String {
1207 key.chars()
1208 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1209 .collect()
1210}
1211
1212fn 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 ops.extend(pin_extraction_model(doc));
1253 ops
1254 }
1255 }
1256}
1257
1258pub type Decisions = BTreeMap<String, String>;
1266
1267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1269#[serde(rename_all = "snake_case")]
1270pub enum QuestionKind {
1271 Choice,
1273 Text,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1279pub struct AnswerOption {
1280 pub value: String,
1282 pub label: String,
1284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1287 pub patch: Vec<PatchOp>,
1288 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1291 pub needs_value: bool,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1296pub struct Question {
1297 pub id: String,
1299 pub ask: String,
1301 pub why: String,
1303 pub kind: QuestionKind,
1305 pub options: Vec<AnswerOption>,
1307 #[serde(default, skip_serializing_if = "Option::is_none")]
1310 pub default: Option<String>,
1311 pub blocking: bool,
1313 pub affects: Vec<String>,
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1319pub struct Plan {
1320 pub ready: bool,
1322 pub blocking: usize,
1324 pub questions: Vec<Question>,
1326}
1327
1328pub 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
1427const 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
1461fn 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 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
1565const 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1873pub struct Answer {
1874 pub id: String,
1876 #[serde(default, skip_serializing_if = "Option::is_none")]
1879 pub choice: Option<String>,
1880 #[serde(default, skip_serializing_if = "Option::is_none")]
1882 pub value: Option<Value>,
1883}
1884
1885impl Answer {
1886 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
1907pub struct Answered {
1908 pub spec: Value,
1910 pub applied: Vec<String>,
1912 pub decisions: Decisions,
1914}
1915
1916#[derive(Debug, Clone, PartialEq)]
1919pub enum AnswerError {
1920 NotOpen(String),
1923 MissingChoice(String),
1925 UnknownChoice {
1927 id: String,
1929 choice: String,
1931 options: Vec<String>,
1933 },
1934 MissingValue {
1936 id: String,
1938 choice: String,
1940 },
1941 Patch {
1943 id: String,
1945 error: PatchError,
1947 },
1948 Invalid {
1950 id: String,
1952 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
1990pub 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 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 assert!(!codes(&report).contains(&"validation"), "{report:#?}");
2322 let fixed = apply_patch(&doc, &fix(&report, "unknown_tool")).unwrap();
2323 assert!(check(&fixed).valid);
2324
2325 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 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 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 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 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 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 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 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}