diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index bb9c6421f2..fb77258950 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -17,7 +17,6 @@ use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes}; use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; use serde_json::Value; -use serde_yaml; use tracing::error; pub struct RecipeValidationError { @@ -63,7 +62,7 @@ pub fn get_all_recipes_manifests() -> Result> { } pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> { - let recipe_yaml = serde_yaml::to_string(recipe).map_err(|err| { + let recipe_yaml = recipe.to_yaml().map_err(|err| { let message = err.to_string(); error!("Failed to serialize recipe for validation: {}", message); RecipeValidationError { @@ -132,7 +131,7 @@ pub async fn build_recipe_with_parameter_values( original_recipe: &Recipe, user_recipe_values: HashMap, ) -> Result> { - let recipe_content = serde_yaml::to_string(&original_recipe)?; + let recipe_content = original_recipe.to_yaml()?; let recipe_dir = get_recipe_library_dir(true); let params = user_recipe_values.into_iter().collect(); diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 9f6e27ce11..4188298b3d 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -7,6 +7,7 @@ use std::path::Path; use crate::agents::extension::ExtensionConfig; use crate::agents::types::RetryConfig; use crate::recipe::read_recipe_file_content::read_recipe_file; +use crate::recipe::yaml_format_utils::reformat_fields_with_multiline_values; use crate::utils::contains_unicode_tags; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; @@ -18,6 +19,7 @@ pub mod read_recipe_file_content; mod recipe_extension_adapter; pub mod template_recipe; pub mod validate_recipe; +pub mod yaml_format_utils; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; @@ -234,6 +236,14 @@ impl Recipe { false } + pub fn to_yaml(&self) -> Result { + let recipe_yaml = serde_yaml::to_string(self) + .map_err(|err| anyhow::anyhow!("Failed to serialize recipe: {}", err))?; + let formatted_recipe_yaml = + reformat_fields_with_multiline_values(&recipe_yaml, &["prompt", "instructions"]); + Ok(formatted_recipe_yaml) + } + pub fn builder() -> RecipeBuilder { RecipeBuilder { version: default_version(), diff --git a/crates/goose/src/recipe/yaml_format_utils.rs b/crates/goose/src/recipe/yaml_format_utils.rs new file mode 100644 index 0000000000..38773722f6 --- /dev/null +++ b/crates/goose/src/recipe/yaml_format_utils.rs @@ -0,0 +1,122 @@ +use std::fmt::Write; + +/// Normalizes how `serde_yaml` outputs multi-line strings. +/// It uses internal heuristics to decide between `|` and quoted text with escaped +/// `\n` and `\"`, and the quoted form breaks MiniJinja parsing. +/// Example before: +/// prompt: "Hello \\\"World\\\"\\n{% if user == \\\"admin\\\" %}Welcome{% endif %}" +/// After fix: +/// prompt: | +/// Hello "World" +/// {% if user == "admin" %}Welcome{% endif %} +pub fn reformat_fields_with_multiline_values(yaml: &str, multiline_fields: &[&str]) -> String { + let mut result = String::new(); + + for line in yaml.lines() { + let trimmed = line.trim_start(); + if trimmed.is_empty() { + writeln!(result).unwrap(); + continue; + } + + let indent = line.len() - trimmed.len(); + let indent_str = " ".repeat(indent); + + let matched_field = multiline_fields + .iter() + .find(|&f| trimmed.starts_with(&format!("{f}: "))); + + if let Some(field) = matched_field { + if let Some((_, raw_val)) = trimmed.split_once(": ") { + if raw_val.contains("\\n") { + // Clean escaped content and unescape quotes + let mut value = raw_val.trim_matches('"').to_string(); + + // Unescape quotes and double backslashes (MiniJinja + newlines) + value = value.replace("\\\"", "\"").replace("\\\\n", "\\n"); + + writeln!(result, "{indent_str}{field}: |").unwrap(); + for l in value.split("\\n") { + writeln!(result, "{indent_str} {l}").unwrap(); + } + continue; + } + } + } + + writeln!(result, "{line}").unwrap(); + } + + let mut output = result.trim_end_matches('\n').to_string(); + output.push('\n'); + output +} + +#[cfg(test)] +mod tests { + use super::reformat_fields_with_multiline_values; + + #[test] + fn keeps_simple_fields_unchanged() { + let yaml = "version: \"1.0\"\ntitle: \"Simple\"\nprompt: \"Hello\""; + let expected = "version: \"1.0\"\ntitle: \"Simple\"\nprompt: \"Hello\"\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn converts_multiline_prompt_to_literal_block() { + let yaml = "version: \"1.0\"\nprompt: \"line1\\\\nline2\""; + let expected = "version: \"1.0\"\nprompt: |\n line1\n line2\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn unescapes_quotes_inside_block() { + let yaml = "prompt: \"Hello \\\"World\\\"\\nHow are you?\""; + let expected = "prompt: |\n Hello \"World\"\n How are you?\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn preserves_unlisted_fields() { + let yaml = "version: \"1.0\"\nprompt: \"line1\\\\nline2\"\nnotes: \"note1\\\\nnote2\""; + let expected = + "version: \"1.0\"\nprompt: |\n line1\n line2\nnotes: \"note1\\\\nnote2\"\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn handles_indented_nested_field() { + let yaml = "settings:\n prompt: \"line1\\\\nline2\""; + let expected = "settings:\n prompt: |\n line1\n line2\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn ignores_existing_literal_blocks() { + let yaml = "prompt: |\n already good\n block"; + let expected = "prompt: |\n already good\n block\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } + + #[test] + fn ignores_fields_without_newlines() { + let yaml = "prompt: \"single line text\""; + let expected = "prompt: \"single line text\"\n"; + + let result = reformat_fields_with_multiline_values(yaml, &["prompt"]); + assert_eq!(result, expected); + } +}