feat: add recipes, a custom goose agent configuration (#2115)
This commit is contained in:
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{debug, error, instrument, warn};
|
||||
@@ -21,6 +22,7 @@ use crate::providers::errors::ProviderError;
|
||||
use crate::providers::toolshim::{
|
||||
augment_message_with_tool_calls, modify_system_prompt_for_tool_json, OllamaInterpreter,
|
||||
};
|
||||
use crate::recipe::{Author, Recipe};
|
||||
use crate::session;
|
||||
use crate::token_counter::TokenCounter;
|
||||
use crate::truncate::{truncate_messages, OldestFirstTruncation};
|
||||
@@ -787,4 +789,116 @@ impl Agent {
|
||||
tracing::error!("Failed to send tool result: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_recipe(&self, mut messages: Vec<Message>) -> Result<Recipe> {
|
||||
let mut extension_manager = self.extension_manager.lock().await;
|
||||
let extensions_info = extension_manager.get_extensions_info().await;
|
||||
let system_prompt = self
|
||||
.prompt_manager
|
||||
.build_system_prompt(extensions_info, self.frontend_instructions.clone());
|
||||
|
||||
let recipe_prompt = self.prompt_manager.get_recipe_prompt().await;
|
||||
let tools = extension_manager.get_prefixed_tools().await?;
|
||||
|
||||
messages.push(Message::user().with_text(recipe_prompt));
|
||||
|
||||
let (result, _usage) = self
|
||||
.provider
|
||||
.complete(&system_prompt, &messages, &tools)
|
||||
.await?;
|
||||
|
||||
let content = result.as_concat_text();
|
||||
|
||||
// the response may be contained in ```json ```, strip that before parsing json
|
||||
let re = Regex::new(r"(?s)^```[^\n]*\n(.*?)\n```$").unwrap();
|
||||
let clean_content = re
|
||||
.captures(&content)
|
||||
.and_then(|caps| caps.get(1).map(|m| m.as_str()))
|
||||
.unwrap_or(&content)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// try to parse json response from the LLM
|
||||
let (instructions, activities) =
|
||||
if let Ok(json_content) = serde_json::from_str::<Value>(&clean_content) {
|
||||
let instructions = json_content
|
||||
.get("instructions")
|
||||
.ok_or_else(|| anyhow!("Missing 'instructions' in json response"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("instructions' is not a string"))?
|
||||
.to_string();
|
||||
|
||||
let activities = json_content
|
||||
.get("activities")
|
||||
.ok_or_else(|| anyhow!("Missing 'activities' in json response"))?
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("'activities' is not an array'"))?
|
||||
.iter()
|
||||
.map(|act| {
|
||||
act.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or(anyhow!("'activities' array element is not a string"))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
(instructions, activities)
|
||||
} else {
|
||||
// If we can't get valid JSON, try string parsing
|
||||
// Use split_once to get the content after "Instructions:".
|
||||
let after_instructions = content
|
||||
.split_once("instructions:")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(&content);
|
||||
|
||||
// Split once more to separate instructions from activities.
|
||||
let (instructions_part, activities_text) = after_instructions
|
||||
.split_once("activities:")
|
||||
.unwrap_or((after_instructions, ""));
|
||||
|
||||
let instructions = instructions_part
|
||||
.trim_end_matches(|c: char| c.is_whitespace() || c == '#')
|
||||
.trim()
|
||||
.to_string();
|
||||
let activities_text = activities_text.trim();
|
||||
|
||||
// Regex to remove bullet markers or numbers with an optional dot.
|
||||
let bullet_re = Regex::new(r"^[•\-\*\d]+\.?\s*").expect("Invalid regex");
|
||||
|
||||
// Process each line in the activities section.
|
||||
let activities: Vec<String> = activities_text
|
||||
.lines()
|
||||
.map(|line| bullet_re.replace(line, "").to_string())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
(instructions, activities)
|
||||
};
|
||||
|
||||
let extensions = ExtensionConfigManager::get_all().unwrap_or_default();
|
||||
let extension_configs: Vec<_> = extensions
|
||||
.iter()
|
||||
.filter(|e| e.enabled)
|
||||
.map(|e| e.config.clone())
|
||||
.collect();
|
||||
|
||||
let author = Author {
|
||||
contact: std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.ok(),
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let recipe = Recipe::builder()
|
||||
.title("Custom recipe from chat")
|
||||
.description("a custom recipe instance from this chat session")
|
||||
.instructions(instructions)
|
||||
.activities(activities)
|
||||
.extensions(extension_configs)
|
||||
.author(author)
|
||||
.build()
|
||||
.expect("valid recipe");
|
||||
|
||||
Ok(recipe)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,4 +92,10 @@ impl PromptManager {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the recipe prompt
|
||||
pub async fn get_recipe_prompt(&self) -> String {
|
||||
let context: HashMap<&str, Value> = HashMap::new();
|
||||
prompt_template::render_global_file("recipe.md", &context).expect("Prompt should render")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod model;
|
||||
pub mod permission;
|
||||
pub mod prompt_template;
|
||||
pub mod providers;
|
||||
pub mod recipe;
|
||||
pub mod session;
|
||||
pub mod token_counter;
|
||||
pub mod tracing;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Based on our conversation so far, could you create:
|
||||
|
||||
1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Make the instructions generic, and higher-level so that can be re-used across various similar tasks. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.
|
||||
|
||||
2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic
|
||||
|
||||
Format your response in _VALID_ json, with one key being `instructions` which contains a string, and the other key `activities` as an array of strings.
|
||||
For example, perhaps we have been discussing fruit and you might write:
|
||||
|
||||
{
|
||||
"instructions": "Using web searches we find pictures of fruit, and always check what language to reply in.",
|
||||
"activities": [
|
||||
"Show pics of apples",
|
||||
"say a random fruit",
|
||||
"share a fruit fact"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use crate::agents::extension::ExtensionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_version() -> String {
|
||||
"1.0.0".to_string()
|
||||
}
|
||||
|
||||
/// A Recipe represents a personalized, user-generated agent configuration that defines
|
||||
/// specific behaviors and capabilities within the Goose system.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// ## Required Fields
|
||||
/// * `version` - Semantic version of the Recipe file format (defaults to "1.0.0")
|
||||
/// * `title` - Short, descriptive name of the Recipe
|
||||
/// * `description` - Detailed description explaining the Recipe's purpose and functionality
|
||||
/// * `Instructions` - Instructions that defines the Recipe's behavior
|
||||
///
|
||||
/// ## Optional Fields
|
||||
/// * `prompt` - the initial prompt to the session to start with
|
||||
/// * `extensions` - List of extension configurations required by the Recipe
|
||||
/// * `context` - Supplementary context information for the Recipe
|
||||
/// * `activities` - Activity labels that appear when loading the Recipe
|
||||
/// * `author` - Information about the Recipe's creator and metadata
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use goose::recipe::Recipe;
|
||||
///
|
||||
/// // Using the builder pattern
|
||||
/// let recipe = Recipe::builder()
|
||||
/// .title("Example Agent")
|
||||
/// .description("An example Recipe configuration")
|
||||
/// .instructions("Act as a helpful assistant")
|
||||
/// .build()
|
||||
/// .expect("Missing required fields");
|
||||
///
|
||||
/// // Or using struct initialization
|
||||
/// let recipe = Recipe {
|
||||
/// version: "1.0.0".to_string(),
|
||||
/// title: "Example Agent".to_string(),
|
||||
/// description: "An example Recipe configuration".to_string(),
|
||||
/// instructions: "Act as a helpful assistant".to_string(),
|
||||
/// prompt: None,
|
||||
/// extensions: None,
|
||||
/// context: None,
|
||||
/// activities: None,
|
||||
/// author: None,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Recipe {
|
||||
// Required fields
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String, // version of the file format, sem ver
|
||||
|
||||
pub title: String, // short title of the recipe
|
||||
|
||||
pub description: String, // a longer description of the recipe
|
||||
|
||||
pub instructions: String, // the instructions for the model
|
||||
|
||||
// Optional fields
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>, // the prompt to start the session with
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<Vec<String>>, // any additional context
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activities: Option<Vec<String>>, // the activity pills that show up when loading the
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<Author>, // any additional author information
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Author {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact: Option<String>, // creator/contact information of the recipe
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<String>, // any additional metadata for the author
|
||||
}
|
||||
|
||||
/// Builder for creating Recipe instances
|
||||
pub struct RecipeBuilder {
|
||||
// Required fields with default values
|
||||
version: String,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
instructions: Option<String>,
|
||||
|
||||
// Optional fields
|
||||
prompt: Option<String>,
|
||||
extensions: Option<Vec<ExtensionConfig>>,
|
||||
context: Option<Vec<String>>,
|
||||
activities: Option<Vec<String>>,
|
||||
author: Option<Author>,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
/// Creates a new RecipeBuilder to construct a Recipe instance
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use goose::recipe::Recipe;
|
||||
///
|
||||
/// let recipe = Recipe::builder()
|
||||
/// .title("My Recipe")
|
||||
/// .description("A helpful assistant")
|
||||
/// .instructions("Act as a helpful assistant")
|
||||
/// .build()
|
||||
/// .expect("Failed to build Recipe: missing required fields");
|
||||
/// ```
|
||||
pub fn builder() -> RecipeBuilder {
|
||||
RecipeBuilder {
|
||||
version: default_version(),
|
||||
title: None,
|
||||
description: None,
|
||||
instructions: None,
|
||||
prompt: None,
|
||||
extensions: None,
|
||||
context: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipeBuilder {
|
||||
/// Sets the version of the Recipe
|
||||
pub fn version(mut self, version: impl Into<String>) -> Self {
|
||||
self.version = version.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the title of the Recipe (required)
|
||||
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the description of the Recipe (required)
|
||||
pub fn description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the instructions for the Recipe (required)
|
||||
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
|
||||
self.instructions = Some(instructions.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
|
||||
self.prompt = Some(prompt.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the extensions for the Recipe
|
||||
pub fn extensions(mut self, extensions: Vec<ExtensionConfig>) -> Self {
|
||||
self.extensions = Some(extensions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the context for the Recipe
|
||||
pub fn context(mut self, context: Vec<String>) -> Self {
|
||||
self.context = Some(context);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the activities for the Recipe
|
||||
pub fn activities(mut self, activities: Vec<String>) -> Self {
|
||||
self.activities = Some(activities);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the author information for the Recipe
|
||||
pub fn author(mut self, author: Author) -> Self {
|
||||
self.author = Some(author);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the Recipe instance
|
||||
///
|
||||
/// Returns an error if any required fields are missing
|
||||
pub fn build(self) -> Result<Recipe, &'static str> {
|
||||
let title = self.title.ok_or("Title is required")?;
|
||||
let description = self.description.ok_or("Description is required")?;
|
||||
let instructions = self.instructions.ok_or("Instructions are required")?;
|
||||
|
||||
Ok(Recipe {
|
||||
version: self.version,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
prompt: self.prompt,
|
||||
extensions: self.extensions,
|
||||
context: self.context,
|
||||
activities: self.activities,
|
||||
author: self.author,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user