mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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::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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user