feat: Structured output for recipes (#3188)

This commit is contained in:
Jarrod Sibbison
2025-07-02 12:16:57 +10:00
committed by GitHub
parent 620474b76e
commit 0a00b0f588
13 changed files with 754 additions and 7 deletions

View File

@@ -687,6 +687,7 @@ pub async fn cli() -> Result<()> {
interactive: true,
quiet: false,
sub_recipes: None,
final_output_response: None,
})
.await;
setup_logging(
@@ -736,7 +737,7 @@ pub async fn cli() -> Result<()> {
quiet,
additional_sub_recipes,
}) => {
let (input_config, session_settings, sub_recipes) = match (
let (input_config, session_settings, sub_recipes, final_output_response) = match (
instructions,
input_text,
recipe,
@@ -755,6 +756,7 @@ pub async fn cli() -> Result<()> {
},
None,
None,
None,
)
}
(Some(file), _, _) => {
@@ -773,6 +775,7 @@ pub async fn cli() -> Result<()> {
},
None,
None,
None,
)
}
(_, Some(text), _) => (
@@ -783,6 +786,7 @@ pub async fn cli() -> Result<()> {
},
None,
None,
None,
),
(_, _, Some(recipe_name)) => {
if explain {
@@ -822,6 +826,7 @@ pub async fn cli() -> Result<()> {
interactive, // Use the interactive flag from the Run command
quiet,
sub_recipes,
final_output_response,
})
.await;
@@ -941,6 +946,7 @@ pub async fn cli() -> Result<()> {
interactive: true, // Default case is always interactive
quiet: false,
sub_recipes: None,
final_output_response: None,
})
.await;
setup_logging(

View File

@@ -47,6 +47,7 @@ pub async fn agent_generator(
scheduled_job_id: None,
quiet: false,
sub_recipes: None,
final_output_response: None,
})
.await;

View File

@@ -1,15 +1,21 @@
use std::path::PathBuf;
use anyhow::Result;
use goose::recipe::SubRecipe;
use goose::recipe::{Response, SubRecipe};
use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings};
#[allow(clippy::type_complexity)]
pub fn extract_recipe_info_from_cli(
recipe_name: String,
params: Vec<(String, String)>,
additional_sub_recipes: Vec<String>,
) -> Result<(InputConfig, Option<SessionSettings>, Option<Vec<SubRecipe>>)> {
) -> Result<(
InputConfig,
Option<SessionSettings>,
Option<Vec<SubRecipe>>,
Option<Response>,
)> {
let recipe = load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| {
eprintln!("{}: {}", console::style("Error").red().bold(), err);
std::process::exit(1);
@@ -43,6 +49,7 @@ pub fn extract_recipe_info_from_cli(
temperature: s.temperature,
}),
Some(all_sub_recipes),
recipe.response,
))
}
@@ -69,7 +76,7 @@ mod tests {
let params = vec![("name".to_string(), "my_value".to_string())];
let recipe_name = recipe_path.to_str().unwrap().to_string();
let (input_config, settings, sub_recipes) =
let (input_config, settings, sub_recipes, response) =
extract_recipe_info_from_cli(recipe_name, params, Vec::new()).unwrap();
assert_eq!(input_config.contents, Some("test_prompt".to_string()));
@@ -91,6 +98,17 @@ mod tests {
assert_eq!(sub_recipes[0].path, "existing_sub_recipe.yaml".to_string());
assert_eq!(sub_recipes[0].name, "existing_sub_recipe".to_string());
assert!(sub_recipes[0].values.is_none());
assert!(response.is_some());
let response = response.unwrap();
assert_eq!(
response.json_schema,
Some(serde_json::json!({
"type": "object",
"properties": {
"result": {"type": "string"}
}
}))
);
}
#[test]
@@ -103,7 +121,7 @@ mod tests {
"another/sub_recipe2.yaml".to_string(),
];
let (input_config, settings, sub_recipes) =
let (input_config, settings, sub_recipes, response) =
extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes).unwrap();
assert_eq!(input_config.contents, Some("test_prompt".to_string()));
@@ -131,6 +149,17 @@ mod tests {
assert_eq!(sub_recipes[2].path, "another/sub_recipe2.yaml".to_string());
assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string());
assert!(sub_recipes[2].values.is_none());
assert!(response.is_some());
let response = response.unwrap();
assert_eq!(
response.json_schema,
Some(serde_json::json!({
"type": "object",
"properties": {
"result": {"type": "string"}
}
}))
);
}
fn create_recipe() -> (TempDir, PathBuf) {
@@ -151,6 +180,12 @@ settings:
sub_recipes:
- path: existing_sub_recipe.yaml
name: existing_sub_recipe
response:
json_schema:
type: object
properties:
result:
type: string
"#;
let temp_dir = tempfile::tempdir().unwrap();
let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.yaml");

View File

@@ -3,7 +3,7 @@ use goose::agents::extension::ExtensionError;
use goose::agents::Agent;
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
use goose::providers::create;
use goose::recipe::SubRecipe;
use goose::recipe::{Response, SubRecipe};
use goose::session;
use goose::session::Identifier;
use mcp_client::transport::Error as McpClientError;
@@ -49,6 +49,8 @@ pub struct SessionBuilderConfig {
pub quiet: bool,
/// Sub-recipes to add to the session
pub sub_recipes: Option<Vec<SubRecipe>>,
/// Final output expected response
pub final_output_response: Option<Response>,
}
/// Offers to help debug an extension failure by creating a minimal debugging session
@@ -180,6 +182,11 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
if let Some(sub_recipes) = session_config.sub_recipes {
agent.add_sub_recipes(sub_recipes).await;
}
if let Some(final_output_response) = session_config.final_output_response {
agent.add_final_output_tool(final_output_response).await;
}
let new_provider = match create(&provider_name, model_config) {
Ok(provider) => provider,
Err(e) => {
@@ -520,6 +527,7 @@ mod tests {
interactive: true,
quiet: false,
sub_recipes: None,
final_output_response: None,
};
assert_eq!(config.extensions.len(), 1);
@@ -549,6 +557,7 @@ mod tests {
assert!(config.scheduled_job_id.is_none());
assert!(!config.interactive);
assert!(!config.quiet);
assert!(config.final_output_response.is_none());
}
#[tokio::test]