Fix frontend extension session state and tool routes (#8464)
Signed-off-by: sunilkumarvalmiki <g.sunilkumarvalmiki@gmail.com> Signed-off-by: jh-block <jhugo@block.xyz> Signed-off-by: Douwe Osinga <douwe@squareup.com> Signed-off-by: Michael Neale <michael.neale@gmail.com> Signed-off-by: Angie Jones <jones.angie@gmail.com> Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Signed-off-by: Adam Miller <admiller@redhat.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: Andrew Harvard <aharvard@squareup.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Wes <wesb@block.xyz> Signed-off-by: Clay Delk <clay.delk@gmail.com> Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Alex Hancock <alexhancock@block.xyz> Signed-off-by: Treebird <treebird@treebird.dev> Signed-off-by: tulsi <tulsi@block.xyz> Signed-off-by: vonbai <nswanqi@gmail.com> Signed-off-by: olaservo <olahungerford@gmail.com> Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com> Signed-off-by: Rodolfo Olivieri <rolivier@redhat.com> Signed-off-by: fresh3nough <anonwurcod@proton.me> Signed-off-by: Jheison Martinez Bolivar <jheison.mb@gmail.com> Co-authored-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Lifei Zhou <lifei@squareup.com> Co-authored-by: jh-block <jhugo@block.xyz> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Jack Amadeo <jackamadeo@block.xyz> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Angie Jones <jones.angie@gmail.com> Co-authored-by: SomeSolutionsArchitect <139817767+SomeSolutionsArchitect@users.noreply.github.com> Co-authored-by: Christian <chvargas@wfscorp.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Co-authored-by: Adam Miller <admiller@redhat.com> Co-authored-by: Jack Amadeo <jackamadeo@squareup.com> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andrew Harvard <aharvard@squareup.com> Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com> Co-authored-by: Adewale Abati <acekyd01@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: dorien-koelemeijer <62866702+dorien-koelemeijer@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Monroe Williams <monroe@pobox.com> Co-authored-by: Spike Wang <spike@spikewang.me> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Treebird <treebird@treebird.dev> Co-authored-by: Sarthak Bhardwaj <100398847+SarthakB11@users.noreply.github.com> Co-authored-by: Douwe Osinga <douwe@block.xyz> Co-authored-by: tulsi <tulsi@block.xyz> Co-authored-by: Vonbai <107612985+vonbai@users.noreply.github.com> Co-authored-by: Ola Hungerford <olahungerford@gmail.com> Co-authored-by: Lucas Kim <45152028+Raptors65@users.noreply.github.com> Co-authored-by: Abhijay Jain <abhijay007j@gmail.com> Co-authored-by: Rodolfo Olivieri <rolivier@redhat.com> Co-authored-by: fre$h <anonwurcod@proton.me> Co-authored-by: Jheison Martinez Bolivar <78370841+JheisonMB@users.noreply.github.com>
This commit is contained in:
@@ -1054,16 +1054,17 @@ async fn read_resource(
|
|||||||
request_body = CallToolRequest,
|
request_body = CallToolRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Resource read successfully", body = CallToolResponse),
|
(status = 200, description = "Resource read successfully", body = CallToolResponse),
|
||||||
|
(status = 403, description = "Forbidden - tool is not app-visible", body = ErrorResponse),
|
||||||
(status = 401, description = "Unauthorized - invalid secret key"),
|
(status = 401, description = "Unauthorized - invalid secret key"),
|
||||||
(status = 424, description = "Agent not initialized"),
|
(status = 424, description = "Frontend tool execution requires the frontend host", body = ErrorResponse),
|
||||||
(status = 404, description = "Resource not found"),
|
(status = 404, description = "Resource not found", body = ErrorResponse),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
async fn call_tool(
|
async fn call_tool(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(payload): Json<CallToolRequest>,
|
Json(payload): Json<CallToolRequest>,
|
||||||
) -> Result<Json<CallToolResponse>, StatusCode> {
|
) -> Result<Json<CallToolResponse>, ErrorResponse> {
|
||||||
ensure_extensions_loaded(&state, &payload.session_id).await;
|
ensure_extensions_loaded(&state, &payload.session_id).await;
|
||||||
|
|
||||||
let agent = state
|
let agent = state
|
||||||
@@ -1078,10 +1079,23 @@ async fn call_tool(
|
|||||||
tool = %payload.name,
|
tool = %payload.name,
|
||||||
"Rejected app call to model-only tool"
|
"Rejected app call to model-only tool"
|
||||||
);
|
);
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(ErrorResponse {
|
||||||
|
message: format!("Tool '{}' cannot be called by the app", payload.name),
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if agent.is_frontend_tool(&payload.name).await {
|
||||||
|
return Err(ErrorResponse {
|
||||||
|
message: format!(
|
||||||
|
"Tool '{}' is provided by the frontend and must be executed by the frontend host",
|
||||||
|
payload.name
|
||||||
|
),
|
||||||
|
status: StatusCode::FAILED_DEPENDENCY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let arguments = match payload.arguments {
|
let arguments = match payload.arguments {
|
||||||
Value::Object(map) => Some(map),
|
Value::Object(map) => Some(map),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -1100,19 +1114,19 @@ async fn call_tool(
|
|||||||
.extension_manager
|
.extension_manager
|
||||||
.dispatch_tool_call(&ctx, tool_call, CancellationToken::default())
|
.dispatch_tool_call(&ctx, tool_call, CancellationToken::default())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(ErrorResponse::from)?;
|
||||||
|
|
||||||
let result = tool_result
|
let result = tool_result.result.await.map_err(|err| ErrorResponse {
|
||||||
.result
|
message: err.to_string(),
|
||||||
.await
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
})?;
|
||||||
|
|
||||||
let content = result
|
let content = result
|
||||||
.content
|
.content
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(serde_json::to_value)
|
.map(serde_json::to_value)
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(ErrorResponse::from)?;
|
||||||
|
|
||||||
Ok(Json(CallToolResponse {
|
Ok(Json(CallToolResponse {
|
||||||
content,
|
content,
|
||||||
@@ -1340,3 +1354,87 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
|||||||
.route("/agent/stop", post(stop_agent))
|
.route("/agent/stop", post(stop_agent))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use goose::config::GooseMode;
|
||||||
|
use goose::session::session_manager::SessionType;
|
||||||
|
use rmcp::model::Tool;
|
||||||
|
use rmcp::object;
|
||||||
|
|
||||||
|
fn frontend_extension() -> ExtensionConfig {
|
||||||
|
ExtensionConfig::Frontend {
|
||||||
|
name: "frontend-e2e".to_string(),
|
||||||
|
description: "Frontend test extension".to_string(),
|
||||||
|
tools: vec![Tool::new(
|
||||||
|
"frontend__echo".to_string(),
|
||||||
|
"Echo a string from the frontend".to_string(),
|
||||||
|
object!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
instructions: Some("Use the frontend echo tool.".to_string()),
|
||||||
|
bundled: None,
|
||||||
|
available_tools: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn frontend_extensions_are_listed_and_rejected_cleanly_by_call_tool() {
|
||||||
|
let state = AppState::new(true).await.unwrap();
|
||||||
|
let session = state
|
||||||
|
.session_manager()
|
||||||
|
.create_session(
|
||||||
|
std::env::current_dir().unwrap(),
|
||||||
|
"frontend-route-test".to_string(),
|
||||||
|
SessionType::Hidden,
|
||||||
|
GooseMode::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
agent_add_extension(
|
||||||
|
State(state.clone()),
|
||||||
|
Json(AddExtensionRequest {
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
config: frontend_extension(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Json(tools) = get_tools(
|
||||||
|
State(state.clone()),
|
||||||
|
Query(GetToolsQuery {
|
||||||
|
extension_name: None,
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(tools.iter().any(|tool| tool.name == "frontend__echo"));
|
||||||
|
|
||||||
|
let error = match call_tool(
|
||||||
|
State(state),
|
||||||
|
Json(CallToolRequest {
|
||||||
|
session_id: session.id,
|
||||||
|
name: "frontend__echo".to_string(),
|
||||||
|
arguments: Value::Object(serde_json::Map::new()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("frontend tools should not be callable through /agent/call_tool"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error.status, StatusCode::FAILED_DEPENDENCY);
|
||||||
|
assert!(error.message.contains("frontend host"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
|
|||||||
use crate::agents::prompt_manager::PromptManager;
|
use crate::agents::prompt_manager::PromptManager;
|
||||||
use crate::agents::retry::{RetryManager, RetryResult};
|
use crate::agents::retry::{RetryManager, RetryResult};
|
||||||
use crate::agents::types::{FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver};
|
use crate::agents::types::{FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver};
|
||||||
|
use crate::config::extensions::name_to_key;
|
||||||
use crate::config::permission::PermissionManager;
|
use crate::config::permission::PermissionManager;
|
||||||
use crate::config::{get_enabled_extensions, Config, GooseMode};
|
use crate::config::{get_enabled_extensions, Config, GooseMode};
|
||||||
use crate::context_mgmt::{
|
use crate::context_mgmt::{
|
||||||
@@ -66,6 +67,8 @@ use tracing::{debug, error, info, instrument, warn};
|
|||||||
|
|
||||||
const DEFAULT_MAX_TURNS: u32 = 1000;
|
const DEFAULT_MAX_TURNS: u32 = 1000;
|
||||||
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
|
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
|
||||||
|
const DEFAULT_FRONTEND_INSTRUCTIONS: &str =
|
||||||
|
"The following tools are provided directly by the frontend and will be executed by the frontend when called.";
|
||||||
|
|
||||||
/// Context needed for the reply function
|
/// Context needed for the reply function
|
||||||
pub struct ReplyContext {
|
pub struct ReplyContext {
|
||||||
@@ -162,6 +165,7 @@ pub struct Agent {
|
|||||||
|
|
||||||
pub extension_manager: Arc<ExtensionManager>,
|
pub extension_manager: Arc<ExtensionManager>,
|
||||||
pub(super) final_output_tool: Arc<Mutex<Option<FinalOutputTool>>>,
|
pub(super) final_output_tool: Arc<Mutex<Option<FinalOutputTool>>>,
|
||||||
|
pub(super) frontend_extensions: Mutex<HashMap<String, ExtensionConfig>>,
|
||||||
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
||||||
pub(super) frontend_instructions: Mutex<Option<String>>,
|
pub(super) frontend_instructions: Mutex<Option<String>>,
|
||||||
pub(super) prompt_manager: Mutex<PromptManager>,
|
pub(super) prompt_manager: Mutex<PromptManager>,
|
||||||
@@ -272,6 +276,7 @@ impl Agent {
|
|||||||
capabilities,
|
capabilities,
|
||||||
)),
|
)),
|
||||||
final_output_tool: Arc::new(Mutex::new(None)),
|
final_output_tool: Arc::new(Mutex::new(None)),
|
||||||
|
frontend_extensions: Mutex::new(HashMap::new()),
|
||||||
frontend_tools: Mutex::new(HashMap::new()),
|
frontend_tools: Mutex::new(HashMap::new()),
|
||||||
frontend_instructions: Mutex::new(None),
|
frontend_instructions: Mutex::new(None),
|
||||||
prompt_manager: Mutex::new(PromptManager::new()),
|
prompt_manager: Mutex::new(PromptManager::new()),
|
||||||
@@ -626,6 +631,111 @@ impl Agent {
|
|||||||
self.frontend_tools.lock().await.get(name).cloned()
|
self.frontend_tools.lock().await.get(name).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn frontend_extension_configs(&self) -> Vec<ExtensionConfig> {
|
||||||
|
let mut configs = self
|
||||||
|
.frontend_extensions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
configs.sort_by_key(|config| config.key());
|
||||||
|
configs
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn frontend_tools_for_extension(&self, extension_name: Option<&str>) -> Vec<Tool> {
|
||||||
|
let requested_extension = extension_name.map(name_to_key);
|
||||||
|
|
||||||
|
self.frontend_extension_configs()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|config| {
|
||||||
|
let include = requested_extension
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|name| *name == config.key());
|
||||||
|
|
||||||
|
match config {
|
||||||
|
ExtensionConfig::Frontend { tools, .. } if include => Some(tools),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rebuild_frontend_derived_state(&self, extensions: &HashMap<String, ExtensionConfig>) {
|
||||||
|
let multiple = extensions.len() > 1;
|
||||||
|
let mut tools = HashMap::new();
|
||||||
|
let mut instructions = Vec::new();
|
||||||
|
|
||||||
|
for config in extensions.values() {
|
||||||
|
if let ExtensionConfig::Frontend {
|
||||||
|
name,
|
||||||
|
tools: ext_tools,
|
||||||
|
instructions: ext_instructions,
|
||||||
|
..
|
||||||
|
} = config
|
||||||
|
{
|
||||||
|
for tool in ext_tools {
|
||||||
|
let tool_name = tool.name.to_string();
|
||||||
|
tools.insert(
|
||||||
|
tool_name.clone(),
|
||||||
|
FrontendTool {
|
||||||
|
name: tool_name,
|
||||||
|
tool: tool.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = ext_instructions
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_FRONTEND_INSTRUCTIONS.to_string());
|
||||||
|
instructions.push(if multiple {
|
||||||
|
format!("{name}: {text}")
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.frontend_tools.lock().await = tools;
|
||||||
|
*self.frontend_instructions.lock().await = if instructions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(instructions.join("\n\n"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_frontend_extension(&self, extension: ExtensionConfig) {
|
||||||
|
let mut extensions = self.frontend_extensions.lock().await;
|
||||||
|
extensions.insert(extension.key(), extension);
|
||||||
|
self.rebuild_frontend_derived_state(&extensions).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_frontend_extension(&self, name: &str) {
|
||||||
|
let mut extensions = self.frontend_extensions.lock().await;
|
||||||
|
extensions.remove(&name_to_key(name));
|
||||||
|
self.rebuild_frontend_derived_state(&extensions).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extension_configs_for_persistence(&self) -> Vec<ExtensionConfig> {
|
||||||
|
let mut extension_configs = self.extension_manager.get_extension_configs().await;
|
||||||
|
extension_configs.extend(self.frontend_extension_configs().await);
|
||||||
|
extension_configs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn total_extension_and_tool_counts(&self, session_id: &str) -> (usize, usize) {
|
||||||
|
let (extension_count, tool_count) = self
|
||||||
|
.extension_manager
|
||||||
|
.get_extension_and_tool_counts(session_id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
(
|
||||||
|
extension_count + self.frontend_extensions.lock().await.len(),
|
||||||
|
tool_count + self.frontend_tools.lock().await.len(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_final_output_tool(&self, response: Response) {
|
pub async fn add_final_output_tool(&self, response: Response) {
|
||||||
let mut final_output_tool = self.final_output_tool.lock().await;
|
let mut final_output_tool = self.final_output_tool.lock().await;
|
||||||
let created_final_output_tool = FinalOutputTool::new(response);
|
let created_final_output_tool = FinalOutputTool::new(response);
|
||||||
@@ -771,9 +881,8 @@ impl Agent {
|
|||||||
/// Save current extension state to session metadata
|
/// Save current extension state to session metadata
|
||||||
/// Should be called after any extension add/remove operation
|
/// Should be called after any extension add/remove operation
|
||||||
pub async fn save_extension_state(&self, session: &SessionConfig) -> Result<()> {
|
pub async fn save_extension_state(&self, session: &SessionConfig) -> Result<()> {
|
||||||
let extension_configs = self.extension_manager.get_extension_configs().await;
|
let extensions_state =
|
||||||
|
EnabledExtensionsState::new(self.extension_configs_for_persistence().await);
|
||||||
let extensions_state = EnabledExtensionsState::new(extension_configs);
|
|
||||||
|
|
||||||
let session_manager = self.config.session_manager.clone();
|
let session_manager = self.config.session_manager.clone();
|
||||||
let mut session_data = session_manager.get_session(&session.id, false).await?;
|
let mut session_data = session_manager.get_session(&session.id, false).await?;
|
||||||
@@ -794,8 +903,8 @@ impl Agent {
|
|||||||
|
|
||||||
/// Save current extension state to session by session_id
|
/// Save current extension state to session by session_id
|
||||||
pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> {
|
pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> {
|
||||||
let extension_configs = self.extension_manager.get_extension_configs().await;
|
let extensions_state =
|
||||||
let extensions_state = EnabledExtensionsState::new(extension_configs);
|
EnabledExtensionsState::new(self.extension_configs_for_persistence().await);
|
||||||
|
|
||||||
let session_manager = self.config.session_manager.clone();
|
let session_manager = self.config.session_manager.clone();
|
||||||
let session = session_manager.get_session(session_id, false).await?;
|
let session = session_manager.get_session(session_id, false).await?;
|
||||||
@@ -999,30 +1108,8 @@ impl Agent {
|
|||||||
let working_dir = Some(session.working_dir);
|
let working_dir = Some(session.working_dir);
|
||||||
|
|
||||||
match &extension {
|
match &extension {
|
||||||
ExtensionConfig::Frontend {
|
ExtensionConfig::Frontend { .. } => {
|
||||||
tools,
|
self.insert_frontend_extension(extension.clone()).await;
|
||||||
instructions,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
// For frontend tools, just store them in the frontend_tools map
|
|
||||||
let mut frontend_tools = self.frontend_tools.lock().await;
|
|
||||||
for tool in tools {
|
|
||||||
let frontend_tool = FrontendTool {
|
|
||||||
name: tool.name.to_string(),
|
|
||||||
tool: tool.clone(),
|
|
||||||
};
|
|
||||||
frontend_tools.insert(tool.name.to_string(), frontend_tool);
|
|
||||||
}
|
|
||||||
// Store instructions if provided, using "frontend" as the key
|
|
||||||
let mut frontend_instructions = self.frontend_instructions.lock().await;
|
|
||||||
if let Some(instructions) = instructions {
|
|
||||||
*frontend_instructions = Some(instructions.clone());
|
|
||||||
} else {
|
|
||||||
// Default frontend instructions if none provided
|
|
||||||
*frontend_instructions = Some(
|
|
||||||
"The following tools are provided directly by the frontend and will be executed by the frontend when called.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let container = self.container.lock().await;
|
let container = self.container.lock().await;
|
||||||
@@ -1047,6 +1134,11 @@ impl Agent {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
prefixed_tools.extend(
|
||||||
|
self.frontend_tools_for_extension(extension_name.as_deref())
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
if (extension_name.is_none() || extension_name.as_deref() == Some("platform"))
|
if (extension_name.is_none() || extension_name.as_deref() == Some("platform"))
|
||||||
&& self.config.scheduler_service.is_some()
|
&& self.config.scheduler_service.is_some()
|
||||||
{
|
{
|
||||||
@@ -1064,6 +1156,7 @@ impl Agent {
|
|||||||
|
|
||||||
pub async fn remove_extension(&self, name: &str, session_id: &str) -> Result<()> {
|
pub async fn remove_extension(&self, name: &str, session_id: &str) -> Result<()> {
|
||||||
self.extension_manager.remove_extension(name).await?;
|
self.extension_manager.remove_extension(name).await?;
|
||||||
|
self.remove_frontend_extension(name).await;
|
||||||
|
|
||||||
// Persist extension state after successful removal
|
// Persist extension state after successful removal
|
||||||
self.persist_extension_state(session_id)
|
self.persist_extension_state(session_id)
|
||||||
@@ -1077,14 +1170,22 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_extensions(&self) -> Vec<String> {
|
pub async fn list_extensions(&self) -> Vec<String> {
|
||||||
self.extension_manager
|
let mut extensions = self
|
||||||
|
.extension_manager
|
||||||
.list_extensions()
|
.list_extensions()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to list extensions")
|
.expect("Failed to list extensions");
|
||||||
|
extensions.extend(
|
||||||
|
self.frontend_extension_configs()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|config| config.name()),
|
||||||
|
);
|
||||||
|
extensions
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_extension_configs(&self) -> Vec<ExtensionConfig> {
|
pub async fn get_extension_configs(&self) -> Vec<ExtensionConfig> {
|
||||||
self.extension_manager.get_extension_configs().await
|
self.extension_configs_for_persistence().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a confirmation response for a tool request
|
/// Handle a confirmation response for a tool request
|
||||||
@@ -2243,10 +2344,7 @@ impl Agent {
|
|||||||
.get_extensions_info(&session.working_dir)
|
.get_extensions_info(&session.working_dir)
|
||||||
.await;
|
.await;
|
||||||
tracing::debug!("Retrieved {} extensions info", extensions_info.len());
|
tracing::debug!("Retrieved {} extensions info", extensions_info.len());
|
||||||
let (extension_count, tool_count) = self
|
let (extension_count, tool_count) = self.total_extension_and_tool_counts(session_id).await;
|
||||||
.extension_manager
|
|
||||||
.get_extension_and_tool_counts(session_id)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Get model name from provider
|
// Get model name from provider
|
||||||
let provider = self.provider().await.map_err(|e| {
|
let provider = self.provider().await.map_err(|e| {
|
||||||
|
|||||||
@@ -138,15 +138,8 @@ impl Agent {
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
working_dir: &std::path::Path,
|
working_dir: &std::path::Path,
|
||||||
) -> Result<(Vec<Tool>, Vec<Tool>, String)> {
|
) -> Result<(Vec<Tool>, Vec<Tool>, String)> {
|
||||||
// Get tools from extension manager
|
|
||||||
let mut tools = self.list_tools(session_id, None).await;
|
let mut tools = self.list_tools(session_id, None).await;
|
||||||
|
|
||||||
// Add frontend tools
|
|
||||||
let frontend_tools = self.frontend_tools.lock().await;
|
|
||||||
for frontend_tool in frontend_tools.values() {
|
|
||||||
tools.push(frontend_tool.tool.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "code-mode")]
|
#[cfg(feature = "code-mode")]
|
||||||
let code_execution_active = self
|
let code_execution_active = self
|
||||||
.extension_manager
|
.extension_manager
|
||||||
@@ -214,10 +207,7 @@ impl Agent {
|
|||||||
.extension_manager
|
.extension_manager
|
||||||
.get_extensions_info(working_dir)
|
.get_extensions_info(working_dir)
|
||||||
.await;
|
.await;
|
||||||
let (extension_count, tool_count) = self
|
let (extension_count, tool_count) = self.total_extension_and_tool_counts(session_id).await;
|
||||||
.extension_manager
|
|
||||||
.get_extension_and_tool_counts(session_id)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Get model name from provider
|
// Get model name from provider
|
||||||
let provider = self.provider().await?;
|
let provider = self.provider().await?;
|
||||||
|
|||||||
@@ -1118,4 +1118,192 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod frontend_extension_tests {
|
||||||
|
use super::*;
|
||||||
|
use goose::agents::{AgentConfig, ExtensionConfig};
|
||||||
|
use goose::config::permission::PermissionManager;
|
||||||
|
use goose::config::GooseMode;
|
||||||
|
use goose::session::session_manager::SessionType;
|
||||||
|
use goose::session::{
|
||||||
|
EnabledExtensionsState, ExtensionData, ExtensionState, SessionManager,
|
||||||
|
};
|
||||||
|
use rmcp::model::Tool;
|
||||||
|
use rmcp::object;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn frontend_extension_with_tool(name: &str, tool_name: &str) -> ExtensionConfig {
|
||||||
|
ExtensionConfig::Frontend {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: format!("Frontend test extension {name}"),
|
||||||
|
tools: vec![Tool::new(
|
||||||
|
tool_name.to_string(),
|
||||||
|
format!("Run {tool_name} from the frontend"),
|
||||||
|
object!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
instructions: Some(format!("Use the {tool_name} tool.")),
|
||||||
|
bundled: None,
|
||||||
|
available_tools: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frontend_extension() -> ExtensionConfig {
|
||||||
|
frontend_extension_with_tool("frontend-e2e", "frontend__echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_frontend_extensions_are_persisted_listed_and_removed() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let data_dir = temp_dir.path().to_path_buf();
|
||||||
|
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
|
||||||
|
let permission_manager = Arc::new(PermissionManager::new(data_dir));
|
||||||
|
let agent = Agent::with_config(AgentConfig::new(
|
||||||
|
session_manager.clone(),
|
||||||
|
permission_manager,
|
||||||
|
None,
|
||||||
|
GooseMode::default(),
|
||||||
|
false,
|
||||||
|
GoosePlatform::GooseDesktop,
|
||||||
|
));
|
||||||
|
|
||||||
|
let session = session_manager
|
||||||
|
.create_session(
|
||||||
|
std::env::current_dir().unwrap(),
|
||||||
|
"frontend-extension-test".to_string(),
|
||||||
|
SessionType::Hidden,
|
||||||
|
GooseMode::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
agent
|
||||||
|
.add_extension(frontend_extension(), &session.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed_tools = agent.list_tools(&session.id, None).await;
|
||||||
|
assert!(listed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.name == "frontend__echo"));
|
||||||
|
|
||||||
|
let filtered_tools = agent
|
||||||
|
.list_tools(&session.id, Some("frontend-e2e".to_string()))
|
||||||
|
.await;
|
||||||
|
assert_eq!(filtered_tools.len(), 1);
|
||||||
|
assert_eq!(filtered_tools[0].name, "frontend__echo");
|
||||||
|
|
||||||
|
let extension_names = agent.list_extensions().await;
|
||||||
|
assert!(extension_names.iter().any(|name| name == "frontend-e2e"));
|
||||||
|
|
||||||
|
let persisted_session = session_manager
|
||||||
|
.get_session(&session.id, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let persisted_extensions =
|
||||||
|
EnabledExtensionsState::from_extension_data(&persisted_session.extension_data)
|
||||||
|
.unwrap()
|
||||||
|
.extensions;
|
||||||
|
assert!(persisted_extensions
|
||||||
|
.iter()
|
||||||
|
.any(|extension| extension.name() == "frontend-e2e"));
|
||||||
|
|
||||||
|
agent
|
||||||
|
.remove_extension("frontend-e2e", &session.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed_tools = agent.list_tools(&session.id, None).await;
|
||||||
|
assert!(!listed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.name == "frontend__echo"));
|
||||||
|
|
||||||
|
let persisted_session = session_manager
|
||||||
|
.get_session(&session.id, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let persisted_extensions =
|
||||||
|
EnabledExtensionsState::from_extension_data(&persisted_session.extension_data)
|
||||||
|
.unwrap()
|
||||||
|
.extensions;
|
||||||
|
assert!(persisted_extensions
|
||||||
|
.iter()
|
||||||
|
.all(|extension| extension.name() != "frontend-e2e"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_frontend_session_load_keeps_all_tools() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let data_dir = temp_dir.path().to_path_buf();
|
||||||
|
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
|
||||||
|
let permission_manager = Arc::new(PermissionManager::new(data_dir));
|
||||||
|
let agent = Arc::new(Agent::with_config(AgentConfig::new(
|
||||||
|
session_manager.clone(),
|
||||||
|
permission_manager,
|
||||||
|
None,
|
||||||
|
GooseMode::default(),
|
||||||
|
false,
|
||||||
|
GoosePlatform::GooseDesktop,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let session = session_manager
|
||||||
|
.create_session(
|
||||||
|
std::env::current_dir().unwrap(),
|
||||||
|
"frontend-extension-load-test".to_string(),
|
||||||
|
SessionType::Hidden,
|
||||||
|
GooseMode::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let expected_tools = (0..12)
|
||||||
|
.map(|index| format!("frontend__tool_{index}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let extensions = expected_tools
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, tool_name)| {
|
||||||
|
frontend_extension_with_tool(&format!("frontend-{index}"), tool_name)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut extension_data = ExtensionData::new();
|
||||||
|
EnabledExtensionsState::new(extensions)
|
||||||
|
.to_extension_data(&mut extension_data)
|
||||||
|
.unwrap();
|
||||||
|
session_manager
|
||||||
|
.update(&session.id)
|
||||||
|
.extension_data(extension_data)
|
||||||
|
.apply()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session = session_manager
|
||||||
|
.get_session(&session.id, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let load_results = agent.load_extensions_from_session(&session).await;
|
||||||
|
assert!(
|
||||||
|
load_results.iter().all(|result| result.success),
|
||||||
|
"failed to load frontend extensions: {load_results:?}",
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed_tools = agent.list_tools(&session.id, None).await;
|
||||||
|
for tool_name in expected_tools {
|
||||||
|
assert!(
|
||||||
|
listed_tools.iter().any(|tool| tool.name == tool_name),
|
||||||
|
"expected listed frontend tool {tool_name}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
agent.is_frontend_tool(&tool_name).await,
|
||||||
|
"expected frontend dispatch state for {tool_name}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-3
@@ -116,14 +116,45 @@
|
|||||||
"401": {
|
"401": {
|
||||||
"description": "Unauthorized - invalid secret key"
|
"description": "Unauthorized - invalid secret key"
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden - tool is not app-visible",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Resource not found"
|
"description": "Resource not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"424": {
|
"424": {
|
||||||
"description": "Agent not initialized"
|
"description": "Frontend tool execution requires the frontend host",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal server error"
|
"description": "Internal server error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1755,20 +1755,26 @@ export type CallToolErrors = {
|
|||||||
* Unauthorized - invalid secret key
|
* Unauthorized - invalid secret key
|
||||||
*/
|
*/
|
||||||
401: unknown;
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Forbidden - tool is not app-visible
|
||||||
|
*/
|
||||||
|
403: ErrorResponse;
|
||||||
/**
|
/**
|
||||||
* Resource not found
|
* Resource not found
|
||||||
*/
|
*/
|
||||||
404: unknown;
|
404: ErrorResponse;
|
||||||
/**
|
/**
|
||||||
* Agent not initialized
|
* Frontend tool execution requires the frontend host
|
||||||
*/
|
*/
|
||||||
424: unknown;
|
424: ErrorResponse;
|
||||||
/**
|
/**
|
||||||
* Internal server error
|
* Internal server error
|
||||||
*/
|
*/
|
||||||
500: unknown;
|
500: ErrorResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CallToolError = CallToolErrors[keyof CallToolErrors];
|
||||||
|
|
||||||
export type CallToolResponses = {
|
export type CallToolResponses = {
|
||||||
/**
|
/**
|
||||||
* Resource read successfully
|
* Resource read successfully
|
||||||
|
|||||||
Reference in New Issue
Block a user