diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index 4f2cd8120ff1..923dea740518 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -1,4 +1,6 @@ -use goose::conversation::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use goose::conversation::message::{ + ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, +}; use goose::utils::safe_truncate; use rmcp::model::{RawContent, ResourceContents, Role}; use serde_json::Value; @@ -340,6 +342,14 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin let mut md = String::new(); for content in &message.content { match content { + MessageContent::ActionRequired(action) => match &action.data { + ActionRequiredData::ToolConfirmation { tool_name, .. } => { + md.push_str(&format!( + "**Action Required** (tool_confirmation): {}\n\n", + tool_name + )); + } + }, MessageContent::Text(text) => { md.push_str(&text.text); md.push_str("\n\n"); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index c3fb5cede007..a32939223eed 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -40,7 +40,7 @@ use rmcp::model::ServerNotification; use rmcp::model::{ErrorCode, ErrorData}; use goose::config::paths::Paths; -use goose::conversation::message::{Message, MessageContent}; +use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; use rand::{distributions::Alphanumeric, Rng}; use rustyline::EditMode; use serde::{Deserialize, Serialize}; @@ -852,12 +852,24 @@ impl CliSession { result = stream.next() => { match result { Some(Ok(AgentEvent::Message(message))) => { - // If it's a confirmation request, get approval but otherwise do not render/persist - if let Some(MessageContent::ToolConfirmationRequest(confirmation)) = message.content.first() { + let tool_call_confirmation = message.content.iter().find_map(|content| { + if let MessageContent::ActionRequired(action) = content { + #[allow(irrefutable_let_patterns)] // this is a one variant enum right now but it will have more + if let ActionRequiredData::ToolConfirmation { id, tool_name, arguments, prompt } = &action.data { + Some((id.clone(), tool_name.clone(), arguments.clone(), prompt.clone())) + } else { + None + } + } else { + None + } + }); + + if let Some((id, _tool_name, _arguments, security_prompt)) = tool_call_confirmation { output::hide_thinking(); // Format the confirmation prompt - use security message if present, otherwise use generic message - let prompt = if let Some(security_message) = &confirmation.prompt { + let prompt = if let Some(security_message) = &security_prompt { println!("\n{}", security_message); "Do you allow this tool call?".to_string() } else { @@ -865,7 +877,7 @@ impl CliSession { }; // Get confirmation from user - let permission_result = if confirmation.prompt.is_none() { + let permission_result = if security_prompt.is_none() { // No security message - show all options including "Always Allow" cliclack::select(prompt) .item(Permission::AllowOnce, "Allow", "Allow the tool call once") @@ -883,13 +895,12 @@ impl CliSession { }; let permission = match permission_result { - Ok(p) => p, // If Ok, use the selected permission + Ok(p) => p, Err(e) => { - // Check if the error is an interruption (Ctrl+C/Cmd+C, Escape) if e.kind() == std::io::ErrorKind::Interrupted { - Permission::Cancel // If interrupted, set permission to Cancel + Permission::Cancel } else { - return Err(e.into()); // Otherwise, convert and propagate the original error + return Err(e.into()); } } }; @@ -899,7 +910,7 @@ impl CliSession { let mut response_message = Message::user(); response_message.content.push(MessageContent::tool_response( - confirmation.id.clone(), + id.clone(), Err(ErrorData { code: ErrorCode::INVALID_REQUEST, message: std::borrow::Cow::from("Tool call cancelled by user".to_string()), data: None }) )); self.messages.push(response_message); @@ -907,10 +918,10 @@ impl CliSession { drop(stream); break; } else { - self.agent.handle_confirmation(confirmation.id.clone(), PermissionConfirmation { + self.agent.handle_confirmation(id.clone(), PermissionConfirmation { principal_type: PrincipalType::Tool, permission, - },).await; + }).await; } } else { diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index f9785061594a..881d30690133 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -2,7 +2,9 @@ use anstream::println; use bat::WrappingMode; use console::{measure_text_width, style, Color, Term}; use goose::config::Config; -use goose::conversation::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use goose::conversation::message::{ + ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse, +}; use goose::providers::pricing::get_model_pricing; use goose::providers::pricing::parse_model_id; use goose::utils::safe_truncate; @@ -166,6 +168,11 @@ pub fn render_message(message: &Message, debug: bool) { for content in &message.content { match content { + MessageContent::ActionRequired(action) => match &action.data { + ActionRequiredData::ToolConfirmation { tool_name, .. } => { + println!("action_required(tool_confirmation): {}", tool_name) + } + }, MessageContent::Text(text) => print_markdown(&text.text, theme), MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), MessageContent::ToolResponse(resp) => render_tool_response(resp, theme, debug), diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index edd101ecb6fc..8909234e7f4e 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -19,9 +19,9 @@ use goose::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ - FrontendToolRequest, Message, MessageContent, MessageMetadata, RedactedThinkingContent, - SystemNotificationContent, SystemNotificationType, ThinkingContent, TokenState, - ToolConfirmationRequest, ToolRequest, ToolResponse, + ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent, + MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, + ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse, }; use crate::routes::recipe_utils::RecipeManifest; @@ -358,7 +358,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::agent_remove_extension, super::routes::agent::update_agent_provider, super::routes::agent::update_router_tool_selector, - super::routes::reply::confirm_permission, + super::routes::action_required::confirm_tool_action, super::routes::reply::reply, super::routes::session::list_sessions, super::routes::session::get_session, @@ -411,7 +411,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::UpdateCustomProviderRequest, super::routes::config_management::CheckProviderRequest, super::routes::config_management::SetProviderRequest, - super::routes::reply::PermissionConfirmationRequest, + super::routes::action_required::ConfirmToolActionRequest, super::routes::reply::ChatRequest, super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, @@ -438,6 +438,8 @@ derive_utoipa!(Icon as IconSchema); ToolResponse, ToolRequest, ToolConfirmationRequest, + ActionRequired, + ActionRequiredData, ThinkingContent, RedactedThinkingContent, FrontendToolRequest, diff --git a/crates/goose-server/src/routes/action_required.rs b/crates/goose-server/src/routes/action_required.rs new file mode 100644 index 000000000000..9f7bfdfc12c9 --- /dev/null +++ b/crates/goose-server/src/routes/action_required.rs @@ -0,0 +1,104 @@ +use crate::state::AppState; +use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use goose::permission::permission_confirmation::PrincipalType; +use goose::permission::{Permission, PermissionConfirmation}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmToolActionRequest { + id: String, + #[serde(default = "default_principal_type")] + principal_type: PrincipalType, + action: String, + session_id: String, +} + +fn default_principal_type() -> PrincipalType { + PrincipalType::Tool +} + +#[utoipa::path( + post, + path = "/action-required/tool-confirmation", + request_body = ConfirmToolActionRequest, + responses( + (status = 200, description = "Tool confirmation action is confirmed", body = Value), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn confirm_tool_action( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + let agent = state.get_agent_for_route(request.session_id).await?; + let permission = match request.action.as_str() { + "always_allow" => Permission::AlwaysAllow, + "allow_once" => Permission::AllowOnce, + "deny" => Permission::DenyOnce, + _ => Permission::DenyOnce, + }; + + agent + .handle_confirmation( + request.id.clone(), + PermissionConfirmation { + principal_type: request.principal_type, + permission, + }, + ) + .await; + + Ok(Json(Value::Object(serde_json::Map::new()))) +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route( + "/action-required/tool-confirmation", + post(confirm_tool_action), + ) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod integration_tests { + use super::*; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + #[tokio::test(flavor = "multi_thread")] + async fn test_tool_confirmation_endpoint() { + let state = AppState::new().await.unwrap(); + + let app = routes(state); + + let request = Request::builder() + .uri("/action-required/tool-confirmation") + .method("POST") + .header("content-type", "application/json") + .header("x-secret-key", "test-secret") + .body(Body::from( + serde_json::to_string(&ConfirmToolActionRequest { + id: "test-id".to_string(), + principal_type: PrincipalType::Tool, + action: "allow_once".to_string(), + session_id: "test-session".to_string(), + }) + .unwrap(), + )) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + } +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 9c11afee3595..a79a8b9bf597 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod action_required; pub mod agent; pub mod audio; pub mod config_management; @@ -22,6 +23,7 @@ pub fn configure(state: Arc, secret_key: String) -> Rout Router::new() .merge(status::routes()) .merge(reply::routes(state.clone())) + .merge(action_required::routes(state.clone())) .merge(agent::routes(state.clone())) .merge(audio::routes(state.clone())) .merge(config_management::routes(state.clone())) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 5a340fa3d947..5b98f9c70126 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -8,17 +8,12 @@ use axum::{ }; use bytes::Bytes; use futures::{stream::StreamExt, Stream}; +use goose::agents::{AgentEvent, SessionConfig}; use goose::conversation::message::{Message, MessageContent, TokenState}; use goose::conversation::Conversation; -use goose::permission::{Permission, PermissionConfirmation}; use goose::session::SessionManager; -use goose::{ - agents::{AgentEvent, SessionConfig}, - permission::permission_confirmation::PrincipalType, -}; use rmcp::model::ServerNotification; use serde::{Deserialize, Serialize}; -use serde_json::Value; use std::{ convert::Infallible, pin::Pin, @@ -30,7 +25,6 @@ use tokio::sync::mpsc; use tokio::time::timeout; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; -use utoipa::ToSchema; fn track_tool_telemetry(content: &MessageContent, all_messages: &[Message]) { match content { @@ -452,60 +446,12 @@ pub async fn reply( Ok(SseResponse::new(stream)) } -#[derive(Debug, Deserialize, Serialize, ToSchema)] -pub struct PermissionConfirmationRequest { - id: String, - #[serde(default = "default_principal_type")] - principal_type: PrincipalType, - action: String, - session_id: String, -} - -fn default_principal_type() -> PrincipalType { - PrincipalType::Tool -} - -#[utoipa::path( - post, - path = "/confirm", - request_body = PermissionConfirmationRequest, - responses( - (status = 200, description = "Permission action is confirmed", body = Value), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn confirm_permission( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(request.session_id).await?; - let permission = match request.action.as_str() { - "always_allow" => Permission::AlwaysAllow, - "allow_once" => Permission::AllowOnce, - "deny" => Permission::DenyOnce, - _ => Permission::DenyOnce, - }; - - agent - .handle_confirmation( - request.id.clone(), - PermissionConfirmation { - principal_type: request.principal_type, - permission, - }, - ) - .await; - Ok(Json(Value::Object(serde_json::Map::new()))) -} - pub fn routes(state: Arc) -> Router { Router::new() .route( "/reply", post(reply).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ) - .route("/confirm", post(confirm_permission)) .with_state(state) } diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 402bb305ba8c..f7fdfa914a2f 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -73,7 +73,7 @@ impl Agent { }); let confirmation = Message::assistant() - .with_tool_confirmation_request( + .with_action_required( request.id.clone(), tool_call.name.to_string().clone(), tool_call.arguments.clone().unwrap_or_default(), diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 1b5deb3ed369..d2c6a06060c1 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -375,6 +375,14 @@ fn format_message_for_compacting(msg: &Message) -> String { MessageContent::ToolConfirmationRequest(req) => { format!("tool_confirmation_request: {}", req.tool_name) } + MessageContent::ActionRequired(action) => match &action.data { + crate::conversation::message::ActionRequiredData::ToolConfirmation { + tool_name, + .. + } => { + format!("action_required(tool_confirmation): {}", tool_name) + } + }, MessageContent::FrontendToolRequest(req) => { if let Ok(call) = &req.tool_call { format!("frontend_tool_request: {}", call.name) diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 16951f4395b8..ea5f1d63da13 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -101,6 +101,24 @@ pub struct ToolConfirmationRequest { pub prompt: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(tag = "actionType", rename_all = "camelCase")] +pub enum ActionRequiredData { + #[serde(rename_all = "camelCase")] + ToolConfirmation { + id: String, + tool_name: String, + arguments: JsonObject, + prompt: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActionRequired { + pub data: ActionRequiredData, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] pub struct ThinkingContent { pub thinking: String, @@ -144,6 +162,7 @@ pub enum MessageContent { ToolRequest(ToolRequest), ToolResponse(ToolResponse), ToolConfirmationRequest(ToolConfirmationRequest), + ActionRequired(ActionRequired), FrontendToolRequest(FrontendToolRequest), Thinking(ThinkingContent), RedactedThinking(RedactedThinkingContent), @@ -169,6 +188,11 @@ impl fmt::Display for MessageContent { MessageContent::ToolConfirmationRequest(r) => { write!(f, "[ToolConfirmationRequest: {}]", r.tool_name) } + MessageContent::ActionRequired(a) => match &a.data { + ActionRequiredData::ToolConfirmation { tool_name, .. } => { + write!(f, "[ActionRequired: ToolConfirmation for {}]", tool_name) + } + }, MessageContent::FrontendToolRequest(r) => match &r.tool_call { Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name), Err(e) => write!(f, "[FrontendToolRequest: Error: {}]", e), @@ -234,17 +258,19 @@ impl MessageContent { }) } - pub fn tool_confirmation_request>( + pub fn action_required>( id: S, tool_name: String, arguments: JsonObject, prompt: Option, ) -> Self { - MessageContent::ToolConfirmationRequest(ToolConfirmationRequest { - id: id.into(), - tool_name, - arguments, - prompt, + MessageContent::ActionRequired(ActionRequired { + data: ActionRequiredData::ToolConfirmation { + id: id.into(), + tool_name, + arguments, + prompt, + }, }) } @@ -303,9 +329,9 @@ impl MessageContent { } } - pub fn as_tool_confirmation_request(&self) -> Option<&ToolConfirmationRequest> { - if let MessageContent::ToolConfirmationRequest(ref tool_confirmation_request) = self { - Some(tool_confirmation_request) + pub fn as_action_required(&self) -> Option<&ActionRequired> { + if let MessageContent::ActionRequired(ref action_required) = self { + Some(action_required) } else { None } @@ -582,15 +608,15 @@ impl Message { self.with_content(MessageContent::tool_response(id, result)) } - /// Add a tool confirmation request to the message - pub fn with_tool_confirmation_request>( + /// Add an action required message for tool confirmation + pub fn with_action_required>( self, id: S, tool_name: String, arguments: JsonObject, prompt: Option, ) -> Self { - self.with_content(MessageContent::tool_confirmation_request( + self.with_content(MessageContent::action_required( id, tool_name, arguments, prompt, )) } diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index de20acccaef4..da573678b39e 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -91,6 +91,9 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => { // Skip tool confirmation requests } + MessageContent::ActionRequired(_action_required) => { + // Skip action required messages - they're for UI only + } MessageContent::SystemNotification(_) => { // Skip } diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index d6488163000b..f343059df51d 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -37,6 +37,9 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result { bedrock::ContentBlock::Text("".to_string()) } + MessageContent::ActionRequired(_action_required) => { + bedrock::ContentBlock::Text("".to_string()) + } MessageContent::Image(image) => { bedrock::ContentBlock::Image(to_bedrock_image(&image.data, &image.mime_type)?) } diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index cbc047e6ee27..4083fbef7e68 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -208,9 +208,8 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec { - // Skip tool confirmation requests - } + MessageContent::ToolConfirmationRequest(_) => {} + MessageContent::ActionRequired(_) => {} MessageContent::Image(image) => { content_array.push(convert_image(image, image_format)); } diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index afd0d536ac4e..a0b4a16cf3d1 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -19,10 +19,12 @@ pub fn format_messages(messages: &[Message]) -> Vec { .iter() .filter(|m| m.is_agent_visible()) .filter(|message| { - message - .content - .iter() - .any(|content| !matches!(content, MessageContent::ToolConfirmationRequest(_))) + message.content.iter().any(|content| { + !matches!( + content, + MessageContent::ToolConfirmationRequest(_) | MessageContent::ActionRequired(_) + ) + }) }) .map(|message| { let role = if message.role == Role::User { @@ -408,11 +410,11 @@ mod tests { ) } - fn set_up_tool_confirmation_message(id: &str, tool_call: CallToolRequestParam) -> Message { + fn set_up_action_required_message(id: &str, tool_call: CallToolRequestParam) -> Message { Message::new( Role::User, 0, - vec![MessageContent::tool_confirmation_request( + vec![MessageContent::action_required( id.to_string(), tool_call.name.to_string().clone(), tool_call.arguments.unwrap_or_default().clone(), @@ -474,7 +476,7 @@ mod tests { arguments: Some(object(arguments.clone())), }, ), - set_up_tool_confirmation_message( + set_up_action_required_message( "id2", CallToolRequestParam { name: "tool_name_2".into(), diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 9385e490b18a..fab1b8d5ed6e 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -198,9 +198,8 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< } } } - MessageContent::ToolConfirmationRequest(_) => { - // Skip tool confirmation requests - } + MessageContent::ToolConfirmationRequest(_) => {} + MessageContent::ActionRequired(_) => {} MessageContent::Image(image) => { content_array.push(convert_image(image, image_format)); } diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose/src/providers/formats/snowflake.rs index 2f8d492ad3ec..19f3012f102c 100644 --- a/crates/goose/src/providers/formats/snowflake.rs +++ b/crates/goose/src/providers/formats/snowflake.rs @@ -50,9 +50,8 @@ pub fn format_messages(messages: &[Message]) -> Vec { } } } - MessageContent::ToolConfirmationRequest(_) => { - // Skip tool confirmation requests - } + MessageContent::ToolConfirmationRequest(_) => {} + MessageContent::ActionRequired(_) => {} MessageContent::SystemNotification(_) => { // Skip } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 564470494fcb..f8acee9d6302 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -13,6 +13,40 @@ "version": "1.15.0" }, "paths": { + "/action-required/tool-confirmation": { + "post": { + "tags": [ + "super::routes::action_required" + ], + "operationId": "confirm_tool_action", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmToolActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tool confirmation action is confirmed", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/add_extension": { "post": { "tags": [ @@ -954,40 +988,6 @@ } } }, - "/confirm": { - "post": { - "tags": [ - "super::routes::reply" - ], - "operationId": "confirm_permission", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PermissionConfirmationRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Permission action is confirmed", - "content": { - "application/json": { - "schema": {} - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "500": { - "description": "Internal server error" - } - } - } - }, "/diagnostics/{session_id}": { "get": { "tags": [ @@ -2353,6 +2353,54 @@ }, "components": { "schemas": { + "ActionRequired": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/ActionRequiredData" + } + } + }, + "ActionRequiredData": { + "oneOf": [ + { + "type": "object", + "required": [ + "id", + "toolName", + "arguments", + "actionType" + ], + "properties": { + "actionType": { + "type": "string", + "enum": [ + "toolConfirmation" + ] + }, + "arguments": { + "$ref": "#/components/schemas/JsonObject" + }, + "id": { + "type": "string" + }, + "prompt": { + "type": "string", + "nullable": true + }, + "toolName": { + "type": "string" + } + } + } + ], + "discriminator": { + "propertyName": "actionType" + } + }, "AddExtensionRequest": { "type": "object", "required": [ @@ -2516,6 +2564,28 @@ } } }, + "ConfirmToolActionRequest": { + "type": "object", + "required": [ + "id", + "action", + "sessionId" + ], + "properties": { + "action": { + "type": "string" + }, + "id": { + "type": "string" + }, + "principalType": { + "$ref": "#/components/schemas/PrincipalType" + }, + "sessionId": { + "type": "string" + } + } + }, "Content": { "oneOf": [ { @@ -3518,6 +3588,27 @@ } ] }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ActionRequired" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "actionRequired" + ] + } + } + } + ] + }, { "allOf": [ { @@ -3872,28 +3963,6 @@ } } }, - "PermissionConfirmationRequest": { - "type": "object", - "required": [ - "id", - "action", - "session_id" - ], - "properties": { - "action": { - "type": "string" - }, - "id": { - "type": "string" - }, - "principal_type": { - "$ref": "#/components/schemas/PrincipalType" - }, - "session_id": { - "type": "string" - } - } - }, "PermissionLevel": { "type": "string", "description": "Enum representing the possible permission levels for a tool.", diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c81373db530c..0a44ccc1de55 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -18,6 +18,17 @@ export type Options; }; +export const confirmToolAction = (options: Options) => { + return (options.client ?? client).post({ + url: '/action-required/tool-confirmation', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const agentAddExtension = (options: Options) => { return (options.client ?? client).post({ url: '/agent/add_extension', @@ -285,17 +296,6 @@ export const validateConfig = (options?: O }); }; -export const confirmPermission = (options: Options) => { - return (options.client ?? client).post({ - url: '/confirm', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - export const diagnostics = (options: Options) => { return (options.client ?? client).get({ url: '/diagnostics/{session_id}', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f4536a91a1e5..e082260e9b77 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4,6 +4,18 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; +export type ActionRequired = { + data: ActionRequiredData; +}; + +export type ActionRequiredData = { + actionType: 'toolConfirmation'; + arguments: JsonObject; + id: string; + prompt?: string | null; + toolName: string; +}; + export type AddExtensionRequest = { config: ExtensionConfig; session_id: string; @@ -76,6 +88,13 @@ export type ConfigResponse = { }; }; +export type ConfirmToolActionRequest = { + action: string; + id: string; + principalType?: PrincipalType; + sessionId: string; +}; + export type Content = RawTextContent | RawImageContent | RawEmbeddedResource | RawAudioContent | RawResource; export type Conversation = Array; @@ -371,6 +390,8 @@ export type MessageContent = (TextContent & { type: 'toolResponse'; }) | (ToolConfirmationRequest & { type: 'toolConfirmationRequest'; +}) | (ActionRequired & { + type: 'actionRequired'; }) | (FrontendToolRequest & { type: 'frontendToolRequest'; }) | (ThinkingContent & { @@ -471,13 +492,6 @@ export type ParseRecipeResponse = { recipe: Recipe; }; -export type PermissionConfirmationRequest = { - action: string; - id: string; - principal_type?: PrincipalType; - session_id: string; -}; - /** * Enum representing the possible permission levels for a tool. */ @@ -990,6 +1004,31 @@ export type UpsertPermissionsQuery = { tool_permissions: Array; }; +export type ConfirmToolActionData = { + body: ConfirmToolActionRequest; + path?: never; + query?: never; + url: '/action-required/tool-confirmation'; +}; + +export type ConfirmToolActionErrors = { + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ConfirmToolActionResponses = { + /** + * Tool confirmation action is confirmed + */ + 200: unknown; +}; + export type AgentAddExtensionData = { body: AddExtensionRequest; path?: never; @@ -1726,31 +1765,6 @@ export type ValidateConfigResponses = { export type ValidateConfigResponse = ValidateConfigResponses[keyof ValidateConfigResponses]; -export type ConfirmPermissionData = { - body: PermissionConfirmationRequest; - path?: never; - query?: never; - url: '/confirm'; -}; - -export type ConfirmPermissionErrors = { - /** - * Unauthorized - invalid secret key - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ConfirmPermissionResponses = { - /** - * Permission action is confirmed - */ - 200: unknown; -}; - export type DiagnosticsData = { body?: never; path: { diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index d9734c8cabc7..c3aac2cfbef2 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -11,7 +11,7 @@ import { getToolConfirmationContent, NotificationEvent, } from '../types/message'; -import { Message, confirmPermission } from '../api'; +import { Message, confirmToolAction } from '../api'; import ToolCallConfirmation from './ToolCallConfirmation'; import MessageCopyLink from './MessageCopyLink'; import { cn } from '../utils'; @@ -100,22 +100,22 @@ export default function GooseMessage({ messageIndex === messageHistoryIndex - 1 && hasToolConfirmation && toolConfirmationContent && - !handledToolConfirmations.current.has(toolConfirmationContent.id) + !handledToolConfirmations.current.has(toolConfirmationContent.data.id) ) { const hasExistingResponse = messages.some((msg) => - getToolResponses(msg).some((response) => response.id === toolConfirmationContent.id) + getToolResponses(msg).some((response) => response.id === toolConfirmationContent.data.id) ); if (!hasExistingResponse) { - handledToolConfirmations.current.add(toolConfirmationContent.id); + handledToolConfirmations.current.add(toolConfirmationContent.data.id); void (async () => { try { - await confirmPermission({ + await confirmToolAction({ body: { - session_id: sessionId, - id: toolConfirmationContent.id, + sessionId, action: 'deny', + id: toolConfirmationContent.data.id, }, throwOnError: true, }); @@ -216,7 +216,7 @@ export default function GooseMessage({ sessionId={sessionId} isCancelledMessage={messageIndex == messageHistoryIndex - 1} isClicked={messageIndex < messageHistoryIndex} - toolConfirmationContent={toolConfirmationContent} + actionRequiredContent={toolConfirmationContent} /> )} diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index ab0b080ed78a..18d7d046d0f4 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { snakeToTitleCase } from '../utils'; import PermissionModal from './settings/permission/PermissionModal'; import { ChevronRight } from 'lucide-react'; -import { confirmPermission, ToolConfirmationRequest } from '../api'; +import { confirmToolAction, ActionRequired } from '../api'; import { Button } from './ui/button'; const ALLOW_ONCE = 'allow_once'; @@ -24,16 +24,16 @@ interface ToolConfirmationProps { sessionId: string; isCancelledMessage: boolean; isClicked: boolean; - toolConfirmationContent: ToolConfirmationRequest & { type: 'toolConfirmationRequest' }; + actionRequiredContent: ActionRequired & { type: 'actionRequired' }; } export default function ToolConfirmation({ sessionId, isCancelledMessage, isClicked, - toolConfirmationContent, + actionRequiredContent, }: ToolConfirmationProps) { - const { id: toolConfirmationId, toolName, prompt } = toolConfirmationContent; + const { id: toolConfirmationId, toolName, prompt } = actionRequiredContent.data; // Check if we have a stored state for this tool confirmation const storedState = toolConfirmationState.get(toolConfirmationId); @@ -96,19 +96,19 @@ export default function ToolConfirmation({ }); try { - const response = await confirmPermission({ + const response = await confirmToolAction({ body: { - session_id: sessionId, + sessionId: sessionId, id: toolConfirmationId, action: newStatus, - principal_type: 'Tool', + principalType: 'Tool', }, }); if (response.error) { - console.error('Failed to confirm permission:', response.error); + console.error('Failed to confirm tool action:', response.error); } } catch (err) { - console.error('Error confirming permission:', err); + console.error('Error confirming tool action:', err); } }; diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 8c2cc4125fde..7603ef1a4077 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -1,4 +1,4 @@ -import { Message, MessageEvent, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api'; +import { Message, MessageEvent, ActionRequired, ToolRequest, ToolResponse } from '../api'; export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' }; export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' }; @@ -44,10 +44,10 @@ export function getToolResponses(message: Message): (ToolResponse & { type: 'too export function getToolConfirmationContent( message: Message -): (ToolConfirmationRequest & { type: 'toolConfirmationRequest' }) | undefined { +): (ActionRequired & { type: 'actionRequired' }) | undefined { return message.content.find( - (content): content is ToolConfirmationRequest & { type: 'toolConfirmationRequest' } => - content.type === 'toolConfirmationRequest' + (content): content is ActionRequired & { type: 'actionRequired' } => + content.type === 'actionRequired' && content.data.actionType === 'toolConfirmation' ); }