Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions crates/goose-server/src/routes/recipe_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -63,7 +62,7 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
}

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 {
Expand Down Expand Up @@ -132,7 +131,7 @@ pub async fn build_recipe_with_parameter_values(
original_recipe: &Recipe,
user_recipe_values: HashMap<String, String>,
) -> Result<Option<Recipe>> {
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();
Expand Down
10 changes: 10 additions & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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"];
Expand Down Expand Up @@ -234,6 +236,14 @@ impl Recipe {
false
}

pub fn to_yaml(&self) -> Result<String> {
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)
}

Comment on lines +243 to +246

Copilot AI Nov 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The list of fields to reformat is hardcoded here. Consider making this configurable or deriving it from the Recipe struct to ensure all relevant string fields are handled consistently as the struct evolves.

Suggested change
reformat_fields_with_multiline_values(&recipe_yaml, &["prompt", "instructions"]);
Ok(formatted_recipe_yaml)
}
reformat_fields_with_multiline_values(&recipe_yaml, Self::multiline_fields());
Ok(formatted_recipe_yaml)
}
/// Returns the list of Recipe fields that should be reformatted for multiline YAML output.
fn multiline_fields() -> &'static [&'static str] {
&["prompt", "instructions"]
}

Copilot uses AI. Check for mistakes.
pub fn builder() -> RecipeBuilder {
RecipeBuilder {
version: default_version(),
Expand Down
122 changes: 122 additions & 0 deletions crates/goose/src/recipe/yaml_format_utils.rs
Original file line number Diff line number Diff line change
@@ -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") {

Copilot AI Nov 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detection logic checks for literal \\n in the string, but this may not correctly identify all multi-line strings. Consider checking for both \\n (single backslash) and \\\\n (double backslash) patterns, or using a more robust method to detect escaped newlines in serialized YAML strings.

Suggested change
if raw_val.contains("\\n") {
if raw_val.contains("\\n") || raw_val.contains("\\\\n") {

Copilot uses AI. Check for mistakes.
// 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);
}
}