feat(agent): event-driven tool consent handshake (explicit UI prompts)
This commit is contained in:
@@ -17,7 +17,7 @@ use owlen_core::{
|
||||
mode::Mode,
|
||||
provider::ProviderManager,
|
||||
providers::OllamaProvider,
|
||||
session::SessionController,
|
||||
session::{ControllerEvent, SessionController},
|
||||
storage::StorageManager,
|
||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||
};
|
||||
@@ -88,11 +88,19 @@ pub async fn launch(initial_mode: Mode) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let controller =
|
||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||
let (controller_event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||
let controller = SessionController::new(
|
||||
provider,
|
||||
cfg,
|
||||
storage.clone(),
|
||||
tui_controller,
|
||||
false,
|
||||
Some(controller_event_tx),
|
||||
)
|
||||
.await?;
|
||||
let provider_manager = Arc::new(ProviderManager::default());
|
||||
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?;
|
||||
if let Some(notice) = offline_notice.clone() {
|
||||
app.set_status_message(¬ice);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::{Config, McpResourceConfig, McpServerConfig};
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::consent::{ConsentManager, ConsentScope};
|
||||
use crate::conversation::ConversationManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::{self, VaultHandle};
|
||||
@@ -34,6 +34,7 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use uuid::Uuid;
|
||||
|
||||
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> {
|
||||
match value {
|
||||
Value::Null => Some(String::new()),
|
||||
@@ -111,6 +142,8 @@ pub struct SessionController {
|
||||
enable_code_tools: bool,
|
||||
current_mode: Mode,
|
||||
missing_oauth_servers: Vec<String>,
|
||||
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||
pending_tool_requests: HashMap<Uuid, PendingToolRequest>,
|
||||
}
|
||||
|
||||
async fn build_tools(
|
||||
@@ -331,6 +364,7 @@ impl SessionController {
|
||||
storage: Arc<StorageManager>,
|
||||
ui: Arc<dyn UiController>,
|
||||
enable_code_tools: bool,
|
||||
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||
) -> Result<Self> {
|
||||
let config_arc = Arc::new(TokioMutex::new(config));
|
||||
// Acquire the config asynchronously to avoid blocking the runtime.
|
||||
@@ -435,6 +469,8 @@ impl SessionController {
|
||||
enable_code_tools,
|
||||
current_mode: initial_mode,
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||
self.conversation
|
||||
pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||
let maybe_calls = self
|
||||
.conversation
|
||||
.active()
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.id == message_id)
|
||||
.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<()> {
|
||||
@@ -1352,7 +1458,7 @@ mod tests {
|
||||
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default()) as Arc<dyn Provider>;
|
||||
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
|
||||
.expect("session");
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ pub trait UiRuntime: MessageState {
|
||||
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>;
|
||||
async fn process_pending_llm_request(&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 streaming_count(&self) -> usize;
|
||||
}
|
||||
@@ -116,6 +117,7 @@ impl App {
|
||||
|
||||
state.process_pending_llm_request().await?;
|
||||
state.process_pending_tool_execution().await?;
|
||||
state.poll_controller_events()?;
|
||||
|
||||
loop {
|
||||
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)]
|
||||
pub struct AppModel {
|
||||
@@ -25,6 +26,10 @@ impl Default for ComposerModel {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
Composer(ComposerEvent),
|
||||
ToolPermission {
|
||||
request_id: Uuid,
|
||||
scope: ConsentScope,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -46,11 +51,18 @@ pub enum SubmissionOutcome {
|
||||
pub enum AppEffect {
|
||||
SetStatus(String),
|
||||
RequestSubmit,
|
||||
ResolveToolConsent {
|
||||
request_id: Uuid,
|
||||
scope: ConsentScope,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn update(model: &mut AppModel, event: AppEvent) -> Vec<AppEffect> {
|
||||
match 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,
|
||||
terminal::{disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use owlen_core::consent::ConsentScope;
|
||||
use owlen_core::facade::llm_client::LlmClient;
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
||||
@@ -17,7 +18,7 @@ use owlen_core::{
|
||||
config::McpResourceConfig,
|
||||
model::DetailedModelInfo,
|
||||
oauth::{DeviceAuthorization, DevicePollState},
|
||||
session::{SessionController, SessionOutcome},
|
||||
session::{ControllerEvent, SessionController, SessionOutcome, ToolConsentResolution},
|
||||
storage::SessionMeta,
|
||||
theme::Theme,
|
||||
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
|
||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||
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::fs;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -260,12 +261,6 @@ pub enum SessionEvent {
|
||||
message_id: Uuid,
|
||||
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)
|
||||
AgentUpdate { content: String },
|
||||
/// Agent execution completed with final answer
|
||||
@@ -318,6 +313,7 @@ pub struct ChatApp {
|
||||
textarea: TextArea<'static>, // Advanced text input widget
|
||||
mvu_model: AppModel,
|
||||
keymap: Keymap,
|
||||
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||
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)
|
||||
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
|
||||
selected_theme_index: usize, // Index of selected theme in browser
|
||||
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)
|
||||
toasts: ToastManager,
|
||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||
@@ -378,10 +375,12 @@ pub struct ChatApp {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsentDialogState {
|
||||
pub request_id: Uuid,
|
||||
pub tool_name: String,
|
||||
pub data_types: 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)]
|
||||
@@ -505,6 +504,7 @@ impl FileActionPrompt {
|
||||
impl ChatApp {
|
||||
pub async fn new(
|
||||
controller: SessionController,
|
||||
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||
let (session_tx, session_rx) = mpsc::unbounded_channel();
|
||||
let mut textarea = TextArea::default();
|
||||
@@ -572,6 +572,7 @@ impl ChatApp {
|
||||
textarea,
|
||||
mvu_model: AppModel::default(),
|
||||
keymap,
|
||||
controller_event_rx,
|
||||
pending_llm_request: false,
|
||||
pending_tool_execution: None,
|
||||
loading_animation_frame: 0,
|
||||
@@ -615,6 +616,7 @@ impl ChatApp {
|
||||
available_themes: Vec::new(),
|
||||
selected_theme_index: 0,
|
||||
pending_consent: None,
|
||||
queued_consents: VecDeque::new(),
|
||||
system_status: if show_onboarding {
|
||||
ONBOARDING_SYSTEM_STATUS.to_string()
|
||||
} else {
|
||||
@@ -672,6 +674,89 @@ impl ChatApp {
|
||||
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 {
|
||||
&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,84 +4694,22 @@ impl ChatApp {
|
||||
|
||||
// Handle consent dialog first (highest priority)
|
||||
if let Some(consent_state) = &self.pending_consent {
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
// Allow once
|
||||
let tool_name = consent_state.tool_name.clone();
|
||||
let data_types = consent_state.data_types.clone();
|
||||
let endpoints = consent_state.endpoints.clone();
|
||||
let scope = match key.code {
|
||||
KeyCode::Char('1') => Some(ConsentScope::Once),
|
||||
KeyCode::Char('2') => Some(ConsentScope::Session),
|
||||
KeyCode::Char('3') => Some(ConsentScope::Permanent),
|
||||
KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
self.controller.grant_consent_with_scope(
|
||||
&tool_name,
|
||||
data_types,
|
||||
endpoints,
|
||||
owlen_core::consent::ConsentScope::Once,
|
||||
);
|
||||
self.pending_consent = None;
|
||||
self.status = format!("✓ Consent granted (once) for {}", tool_name);
|
||||
self.set_system_status(format!(
|
||||
"✓ Consent granted (once): {}",
|
||||
tool_name
|
||||
));
|
||||
if let Some(scope) = scope {
|
||||
let request_id = consent_state.request_id;
|
||||
let effects =
|
||||
self.apply_app_event(AppEvent::ToolPermission { request_id, scope });
|
||||
self.handle_app_effects(effects).await?;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.try_execute_command(&key).await? {
|
||||
return Ok(AppState::Running);
|
||||
@@ -7539,21 +7566,6 @@ impl ChatApp {
|
||||
// Store tool execution for async processing on next event loop iteration
|
||||
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 } => {
|
||||
// Update agent actions panel with latest ReAct iteration
|
||||
self.set_agent_actions(content);
|
||||
@@ -9088,6 +9100,7 @@ impl ChatApp {
|
||||
self.stop_loading_animation();
|
||||
self.pending_tool_execution = None;
|
||||
self.pending_consent = None;
|
||||
self.queued_consents.clear();
|
||||
self.current_thinking = None;
|
||||
self.agent_actions = None;
|
||||
self.status = "Generation cancelled".to_string();
|
||||
@@ -9105,6 +9118,7 @@ impl ChatApp {
|
||||
self.pending_llm_request = false;
|
||||
self.pending_tool_execution = None;
|
||||
self.pending_consent = None;
|
||||
self.queued_consents.clear();
|
||||
self.pending_key = None;
|
||||
self.visual_start = None;
|
||||
self.visual_end = None;
|
||||
@@ -9296,42 +9310,20 @@ impl ChatApp {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Check if consent is needed for any of these tools
|
||||
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
||||
|
||||
if !consent_needed.is_empty() {
|
||||
// If a consent dialog is already being shown, don't send another request
|
||||
// Just re-queue the tool execution and wait for user response
|
||||
// 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(());
|
||||
}
|
||||
|
||||
// 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
|
||||
// Check if consent is needed for any of these tools
|
||||
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
|
||||
|
||||
if !consent_needed.is_empty() {
|
||||
// Re-queue the execution and ensure a controller event is emitted
|
||||
self.pending_tool_execution = Some((message_id, tool_calls));
|
||||
self.controller.check_streaming_tool_calls(message_id);
|
||||
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
|
||||
@@ -11253,9 +11245,25 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
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::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> {
|
||||
lines
|
||||
@@ -11328,6 +11336,139 @@ mod tests {
|
||||
let wrapped = wrap_unicode("hello", 0);
|
||||
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<()> {
|
||||
@@ -11397,6 +11538,17 @@ impl UiRuntime for ChatApp {
|
||||
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) {
|
||||
ChatApp::advance_loading_animation(self);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use owlen_core::session::SessionController;
|
||||
use owlen_core::session::{ControllerEvent, SessionController};
|
||||
use owlen_core::ui::{AppState, InputMode};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -16,11 +16,12 @@ pub struct CodeApp {
|
||||
impl CodeApp {
|
||||
pub async fn new(
|
||||
mut controller: SessionController,
|
||||
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||
controller
|
||||
.conversation_mut()
|
||||
.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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user