feat:skip read only tool approval (#1371)

This commit is contained in:
Yingjie He
2025-02-25 09:30:39 -08:00
committed by GitHub
parent 9ae1f62633
commit 111773a426
3 changed files with 300 additions and 21 deletions
+2
View File
@@ -2,6 +2,7 @@ mod agent;
mod capabilities;
pub mod extension;
mod factory;
mod permission_judge;
mod reference;
mod truncate;
@@ -9,3 +10,4 @@ pub use agent::Agent;
pub use capabilities::Capabilities;
pub use extension::ExtensionConfig;
pub use factory::{register_agent, AgentFactory};
pub use permission_judge::detect_read_only_tools;
+265
View File
@@ -0,0 +1,265 @@
use crate::agents::capabilities::Capabilities;
use crate::message::{Message, MessageContent, ToolRequest};
use chrono::Utc;
use indoc::indoc;
use mcp_core::{tool::Tool, TextContent};
use serde_json::{json, Value};
/// Creates the tool definition for checking read-only permissions.
fn create_read_only_tool() -> Tool {
Tool::new(
"platform__tool_by_tool_permission".to_string(),
indoc! {r#"
Analyze the tool requests and determine which ones perform read-only operations.
What constitutes a read-only operation:
- A read-only operation retrieves information without modifying any data or state.
- Examples include:
- Reading a file without writing to it.
- Querying a database without making updates.
- Retrieving information from APIs without performing POST, PUT, or DELETE operations.
Examples of read vs. write operations:
- Read Operations:
- `SELECT` query in SQL.
- Reading file metadata or content.
- Listing directory contents.
- Write Operations:
- `INSERT`, `UPDATE`, or `DELETE` in SQL.
- Writing or appending to a file.
- Modifying system configurations.
How to analyze tool requests:
- Inspect each tool request to identify its purpose based on its name and arguments.
- Categorize the operation as read-only if it does not involve any state or data modification.
- Return a list of tool names that are strictly read-only.
Use this analysis to generate the list of tools performing read-only operations from the provided tool requests.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"read_only_tools": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional list of tool names which has read-only operations."
}
},
"required": []
}),
)
}
/// Builds the message to be sent to the LLM for detecting read-only operations.
fn create_check_messages(tool_requests: Vec<&ToolRequest>) -> Vec<Message> {
let mut check_messages = vec![];
check_messages.push(Message {
role: mcp_core::Role::User,
created: Utc::now().timestamp(),
content: vec![MessageContent::Text(TextContent {
text: format!(
"Here are the tool requests: {:?}\n\nAnalyze the tool requests and list the tools that perform read-only operations. \
\n\nGuidelines for Read-Only Operations: \
\n- Read-only operations do not modify any data or state. \
\n- Examples include file reading, SELECT queries in SQL, and directory listing. \
\n- Write operations include INSERT, UPDATE, DELETE, and file writing. \
\n\nPlease provide a list of tool names that qualify as read-only:",
tool_requests,
),
annotations: None,
})],
});
check_messages
}
/// Processes the response to extract the list of tools with read-only operations.
fn extract_read_only_tools(response: &Message) -> Option<Vec<String>> {
for content in &response.content {
if let MessageContent::ToolRequest(tool_request) = content {
if let Ok(tool_call) = &tool_request.tool_call {
if tool_call.name == "platform__tool_by_tool_permission" {
if let Value::Object(arguments) = &tool_call.arguments {
if let Some(Value::Array(read_only_tools)) =
arguments.get("read_only_tools")
{
return Some(
read_only_tools
.iter()
.filter_map(|tool| tool.as_str().map(String::from))
.collect(),
);
}
}
}
}
}
}
None
}
/// Executes the read-only tools detection and returns the list of tools with read-only operations.
pub async fn detect_read_only_tools(
capabilities: &Capabilities,
tool_requests: Vec<&ToolRequest>,
) -> Vec<String> {
if tool_requests.is_empty() {
return vec![];
}
let tool = create_read_only_tool();
let check_messages = create_check_messages(tool_requests);
let res = capabilities
.provider()
.complete(
"You are a good analyst and can detect operations whether they have read-only operations.",
&check_messages,
&[tool.clone()],
)
.await;
// Process the response and return an empty vector if the response is invalid
if let Ok((message, _usage)) = res {
extract_read_only_tools(&message).unwrap_or_default()
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::capabilities::Capabilities;
use crate::message::{Message, MessageContent, ToolRequest};
use crate::model::ModelConfig;
use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::errors::ProviderError;
use chrono::Utc;
use mcp_core::ToolCall;
use mcp_core::{tool::Tool, Role, ToolResult};
use serde_json::json;
#[derive(Clone)]
struct MockProvider {
model_config: ModelConfig,
}
#[async_trait::async_trait]
impl Provider for MockProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::empty()
}
fn get_model_config(&self) -> ModelConfig {
self.model_config.clone()
}
async fn complete(
&self,
_system: &str,
_messages: &[Message],
_tools: &[Tool],
) -> anyhow::Result<(Message, ProviderUsage), ProviderError> {
Ok((
Message {
role: Role::Assistant,
created: Utc::now().timestamp(),
content: vec![MessageContent::ToolRequest(ToolRequest {
id: "mock_tool_request".to_string(),
tool_call: ToolResult::Ok(ToolCall {
name: "platform__tool_by_tool_permission".to_string(),
arguments: json!({
"read_only_tools": ["file_reader", "data_fetcher"]
}),
}),
})],
},
ProviderUsage::new("mock".to_string(), Usage::default()),
))
}
}
fn create_mock_capabilities() -> Capabilities {
let mock_model_config =
ModelConfig::new("test-model".to_string()).with_context_limit(200_000.into());
Capabilities::new(Box::new(MockProvider {
model_config: mock_model_config,
}))
}
#[tokio::test]
async fn test_create_read_only_tool() {
let tool = create_read_only_tool();
assert_eq!(tool.name, "platform__tool_by_tool_permission");
assert!(tool.description.contains("read-only operation"));
}
#[test]
fn test_create_check_messages() {
let tool_request = ToolRequest {
id: "tool_1".to_string(),
tool_call: ToolResult::Ok(ToolCall {
name: "file_reader".to_string(),
arguments: json!({"path": "/path/to/file"}),
}),
};
let messages = create_check_messages(vec![&tool_request]);
assert_eq!(messages.len(), 1);
let content = &messages[0].content[0];
if let MessageContent::Text(text_content) = content {
assert!(text_content
.text
.contains("Analyze the tool requests and list the tools"));
assert!(text_content.text.contains("file_reader"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_extract_read_only_tools() {
let message = Message {
role: Role::Assistant,
created: Utc::now().timestamp(),
content: vec![MessageContent::ToolRequest(ToolRequest {
id: "tool_2".to_string(),
tool_call: ToolResult::Ok(ToolCall {
name: "platform__tool_by_tool_permission".to_string(),
arguments: json!({
"read_only_tools": ["file_reader", "data_fetcher"]
}),
}),
})],
};
let result = extract_read_only_tools(&message);
assert!(result.is_some());
let tools = result.unwrap();
assert_eq!(tools, vec!["file_reader", "data_fetcher"]);
}
#[tokio::test]
async fn test_detect_read_only_tools() {
let capabilities = create_mock_capabilities();
let tool_request = ToolRequest {
id: "tool_1".to_string(),
tool_call: ToolResult::Ok(ToolCall {
name: "file_reader".to_string(),
arguments: json!({"path": "/path/to/file"}),
}),
};
let result = detect_read_only_tools(&capabilities, vec![&tool_request]).await;
assert_eq!(result, vec!["file_reader", "data_fetcher"]);
}
#[tokio::test]
async fn test_detect_read_only_tools_empty_requests() {
let capabilities = create_mock_capabilities();
let result = detect_read_only_tools(&capabilities, vec![]).await;
assert!(result.is_empty());
}
}
+33 -21
View File
@@ -6,6 +6,7 @@ use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tracing::{debug, error, instrument, warn};
use super::detect_read_only_tools;
use super::Agent;
use crate::agents::capabilities::Capabilities;
use crate::agents::extension::{ExtensionConfig, ExtensionResult};
@@ -237,6 +238,8 @@ impl Agent for TruncateAgent {
break;
}
let read_only_tools = detect_read_only_tools(&capabilities, tool_requests.clone()).await;
// Process tool requests depending on goose_mode
let mut message_tool_response = Message::user();
// Clone goose_mode once before the match to avoid move issues
@@ -246,31 +249,40 @@ impl Agent for TruncateAgent {
// Process each tool request sequentially with confirmation
for request in &tool_requests {
if let Ok(tool_call) = request.tool_call.clone() {
let confirmation = Message::user().with_tool_confirmation_request(
request.id.clone(),
tool_call.name.clone(),
tool_call.arguments.clone(),
Some("Goose would like to call the tool: {}\nAllow? (y/n): ".to_string()),
);
yield confirmation;
// Wait for confirmation response through the channel
let mut rx = self.confirmation_rx.lock().await;
if let Some((req_id, confirmed)) = rx.recv().await {
if req_id == request.id {
if confirmed {
// User approved - dispatch the tool call
let output = capabilities.dispatch_tool_call(tool_call).await;
// Skip confirmation if the tool_call.name is in the read_only_tools list
if read_only_tools.contains(&tool_call.name) {
let output = capabilities.dispatch_tool_call(tool_call).await;
message_tool_response = message_tool_response.with_tool_response(
request.id.clone(),
output,
);
} else {
// User declined - add declined response
message_tool_response = message_tool_response.with_tool_response(
request.id.clone(),
Ok(vec![Content::text("User declined to run this tool.")]),
);
} else {
let confirmation = Message::user().with_tool_confirmation_request(
request.id.clone(),
tool_call.name.clone(),
tool_call.arguments.clone(),
Some("Goose would like to call the tool: {}\nAllow? (y/n): ".to_string()),
);
yield confirmation;
// Wait for confirmation response through the channel
let mut rx = self.confirmation_rx.lock().await;
if let Some((req_id, confirmed)) = rx.recv().await {
if req_id == request.id {
if confirmed {
// User approved - dispatch the tool call
let output = capabilities.dispatch_tool_call(tool_call).await;
message_tool_response = message_tool_response.with_tool_response(
request.id.clone(),
output,
);
} else {
// User declined - add declined response
message_tool_response = message_tool_response.with_tool_response(
request.id.clone(),
Ok(vec![Content::text("User declined to run this tool.")]),
);
}
}
}
}