use crate::json_validation::{validate_json, validate_json_schema};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitHint {
Bare,
Provided(String),
}
impl ExitHint {
pub fn from_optional(s: Option<String>) -> Self {
match s {
Some(s) if !s.trim().is_empty() => Self::Provided(s),
_ => Self::Bare,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Provided(s) => Some(s.as_str()),
Self::Bare => None,
}
}
}
impl Serialize for ExitHint {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
match self {
Self::Bare => ser.serialize_str(""),
Self::Provided(s) => ser.serialize_str(s),
}
}
}
impl<'de> Deserialize<'de> for ExitHint {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
Ok(if s.trim().is_empty() {
Self::Bare
} else {
Self::Provided(s)
})
}
}
fn is_false(v: &bool) -> bool {
!v
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExitConstraints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<ExitHint>,
#[serde(default, skip_serializing_if = "is_false")]
pub json_mode: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
}
impl ExitConstraints {
pub fn validate(&self, result: &str) -> Result<(), ExitValidationError> {
validate_exit_result(
result,
self.hint.as_ref().and_then(|h| h.as_str()),
self.json_mode,
self.schema.as_ref(),
)
}
}
const EXIT_TEMPLATE_SOURCE: &str = include_str!("../prompts/exit/1_0_0.md");
pub fn exit_template() -> &'static str {
crate::prompts::strip_front_matter(EXIT_TEMPLATE_SOURCE)
}
pub fn build_exit_suffix(
hint: Option<&str>,
json_mode: bool,
json_schema: Option<&serde_json::Value>,
) -> String {
let hint_section = match hint.map(str::trim).filter(|s| !s.is_empty()) {
Some(h) => format!("Expected result: {h}\n\n"),
None => String::new(),
};
let json_instruction = if json_mode || json_schema.is_some() {
"The result you pass to `zag ps kill self` MUST be valid JSON. \
Do not wrap it in markdown fences — pass the raw JSON string.\n\n"
.to_string()
} else {
String::new()
};
let schema_instruction = match json_schema {
Some(schema) => {
let pretty = serde_json::to_string_pretty(schema).unwrap_or_default();
format!(
"The JSON result MUST validate against this schema:\n\n```json\n{pretty}\n```\n\n"
)
}
None => String::new(),
};
let rendered = exit_template()
.replace("{HINT_SECTION}", &hint_section)
.replace("{JSON_INSTRUCTION}", &json_instruction)
.replace("{SCHEMA_INSTRUCTION}", &schema_instruction);
debug_assert!(
!rendered.contains("{HINT_SECTION}")
&& !rendered.contains("{JSON_INSTRUCTION}")
&& !rendered.contains("{SCHEMA_INSTRUCTION}"),
"exit prompt template contains unrendered placeholder"
);
rendered
}
#[derive(Debug)]
pub enum ExitValidationError {
EmptyResult { hint: String },
InvalidJson { detail: String },
SchemaViolations { errors: Vec<String> },
}
impl std::fmt::Display for ExitValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyResult { hint } => {
write!(
f,
"Cannot terminate: a non-empty result is required (hint: {hint}). \
Re-run with `zag ps kill self \"<your-result>\"` or \
`zag ps kill self --file <path>`."
)
}
Self::InvalidJson { detail } => {
write!(
f,
"Result is not valid JSON: {detail}. The session was launched with \
--json, so the result must be a JSON value (object, array, string, \
number, boolean, or null). Do not include markdown fences."
)
}
Self::SchemaViolations { errors } => {
writeln!(
f,
"Result failed JSON-schema validation. Fix the result and call kill again:"
)?;
for e in errors {
writeln!(f, " - {e}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for ExitValidationError {}
pub fn validate_exit_result(
result: &str,
exit_hint: Option<&str>,
json_mode: bool,
json_schema: Option<&serde_json::Value>,
) -> Result<(), ExitValidationError> {
if let Some(hint) = exit_hint
&& !hint.trim().is_empty()
&& result.trim().is_empty()
{
return Err(ExitValidationError::EmptyResult {
hint: hint.to_string(),
});
}
if let Some(schema) = json_schema {
if let Err(errors) = validate_json_schema(result, schema) {
return Err(ExitValidationError::SchemaViolations { errors });
}
} else if json_mode && let Err(detail) = validate_json(result) {
return Err(ExitValidationError::InvalidJson { detail });
}
Ok(())
}
#[cfg(test)]
#[path = "exit_mode_tests.rs"]
mod tests;