Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ jsonwebtoken = "9.3.1"
blake3 = "1.5"
fs2 = "0.4.3"
tokio-stream = "0.1.17"
tempfile = "3.15.0"
dashmap = "6.1"
ahash = "0.8"
tokio-util = "0.7.15"
Expand All @@ -94,7 +95,6 @@ winapi = { version = "0.3", features = ["wincred"] }

[dev-dependencies]
criterion = "0.5"
tempfile = "3.15.0"
serial_test = "3.2.0"
mockall = "0.13.1"
wiremock = "0.6.0"
Expand Down
36 changes: 36 additions & 0 deletions crates/goose/src/agents/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum ExtensionError {
SetupError(String),
#[error("Join error occurred during task execution: {0}")]
TaskJoinError(#[from] tokio::task::JoinError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}

pub type ExtensionResult<T> = Result<T, ExtensionError>;
Expand Down Expand Up @@ -202,6 +204,21 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
},
/// Inline Python code that will be executed using uvx
#[serde(rename = "inline_python")]
InlinePython {
/// The name used to identify this extension
name: String,
/// The Python code to execute
code: String,
/// Description of what the extension does
description: Option<String>,
/// Timeout in seconds
timeout: Option<u64>,
/// Python package dependencies required by this extension
#[serde(default)]
dependencies: Option<Vec<String>>,
},
}

impl Default for ExtensionConfig {
Expand Down Expand Up @@ -265,6 +282,21 @@ impl ExtensionConfig {
}
}

pub fn inline_python<S: Into<String>, T: Into<u64>>(
name: S,
code: S,
description: S,
timeout: T,
) -> Self {
Self::InlinePython {
name: name.into(),
code: code.into(),
description: Some(description.into()),
timeout: Some(timeout.into()),
dependencies: None,
}
}

pub fn with_args<I, S>(self, args: I) -> Self
where
I: IntoIterator<Item = S>,
Expand Down Expand Up @@ -307,6 +339,7 @@ impl ExtensionConfig {
Self::Stdio { name, .. } => name,
Self::Builtin { name, .. } => name,
Self::Frontend { name, .. } => name,
Self::InlinePython { name, .. } => name,
}
.to_string()
}
Expand All @@ -328,6 +361,9 @@ impl std::fmt::Display for ExtensionConfig {
ExtensionConfig::Frontend { name, tools, .. } => {
write!(f, "Frontend({}: {} tools)", name, tools.len())
}
ExtensionConfig::InlinePython { name, code, .. } => {
write!(f, "InlinePython({}: {} chars)", name, code.len())
}
}
}
}
Expand Down
52 changes: 51 additions & 1 deletion crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;
use tempfile::tempdir;
use tokio::sync::Mutex;
use tokio::task;
use tokio_stream::wrappers::ReceiverStream;
Expand Down Expand Up @@ -35,6 +36,7 @@ pub struct ExtensionManager {
clients: HashMap<String, McpClientBox>,
instructions: HashMap<String, String>,
resource_capable_extensions: HashSet<String>,
temp_dirs: HashMap<String, tempfile::TempDir>,
}

/// A flattened representation of a resource used by the agent to prepare inference
Expand Down Expand Up @@ -105,6 +107,7 @@ impl ExtensionManager {
clients: HashMap::new(),
instructions: HashMap::new(),
resource_capable_extensions: HashSet::new(),
temp_dirs: HashMap::new(),
}
}

Expand Down Expand Up @@ -267,6 +270,49 @@ impl ExtensionManager {
.await?,
)
}
ExtensionConfig::InlinePython {
name,
code,
timeout,
dependencies,
..
} => {
let temp_dir = tempdir()?;
let file_path = temp_dir.path().join(format!("{}.py", name));
std::fs::write(&file_path, code)?;

let mut args = vec![];

let mut all_deps = vec!["mcp".to_string()];

if let Some(deps) = dependencies.as_ref() {
all_deps.extend(deps.iter().cloned());
}

for dep in all_deps {
args.push("--with".to_string());
args.push(dep);
}

args.push("python".to_string());
args.push(file_path.to_str().unwrap().to_string());

let transport = StdioTransport::new("uvx", args, HashMap::new());
let handle = transport.start().await?;
let client = Box::new(
McpClient::connect(
handle,
Duration::from_secs(
timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT),
),
)
.await?,
);

self.temp_dirs.insert(sanitized_name.clone(), temp_dir);

client
}
_ => unreachable!(),
};

