mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: Implement a simplified reference agent and dev toolkit (#564)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use goose_mcp::NonDeveloperRouter;
|
||||
use goose_mcp::{DeveloperRouter, JetBrainsRouter};
|
||||
use goose_mcp::{Developer2Router, DeveloperRouter, JetBrainsRouter};
|
||||
use mcp_server::router::RouterService;
|
||||
use mcp_server::{BoundedService, ByteTransport, Server};
|
||||
use tokio::io::{stdin, stdout};
|
||||
@@ -10,6 +10,7 @@ pub async fn run_server(name: &str) -> Result<()> {
|
||||
|
||||
let router: Option<Box<dyn BoundedService>> = match name {
|
||||
"developer" => Some(Box::new(RouterService(DeveloperRouter::new()))),
|
||||
"developer2" => Some(Box::new(RouterService(Developer2Router::new()))),
|
||||
"nondeveloper" => Some(Box::new(RouterService(NonDeveloperRouter::new()))),
|
||||
"jetbrains" => Some(Box::new(RouterService(JetBrainsRouter::new()))),
|
||||
_ => None,
|
||||
|
||||
@@ -55,13 +55,15 @@ pub async fn build_session<'a>(
|
||||
// TODO use systems from the profile
|
||||
// TODO once the client/server for MCP has stabilized, we should probably add InProcess transport to each
|
||||
// and avoid spawning here. But it is at least included in the CLI for portability
|
||||
|
||||
let system = std::env::var("GOOSE_SYSTEM").unwrap_or("developer".to_string());
|
||||
let config = SystemConfig::stdio(
|
||||
std::env::current_exe()
|
||||
.expect("should find the current executable")
|
||||
.to_str()
|
||||
.expect("should resolve executable to string path"),
|
||||
)
|
||||
.with_args(vec!["mcp", "developer"]);
|
||||
.with_args(vec!["mcp", &system]);
|
||||
agent
|
||||
.add_system(config)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::path::Path;
|
||||
|
||||
/// Get the markdown language identifier for a file extension
|
||||
pub fn get_language_identifier(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("rs") => "rust",
|
||||
Some("py") => "python",
|
||||
Some("js") => "javascript",
|
||||
Some("ts") => "typescript",
|
||||
Some("json") => "json",
|
||||
Some("toml") => "toml",
|
||||
Some("yaml") | Some("yml") => "yaml",
|
||||
Some("sh") => "bash",
|
||||
Some("go") => "go",
|
||||
Some("md") => "markdown",
|
||||
Some("html") => "html",
|
||||
Some("css") => "css",
|
||||
Some("sql") => "sql",
|
||||
Some("java") => "java",
|
||||
Some("cpp") | Some("cc") | Some("cxx") => "cpp",
|
||||
Some("c") => "c",
|
||||
Some("h") | Some("hpp") => "cpp",
|
||||
Some("rb") => "ruby",
|
||||
Some("php") => "php",
|
||||
Some("swift") => "swift",
|
||||
Some("kt") | Some("kts") => "kotlin",
|
||||
Some("scala") => "scala",
|
||||
Some("r") => "r",
|
||||
Some("m") => "matlab",
|
||||
Some("pl") => "perl",
|
||||
Some("dockerfile") => "dockerfile",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
mod lang;
|
||||
|
||||
use anyhow::Result;
|
||||
use indoc::formatdoc;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
use url::Url;
|
||||
|
||||
use mcp_core::{
|
||||
handler::{ResourceError, ToolError},
|
||||
protocol::ServerCapabilities,
|
||||
resource::Resource,
|
||||
tool::Tool,
|
||||
};
|
||||
use mcp_server::router::CapabilitiesBuilder;
|
||||
use mcp_server::Router;
|
||||
|
||||
use mcp_core::content::Content;
|
||||
use mcp_core::role::Role;
|
||||
|
||||
use indoc::indoc;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct Developer2Router {
|
||||
tools: Vec<Tool>,
|
||||
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
||||
instructions: String,
|
||||
}
|
||||
|
||||
impl Default for Developer2Router {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Developer2Router {
|
||||
pub fn new() -> Self {
|
||||
// TODO consider rust native search tools, we could use
|
||||
// https://docs.rs/ignore/latest/ignore/
|
||||
|
||||
let bash_tool = Tool::new(
|
||||
"shell".to_string(),
|
||||
indoc! {r#"
|
||||
Execute a command in the shell.
|
||||
|
||||
This will return the output and error concatenated into a single string, as
|
||||
you would see from running on the command line. There will also be an indication
|
||||
of if the command succeeded or failed.
|
||||
|
||||
Avoid commands that produce a large amount of ouput, and consider piping those outputs to files.
|
||||
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**: 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`
|
||||
- To locate a file by name: `rg --files | rg example.py`
|
||||
- To locate consent inside files: `rg 'class Example'`
|
||||
"#}.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["command"],
|
||||
"properties": {
|
||||
"command": {"type": "string"}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let text_editor_tool = Tool::new(
|
||||
"text_editor".to_string(),
|
||||
indoc! {r#"
|
||||
Perform text editing operations on files.
|
||||
|
||||
The `command` parameter specifies the operation to perform. Allowed options are:
|
||||
- `view`: View the content of a file.
|
||||
- `create`: Create a new file with the given content (it will fail if the file already exists)
|
||||
- `str_replace`: Replace a string in a file with a new string.
|
||||
- `undo_edit`: Undo the last edit made to a file.
|
||||
|
||||
To use the create command, you must specify `file_text` which will become the content of the new file.
|
||||
|
||||
To use the str_replace command, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one
|
||||
unique section of the original file, including any whitespace. Make sure to include enough context that the match is not
|
||||
ambiguous. The entire original string will be replaced with `new_str`.
|
||||
"#}.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["command", "path"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.",
|
||||
"type": "string"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"enum": ["view", "create", "str_replace", "undo_edit"],
|
||||
"description": "Allowed options are: `view`, `create`, `str_replace`, undo_edit`."
|
||||
},
|
||||
"old_str": {"type": "string"},
|
||||
"new_str": {"type": "string"},
|
||||
"file_text": {"type": "string"}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let instructions = formatdoc! {r#"
|
||||
The developer system gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
|
||||
You can use the shell tool to run any command that would work on the relevant operating system.
|
||||
Use the shell tool as needed to locate files or interact with the project.
|
||||
|
||||
operating system: {os}
|
||||
current directory: {cwd}
|
||||
|
||||
"#,
|
||||
os=std::env::consts::OS,
|
||||
cwd=std::env::current_dir().expect("should have a current working dir").to_string_lossy(),
|
||||
};
|
||||
|
||||
Self {
|
||||
tools: vec![bash_tool, text_editor_tool],
|
||||
file_history: Arc::new(Mutex::new(HashMap::new())),
|
||||
instructions,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to resolve a path relative to cwd
|
||||
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
|
||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||
let expanded = shellexpand::tilde(path_str);
|
||||
let path = Path::new(expanded.as_ref());
|
||||
|
||||
let suggestion = cwd.join(path);
|
||||
|
||||
match path.is_absolute() {
|
||||
true => Ok(path.to_path_buf()),
|
||||
false => Err(ToolError::InvalidParameters(format!(
|
||||
"The path {} is not an absolute path, did you possibly mean {}?",
|
||||
path_str,
|
||||
suggestion.to_string_lossy(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Implement bash tool functionality
|
||||
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let command =
|
||||
params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ToolError::InvalidParameters(
|
||||
"The command string is required".to_string(),
|
||||
))?;
|
||||
|
||||
// TODO consider command suggestions and safety rails
|
||||
|
||||
// TODO be more careful about backgrounding, revisit interleave
|
||||
// Redirect stderr to stdout to interleave outputs
|
||||
let cmd_with_redirect = format!("{} 2>&1", command);
|
||||
|
||||
// Execute the command
|
||||
let child = Command::new("bash")
|
||||
.stdout(Stdio::piped()) // These two pipes required to capture output later.
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted.
|
||||
.arg("-c")
|
||||
.arg(cmd_with_redirect)
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
|
||||
// Wait for the command to complete and get output
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(vec![
|
||||
Content::text(output_str.clone()).with_audience(vec![Role::Assistant]),
|
||||
Content::text(output_str)
|
||||
.with_audience(vec![Role::User])
|
||||
.with_priority(0.0),
|
||||
])
|
||||
}
|
||||
|
||||
async fn text_editor(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing 'command' parameter".to_string())
|
||||
})?;
|
||||
|
||||
let path_str = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?;
|
||||
|
||||
let path = self.resolve_path(path_str)?;
|
||||
|
||||
match command {
|
||||
"view" => self.text_editor_view(&path).await,
|
||||
"create" => {
|
||||
let file_text = params
|
||||
.get("file_text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing 'file_text' parameter".into())
|
||||
})?;
|
||||
|
||||
self.text_editor_create(&path, file_text).await
|
||||
}
|
||||
"str_replace" => {
|
||||
let old_str = params
|
||||
.get("old_str")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing 'old_str' parameter".into())
|
||||
})?;
|
||||
let new_str = params
|
||||
.get("new_str")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing 'new_str' parameter".into())
|
||||
})?;
|
||||
|
||||
self.text_editor_replace(&path, old_str, new_str).await
|
||||
}
|
||||
"undo_edit" => self.text_editor_undo(&path).await,
|
||||
_ => Err(ToolError::InvalidParameters(format!(
|
||||
"Unknown command '{}'",
|
||||
command
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn text_editor_view(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
|
||||
if path.is_file() {
|
||||
// Check file size first (2MB limit)
|
||||
const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; // 2MB in bytes
|
||||
const MAX_CHAR_COUNT: usize = 1 << 20; // 2^20 characters (1,048,576)
|
||||
|
||||
let file_size = std::fs::metadata(path)
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to get file metadata: {}", e))
|
||||
})?
|
||||
.len();
|
||||
|
||||
if file_size > MAX_FILE_SIZE {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"File '{}' is too large ({:.2}MB). Maximum size is 2MB to prevent memory issues.",
|
||||
path.display(),
|
||||
file_size as f64 / 1024.0 / 1024.0
|
||||
)));
|
||||
}
|
||||
|
||||
let uri = Url::from_file_path(path)
|
||||
.map_err(|_| ToolError::ExecutionError("Invalid file path".into()))?
|
||||
.to_string();
|
||||
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
let char_count = content.chars().count();
|
||||
if char_count > MAX_CHAR_COUNT {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"File '{}' has too many characters ({}). Maximum character count is {}.",
|
||||
path.display(),
|
||||
char_count,
|
||||
MAX_CHAR_COUNT
|
||||
)));
|
||||
}
|
||||
|
||||
let language = lang::get_language_identifier(path);
|
||||
let formatted = formatdoc! {"
|
||||
### {path}
|
||||
```{language}
|
||||
{content}
|
||||
```
|
||||
",
|
||||
path=path.display(),
|
||||
language=language,
|
||||
content=content,
|
||||
};
|
||||
|
||||
// The LLM gets just a quick update as we expect the file to view in the status
|
||||
// but we send a low priority message for the human
|
||||
Ok(vec![
|
||||
Content::embedded_text(uri, content).with_audience(vec![Role::Assistant]),
|
||||
Content::text(formatted)
|
||||
.with_audience(vec![Role::User])
|
||||
.with_priority(0.0),
|
||||
])
|
||||
} else {
|
||||
Err(ToolError::ExecutionError(format!(
|
||||
"The path '{}' does not exist or is not a file.",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn text_editor_create(
|
||||
&self,
|
||||
path: &PathBuf,
|
||||
file_text: &str,
|
||||
) -> Result<Vec<Content>, ToolError> {
|
||||
// Check if file already exists
|
||||
if path.exists() {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"File '{}' already exists - you will need to edit it with the `str_replace` command",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Write to the file
|
||||
std::fs::write(path, file_text)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
|
||||
// The assistant output does not show the file again because the content is already in the tool request
|
||||
// but we do show it to the user here
|
||||
Ok(vec![
|
||||
Content::text(format!("Successfully wrote to {}", path.display()))
|
||||
.with_audience(vec![Role::Assistant]),
|
||||
Content::text(formatdoc! {r#"
|
||||
### {path}
|
||||
```{language}
|
||||
{content}
|
||||
```
|
||||
"#,
|
||||
path=path.display(),
|
||||
language=language,
|
||||
content=file_text,
|
||||
})
|
||||
.with_audience(vec![Role::User])
|
||||
.with_priority(0.2),
|
||||
])
|
||||
}
|
||||
|
||||
async fn text_editor_replace(
|
||||
&self,
|
||||
path: &PathBuf,
|
||||
old_str: &str,
|
||||
new_str: &str,
|
||||
) -> Result<Vec<Content>, ToolError> {
|
||||
// Check if file exists and is active
|
||||
if !path.exists() {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"File '{}' does not exist, you can write a new file with the `create` command",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Read content
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
// Ensure 'old_str' appears exactly once
|
||||
if content.matches(old_str).count() > 1 {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"'old_str' must appear exactly once in the file, but it appears multiple times"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if content.matches(old_str).count() == 0 {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"'old_str' must appear exactly once in the file, but it does not appear in the file. Make sure the string exactly matches existing file content, including whitespace!".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Save history for undo
|
||||
self.save_file_history(path)?;
|
||||
|
||||
// Replace and write back
|
||||
let new_content = content.replace(old_str, new_str);
|
||||
std::fs::write(path, &new_content)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
|
||||
// Show a snippet of the changed content with context
|
||||
const SNIPPET_LINES: usize = 4;
|
||||
|
||||
// Count newlines before the replacement to find the line number
|
||||
let replacement_line = content
|
||||
.split(old_str)
|
||||
.next()
|
||||
.expect("should split on already matched content")
|
||||
.matches('\n')
|
||||
.count();
|
||||
|
||||
// Calculate start and end lines for the snippet
|
||||
let start_line = replacement_line.saturating_sub(SNIPPET_LINES);
|
||||
let end_line = replacement_line + SNIPPET_LINES + new_str.matches('\n').count();
|
||||
|
||||
// Get the relevant lines for our snippet
|
||||
let lines: Vec<&str> = new_content.lines().collect();
|
||||
let snippet = lines
|
||||
.iter()
|
||||
.skip(start_line)
|
||||
.take(end_line - start_line + 1)
|
||||
.cloned()
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
|
||||
let output = formatdoc! {r#"
|
||||
```{language}
|
||||
{snippet}
|
||||
```
|
||||
"#,
|
||||
language=language,
|
||||
snippet=snippet
|
||||
};
|
||||
|
||||
let success_message = formatdoc! {r#"
|
||||
The file {} has been edited, and the section now reads:
|
||||
{}
|
||||
Review the changes above for errors. Undo and edit the file again if necessary!
|
||||
"#,
|
||||
path.display(),
|
||||
output
|
||||
};
|
||||
|
||||
Ok(vec![
|
||||
Content::text(success_message).with_audience(vec![Role::Assistant]),
|
||||
Content::text(output)
|
||||
.with_audience(vec![Role::User])
|
||||
.with_priority(0.2),
|
||||
])
|
||||
}
|
||||
|
||||
async fn text_editor_undo(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
|
||||
let mut history = self.file_history.lock().unwrap();
|
||||
if let Some(contents) = history.get_mut(path) {
|
||||
if let Some(previous_content) = contents.pop() {
|
||||
// Write previous content back to file
|
||||
std::fs::write(path, previous_content).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write file: {}", e))
|
||||
})?;
|
||||
Ok(vec![Content::text("Undid the last edit")])
|
||||
} else {
|
||||
Err(ToolError::InvalidParameters(
|
||||
"No edit history available to undo".into(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(ToolError::InvalidParameters(
|
||||
"No edit history available to undo".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn save_file_history(&self, path: &PathBuf) -> Result<(), ToolError> {
|
||||
let mut history = self.file_history.lock().unwrap();
|
||||
let content = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
history.entry(path.clone()).or_default().push(content);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Router for Developer2Router {
|
||||
fn name(&self) -> String {
|
||||
"developer".to_string()
|
||||
}
|
||||
|
||||
fn instructions(&self) -> String {
|
||||
self.instructions.clone()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ServerCapabilities {
|
||||
CapabilitiesBuilder::new().with_tools(true).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() {
|
||||
"shell" => this.bash(arguments).await,
|
||||
"text_editor" => this.text_editor(arguments).await,
|
||||
_ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TODO see if we can make it easy to skip implementing these
|
||||
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 Developer2Router {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
tools: self.tools.clone(),
|
||||
file_history: Arc::clone(&self.file_history),
|
||||
instructions: self.instructions.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod developer;
|
||||
mod developer2;
|
||||
mod jetbrains;
|
||||
mod nondeveloper;
|
||||
|
||||
pub use developer::DeveloperRouter;
|
||||
pub use developer2::Developer2Router;
|
||||
pub use jetbrains::JetBrainsRouter;
|
||||
pub use nondeveloper::NonDeveloperRouter;
|
||||
|
||||
@@ -3,6 +3,7 @@ mod capabilities;
|
||||
mod default;
|
||||
mod factory;
|
||||
mod system;
|
||||
mod reference;
|
||||
|
||||
pub use agent::Agent;
|
||||
pub use capabilities::Capabilities;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/// A simplified agent implementation used as a reference
|
||||
/// It makes no attempt to handle context limits, and cannot read resources
|
||||
use async_trait::async_trait;
|
||||
use futures::stream::BoxStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use super::Agent;
|
||||
use crate::agents::capabilities::Capabilities;
|
||||
use crate::agents::system::{SystemConfig, SystemResult};
|
||||
use crate::message::{Message, ToolRequest};
|
||||
use crate::providers::base::Provider;
|
||||
use crate::providers::base::ProviderUsage;
|
||||
use crate::register_agent;
|
||||
use crate::token_counter::TokenCounter;
|
||||
use serde_json::Value;
|
||||
/// Reference implementation of an Agent
|
||||
pub struct ReferenceAgent {
|
||||
capabilities: Mutex<Capabilities>,
|
||||
_token_counter: TokenCounter,
|
||||
}
|
||||
|
||||
impl ReferenceAgent {
|
||||
pub fn new(provider: Box<dyn Provider>) -> Self {
|
||||
Self {
|
||||
capabilities: Mutex::new(Capabilities::new(provider)),
|
||||
_token_counter: TokenCounter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Agent for ReferenceAgent {
|
||||
async fn add_system(&mut self, system: SystemConfig) -> SystemResult<()> {
|
||||
let mut capabilities = self.capabilities.lock().await;
|
||||
capabilities.add_system(system).await
|
||||
}
|
||||
|
||||
async fn remove_system(&mut self, name: &str) {
|
||||
let mut capabilities = self.capabilities.lock().await;
|
||||
capabilities
|
||||
.remove_system(name)
|
||||
.await
|
||||
.expect("Failed to remove system");
|
||||
}
|
||||
|
||||
async fn list_systems(&self) -> Vec<String> {
|
||||
let capabilities = self.capabilities.lock().await;
|
||||
capabilities
|
||||
.list_systems()
|
||||
.await
|
||||
.expect("Failed to list systems")
|
||||
}
|
||||
|
||||
async fn passthrough(&self, _system: &str, _request: Value) -> SystemResult<Value> {
|
||||
// TODO implement
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, messages), fields(user_message))]
|
||||
async fn reply(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
|
||||
let mut messages = messages.to_vec();
|
||||
let reply_span = tracing::Span::current();
|
||||
let mut capabilities = self.capabilities.lock().await;
|
||||
let tools = capabilities.get_prefixed_tools().await?;
|
||||
let system_prompt = capabilities.get_system_prompt().await;
|
||||
let _estimated_limit = capabilities
|
||||
.provider()
|
||||
.get_model_config()
|
||||
.get_estimated_limit();
|
||||
|
||||
// Set the user_message field in the span instead of creating a new event
|
||||
if let Some(content) = messages
|
||||
.last()
|
||||
.and_then(|msg| msg.content.first())
|
||||
.and_then(|c| c.as_text())
|
||||
{
|
||||
debug!("user_message" = &content);
|
||||
}
|
||||
|
||||
// Update conversation history for the start of the reply
|
||||
let _resources = capabilities.get_resources().await?;
|
||||
|
||||
Ok(Box::pin(async_stream::try_stream! {
|
||||
let _reply_guard = reply_span.enter();
|
||||
loop {
|
||||
// Get completion from provider
|
||||
let (response, usage) = capabilities.provider().complete(
|
||||
&system_prompt,
|
||||
&messages,
|
||||
&tools,
|
||||
).await?;
|
||||
capabilities.record_usage(usage).await;
|
||||
|
||||
// Yield the assistant's response
|
||||
yield response.clone();
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// First collect any tool requests
|
||||
let tool_requests: Vec<&ToolRequest> = response.content
|
||||
.iter()
|
||||
.filter_map(|content| content.as_tool_request())
|
||||
.collect();
|
||||
|
||||
if tool_requests.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Then dispatch each in parallel
|
||||
let futures: Vec<_> = tool_requests
|
||||
.iter()
|
||||
.filter_map(|request| request.tool_call.clone().ok())
|
||||
.map(|tool_call| capabilities.dispatch_tool_call(tool_call))
|
||||
.collect();
|
||||
|
||||
// Process all the futures in parallel but wait until all are finished
|
||||
let outputs = futures::future::join_all(futures).await;
|
||||
|
||||
// Create a message with the responses
|
||||
let mut message_tool_response = Message::user();
|
||||
// Now combine these into MessageContent::ToolResponse using the original ID
|
||||
for (request, output) in tool_requests.iter().zip(outputs.into_iter()) {
|
||||
message_tool_response = message_tool_response.with_tool_response(
|
||||
request.id.clone(),
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
yield message_tool_response.clone();
|
||||
|
||||
messages.push(response);
|
||||
messages.push(message_tool_response);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn usage(&self) -> Vec<ProviderUsage> {
|
||||
let capabilities = self.capabilities.lock().await;
|
||||
capabilities.get_usage().await
|
||||
}
|
||||
}
|
||||
|
||||
register_agent!("reference", ReferenceAgent);
|
||||
@@ -107,6 +107,10 @@ impl From<Content> for MessageContent {
|
||||
match content {
|
||||
Content::Text(text) => MessageContent::Text(text),
|
||||
Content::Image(image) => MessageContent::Image(image),
|
||||
Content::Resource(resource) => MessageContent::Text(TextContent {
|
||||
text: resource.get_text(),
|
||||
annotations: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ pub fn messages_to_openai_spec(
|
||||
"content": [convert_image(&image, image_format)]
|
||||
}));
|
||||
}
|
||||
Content::Resource(resource) => {
|
||||
tool_content.push(Content::text(resource.get_text()));
|
||||
}
|
||||
_ => {
|
||||
tool_content.push(content);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/// The various content types can be display to humans but also understood by models
|
||||
/// They include optional annotations used to help inform agent usage
|
||||
use super::role::Role;
|
||||
use crate::resource::ResourceContents;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -49,11 +50,29 @@ pub struct ImageContent {
|
||||
pub annotations: Option<Annotations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmbeddedResource {
|
||||
resource: ResourceContents,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub annotations: Option<Annotations>,
|
||||
}
|
||||
|
||||
impl EmbeddedResource {
|
||||
pub fn get_text(&self) -> String {
|
||||
match &self.resource {
|
||||
ResourceContents::TextResourceContents { text, .. } => text.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Content {
|
||||
Text(TextContent),
|
||||
Image(ImageContent),
|
||||
Resource(EmbeddedResource),
|
||||
}
|
||||
|
||||
impl Content {
|
||||
@@ -72,6 +91,24 @@ impl Content {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resource(resource: ResourceContents) -> Self {
|
||||
Content::Resource(EmbeddedResource {
|
||||
resource,
|
||||
annotations: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
|
||||
Content::Resource(EmbeddedResource {
|
||||
resource: ResourceContents::TextResourceContents {
|
||||
uri: uri.into(),
|
||||
mime_type: Some("text".to_string()),
|
||||
text: content.into(),
|
||||
},
|
||||
annotations: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the text content if this is a TextContent variant
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
@@ -93,6 +130,7 @@ impl Content {
|
||||
let annotations = match &mut self {
|
||||
Content::Text(text) => &mut text.annotations,
|
||||
Content::Image(image) => &mut image.annotations,
|
||||
Content::Resource(resource) => &mut resource.annotations,
|
||||
};
|
||||
*annotations = Some(match annotations.take() {
|
||||
Some(mut a) => {
|
||||
@@ -118,6 +156,7 @@ impl Content {
|
||||
let annotations = match &mut self {
|
||||
Content::Text(text) => &mut text.annotations,
|
||||
Content::Image(image) => &mut image.annotations,
|
||||
Content::Resource(resource) => &mut resource.annotations,
|
||||
};
|
||||
*annotations = Some(match annotations.take() {
|
||||
Some(mut a) => {
|
||||
@@ -138,6 +177,10 @@ impl Content {
|
||||
match self {
|
||||
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.audience.as_ref()),
|
||||
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.audience.as_ref()),
|
||||
Content::Resource(resource) => resource
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.audience.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +189,7 @@ impl Content {
|
||||
match self {
|
||||
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.priority),
|
||||
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.priority),
|
||||
Content::Resource(resource) => resource.annotations.as_ref().and_then(|a| a.priority),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +197,7 @@ impl Content {
|
||||
match self {
|
||||
Content::Text(text) => Content::text(text.text.clone()),
|
||||
Content::Image(image) => Content::image(image.data.clone(), image.mime_type.clone()),
|
||||
Content::Resource(resource) => Content::resource(resource.resource.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user