fix: customised recipe to yaml string to avoid minininjia parsing error (#5494)

This commit is contained in:
Lifei Zhou
2025-11-06 18:50:51 +11:00
committed by GitHub
parent ec6faf92a3
commit bdd04d6fbb
3 changed files with 134 additions and 3 deletions
@@ -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<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 {
@@ -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();
+10
View File
@@ -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<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)
}
pub fn builder() -> RecipeBuilder {
RecipeBuilder {
version: default_version(),
@@ -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);
}
}