1use crate::json_validation::{validate_json, validate_json_schema};
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ExitHint {
29 Bare,
30 Provided(String),
31}
32
33impl ExitHint {
34 pub fn from_optional(s: Option<String>) -> Self {
37 match s {
38 Some(s) if !s.trim().is_empty() => Self::Provided(s),
39 _ => Self::Bare,
40 }
41 }
42
43 pub fn as_str(&self) -> Option<&str> {
45 match self {
46 Self::Provided(s) => Some(s.as_str()),
47 Self::Bare => None,
48 }
49 }
50}
51
52impl Serialize for ExitHint {
53 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
54 match self {
55 Self::Bare => ser.serialize_str(""),
56 Self::Provided(s) => ser.serialize_str(s),
57 }
58 }
59}
60
61impl<'de> Deserialize<'de> for ExitHint {
62 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
63 let s = String::deserialize(de)?;
64 Ok(if s.trim().is_empty() {
65 Self::Bare
66 } else {
67 Self::Provided(s)
68 })
69 }
70}
71
72fn is_false(v: &bool) -> bool {
73 !v
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct ExitConstraints {
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub hint: Option<ExitHint>,
90 #[serde(default, skip_serializing_if = "is_false")]
91 pub json_mode: bool,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub schema: Option<serde_json::Value>,
94}
95
96impl ExitConstraints {
97 pub fn validate(&self, result: &str) -> Result<(), ExitValidationError> {
101 validate_exit_result(
102 result,
103 self.hint.as_ref().and_then(|h| h.as_str()),
104 self.json_mode,
105 self.schema.as_ref(),
106 )
107 }
108}
109
110const EXIT_TEMPLATE_SOURCE: &str = include_str!("../prompts/exit/1_0_0.md");
112
113pub fn exit_template() -> &'static str {
116 crate::prompts::strip_front_matter(EXIT_TEMPLATE_SOURCE)
117}
118
119pub fn build_exit_suffix(
128 hint: Option<&str>,
129 json_mode: bool,
130 json_schema: Option<&serde_json::Value>,
131) -> String {
132 let hint_section = match hint.map(str::trim).filter(|s| !s.is_empty()) {
133 Some(h) => format!("Expected result: {h}\n\n"),
134 None => String::new(),
135 };
136 let json_instruction = if json_mode || json_schema.is_some() {
137 "The result you pass to `zag ps kill self` MUST be valid JSON. \
138 Do not wrap it in markdown fences — pass the raw JSON string.\n\n"
139 .to_string()
140 } else {
141 String::new()
142 };
143 let schema_instruction = match json_schema {
144 Some(schema) => {
145 let pretty = serde_json::to_string_pretty(schema).unwrap_or_default();
146 format!(
147 "The JSON result MUST validate against this schema:\n\n```json\n{pretty}\n```\n\n"
148 )
149 }
150 None => String::new(),
151 };
152 let rendered = exit_template()
153 .replace("{HINT_SECTION}", &hint_section)
154 .replace("{JSON_INSTRUCTION}", &json_instruction)
155 .replace("{SCHEMA_INSTRUCTION}", &schema_instruction);
156 debug_assert!(
160 !rendered.contains("{HINT_SECTION}")
161 && !rendered.contains("{JSON_INSTRUCTION}")
162 && !rendered.contains("{SCHEMA_INSTRUCTION}"),
163 "exit prompt template contains unrendered placeholder"
164 );
165 rendered
166}
167
168#[derive(Debug)]
172pub enum ExitValidationError {
173 EmptyResult { hint: String },
176 InvalidJson { detail: String },
178 SchemaViolations { errors: Vec<String> },
180}
181
182impl std::fmt::Display for ExitValidationError {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 match self {
185 Self::EmptyResult { hint } => {
186 write!(
187 f,
188 "Cannot terminate: a non-empty result is required (hint: {hint}). \
189 Re-run with `zag ps kill self \"<your-result>\"` or \
190 `zag ps kill self --file <path>`."
191 )
192 }
193 Self::InvalidJson { detail } => {
194 write!(
195 f,
196 "Result is not valid JSON: {detail}. The session was launched with \
197 --json, so the result must be a JSON value (object, array, string, \
198 number, boolean, or null). Do not include markdown fences."
199 )
200 }
201 Self::SchemaViolations { errors } => {
202 writeln!(
203 f,
204 "Result failed JSON-schema validation. Fix the result and call kill again:"
205 )?;
206 for e in errors {
207 writeln!(f, " - {e}")?;
208 }
209 Ok(())
210 }
211 }
212 }
213}
214
215impl std::error::Error for ExitValidationError {}
216
217pub fn validate_exit_result(
220 result: &str,
221 exit_hint: Option<&str>,
222 json_mode: bool,
223 json_schema: Option<&serde_json::Value>,
224) -> Result<(), ExitValidationError> {
225 if let Some(hint) = exit_hint
226 && !hint.trim().is_empty()
227 && result.trim().is_empty()
228 {
229 return Err(ExitValidationError::EmptyResult {
230 hint: hint.to_string(),
231 });
232 }
233
234 if let Some(schema) = json_schema {
235 if let Err(errors) = validate_json_schema(result, schema) {
236 return Err(ExitValidationError::SchemaViolations { errors });
237 }
238 } else if json_mode && let Err(detail) = validate_json(result) {
239 return Err(ExitValidationError::InvalidJson { detail });
240 }
241
242 Ok(())
243}
244
245#[cfg(test)]
246#[path = "exit_mode_tests.rs"]
247mod tests;