Skip to content

Commit

Permalink
feat: tutorial extension (#1169)
Browse files Browse the repository at this point in the history
Co-authored-by: Kalvin Chau <[email protected]>
  • Loading branch information
baxen and kalvinnchau authored Feb 13, 2025
1 parent 3ef552d commit 911f9b2
Show file tree
Hide file tree
Showing 8 changed files with 776 additions and 0 deletions.
5 changes: 5 additions & 0 deletions crates/goose-cli/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
"Memory",
"Tools to save and retrieve durable memories",
)
.item(
"tutorial",
"Tutorial",
"Access interactive tutorials and guides",
)
.item("jetbrains", "JetBrains", "Connect to jetbrains IDEs")
.interact()?
.to_string();
Expand Down
2 changes: 2 additions & 0 deletions crates/goose-cli/src/commands/mcp.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::Result;
use goose_mcp::{
ComputerControllerRouter, DeveloperRouter, GoogleDriveRouter, JetBrainsRouter, MemoryRouter,
TutorialRouter,
};
use mcp_server::router::RouterService;
use mcp_server::{BoundedService, ByteTransport, Server};
Expand All @@ -21,6 +22,7 @@ pub async fn run_server(name: &str) -> Result<()> {
Some(Box::new(RouterService(router)))
}
"memory" => Some(Box::new(RouterService(MemoryRouter::new()))),
"tutorial" => Some(Box::new(RouterService(TutorialRouter::new()))),
_ => None,
};

Expand Down
4 changes: 4 additions & 0 deletions crates/goose-mcp/src/developer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ impl DeveloperRouter {
If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that
this tool does not run indefinitely.
**Important**: Each shell command runs in its own process. Things like directory changes or
sourcing files do not persist between tool calls. So you may need to repeat them each time by
stringing together commands, e.g. `cd example && ls` or `source env/bin/activate && pip install numpy`
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
- List files by name: `rg --files | rg <filename>`
Expand Down
2 changes: 2 additions & 0 deletions crates/goose-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ mod developer;
mod google_drive;
mod jetbrains;
mod memory;
mod tutorial;

pub use computercontroller::ComputerControllerRouter;
pub use developer::DeveloperRouter;
pub use google_drive::GoogleDriveRouter;
pub use jetbrains::JetBrainsRouter;
pub use memory::MemoryRouter;
pub use tutorial::TutorialRouter;
168 changes: 168 additions & 0 deletions crates/goose-mcp/src/tutorial/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use anyhow::Result;
use include_dir::{include_dir, Dir};
use indoc::formatdoc;
use serde_json::{json, Value};
use std::{future::Future, pin::Pin};

use mcp_core::{
handler::{ResourceError, ToolError},
protocol::ServerCapabilities,
resource::Resource,
role::Role,
tool::Tool,
};
use mcp_server::router::CapabilitiesBuilder;
use mcp_server::Router;

use mcp_core::content::Content;

static TUTORIALS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/tutorial/tutorials");

pub struct TutorialRouter {
tools: Vec<Tool>,
instructions: String,
}

impl Default for TutorialRouter {
fn default() -> Self {
Self::new()
}
}

impl TutorialRouter {
pub fn new() -> Self {
let load_tutorial = Tool::new(
"load_tutorial".to_string(),
"Load a specific tutorial by name. The tutorial will be returned as markdown content that provides step by step instructions.".to_string(),
json!({
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"description": "Name of the tutorial to load, e.g. 'getting-started' or 'developer-mcp'"
}
}
}),
);

// Get base instructions and available tutorials
let available_tutorials = Self::get_available_tutorials();

let instructions = formatdoc! {r#"
Because the tutorial extension is enabled, be aware that the user may be new to using Goose
or looking for help with specific features. Proactively offer relevant tutorials when appropriate.
Available tutorials:
{tutorials}
The specific content of the tutorial are available in by running load_tutorial.
To run through a tutorial, make sure to be interactive with the user. Don't run more than
a few related tool calls in a row. Make sure to prompt the user for understanding and participation.
**Important**: Make sure that you provide guidance or info *before* you run commands, as the command will
run immediately for the user. For example while running a game tutorial, let the user know what to expect
before you run a command to start the game itself.
"#,
tutorials=available_tutorials,
};

Self {
tools: vec![load_tutorial],
instructions,
}
}

fn get_available_tutorials() -> String {
let mut tutorials = String::new();
for file in TUTORIALS_DIR.files() {
// Use first line for additional context
let first_line = file
.contents_utf8()
.and_then(|s| s.lines().next().map(|line| line.to_string()))
.unwrap_or_else(String::new);

if let Some(name) = file.path().file_stem() {
tutorials.push_str(&format!("- {}: {}\n", name.to_string_lossy(), first_line));
}
}
tutorials
}

async fn load_tutorial(&self, name: &str) -> Result<String, ToolError> {
let file_name = format!("{}.md", name);
let file = TUTORIALS_DIR
.get_file(&file_name)
.ok_or(ToolError::ExecutionError(format!(
"Could not locate tutorial '{}'",
name
)))?;
Ok(String::from_utf8_lossy(file.contents()).into_owned())
}
}

impl Router for TutorialRouter {
fn name(&self) -> String {
"tutorial".to_string()
}

fn instructions(&self) -> String {
self.instructions.clone()
}

fn capabilities(&self) -> ServerCapabilities {
CapabilitiesBuilder::new().with_tools(false).build()
}

fn list_tools(&self) -> Vec<Tool> {
self.tools.clone()
}

fn call_tool(
&self,
tool_name: &str,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Vec<Content>, ToolError>> + Send + 'static>> {
let this = self.clone();
let tool_name = tool_name.to_string();

Box::pin(async move {
match tool_name.as_str() {
"load_tutorial" => {
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'name' parameter".to_string())
})?;

let content = this.load_tutorial(name).await?;
Ok(vec![
Content::text(content).with_audience(vec![Role::Assistant])
])
}
_ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))),
}
})
}

fn list_resources(&self) -> Vec<Resource> {
Vec::new()
}

fn read_resource(
&self,
_uri: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ResourceError>> + Send + 'static>> {
Box::pin(async move { Ok("".to_string()) })
}
}

impl Clone for TutorialRouter {
fn clone(&self) -> Self {
Self {
tools: self.tools.clone(),
instructions: self.instructions.clone(),
}
}
}
Loading

0 comments on commit 911f9b2

Please sign in to comment.