Expand Down Expand Up @@ -317,6 +363,7 @@ impl ExtensionManager {
self.clients.remove(&sanitized_name);
self.instructions.remove(&sanitized_name);
self.resource_capable_extensions.remove(&sanitized_name);
self.temp_dirs.remove(&sanitized_name);
Ok(())
}

Expand Down Expand Up @@ -777,8 +824,11 @@ impl ExtensionManager {
}
| ExtensionConfig::Stdio {
description, name, ..
}
| ExtensionConfig::InlinePython {
description, name, ..
} => {
// For SSE/StreamableHttp/Stdio, use description if available
// For SSE/StreamableHttp/Stdio/InlinePython, use description if available
description
.as_ref()
.map(|s| s.to_string())
Expand Down
47 changes: 47 additions & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ impl RecipeBuilder {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;

#[test]
fn test_from_content_with_json() {
Expand Down Expand Up @@ -653,6 +654,52 @@ sub_recipes:
assert_eq!(author.contact, Some("[email protected]".to_string()));
}

#[test]
fn test_inline_python_extension() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"extensions": [
{
"type": "inline_python",
"name": "test_python",
"code": "print('hello world')",
"timeout": 300,
"description": "Test python extension",
"dependencies": ["numpy", "matplotlib"]
}
]
}"#;

let recipe = Recipe::from_content(content).unwrap();

assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);

match &extensions[0] {
ExtensionConfig::InlinePython {
name,
code,
description,
timeout,
dependencies,
} => {
assert_eq!(name, "test_python");
assert_eq!(code, "print('hello world')");
assert_eq!(description.as_deref(), Some("Test python extension"));
assert_eq!(timeout, &Some(300));
assert!(dependencies.is_some());
let deps = dependencies.as_ref().unwrap();
assert!(deps.contains(&"numpy".to_string()));
assert!(deps.contains(&"matplotlib".to_string()));
}
_ => panic!("Expected InlinePython extension"),
}
}

#[test]
fn test_from_content_with_activities() {
let content = r#"{
Expand Down
87 changes: 87 additions & 0 deletions recipe-inline-python.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
version: "1.0.0"
title: "Word Counter"
description: "A recipe that counts words in text using an inline Python extension"

instructions: |
This recipe provides a simple word counting tool. You can:
1. Count words in a text string
2. Get statistics about the text (unique words, average word length)
3. Generate word clouds from the text

parameters:
- key: text
input_type: string
requirement: required
description: "The text to analyze"

extensions:
- type: inline_python
name: word_counter
code: |
from mcp.server.fastmcp import FastMCP
import json
from collections import Counter
import numpy as np
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import base64
from io import BytesIO

mcp = FastMCP("word_counter")

@mcp.tool()
def count_words(text: str) -> str:
"""Count the number of words in a text string and generate statistics"""
words = text.split()
word_count = len(words)
unique_words = len(set(words))
avg_length = sum(len(word) for word in words) / word_count if word_count > 0 else 0

# Generate word cloud
wordcloud = WordCloud(width=800, height=400, background_color='white').generate(text)

# Save word cloud to base64
plt.figure(figsize=(10, 5))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')

# Save to bytes
buf = BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0)
buf.seek(0)
img_base64 = base64.b64encode(buf.getvalue()).decode()
plt.close()

result = {
"total_words": word_count,
"unique_words": unique_words,
"average_word_length": round(avg_length, 2),
"most_common": dict(Counter(words).most_common(5)),
"wordcloud_base64": img_base64
}

return json.dumps(result, indent=2)

if __name__ == "__main__":
mcp.run()
timeout: 300
description: "Count words and provide text statistics with visualization"
dependencies:
- "mcp"
- "numpy"
- "matplotlib"
- "wordcloud"
- "beautifulsoup4"
- "html2text"
- "requests"

prompt: |
You are a helpful assistant that can analyze text using word counting tools.
The user has provided the following text to analyze: {{ text }}

Use the word_counter__count_words tool to analyze this text and provide insights about:
- Total word count
- Number of unique words
- Average word length
- Most commonly used words
- A word cloud visualization of the text
45 changes: 45 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,51 @@
]
}
}
},
{
"type": "object",
"description": "Inline Python code that will be executed using uvx",
"required": [
"name",
"code",
"type"
],
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "Python package dependencies required by this extension",
"nullable": true
},
"description": {
"type": "string",
"description": "Description of what the extension does",
"nullable": true
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"description": "Timeout in seconds",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"inline_python"
]
}
}
}
],
"description": "Represents the different types of MCP extensions that can be added to the manager",
Expand Down
Loading
Loading