feat(agent): event-driven tool consent handshake (explicit UI prompts)
This commit is contained in:
@@ -17,7 +17,7 @@ use owlen_core::{
|
|||||||
mode::Mode,
|
mode::Mode,
|
||||||
provider::ProviderManager,
|
provider::ProviderManager,
|
||||||
providers::OllamaProvider,
|
providers::OllamaProvider,
|
||||||
session::SessionController,
|
session::{ControllerEvent, SessionController},
|
||||||
storage::StorageManager,
|
storage::StorageManager,
|
||||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||||
};
|
};
|
||||||
@@ -88,11 +88,19 @@ pub async fn launch(initial_mode: Mode) -> Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let controller =
|
let (controller_event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
let controller = SessionController::new(
|
||||||
|
provider,
|
||||||
|
cfg,
|
||||||
|
storage.clone(),
|
||||||
|
tui_controller,
|
||||||
|
false,
|
||||||
|
Some(controller_event_tx),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let provider_manager = Arc::new(ProviderManager::default());
|
let provider_manager = Arc::new(ProviderManager::default());
|
||||||
let mut runtime = RuntimeApp::new(provider_manager);
|
let mut runtime = RuntimeApp::new(provider_manager);
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
let (mut app, mut session_rx) = ChatApp::new(controller, controller_event_rx).await?;
|
||||||
app.initialize_models().await?;
|
app.initialize_models().await?;
|
||||||
if let Some(notice) = offline_notice.clone() {
|
if let Some(notice) = offline_notice.clone() {
|
||||||
app.set_status_message(¬ice);
|
app.set_status_message(¬ice);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::config::{Config, McpResourceConfig, McpServerConfig};
|
use crate::config::{Config, McpResourceConfig, McpServerConfig};
|
||||||
use crate::consent::ConsentManager;
|
use crate::consent::{ConsentManager, ConsentScope};
|
||||||
use crate::conversation::ConversationManager;
|
use crate::conversation::ConversationManager;
|
||||||
use crate::credentials::CredentialManager;
|
use crate::credentials::CredentialManager;
|
||||||
use crate::encryption::{self, VaultHandle};
|
use crate::encryption::{self, VaultHandle};
|
||||||
@@ -34,6 +34,7 @@ use std::env;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub enum SessionOutcome {
|
pub enum SessionOutcome {
|
||||||
@@ -44,6 +45,36 @@ pub enum SessionOutcome {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ControllerEvent {
|
||||||
|
ToolRequested {
|
||||||
|
request_id: Uuid,
|
||||||
|
message_id: Uuid,
|
||||||
|
tool_name: String,
|
||||||
|
data_types: Vec<String>,
|
||||||
|
endpoints: Vec<String>,
|
||||||
|
tool_calls: Vec<ToolCall>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PendingToolRequest {
|
||||||
|
message_id: Uuid,
|
||||||
|
tool_name: String,
|
||||||
|
data_types: Vec<String>,
|
||||||
|
endpoints: Vec<String>,
|
||||||
|
tool_calls: Vec<ToolCall>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ToolConsentResolution {
|
||||||
|
pub request_id: Uuid,
|
||||||
|
pub message_id: Uuid,
|
||||||
|
pub tool_name: String,
|
||||||
|
pub scope: ConsentScope,
|
||||||
|
pub tool_calls: Vec<ToolCall>,
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_resource_content(value: &Value) -> Option<String> {
|
fn extract_resource_content(value: &Value) -> Option<String> {
|
||||||
match value {
|
match value {
|
||||||
Value::Null => Some(String::new()),
|
Value::Null => Some(String::new()),
|
||||||
@@ -111,6 +142,8 @@ pub struct SessionController {
|
|||||||
enable_code_tools: bool,
|
enable_code_tools: bool,
|
||||||
current_mode: Mode,
|
current_mode: Mode,
|
||||||
missing_oauth_servers: Vec<String>,
|
missing_oauth_servers: Vec<String>,
|
||||||
|
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||||
|
pending_tool_requests: HashMap<Uuid, PendingToolRequest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_tools(
|
async fn build_tools(
|
||||||
@@ -331,6 +364,7 @@ impl SessionController {
|
|||||||
storage: Arc<StorageManager>,
|
storage: Arc<StorageManager>,
|
||||||
ui: Arc<dyn UiController>,
|
ui: Arc<dyn UiController>,
|
||||||
enable_code_tools: bool,
|
enable_code_tools: bool,
|
||||||
|
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let config_arc = Arc::new(TokioMutex::new(config));
|
let config_arc = Arc::new(TokioMutex::new(config));
|
||||||
// Acquire the config asynchronously to avoid blocking the runtime.
|
// Acquire the config asynchronously to avoid blocking the runtime.
|
||||||
@@ -435,6 +469,8 @@ impl SessionController {
|
|||||||
enable_code_tools,
|
enable_code_tools,
|
||||||
current_mode: initial_mode,
|
current_mode: initial_mode,
|
||||||
missing_oauth_servers,
|
missing_oauth_servers,
|
||||||
|
event_tx,
|
||||||
|
pending_tool_requests: HashMap::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,14 +1258,84 @@ impl SessionController {
|
|||||||
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
|
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||||
self.conversation
|
let maybe_calls = self
|
||||||
|
.conversation
|
||||||
.active()
|
.active()
|
||||||
.messages
|
.messages
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.id == message_id)
|
.find(|m| m.id == message_id)
|
||||||
.and_then(|m| m.tool_calls.clone())
|
.and_then(|m| m.tool_calls.clone())
|
||||||
.filter(|calls| !calls.is_empty())
|
.filter(|calls| !calls.is_empty());
|
||||||
|
|
||||||
|
let calls = maybe_calls?;
|
||||||
|
|
||||||
|
if !self
|
||||||
|
.pending_tool_requests
|
||||||
|
.values()
|
||||||
|
.any(|pending| pending.message_id == message_id)
|
||||||
|
{
|
||||||
|
if let Some((tool_name, data_types, endpoints)) =
|
||||||
|
self.check_tools_consent_needed(&calls).into_iter().next()
|
||||||
|
{
|
||||||
|
let request_id = Uuid::new_v4();
|
||||||
|
let pending = PendingToolRequest {
|
||||||
|
message_id,
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
data_types: data_types.clone(),
|
||||||
|
endpoints: endpoints.clone(),
|
||||||
|
tool_calls: calls.clone(),
|
||||||
|
};
|
||||||
|
self.pending_tool_requests.insert(request_id, pending);
|
||||||
|
|
||||||
|
if let Some(tx) = &self.event_tx {
|
||||||
|
let _ = tx.send(ControllerEvent::ToolRequested {
|
||||||
|
request_id,
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
tool_calls: calls.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_tool_consent(
|
||||||
|
&mut self,
|
||||||
|
request_id: Uuid,
|
||||||
|
scope: ConsentScope,
|
||||||
|
) -> Result<ToolConsentResolution> {
|
||||||
|
let pending = self
|
||||||
|
.pending_tool_requests
|
||||||
|
.remove(&request_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!("Unknown tool consent request: {}", request_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let PendingToolRequest {
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
tool_calls,
|
||||||
|
..
|
||||||
|
} = pending;
|
||||||
|
|
||||||
|
if !matches!(scope, ConsentScope::Denied) {
|
||||||
|
self.grant_consent_with_scope(&tool_name, data_types, endpoints, scope.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ToolConsentResolution {
|
||||||
|
request_id,
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
scope,
|
||||||
|
tool_calls,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_stream(&mut self, message_id: Uuid, notice: &str) -> Result<()> {
|
pub fn cancel_stream(&mut self, message_id: Uuid, notice: &str) -> Result<()> {
|
||||||
@@ -1352,7 +1458,7 @@ mod tests {
|
|||||||
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default()) as Arc<dyn Provider>;
|
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default()) as Arc<dyn Provider>;
|
||||||
let ui = Arc::new(NoOpUiController);
|
let ui = Arc::new(NoOpUiController);
|
||||||
|
|
||||||
let session = SessionController::new(provider, config, storage, ui, false)
|
let session = SessionController::new(provider, config, storage, ui, false, None)
|
||||||
.await
|
.await
|
||||||
.expect("session");
|
.expect("session");
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub trait UiRuntime: MessageState {
|
|||||||
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>;
|
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>;
|
||||||
async fn process_pending_llm_request(&mut self) -> Result<()>;
|
async fn process_pending_llm_request(&mut self) -> Result<()>;
|
||||||
async fn process_pending_tool_execution(&mut self) -> Result<()>;
|
async fn process_pending_tool_execution(&mut self) -> Result<()>;
|
||||||
|
fn poll_controller_events(&mut self) -> Result<()>;
|
||||||
fn advance_loading_animation(&mut self);
|
fn advance_loading_animation(&mut self);
|
||||||
fn streaming_count(&self) -> usize;
|
fn streaming_count(&self) -> usize;
|
||||||
}
|
}
|
||||||
@@ -116,6 +117,7 @@ impl App {
|
|||||||
|
|
||||||
state.process_pending_llm_request().await?;
|
state.process_pending_llm_request().await?;
|
||||||
state.process_pending_tool_execution().await?;
|
state.process_pending_tool_execution().await?;
|
||||||
|
state.poll_controller_events()?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match session_rx.try_recv() {
|
match session_rx.try_recv() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use owlen_core::ui::InputMode;
|
use owlen_core::{consent::ConsentScope, ui::InputMode};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AppModel {
|
pub struct AppModel {
|
||||||
@@ -25,6 +26,10 @@ impl Default for ComposerModel {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
Composer(ComposerEvent),
|
Composer(ComposerEvent),
|
||||||
|
ToolPermission {
|
||||||
|
request_id: Uuid,
|
||||||
|
scope: ConsentScope,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -46,11 +51,18 @@ pub enum SubmissionOutcome {
|
|||||||
pub enum AppEffect {
|
pub enum AppEffect {
|
||||||
SetStatus(String),
|
SetStatus(String),
|
||||||
RequestSubmit,
|
RequestSubmit,
|
||||||
|
ResolveToolConsent {
|
||||||
|
request_id: Uuid,
|
||||||
|
scope: ConsentScope,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(model: &mut AppModel, event: AppEvent) -> Vec<AppEffect> {
|
pub fn update(model: &mut AppModel, event: AppEvent) -> Vec<AppEffect> {
|
||||||
match event {
|
match event {
|
||||||
AppEvent::Composer(event) => update_composer(&mut model.composer, event),
|
AppEvent::Composer(event) => update_composer(&mut model.composer, event),
|
||||||
|
AppEvent::ToolPermission { request_id, scope } => {
|
||||||
|
vec![AppEffect::ResolveToolConsent { request_id, scope }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crossterm::{
|
|||||||
event::KeyEvent,
|
event::KeyEvent,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode},
|
terminal::{disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
|
use owlen_core::consent::ConsentScope;
|
||||||
use owlen_core::facade::llm_client::LlmClient;
|
use owlen_core::facade::llm_client::LlmClient;
|
||||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
||||||
@@ -17,7 +18,7 @@ use owlen_core::{
|
|||||||
config::McpResourceConfig,
|
config::McpResourceConfig,
|
||||||
model::DetailedModelInfo,
|
model::DetailedModelInfo,
|
||||||
oauth::{DeviceAuthorization, DevicePollState},
|
oauth::{DeviceAuthorization, DevicePollState},
|
||||||
session::{SessionController, SessionOutcome},
|
session::{ControllerEvent, SessionController, SessionOutcome, ToolConsentResolution},
|
||||||
storage::SessionMeta,
|
storage::SessionMeta,
|
||||||
theme::Theme,
|
theme::Theme,
|
||||||
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
||||||
@@ -63,7 +64,7 @@ use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
|||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
@@ -260,12 +261,6 @@ pub enum SessionEvent {
|
|||||||
message_id: Uuid,
|
message_id: Uuid,
|
||||||
tool_calls: Vec<owlen_core::types::ToolCall>,
|
tool_calls: Vec<owlen_core::types::ToolCall>,
|
||||||
},
|
},
|
||||||
ConsentNeeded {
|
|
||||||
tool_name: String,
|
|
||||||
data_types: Vec<String>,
|
|
||||||
endpoints: Vec<String>,
|
|
||||||
callback_id: Uuid,
|
|
||||||
},
|
|
||||||
/// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION)
|
/// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION)
|
||||||
AgentUpdate { content: String },
|
AgentUpdate { content: String },
|
||||||
/// Agent execution completed with final answer
|
/// Agent execution completed with final answer
|
||||||
@@ -318,6 +313,7 @@ pub struct ChatApp {
|
|||||||
textarea: TextArea<'static>, // Advanced text input widget
|
textarea: TextArea<'static>, // Advanced text input widget
|
||||||
mvu_model: AppModel,
|
mvu_model: AppModel,
|
||||||
keymap: Keymap,
|
keymap: Keymap,
|
||||||
|
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||||
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
|
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
|
||||||
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
|
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
|
||||||
loading_animation_frame: usize, // Frame counter for loading animation
|
loading_animation_frame: usize, // Frame counter for loading animation
|
||||||
@@ -362,6 +358,7 @@ pub struct ChatApp {
|
|||||||
available_themes: Vec<String>, // Cached list of theme names
|
available_themes: Vec<String>, // Cached list of theme names
|
||||||
selected_theme_index: usize, // Index of selected theme in browser
|
selected_theme_index: usize, // Index of selected theme in browser
|
||||||
pending_consent: Option<ConsentDialogState>, // Pending consent request
|
pending_consent: Option<ConsentDialogState>, // Pending consent request
|
||||||
|
queued_consents: VecDeque<ConsentDialogState>, // Backlog of consent requests
|
||||||
system_status: String, // System/status messages (tool execution, status, etc)
|
system_status: String, // System/status messages (tool execution, status, etc)
|
||||||
toasts: ToastManager,
|
toasts: ToastManager,
|
||||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||||
@@ -378,10 +375,12 @@ pub struct ChatApp {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ConsentDialogState {
|
pub struct ConsentDialogState {
|
||||||
|
pub request_id: Uuid,
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub data_types: Vec<String>,
|
pub data_types: Vec<String>,
|
||||||
pub endpoints: Vec<String>,
|
pub endpoints: Vec<String>,
|
||||||
pub callback_id: Uuid, // ID to match callback with the request
|
pub message_id: Uuid,
|
||||||
|
pub tool_calls: Vec<owlen_core::types::ToolCall>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -505,6 +504,7 @@ impl FileActionPrompt {
|
|||||||
impl ChatApp {
|
impl ChatApp {
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
controller: SessionController,
|
controller: SessionController,
|
||||||
|
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||||
let (session_tx, session_rx) = mpsc::unbounded_channel();
|
let (session_tx, session_rx) = mpsc::unbounded_channel();
|
||||||
let mut textarea = TextArea::default();
|
let mut textarea = TextArea::default();
|
||||||
@@ -572,6 +572,7 @@ impl ChatApp {
|
|||||||
textarea,
|
textarea,
|
||||||
mvu_model: AppModel::default(),
|
mvu_model: AppModel::default(),
|
||||||
keymap,
|
keymap,
|
||||||
|
controller_event_rx,
|
||||||
pending_llm_request: false,
|
pending_llm_request: false,
|
||||||
pending_tool_execution: None,
|
pending_tool_execution: None,
|
||||||
loading_animation_frame: 0,
|
loading_animation_frame: 0,
|
||||||
@@ -615,6 +616,7 @@ impl ChatApp {
|
|||||||
available_themes: Vec::new(),
|
available_themes: Vec::new(),
|
||||||
selected_theme_index: 0,
|
selected_theme_index: 0,
|
||||||
pending_consent: None,
|
pending_consent: None,
|
||||||
|
queued_consents: VecDeque::new(),
|
||||||
system_status: if show_onboarding {
|
system_status: if show_onboarding {
|
||||||
ONBOARDING_SYSTEM_STATUS.to_string()
|
ONBOARDING_SYSTEM_STATUS.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -672,6 +674,89 @@ impl ChatApp {
|
|||||||
self.pending_consent.as_ref()
|
self.pending_consent.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enqueue_consent_request(&mut self, consent: ConsentDialogState) {
|
||||||
|
if self.pending_consent.is_none() {
|
||||||
|
self.pending_consent = Some(consent);
|
||||||
|
} else {
|
||||||
|
self.queued_consents.push_back(consent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_consent_queue(&mut self) {
|
||||||
|
if self.pending_consent.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(next) = self.queued_consents.pop_front() {
|
||||||
|
self.pending_consent = Some(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_controller_event(&mut self, event: ControllerEvent) -> Result<()> {
|
||||||
|
match event {
|
||||||
|
ControllerEvent::ToolRequested {
|
||||||
|
request_id,
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
tool_calls,
|
||||||
|
} => {
|
||||||
|
self.enqueue_consent_request(ConsentDialogState {
|
||||||
|
request_id,
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
data_types,
|
||||||
|
endpoints,
|
||||||
|
tool_calls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tool_consent_resolution(&mut self, resolution: ToolConsentResolution) -> Result<()> {
|
||||||
|
let ToolConsentResolution {
|
||||||
|
message_id,
|
||||||
|
tool_name,
|
||||||
|
scope,
|
||||||
|
tool_calls,
|
||||||
|
..
|
||||||
|
} = resolution;
|
||||||
|
|
||||||
|
match scope {
|
||||||
|
ConsentScope::Denied => {
|
||||||
|
self.pending_tool_execution = None;
|
||||||
|
self.status = format!("✗ Consent denied for {}", tool_name);
|
||||||
|
self.set_system_status(format!("✗ Consent denied: {}", tool_name));
|
||||||
|
self.error = Some(format!("Tool {} was blocked by user", tool_name));
|
||||||
|
|
||||||
|
self.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.push_assistant_message(format!(
|
||||||
|
"I could not execute `{tool_name}` because consent was denied. \
|
||||||
|
Replying without running the tool."
|
||||||
|
));
|
||||||
|
self.notify_new_activity();
|
||||||
|
}
|
||||||
|
ConsentScope::Once | ConsentScope::Session | ConsentScope::Permanent => {
|
||||||
|
let scope_label = match scope {
|
||||||
|
ConsentScope::Once => "once",
|
||||||
|
ConsentScope::Session => "session",
|
||||||
|
ConsentScope::Permanent => "permanent",
|
||||||
|
ConsentScope::Denied => unreachable!("handled above"),
|
||||||
|
};
|
||||||
|
self.status = format!("✓ Consent granted ({scope_label}) for {}", tool_name);
|
||||||
|
self.set_system_status(format!("✓ Consent granted ({scope_label}): {}", tool_name));
|
||||||
|
self.error = None;
|
||||||
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pending_consent = None;
|
||||||
|
self.advance_consent_queue();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn status_message(&self) -> &str {
|
pub fn status_message(&self) -> &str {
|
||||||
&self.status
|
&self.status
|
||||||
}
|
}
|
||||||
@@ -2854,6 +2939,10 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEffect::ResolveToolConsent { request_id, scope } => {
|
||||||
|
let resolution = self.controller.resolve_tool_consent(request_id, scope)?;
|
||||||
|
self.apply_tool_consent_resolution(resolution)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4605,83 +4694,21 @@ impl ChatApp {
|
|||||||
|
|
||||||
// Handle consent dialog first (highest priority)
|
// Handle consent dialog first (highest priority)
|
||||||
if let Some(consent_state) = &self.pending_consent {
|
if let Some(consent_state) = &self.pending_consent {
|
||||||
match key.code {
|
let scope = match key.code {
|
||||||
KeyCode::Char('1') => {
|
KeyCode::Char('1') => Some(ConsentScope::Once),
|
||||||
// Allow once
|
KeyCode::Char('2') => Some(ConsentScope::Session),
|
||||||
let tool_name = consent_state.tool_name.clone();
|
KeyCode::Char('3') => Some(ConsentScope::Permanent),
|
||||||
let data_types = consent_state.data_types.clone();
|
KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied),
|
||||||
let endpoints = consent_state.endpoints.clone();
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
self.controller.grant_consent_with_scope(
|
if let Some(scope) = scope {
|
||||||
&tool_name,
|
let request_id = consent_state.request_id;
|
||||||
data_types,
|
let effects =
|
||||||
endpoints,
|
self.apply_app_event(AppEvent::ToolPermission { request_id, scope });
|
||||||
owlen_core::consent::ConsentScope::Once,
|
self.handle_app_effects(effects).await?;
|
||||||
);
|
|
||||||
self.pending_consent = None;
|
|
||||||
self.status = format!("✓ Consent granted (once) for {}", tool_name);
|
|
||||||
self.set_system_status(format!(
|
|
||||||
"✓ Consent granted (once): {}",
|
|
||||||
tool_name
|
|
||||||
));
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
KeyCode::Char('2') => {
|
|
||||||
// Allow session
|
|
||||||
let tool_name = consent_state.tool_name.clone();
|
|
||||||
let data_types = consent_state.data_types.clone();
|
|
||||||
let endpoints = consent_state.endpoints.clone();
|
|
||||||
|
|
||||||
self.controller.grant_consent_with_scope(
|
|
||||||
&tool_name,
|
|
||||||
data_types,
|
|
||||||
endpoints,
|
|
||||||
owlen_core::consent::ConsentScope::Session,
|
|
||||||
);
|
|
||||||
self.pending_consent = None;
|
|
||||||
self.status = format!("✓ Consent granted (session) for {}", tool_name);
|
|
||||||
self.set_system_status(format!(
|
|
||||||
"✓ Consent granted (session): {}",
|
|
||||||
tool_name
|
|
||||||
));
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
KeyCode::Char('3') => {
|
|
||||||
// Allow always (permanent)
|
|
||||||
let tool_name = consent_state.tool_name.clone();
|
|
||||||
let data_types = consent_state.data_types.clone();
|
|
||||||
let endpoints = consent_state.endpoints.clone();
|
|
||||||
|
|
||||||
self.controller.grant_consent_with_scope(
|
|
||||||
&tool_name,
|
|
||||||
data_types,
|
|
||||||
endpoints,
|
|
||||||
owlen_core::consent::ConsentScope::Permanent,
|
|
||||||
);
|
|
||||||
self.pending_consent = None;
|
|
||||||
self.status =
|
|
||||||
format!("✓ Consent granted (permanent) for {}", tool_name);
|
|
||||||
self.set_system_status(format!(
|
|
||||||
"✓ Consent granted (permanent): {}",
|
|
||||||
tool_name
|
|
||||||
));
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
KeyCode::Char('4') | KeyCode::Esc => {
|
|
||||||
// Deny consent - clear both consent and pending tool execution to prevent retry
|
|
||||||
let tool_name = consent_state.tool_name.clone();
|
|
||||||
self.pending_consent = None;
|
|
||||||
self.pending_tool_execution = None; // Clear to prevent infinite retry
|
|
||||||
self.status = format!("✗ Consent denied for {}", tool_name);
|
|
||||||
self.set_system_status(format!("✗ Consent denied: {}", tool_name));
|
|
||||||
self.error = Some(format!("Tool {} was blocked by user", tool_name));
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Ignore other keys when consent dialog is shown
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.try_execute_command(&key).await? {
|
if self.try_execute_command(&key).await? {
|
||||||
@@ -7539,21 +7566,6 @@ impl ChatApp {
|
|||||||
// Store tool execution for async processing on next event loop iteration
|
// Store tool execution for async processing on next event loop iteration
|
||||||
self.pending_tool_execution = Some((message_id, tool_calls));
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||||
}
|
}
|
||||||
SessionEvent::ConsentNeeded {
|
|
||||||
tool_name,
|
|
||||||
data_types,
|
|
||||||
endpoints,
|
|
||||||
callback_id,
|
|
||||||
} => {
|
|
||||||
// Show consent dialog
|
|
||||||
self.pending_consent = Some(ConsentDialogState {
|
|
||||||
tool_name,
|
|
||||||
data_types,
|
|
||||||
endpoints,
|
|
||||||
callback_id,
|
|
||||||
});
|
|
||||||
self.status = "Consent required - Press Y to allow, N to deny".to_string();
|
|
||||||
}
|
|
||||||
SessionEvent::AgentUpdate { content } => {
|
SessionEvent::AgentUpdate { content } => {
|
||||||
// Update agent actions panel with latest ReAct iteration
|
// Update agent actions panel with latest ReAct iteration
|
||||||
self.set_agent_actions(content);
|
self.set_agent_actions(content);
|
||||||
@@ -9088,6 +9100,7 @@ impl ChatApp {
|
|||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
self.pending_tool_execution = None;
|
self.pending_tool_execution = None;
|
||||||
self.pending_consent = None;
|
self.pending_consent = None;
|
||||||
|
self.queued_consents.clear();
|
||||||
self.current_thinking = None;
|
self.current_thinking = None;
|
||||||
self.agent_actions = None;
|
self.agent_actions = None;
|
||||||
self.status = "Generation cancelled".to_string();
|
self.status = "Generation cancelled".to_string();
|
||||||
@@ -9105,6 +9118,7 @@ impl ChatApp {
|
|||||||
self.pending_llm_request = false;
|
self.pending_llm_request = false;
|
||||||
self.pending_tool_execution = None;
|
self.pending_tool_execution = None;
|
||||||
self.pending_consent = None;
|
self.pending_consent = None;
|
||||||
|
self.queued_consents.clear();
|
||||||
self.pending_key = None;
|
self.pending_key = None;
|
||||||
self.visual_start = None;
|
self.visual_start = None;
|
||||||
self.visual_end = None;
|
self.visual_end = None;
|
||||||
@@ -9296,42 +9310,20 @@ impl ChatApp {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If a consent dialog is active, keep the execution queued until it resolves
|
||||||
|
if self.pending_consent.is_some() {
|
||||||
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Check if consent is needed for any of these tools
|
// Check if consent is needed for any of these tools
|
||||||
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
||||||
|
|
||||||
if !consent_needed.is_empty() {
|
if !consent_needed.is_empty() {
|
||||||
// If a consent dialog is already being shown, don't send another request
|
// Re-queue the execution and ensure a controller event is emitted
|
||||||
// Just re-queue the tool execution and wait for user response
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||||
if self.pending_consent.is_some() {
|
self.controller.check_streaming_tool_calls(message_id);
|
||||||
self.pending_tool_execution = Some((message_id, tool_calls));
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show consent for the first tool that needs it
|
|
||||||
// After consent is granted, the next iteration will check remaining tools
|
|
||||||
if let Some((tool_name, data_types, endpoints)) = consent_needed.into_iter().next() {
|
|
||||||
let callback_id = Uuid::new_v4();
|
|
||||||
let sender = self.session_tx.clone();
|
|
||||||
let _ = sender.send(SessionEvent::ConsentNeeded {
|
|
||||||
tool_name: tool_name.clone(),
|
|
||||||
data_types: data_types.clone(),
|
|
||||||
endpoints: endpoints.clone(),
|
|
||||||
callback_id,
|
|
||||||
});
|
|
||||||
self.pending_consent = Some(ConsentDialogState {
|
|
||||||
tool_name,
|
|
||||||
data_types,
|
|
||||||
endpoints,
|
|
||||||
callback_id,
|
|
||||||
});
|
|
||||||
// Re-queue the tool execution for after consent is granted
|
|
||||||
self.pending_tool_execution = Some((message_id, tool_calls));
|
|
||||||
return Ok(());
|
|
||||||
} else {
|
|
||||||
// No consent entries found; treat as no-op and continue execution.
|
|
||||||
self.pending_tool_execution = Some((message_id, tool_calls));
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show tool execution status
|
// Show tool execution status
|
||||||
@@ -11253,9 +11245,25 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{render_markdown_lines, wrap_unicode};
|
use super::{ChatApp, render_markdown_lines, wrap_unicode};
|
||||||
|
use crate::app::UiRuntime;
|
||||||
|
use futures_util::{future, stream};
|
||||||
|
use owlen_core::{
|
||||||
|
Provider, Result as CoreResult,
|
||||||
|
config::Config,
|
||||||
|
consent::ConsentScope,
|
||||||
|
llm::LlmProvider,
|
||||||
|
session::{ControllerEvent, SessionController},
|
||||||
|
storage::StorageManager,
|
||||||
|
types::{ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall},
|
||||||
|
ui::NoOpUiController,
|
||||||
|
};
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
fn lines_to_strings(lines: &[Line<'_>]) -> Vec<String> {
|
fn lines_to_strings(lines: &[Line<'_>]) -> Vec<String> {
|
||||||
lines
|
lines
|
||||||
@@ -11328,6 +11336,139 @@ mod tests {
|
|||||||
let wrapped = wrap_unicode("hello", 0);
|
let wrapped = wrap_unicode("hello", 0);
|
||||||
assert!(wrapped.is_empty());
|
assert!(wrapped.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StubProvider;
|
||||||
|
|
||||||
|
impl LlmProvider for StubProvider {
|
||||||
|
type Stream = stream::Iter<std::vec::IntoIter<CoreResult<ChatResponse>>>;
|
||||||
|
|
||||||
|
type ListModelsFuture<'a>
|
||||||
|
= future::Ready<CoreResult<Vec<ModelInfo>>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type SendPromptFuture<'a>
|
||||||
|
= future::Ready<CoreResult<ChatResponse>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type StreamPromptFuture<'a>
|
||||||
|
= future::Ready<CoreResult<Self::Stream>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type HealthCheckFuture<'a>
|
||||||
|
= future::Ready<CoreResult<()>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"stub-provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
|
future::ready(Ok(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> {
|
||||||
|
let response = ChatResponse {
|
||||||
|
message: Message::assistant("stub response".to_string()),
|
||||||
|
usage: None,
|
||||||
|
is_streaming: false,
|
||||||
|
is_final: true,
|
||||||
|
};
|
||||||
|
future::ready(Ok(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> {
|
||||||
|
let response = ChatResponse {
|
||||||
|
message: Message::assistant("stub response".to_string()),
|
||||||
|
usage: None,
|
||||||
|
is_streaming: false,
|
||||||
|
is_final: true,
|
||||||
|
};
|
||||||
|
future::ready(Ok(stream::iter(vec![Ok(response)])))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
|
||||||
|
future::ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn tool_consent_denied_generates_fallback_message() {
|
||||||
|
let temp_dir = tempdir().expect("tempdir");
|
||||||
|
let storage_path = temp_dir.path().join("owlen-test.db");
|
||||||
|
let storage = Arc::new(
|
||||||
|
StorageManager::with_database_path(storage_path)
|
||||||
|
.await
|
||||||
|
.expect("storage"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.privacy.encrypt_local_data = false;
|
||||||
|
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
|
||||||
|
let ui = Arc::new(NoOpUiController);
|
||||||
|
let (event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||||
|
|
||||||
|
let session = SessionController::new(provider, config, storage, ui, false, Some(event_tx))
|
||||||
|
.await
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
let (mut app, session_rx) = ChatApp::new(session, controller_event_rx)
|
||||||
|
.await
|
||||||
|
.expect("chat app");
|
||||||
|
// Session events are not needed for this test
|
||||||
|
drop(session_rx);
|
||||||
|
|
||||||
|
let tool_call = ToolCall {
|
||||||
|
id: "call-1".to_string(),
|
||||||
|
name: "file_delete".to_string(),
|
||||||
|
arguments: json!({"path": "/tmp/example.txt"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message_id = app
|
||||||
|
.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.push_assistant_message("Preparing to modify files.");
|
||||||
|
app.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.set_tool_calls_on_message(message_id, vec![tool_call.clone()])
|
||||||
|
.expect("tool calls");
|
||||||
|
|
||||||
|
app.pending_tool_execution = Some((message_id, vec![tool_call.clone()]));
|
||||||
|
app.controller.check_streaming_tool_calls(message_id);
|
||||||
|
|
||||||
|
UiRuntime::poll_controller_events(&mut app).expect("poll controller events");
|
||||||
|
|
||||||
|
let consent_state = app
|
||||||
|
.pending_consent
|
||||||
|
.as_ref()
|
||||||
|
.expect("pending consent")
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
assert_eq!(consent_state.tool_name, "file_delete");
|
||||||
|
|
||||||
|
let resolution = app
|
||||||
|
.controller
|
||||||
|
.resolve_tool_consent(consent_state.request_id, ConsentScope::Denied)
|
||||||
|
.expect("resolution");
|
||||||
|
|
||||||
|
app.apply_tool_consent_resolution(resolution)
|
||||||
|
.expect("apply resolution");
|
||||||
|
|
||||||
|
assert!(app.pending_consent.is_none());
|
||||||
|
assert!(app.pending_tool_execution.is_none());
|
||||||
|
assert!(app.status.to_lowercase().contains("consent denied"));
|
||||||
|
|
||||||
|
let conversation = app.controller.conversation();
|
||||||
|
let last_message = conversation.messages.last().expect("last message");
|
||||||
|
assert_eq!(last_message.role, Role::Assistant);
|
||||||
|
assert!(
|
||||||
|
last_message.content.contains("consent was denied"),
|
||||||
|
"fallback message should acknowledge denial"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> {
|
fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> {
|
||||||
@@ -11397,6 +11538,17 @@ impl UiRuntime for ChatApp {
|
|||||||
ChatApp::process_pending_tool_execution(self).await
|
ChatApp::process_pending_tool_execution(self).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn poll_controller_events(&mut self) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
match self.controller_event_rx.try_recv() {
|
||||||
|
Ok(event) => self.handle_controller_event(event)?,
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn advance_loading_animation(&mut self) {
|
fn advance_loading_animation(&mut self) {
|
||||||
ChatApp::advance_loading_animation(self);
|
ChatApp::advance_loading_animation(self);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use owlen_core::session::SessionController;
|
use owlen_core::session::{ControllerEvent, SessionController};
|
||||||
use owlen_core::ui::{AppState, InputMode};
|
use owlen_core::ui::{AppState, InputMode};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -16,11 +16,12 @@ pub struct CodeApp {
|
|||||||
impl CodeApp {
|
impl CodeApp {
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
mut controller: SessionController,
|
mut controller: SessionController,
|
||||||
|
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||||
controller
|
controller
|
||||||
.conversation_mut()
|
.conversation_mut()
|
||||||
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
||||||
let (inner, rx) = ChatApp::new(controller).await?;
|
let (inner, rx) = ChatApp::new(controller, controller_event_rx).await?;
|
||||||
Ok((Self { inner }, rx))
|
Ok((Self { inner }, rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user