fix: customised recipe to yaml string to avoid minininjia parsing error (#5494)
This commit is contained in:
@@ -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::validate_recipe::validate_recipe_template_from_content;
|
||||||
use goose::recipe::Recipe;
|
use goose::recipe::Recipe;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_yaml;
|
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
pub struct RecipeValidationError {
|
pub struct RecipeValidationError {
|
||||||
@@ -63,7 +62,7 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> {
|
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();
|
let message = err.to_string();
|
||||||
error!("Failed to serialize recipe for validation: {}", message);
|
error!("Failed to serialize recipe for validation: {}", message);
|
||||||
RecipeValidationError {
|
RecipeValidationError {
|
||||||
@@ -132,7 +131,7 @@ pub async fn build_recipe_with_parameter_values(
|
|||||||
original_recipe: &Recipe,
|
original_recipe: &Recipe,
|
||||||
user_recipe_values: HashMap<String, String>,
|
user_recipe_values: HashMap<String, String>,
|
||||||
) -> Result<Option<Recipe>> {
|
) -> 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 recipe_dir = get_recipe_library_dir(true);
|
||||||
let params = user_recipe_values.into_iter().collect();
|
let params = user_recipe_values.into_iter().collect();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::path::Path;
|
|||||||
use crate::agents::extension::ExtensionConfig;
|
use crate::agents::extension::ExtensionConfig;
|
||||||
use crate::agents::types::RetryConfig;
|
use crate::agents::types::RetryConfig;
|
||||||
use crate::recipe::read_recipe_file_content::read_recipe_file;
|
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 crate::utils::contains_unicode_tags;
|
||||||
use serde::de::Deserializer;
|
use serde::de::Deserializer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -18,6 +19,7 @@ pub mod read_recipe_file_content;
|
|||||||
mod recipe_extension_adapter;
|
mod recipe_extension_adapter;
|
||||||
pub mod template_recipe;
|
pub mod template_recipe;
|
||||||
pub mod validate_recipe;
|
pub mod validate_recipe;
|
||||||
|
pub mod yaml_format_utils;
|
||||||
|
|
||||||
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
|
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
|
||||||
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
|
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
|
||||||
@@ -234,6 +236,14 @@ impl Recipe {
|
|||||||
false
|
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 {
|
pub fn builder() -> RecipeBuilder {
|
||||||
RecipeBuilder {
|
RecipeBuilder {
|
||||||
version: default_version(),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user