1use crate::api::Client as ApiClient;
2use crate::app::cancellation::ActiveTool;
3use crate::app::command::ApprovalType;
4use crate::app::conversation::{AppCommandType, AssistantContent, CompactResult, UserContent};
5use crate::auth::ProviderRegistry;
6use crate::catalog::CatalogConfig;
7use crate::config::LlmConfigProvider;
8use crate::config::model::ModelId;
9use crate::error::{Error, Result};
10use crate::model_registry::ModelRegistry;
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15use steer_tools::tools::BASH_TOOL_NAME;
16use steer_tools::tools::bash::BashParams;
17use steer_tools::{ToolCall, ToolError, ToolResult};
18use tokio::sync::{Mutex, mpsc, oneshot};
19use tokio_util::sync::CancellationToken;
20
21use tracing::{debug, error, info, warn};
22use uuid;
23
24pub mod adapters;
25mod agent_executor;
26pub mod cancellation;
27pub mod command;
28pub mod context;
29pub mod conversation;
30pub mod io;
31
32pub mod validation;
33
34#[cfg(test)]
35mod tests;
36
37use crate::app::context::TaskOutcome;
38
39pub use cancellation::CancellationInfo;
40pub use command::AppCommand;
41pub use context::OpContext;
42pub use conversation::{Conversation, Message, MessageData};
43pub use steer_workspace::EnvironmentInfo;
44
45pub use agent_executor::{
46 AgentEvent, AgentExecutor, AgentExecutorError, AgentExecutorRunRequest, ApprovalDecision,
47};
48
49#[derive(Debug, Clone)]
50pub enum AppEvent {
51 MessageAdded {
52 message: Message,
53 model: ModelId,
54 },
55 MessageUpdated {
56 id: String,
57 content: String,
58 },
59 MessagePart {
60 id: String,
61 delta: String,
62 },
63
64 ToolCallStarted {
65 name: String,
66 id: String,
67 parameters: serde_json::Value,
68 model: ModelId,
69 },
70 ToolCallCompleted {
71 name: String,
72 result: ToolResult,
73 id: String,
74 model: ModelId,
75 },
76 ToolCallFailed {
77 name: String,
78 error: String,
79 id: String,
80 model: ModelId,
81 },
82
83 ProcessingStarted, ProcessingCompleted, CommandResponse {
87 command: conversation::AppCommandType,
88 response: conversation::CommandResponse,
89 id: String,
90 },
91
92 RequestToolApproval {
93 name: String,
94 parameters: serde_json::Value,
95 id: String,
96 },
97 OperationCancelled {
98 op_id: Option<uuid::Uuid>, info: CancellationInfo,
100 },
101
102 ModelChanged {
103 model: ModelId,
104 },
105 Error {
106 message: String,
107 },
108 WorkspaceChanged,
109 WorkspaceFiles {
110 files: Vec<String>,
111 },
112 Started {
113 id: uuid::Uuid,
114 op: Operation,
115 },
116 Finished {
117 id: uuid::Uuid,
118 outcome: OperationOutcome,
119 },
120 ActiveMessageIdChanged {
121 message_id: Option<String>,
122 },
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub enum Operation {
127 Bash { cmd: String },
128 Compact,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum OperationOutcome {
133 Bash {
134 elapsed: Duration,
135 result: std::result::Result<(), BashError>,
136 },
137 Compact {
138 elapsed: Duration,
139 result: std::result::Result<(), CompactError>,
140 },
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct BashError {
145 pub exit_code: i32,
146 pub stderr: String,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct CompactError {
151 pub message: String,
152}
153
154#[derive(Clone)]
155pub struct AppConfig {
156 pub llm_config_provider: LlmConfigProvider,
157 pub model_registry: Arc<ModelRegistry>,
158 pub provider_registry: Arc<ProviderRegistry>,
159}
160
161impl AppConfig {
162 pub fn from_auth_storage(auth_storage: Arc<dyn crate::auth::AuthStorage>) -> Result<Self> {
165 Self::from_auth_storage_with_catalog(auth_storage, CatalogConfig::default())
166 }
167
168 pub fn from_auth_storage_with_catalog(
170 auth_storage: Arc<dyn crate::auth::AuthStorage>,
171 catalog_config: CatalogConfig,
172 ) -> Result<Self> {
173 let llm_config_provider = LlmConfigProvider::new(auth_storage);
174 let model_registry = Arc::new(ModelRegistry::load(&catalog_config.catalog_paths)?);
175 let provider_registry = Arc::new(ProviderRegistry::load(&catalog_config.catalog_paths)?);
176
177 Ok(Self {
178 llm_config_provider,
179 model_registry,
180 provider_registry,
181 })
182 }
183
184 #[cfg(not(test))]
186 pub fn new() -> Result<Self> {
187 let auth_storage = Arc::new(crate::auth::DefaultAuthStorage::new()?);
188 Self::from_auth_storage(auth_storage)
189 }
190}
191
192impl Default for AppConfig {
195 fn default() -> Self {
196 let storage = Arc::new(crate::test_utils::InMemoryAuthStorage::new());
197 Self::from_auth_storage(storage).expect("Failed to create test AppConfig")
198 }
199}
200
201pub struct App {
202 pub config: AppConfig,
203 pub conversation: Arc<Mutex<Conversation>>,
204 pub tool_executor: Arc<crate::tools::ToolExecutor>,
205 pub api_client: ApiClient,
206 agent_executor: AgentExecutor,
207 event_sender: mpsc::Sender<AppEvent>,
208 approved_tools: Arc<tokio::sync::RwLock<HashSet<String>>>, approved_bash_patterns: std::sync::Arc<tokio::sync::RwLock<HashSet<String>>>, current_op_context: Option<OpContext>,
211 current_model: ModelId,
212 session_config: Option<crate::session::state::SessionConfig>, workspace: Option<Arc<dyn crate::workspace::Workspace>>, cached_system_prompt: Option<String>, }
216
217fn matches_any_pattern(command: &str, patterns: &[String]) -> bool {
219 patterns.iter().any(|pattern| {
220 if pattern == command {
222 return true;
223 }
224 glob::Pattern::new(pattern)
226 .map(|p| p.matches(command))
227 .unwrap_or(false)
228 })
229}
230
231impl App {
232 pub async fn new_with_conversation(
233 config: AppConfig,
234 event_tx: mpsc::Sender<AppEvent>,
235 initial_model: ModelId,
236 workspace: Arc<dyn crate::workspace::Workspace>,
237 tool_executor: Arc<crate::tools::ToolExecutor>,
238 session_config: Option<crate::session::state::SessionConfig>,
239 conversation: Conversation,
240 ) -> Result<Self> {
241 let api_client = ApiClient::new_with_deps(
242 config.llm_config_provider.clone(),
243 config.provider_registry.clone(),
244 config.model_registry.clone(),
245 );
246 let agent_executor = AgentExecutor::new(Arc::new(api_client.clone()));
247
248 let approved_bash_patterns = if let Some(ref sc) = session_config {
250 if let Some(bash_config) = sc.tool_config.tools.get("bash") {
251 let crate::session::state::ToolSpecificConfig::Bash(bash) = bash_config;
252 bash.approved_patterns.iter().cloned().collect()
253 } else {
254 HashSet::new()
255 }
256 } else {
257 HashSet::new()
258 };
259 let approved_bash_patterns =
260 std::sync::Arc::new(tokio::sync::RwLock::new(approved_bash_patterns));
261
262 Ok(Self {
263 config,
264 conversation: Arc::new(Mutex::new(conversation)),
265 tool_executor,
266 api_client,
267 agent_executor,
268 event_sender: event_tx,
269 approved_tools: Arc::new(tokio::sync::RwLock::new(HashSet::new())),
270 approved_bash_patterns,
271 current_op_context: None,
272 current_model: initial_model,
273 session_config,
274 workspace: Some(workspace),
275 cached_system_prompt: None,
276 })
277 }
278
279 pub async fn new(
280 config: AppConfig,
281 event_tx: mpsc::Sender<AppEvent>,
282 initial_model: ModelId,
283 workspace: Arc<dyn crate::workspace::Workspace>,
284 tool_executor: Arc<crate::tools::ToolExecutor>,
285 session_config: Option<crate::session::state::SessionConfig>,
286 ) -> Result<Self> {
287 let conversation = Conversation::new();
288 Self::new_with_conversation(
289 config,
290 event_tx,
291 initial_model,
292 workspace,
293 tool_executor,
294 session_config,
295 conversation,
296 )
297 .await
298 }
299
300 pub(crate) fn emit_event(&self, event: AppEvent) {
301 match self.event_sender.try_send(event.clone()) {
302 Ok(_) => {
303 if !matches!(event, AppEvent::MessagePart { .. }) {
305 debug!(target: "app.emit_event", "Sent event: {:?}", event);
306 }
307 }
308 Err(mpsc::error::TrySendError::Full(_)) => {
309 warn!(target: "app.emit_event", "Event channel full, discarding event: {:?}", event);
310 }
311 Err(mpsc::error::TrySendError::Closed(_)) => {
312 warn!(target: "app.emit_event", "Event channel closed, discarding event: {:?}", event);
313 }
314 }
315 }
316
317 pub(crate) async fn emit_workspace_files(&self) {
318 if let Some(workspace) = &self.workspace {
319 info!(target: "app.emit_workspace_files", "Attempting to list workspace files...");
320 match workspace.list_files(None, Some(10000)).await {
321 Ok(files) => {
322 info!(target: "app.emit_workspace_files", "Emitting workspace files event with {} files", files.len());
323 if files.is_empty() {
324 warn!(target: "app.emit_workspace_files", "No files found in workspace - check if directory is correct");
325 }
326 self.emit_event(AppEvent::WorkspaceFiles { files });
327 }
328 Err(e) => {
329 warn!(target: "app.emit_workspace_files", "Failed to list workspace files: {}", e);
330 }
331 }
332 } else {
333 warn!(target: "app.emit_workspace_files", "No workspace available to list files");
334 }
335 }
336
337 pub fn get_current_model(&self) -> ModelId {
338 self.current_model.clone()
339 }
340
341 async fn get_or_create_system_prompt(&mut self) -> Result<String> {
343 if let Some(ref cached) = self.cached_system_prompt {
344 debug!(target: "app.get_or_create_system_prompt", "Using cached system prompt");
345 Ok(cached.clone())
346 } else {
347 debug!(target: "app.get_or_create_system_prompt", "Creating new system prompt");
348 let prompt = if let Some(workspace) = &self.workspace {
349 create_system_prompt_with_workspace(
350 Some(self.current_model.clone()),
351 workspace.as_ref(),
352 )
353 .await?
354 } else {
355 if let Some(model) = Some(self.current_model.clone()) {
358 get_model_system_prompt(model)
359 } else {
360 crate::prompts::default_system_prompt()
361 }
362 };
363 self.cached_system_prompt = Some(prompt.clone());
365 Ok(prompt)
366 }
367 }
368
369 pub async fn set_model(&mut self, model: ModelId) -> Result<()> {
370 let provider_id = model.0.clone();
372
373 let auth = self
374 .config
375 .llm_config_provider
376 .get_auth_for_provider(&provider_id)
377 .await?;
378 if auth.is_none() {
379 return Err(crate::error::Error::Configuration(format!(
380 "Cannot set model to {model:?}: missing authentication for {provider_id:?} provider"
381 )));
382 }
383
384 self.current_model = model.clone();
386
387 self.cached_system_prompt = None;
389
390 self.emit_event(AppEvent::ModelChanged { model });
392
393 Ok(())
394 }
395
396 pub async fn add_message(&self, message: Message) {
397 let mut conversation_guard = self.conversation.lock().await;
398 let message_id = message.id().to_string();
399 conversation_guard.add_message(message.clone());
400 self.emit_event(AppEvent::MessageAdded {
401 message,
402 model: self.current_model.clone(),
403 });
404 self.emit_event(AppEvent::ActiveMessageIdChanged {
405 message_id: Some(message_id),
406 });
407 }
408
409 pub async fn add_message_from_data(&self, message_data: MessageData) {
410 let mut conversation_guard = self.conversation.lock().await;
411 let message = conversation_guard.add_message_from_data(message_data);
412
413 self.emit_event(AppEvent::MessageAdded {
414 message: message.clone(),
415 model: self.current_model.clone(),
416 });
417 self.emit_event(AppEvent::ActiveMessageIdChanged {
418 message_id: Some(message.id().to_string()),
419 });
420 }
421
422 pub async fn process_user_message(
425 &mut self,
426 message: String,
427 ) -> Result<Option<mpsc::Receiver<AgentEvent>>> {
428 self.cancel_current_processing().await;
430
431 let op_context = OpContext::new();
433 self.current_op_context = Some(op_context);
434
435 self.add_message_from_data(MessageData::User {
436 content: vec![UserContent::Text {
437 text: message.clone(),
438 }],
439 })
440 .await;
441
442 self.emit_event(AppEvent::ProcessingStarted);
444 match self.spawn_agent_operation().await {
445 Ok(maybe_receiver) => Ok(maybe_receiver), Err(e) => {
447 error!(target:
448 "App.start_standard_operation",
449 "Error spawning agent operation task: {}", e,
450 );
451 self.emit_event(AppEvent::ProcessingCompleted); self.emit_event(AppEvent::Error {
453 message: format!("Failed to start agent operation: {e}"),
454 });
455 self.current_op_context = None; Err(e)
457 }
458 }
459 }
460
461 async fn spawn_agent_operation(&mut self) -> Result<Option<mpsc::Receiver<AgentEvent>>> {
462 debug!(target:
463 "app.spawn_agent_operation",
464 "Spawning agent operation task...",
465 );
466
467 let mut tool_schemas = self.tool_executor.get_tool_schemas().await;
470
471 if let Some(session_config) = &self.session_config {
473 tool_schemas = session_config.filter_tools_by_visibility(tool_schemas);
474 }
475
476 let system_prompt = self.get_or_create_system_prompt().await?;
478
479 let op_context = match &mut self.current_op_context {
481 Some(ctx) => ctx,
482 None => {
483 return Err(crate::error::Error::InvalidOperation(
484 "No operation context available to spawn agent operation".to_string(),
485 ));
486 }
487 };
488 let token = op_context.cancel_token.clone();
489
490 let api_messages = {
492 let conversation_guard = self.conversation.lock().await;
493 conversation_guard
494 .get_thread_messages()
495 .into_iter()
496 .cloned()
497 .collect()
498 };
499
500 let current_model = self.current_model.clone();
501 let agent_executor = self.agent_executor.clone();
502
503 let approved_tools_for_approval = self.approved_tools.clone();
505 let tool_executor_for_approval = self.tool_executor.clone();
506 let command_tx_for_approval = OpContext::command_tx().clone();
507 let approved_bash_patterns_clone = self.approved_bash_patterns.clone(); let session_config_clone = self.session_config.clone(); let tool_approval_callback = move |tool_call: ToolCall| {
511 let approved_tools = approved_tools_for_approval.clone();
512 let executor = tool_executor_for_approval.clone();
513 let command_tx = command_tx_for_approval.clone();
514 let tool_name = tool_call.name.clone();
515 let tool_id = tool_call.id.clone();
516 let approved_bash_patterns = approved_bash_patterns_clone.clone();
517 let session_config = session_config_clone.clone();
518
519 async move {
520 match executor.requires_approval(&tool_name).await {
521 Ok(false) => return Ok(ApprovalDecision::Approved),
522 Ok(true) => {}
523 Err(e) => {
524 return Err(ToolError::InternalError(format!(
525 "Failed to check tool approval status for {tool_name}: {e}"
526 )));
527 }
528 };
529
530 if approved_tools.read().await.contains(&tool_name) {
531 return Ok(ApprovalDecision::Approved);
532 }
533
534 if tool_name == BASH_TOOL_NAME {
536 let params: BashParams = match serde_json::from_value(
538 tool_call.parameters.clone(),
539 ) {
540 Ok(p) => p,
541 Err(e) => {
542 debug!(tool_id=%tool_id, tool_name=%tool_name, "Failed to parse BashParams from tool_call.parameters: {}", e);
543 return Err(ToolError::invalid_params(
544 "bash",
545 format!("Failed to parse BashParams: {e}"),
546 ));
547 }
548 };
549
550 let static_patterns = if let Some(ref session_config) = session_config {
552 if let Some(bash_config) = session_config.tool_config.tools.get("bash") {
553 let crate::session::state::ToolSpecificConfig::Bash(bash) = bash_config;
554 &bash.approved_patterns
555 } else {
556 &Vec::new()
557 }
558 } else {
559 &Vec::new()
560 };
561
562 if matches_any_pattern(params.command.as_str(), static_patterns) {
563 debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} matches static patterns: {:?}", params.command, static_patterns);
564 return Ok(ApprovalDecision::Approved);
565 } else {
566 debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} does not match static patterns: {:?}", params.command, static_patterns);
567 }
568
569 let dynamic_patterns: Vec<String> = {
571 let patterns = approved_bash_patterns.read().await;
572 debug!(tool_id=%tool_id, tool_name=%tool_name, "Dynamic patterns: {:?}", patterns);
573 patterns.iter().cloned().collect()
574 };
575
576 if matches_any_pattern(params.command.as_str(), &dynamic_patterns) {
577 debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} matches dynamic patterns: {:?}", params.command, dynamic_patterns);
578 return Ok(ApprovalDecision::Approved);
579 } else {
580 debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} does not match dynamic patterns: {:?}", params.command, dynamic_patterns);
581 }
582 }
583
584 let (tx, rx) = oneshot::channel();
586
587 if let Err(e) = command_tx
589 .send(AppCommand::RequestToolApprovalInternal {
590 tool_call,
591 responder: tx,
592 })
593 .await
594 {
595 error!(tool_id=%tool_id, tool_name=%tool_name, "Failed to send tool approval request: {}", e);
597 return Err(ToolError::InternalError(format!(
598 "Failed to request tool approval: {e}"
599 )));
600 }
601
602 match rx.await {
604 Ok(d) => Ok(d), Err(_) => {
606 warn!(tool_id=%tool_id, tool_name=%tool_name, "Approval decision channel closed for tool.");
608 Ok(ApprovalDecision::Denied) }
610 }
611 }
612 };
613
614 let tool_executor_for_execution = self.tool_executor.clone();
616 let tool_execution_callback =
617 move |tool_call: ToolCall, callback_token: CancellationToken| {
618 let executor = tool_executor_for_execution.clone();
619 let tool_name = tool_call.name.clone();
620 let tool_id = tool_call.id.clone();
621
622 async move {
623 info!(tool_id=%tool_id, tool_name=%tool_name, "Executing tool via callback.");
624 executor
625 .execute_tool_with_cancellation(&tool_call, callback_token)
626 .await
627 }
628 };
629
630 let (agent_event_tx, agent_event_rx) = mpsc::channel(100);
631 op_context.tasks.spawn(async move {
632 debug!(target:
633 "spawn_agent_operation task",
634 "Agent operation task started.",
635 );
636 let request = AgentExecutorRunRequest {
637 model: current_model,
638 initial_messages: api_messages,
639 system_prompt: Some(system_prompt),
640 available_tools: tool_schemas,
641 tool_approval_callback,
642 tool_execution_callback,
643 };
644 let operation_result = agent_executor
645 .run(request, agent_event_tx, token)
646 .await;
647
648 debug!(target: "spawn_agent_operation task", "Agent operation task finished with result: {:?}", operation_result.is_ok());
649
650 TaskOutcome::AgentOperationComplete {
651 result: operation_result,
652 }
653 });
654
655 debug!(target:
656 "app.spawn_agent_operation",
657 "Agent operation task successfully spawned.",
658 );
659 Ok(Some(agent_event_rx))
660 }
661
662 pub async fn handle_command(
665 &mut self,
666 command: AppCommandType,
667 ) -> Result<Option<conversation::CommandResponse>> {
668 self.cancel_current_processing().await;
671
672 match command {
673 AppCommandType::Clear => {
674 self.conversation.lock().await.clear();
675 self.approved_tools.write().await.clear(); self.cached_system_prompt = None; self.emit_event(AppEvent::ActiveMessageIdChanged { message_id: None });
678
679 Ok(Some(conversation::CommandResponse::Text(
680 "Conversation and tool approvals cleared.".to_string(),
681 )))
682 }
683 AppCommandType::Compact => {
684 let op_id = uuid::Uuid::new_v4();
686 let start_time = Instant::now();
687
688 self.emit_event(AppEvent::Started {
690 id: op_id,
691 op: Operation::Compact,
692 });
693
694 let op_context = OpContext::new_with_id(op_id);
696 self.current_op_context = Some(op_context);
697 let token = self
698 .current_op_context
699 .as_ref()
700 .unwrap()
701 .cancel_token
702 .clone();
703
704 warn!(target:
708 "handle_command",
709 "Compact command task spawning needs TaskOutcome handling in actor loop.",
710 );
711 let result = match self.compact_conversation(token).await {
712 Ok(result) => {
713 let elapsed = start_time.elapsed();
714 self.emit_event(AppEvent::Finished {
716 id: op_id,
717 outcome: OperationOutcome::Compact {
718 elapsed,
719 result: Ok(()),
720 },
721 });
722 Ok(Some(conversation::CommandResponse::Compact(result)))
723 }
724 Err(e) => {
725 let elapsed = start_time.elapsed();
726 error!(target:
727 "App.handle_command",
728 "Error during compact: {}", e,
729 );
730 self.emit_event(AppEvent::Finished {
732 id: op_id,
733 outcome: OperationOutcome::Compact {
734 elapsed,
735 result: Err(CompactError {
736 message: e.to_string(),
737 }),
738 },
739 });
740 Err(e) }
742 }?;
743 self.current_op_context = None; Ok(result)
745 }
746 AppCommandType::Model { target } => {
747 if target.is_none() {
748 let current_model = self.get_current_model();
751
752 let model_registry = &self.config.model_registry;
754
755 let mut items: Vec<(bool, String, String, String, String)> = model_registry
756 .all()
757 .map(|config| {
758 let model_id = (config.provider.clone(), config.id.clone());
759 let is_current = model_id == current_model;
760 let prov = config.provider.storage_key();
761 let sort_name = config
762 .display_name
763 .as_deref()
764 .filter(|s| !s.trim().is_empty())
765 .unwrap_or(&config.id)
766 .to_string();
767 let id = config.id.clone();
768 let alias_str = if config.aliases.is_empty() {
769 String::new()
770 } else if config.aliases.len() == 1 {
771 format!(" (alias: {})", config.aliases[0])
772 } else {
773 format!(" (aliases: {})", config.aliases.join(", "))
774 };
775 (is_current, prov, sort_name, id, alias_str)
776 })
777 .collect();
778
779 items.sort_by(|a, b| {
780 (a.1.as_str(), a.2.as_str()).cmp(&(b.1.as_str(), b.2.as_str()))
781 });
782
783 let available_models: Vec<String> = items
784 .into_iter()
785 .map(|(is_current, prov, _sort_name, id, alias_str)| {
786 let model_str = format!("{prov}/{id}");
787 if is_current {
788 format!("* {model_str}{alias_str}")
789 } else {
790 format!(" {model_str}{alias_str}")
791 }
792 })
793 .collect();
794
795 Ok(Some(conversation::CommandResponse::Text(format!(
796 "Current model: {}/{}\nAvailable models:\n{}",
797 current_model.0.storage_key(),
798 current_model.1,
799 available_models.join("\n")
800 ))))
801 } else if let Some(ref model_name) = target {
802 let model_registry = &self.config.model_registry;
806
807 match model_registry.resolve(model_name) {
808 Ok(model_id) => {
809 let lookup_id = model_id.clone();
811 match self.set_model(model_id).await {
812 Ok(()) => {
813 let display = if let Some(cfg) =
815 self.config.model_registry.get(&lookup_id)
816 {
817 let name = cfg
818 .display_name
819 .as_deref()
820 .filter(|s| !s.trim().is_empty())
821 .unwrap_or(&cfg.id);
822 format!("{}/{}", cfg.provider.storage_key(), name)
823 } else {
824 format!("{}/{}", lookup_id.0.storage_key(), lookup_id.1)
825 };
826 Ok(Some(conversation::CommandResponse::Text(format!(
827 "Model changed to {display}"
828 ))))
829 }
830 Err(e) => Ok(Some(conversation::CommandResponse::Text(format!(
831 "Failed to set model: {e}"
832 )))),
833 }
834 }
835 Err(e) => Ok(Some(conversation::CommandResponse::Text(format!(
836 "Failed to resolve model '{model_name}': {e}"
837 )))),
838 }
839 } else {
840 Ok(None)
842 }
843 }
844 }
845 }
846
847 pub async fn compact_conversation(
848 &mut self,
849 token: CancellationToken,
850 ) -> Result<CompactResult> {
851 info!(target:"App.compact_conversation", "Compacting conversation...");
852 let client = self.api_client.clone();
853 let conversation_arc = self.conversation.clone();
854 let model = self.current_model.clone();
855
856 let result = tokio::select! {
858 biased;
859 res = async { conversation_arc.lock().await.compact(&client, model, token.clone()).await } => res.map_err(|e| Error::InvalidOperation(format!("Compact failed: {e}")))?,
860 _ = token.cancelled() => {
861 info!(target:"App.compact_conversation", "Compaction cancelled.");
862 return Ok(CompactResult::Cancelled);
863 }
864 };
865
866 if matches!(result, CompactResult::Success(_)) {
868 let active_id = conversation_arc.lock().await.active_message_id.clone();
869 self.emit_event(AppEvent::ActiveMessageIdChanged {
870 message_id: active_id,
871 });
872 }
873
874 info!(target:"App.compact_conversation", "Conversation compacted.");
875 Ok(result)
876 }
877
878 pub async fn cancel_current_processing(&mut self) {
879 if let Some(mut op_context) = self.current_op_context.take() {
881 info!(target:
882 "App.cancel_current_processing",
883 "Cancelling current operation via OpContext",
884 );
885
886 let active_tools = op_context
888 .active_tools
889 .values()
890 .map(|(_, _, name)| ActiveTool {
891 id: name.clone(), name: name.clone(),
893 })
894 .collect();
895 let cancellation_info = CancellationInfo {
897 api_call_in_progress: false, active_tools,
899 pending_tool_approvals: false, };
901
902 let op_id = op_context.operation_id;
904
905 op_context.cancel_and_shutdown().await;
906
907 self.emit_event(AppEvent::OperationCancelled {
908 op_id,
909 info: cancellation_info,
910 });
911
912 self.inject_cancelled_tool_results().await;
914
915 self.emit_event(AppEvent::ProcessingCompleted);
917 } else {
919 warn!(target:
920 "App.cancel_current_processing",
921 "Attempted to cancel processing, but no active operation context was found.",
922 );
923 }
924 }
925
926 pub async fn inject_cancelled_tool_results(&mut self) {
929 let incomplete_tool_calls = {
930 let conversation_guard = self.conversation.lock().await;
931 self.find_incomplete_tool_calls(&conversation_guard)
932 };
933
934 if !incomplete_tool_calls.is_empty() {
935 info!(target: "App.inject_cancelled_tool_results",
936 "Found {} incomplete tool calls, injecting cancellation results",
937 incomplete_tool_calls.len());
938
939 for tool_call in incomplete_tool_calls {
940 self.add_message_from_data(MessageData::Tool {
941 tool_use_id: tool_call.id.clone(),
942 result: ToolResult::Error(ToolError::Cancelled(tool_call.name.clone())),
943 })
944 .await;
945
946 self.emit_event(AppEvent::ToolCallFailed {
948 id: tool_call.id.clone(),
949 name: tool_call.name.clone(),
950 error: "Cancelled".to_string(),
951 model: self.current_model.clone(),
952 });
953
954 debug!(target: "App.inject_cancelled_tool_results",
955 "Injected cancellation result for tool call: {} ({})",
956 tool_call.name, tool_call.id);
957 }
958 }
959 }
960
961 fn find_incomplete_tool_calls(&self, conversation: &Conversation) -> Vec<ToolCall> {
963 let mut tool_calls = Vec::new();
964 let mut tool_results = std::collections::HashSet::new();
965
966 for message in &conversation.messages {
968 if let MessageData::Tool { tool_use_id, .. } = &message.data {
969 tool_results.insert(tool_use_id.clone());
970 }
971 }
972
973 for message in &conversation.messages {
975 if let MessageData::Assistant { content, .. } = &message.data {
976 for block in content {
977 if let AssistantContent::ToolCall { tool_call } = block {
978 if !tool_results.contains(&tool_call.id) {
979 tool_calls.push(tool_call.clone());
980 }
981 }
982 }
983 }
984 }
985
986 tool_calls
987 }
988
989 async fn get_active_message_id(&self) -> Option<String> {
990 let conv = self.conversation.lock().await;
991 conv.active_message_id.clone()
992 }
993}
994
995struct ApprovalQueue {
997 current: Option<(String, ToolCall, oneshot::Sender<ApprovalDecision>)>,
998 queued: std::collections::VecDeque<(String, ToolCall, oneshot::Sender<ApprovalDecision>)>,
999}
1000
1001impl ApprovalQueue {
1002 fn new() -> Self {
1003 Self {
1004 current: None,
1005 queued: std::collections::VecDeque::new(),
1006 }
1007 }
1008
1009 fn add_request(
1010 &mut self,
1011 id: String,
1012 tool_call: ToolCall,
1013 responder: oneshot::Sender<ApprovalDecision>,
1014 ) {
1015 self.queued.push_back((id, tool_call, responder));
1016 }
1017
1018 fn cancel_all(&mut self) {
1019 if let Some((id, _, _)) = self.current.take() {
1021 info!(target: "ApprovalQueue", "Cancelled active tool approval request for ID '{}'", id);
1022 }
1023 if !self.queued.is_empty() {
1025 info!(target: "ApprovalQueue", "Clearing {} queued tool approval requests", self.queued.len());
1026 self.queued.clear();
1027 }
1028 }
1029}
1030
1031pub async fn app_actor_loop(mut app: App, mut command_rx: mpsc::Receiver<AppCommand>) {
1033 info!(target: "app_actor_loop", "App actor loop started.");
1034
1035 app.emit_workspace_files().await;
1037
1038 let mut approval_queue = ApprovalQueue::new();
1040
1041 let mut active_agent_event_rx: Option<mpsc::Receiver<AgentEvent>> = None;
1043
1044 let mut agent_task_completed = false;
1046
1047 loop {
1048 tokio::select! {
1049 Some(command) = command_rx.recv() => {
1051 match handle_app_command(
1052 &mut app,
1053 command,
1054 &mut approval_queue,
1055 &mut active_agent_event_rx,
1056 )
1057 .await
1058 {
1059 Ok(should_exit) => {
1060 if should_exit {
1061 break; }
1063 }
1064 Err(e) => {
1065 error!(target: "app_actor_loop", "Error handling app command: {}", e);
1066 app.emit_event(AppEvent::Error {
1067 message: format!("Command failed: {e}"),
1068 });
1069 }
1070 }
1071 if active_agent_event_rx.is_some() {
1073 debug!(target: "app_actor_loop", "Resetting agent_task_completed flag due to new operation.");
1074 agent_task_completed = false;
1075 }
1076 }
1077
1078 result = async {
1081 if let Some(ctx) = app.current_op_context.as_mut() {
1082 if ctx.tasks.is_empty() { None } else { ctx.tasks.join_next().await }
1083 } else {
1084 None
1085 }
1086 }, if app.current_op_context.is_some() => {
1087 if let Some(join_result) = result {
1088 match join_result {
1089 Ok(task_outcome) => {
1090 let is_standard_op = matches!(task_outcome, TaskOutcome::AgentOperationComplete{..});
1091
1092 handle_task_outcome(&mut app, task_outcome).await;
1093
1094 if is_standard_op {
1095 debug!(target: "app_actor_loop", "Agent task completed flag set to true.");
1096 agent_task_completed = true;
1097 }
1098
1099 if agent_task_completed && active_agent_event_rx.is_none() {
1101 debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (Task done, receiver drained).");
1102 app.emit_event(AppEvent::ProcessingCompleted);
1103 agent_task_completed = false;
1104 }
1105 }
1106 Err(join_err) => {
1107 error!(target: "app_actor_loop", "Task join error: {}", join_err);
1108 app.current_op_context = None;
1109 active_agent_event_rx = None;
1110 agent_task_completed = false;
1111 app.emit_event(AppEvent::ProcessingCompleted);
1112 app.emit_event(AppEvent::Error {
1113 message: format!("A task failed unexpectedly: {join_err}")
1114 });
1115 }
1116 }
1117 } else {
1118 if let Some(_ctx) = app.current_op_context.take() {
1120 debug!(target: "app_actor_loop", "JoinSet empty. Clearing context.");
1121 agent_task_completed = true;
1122
1123 if agent_task_completed && active_agent_event_rx.is_none() {
1124 debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (JoinSet empty, receiver drained).");
1125 app.emit_event(AppEvent::ProcessingCompleted);
1126 agent_task_completed = false;
1127 }
1128 }
1129 }
1130 }
1131
1132 maybe_agent_event = async { active_agent_event_rx.as_mut().unwrap().recv().await },
1135 if active_agent_event_rx.is_some() => {
1136 match maybe_agent_event {
1137 Some(event) => {
1138 handle_agent_event(&mut app, event).await;
1139 }
1140 None => {
1141 debug!(target: "app_actor_loop", "Agent event channel closed.");
1143 active_agent_event_rx = None;
1144
1145 if agent_task_completed {
1146 debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (Receiver closed, task completed).");
1147 app.emit_event(AppEvent::ProcessingCompleted);
1148 agent_task_completed = false;
1149 }
1150 }
1151 }
1152 }
1153
1154 else => {}
1156 }
1157 }
1158 info!(target: "app_actor_loop", "App actor loop finished.");
1159}
1160
1161async fn process_next_approval_request(app: &mut App, queue: &mut ApprovalQueue) {
1163 if queue.current.is_some() {
1164 debug!(target: "process_next_approval", "An approval request is already active.");
1165 return;
1166 }
1167
1168 while let Some((id, tool_call, responder)) = queue.queued.pop_front() {
1169 if app.approved_tools.read().await.contains(&tool_call.name) {
1170 info!(target: "process_next_approval", "Auto-approving tool '{}' (ID: {})", tool_call.name, id);
1171 if responder.send(ApprovalDecision::Approved).is_err() {
1172 warn!(target: "process_next_approval", "Failed to send auto-approval for tool ID '{}'", id);
1173 }
1174 } else {
1175 info!(target: "process_next_approval", "Sending tool approval request to UI for '{}' (ID: {})", tool_call.name, id);
1177 let parameters = tool_call.parameters.clone();
1178 let name = tool_call.name.clone();
1179
1180 queue.current = Some((id.clone(), tool_call, responder));
1181
1182 app.emit_event(AppEvent::RequestToolApproval {
1183 name,
1184 parameters,
1185 id,
1186 });
1187 return;
1188 }
1189 }
1190 debug!(target: "process_next_approval", "Approval queue processed.");
1191}
1192
1193async fn handle_app_command(
1195 app: &mut App,
1196 command: AppCommand,
1197 approval_queue: &mut ApprovalQueue,
1198 active_agent_event_rx: &mut Option<mpsc::Receiver<AgentEvent>>,
1199) -> Result<bool> {
1200 debug!(target: "handle_app_command", "Received command: {:?}", command);
1201
1202 match command {
1203 AppCommand::ProcessUserInput(message) => {
1204 if message.starts_with('/') {
1205 if active_agent_event_rx.is_some() {
1207 warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to new command input.");
1208 *active_agent_event_rx = None;
1209 }
1210 match AppCommandType::parse(&message) {
1211 Ok(cmd) => {
1212 handle_slash_command(app, cmd).await;
1213 }
1214 Err(e) => {
1215 app.emit_event(AppEvent::Error {
1216 message: format!("Error parsing command: {e:?}"),
1217 });
1218 }
1219 }
1220 } else {
1221 if active_agent_event_rx.is_some() {
1223 warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to new user input.");
1224 *active_agent_event_rx = None;
1225 }
1226 match app.process_user_message(message).await {
1227 Ok(maybe_receiver) => {
1228 *active_agent_event_rx = maybe_receiver;
1229 }
1230 Err(e) => {
1231 error!(target: "handle_app_command", "Error starting standard operation: {}", e);
1232 }
1233 }
1234 }
1235 Ok(false)
1236 }
1237 AppCommand::EditMessage {
1238 message_id,
1239 new_content,
1240 } => {
1241 debug!(target: "handle_app_command", "Editing message {} with new content", message_id);
1242
1243 app.cancel_current_processing().await;
1245 if active_agent_event_rx.is_some() {
1246 warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to message edit.");
1247 *active_agent_event_rx = None;
1248 }
1249
1250 let (new_message_id, edited_message_opt, active_id) = {
1252 let mut conversation = app.conversation.lock().await;
1253 let result = conversation.edit_message(
1254 &message_id,
1255 vec![UserContent::Text {
1256 text: new_content.clone(),
1257 }],
1258 );
1259
1260 let edited_msg = if result.is_some() {
1262 conversation
1263 .messages
1264 .iter()
1265 .rev()
1266 .find(|m| matches!(m.data, MessageData::User { .. }))
1267 .cloned()
1268 } else {
1269 None
1270 };
1271
1272 (result, edited_msg, conversation.active_message_id.clone())
1273 };
1274
1275 if let Some(edited_message) = edited_message_opt {
1277 app.emit_event(AppEvent::MessageAdded {
1278 message: edited_message,
1279 model: app.current_model.clone(),
1280 });
1281 }
1282
1283 if new_message_id.is_some() {
1284 debug!(target: "handle_app_command", "Successfully edited message and created new branch");
1285
1286 app.emit_event(AppEvent::ActiveMessageIdChanged {
1288 message_id: active_id,
1289 });
1290
1291 app.current_op_context = Some(OpContext::new());
1295 app.emit_event(AppEvent::ProcessingStarted);
1296
1297 match app.spawn_agent_operation().await {
1298 Ok(maybe_receiver) => {
1299 *active_agent_event_rx = maybe_receiver;
1300 }
1301 Err(e) => {
1302 error!(target: "handle_app_command", "Error processing edited message: {}", e);
1303 app.current_op_context = None;
1304 app.emit_event(AppEvent::ProcessingCompleted);
1305 }
1306 }
1307 } else {
1308 error!(target: "handle_app_command", "Failed to edit message {}", message_id);
1309 }
1310 Ok(false)
1311 }
1312 AppCommand::RestoreConversation {
1313 messages,
1314 approved_tools,
1315 approved_bash_patterns,
1316 active_message_id,
1317 } => {
1318 debug!(target:"handle_app_command", "Restoring conversation with {} messages, {} approved tools, and {} approved bash patterns",
1320 messages.len(), approved_tools.len(), approved_bash_patterns.len());
1321
1322 let mut conversation_guard = app.conversation.lock().await;
1324 conversation_guard.messages = messages;
1325 conversation_guard.active_message_id = active_message_id;
1326 drop(conversation_guard);
1327
1328 *app.approved_tools.write().await = approved_tools;
1330
1331 *app.approved_bash_patterns.write().await = approved_bash_patterns;
1333
1334 debug!(target:"handle_app_command", "Conversation restoration complete");
1335 Ok(false)
1336 }
1337
1338 AppCommand::HandleToolResponse { id, approval } => {
1339 if let Some((current_id, current_tool_call, responder)) = approval_queue.current.take()
1340 {
1341 debug!(target: "handle_app_command", "Handling tool response for ID '{}', approval: {:?}", id, approval);
1342 if current_id != id {
1343 error!(target: "handle_app_command", "Mismatched tool ID. Expected '{}', got '{}'", current_id, id);
1344 approval_queue
1345 .queued
1346 .push_front((current_id, current_tool_call, responder));
1347 } else {
1348 let decision = match approval {
1349 ApprovalType::Once => {
1350 debug!(target: "handle_app_command", "Approving tool call with ID '{}' once.", id);
1351 ApprovalDecision::Approved
1352 }
1353 ApprovalType::AlwaysTool => {
1354 debug!(target: "handle_app_command", "Approving tool call with ID '{}' always.", id);
1355 app.approved_tools
1356 .write()
1357 .await
1358 .insert(current_tool_call.name.clone());
1359 ApprovalDecision::Approved
1360 }
1361 ApprovalType::AlwaysBashPattern(pattern) => {
1362 debug!(target: "handle_app_command", "Approving bash command '{}' always.", pattern);
1363 app.approved_bash_patterns
1364 .write()
1365 .await
1366 .insert(pattern.clone());
1367 ApprovalDecision::Approved
1368 }
1369 ApprovalType::Denied => {
1370 debug!(target: "handle_app_command", "Denying tool call with ID '{}'.", id);
1371 ApprovalDecision::Denied
1372 }
1373 };
1374
1375 debug!(target: "handle_app_command", "Sending approval decision for tool ID '{}': {:?}", id, decision);
1376 if responder.send(decision).is_err() {
1377 error!(target: "handle_app_command", "Failed to send approval decision for tool ID '{}'", id);
1378 }
1379 }
1380 } else {
1381 error!(target: "handle_app_command", "Received tool response for ID '{}' but no current approval request was active.", id);
1382 }
1383
1384 process_next_approval_request(app, approval_queue).await;
1385 Ok(false)
1386 }
1387
1388 AppCommand::CancelProcessing => {
1389 debug!(target: "handle_app_command", "Handling CancelProcessing command.");
1390 app.cancel_current_processing().await;
1391 approval_queue.cancel_all();
1392
1393 if active_agent_event_rx.is_some() {
1394 debug!(target: "handle_app_command", "Clearing active agent event receiver due to cancellation.");
1395 *active_agent_event_rx = None;
1396 }
1397 app.emit_event(AppEvent::ProcessingCompleted);
1398 Ok(false)
1399 }
1400
1401 AppCommand::ExecuteBashCommand { command } => {
1402 debug!(target: "handle_app_command", "Executing bash command: {}", command);
1403
1404 app.cancel_current_processing().await;
1406
1407 let op_id = uuid::Uuid::new_v4();
1409 let start_time = Instant::now();
1410
1411 app.emit_event(AppEvent::Started {
1413 id: op_id,
1414 op: Operation::Bash {
1415 cmd: command.clone(),
1416 },
1417 });
1418
1419 let mut op_context = OpContext::new_with_id(op_id);
1421 let token = op_context.cancel_token.clone();
1422
1423 let tool_call = ToolCall {
1425 id: format!("bash_{}", uuid::Uuid::new_v4()),
1426 name: BASH_TOOL_NAME.to_string(),
1427 parameters: serde_json::to_value(BashParams {
1428 command: command.clone(),
1429 timeout: None,
1430 })
1431 .map_err(|e| Error::BashCommandError(e.to_string()))?,
1432 };
1433
1434 let tool_executor = app.tool_executor.clone();
1436 let command_clone = command.clone();
1437
1438 op_context.tasks.spawn(async move {
1440 let result = tool_executor.execute_tool_direct(&tool_call, token).await;
1441
1442 TaskOutcome::BashCommandComplete {
1443 op_id,
1444 command: command_clone,
1445 start_time,
1446 result,
1447 }
1448 });
1449
1450 app.current_op_context = Some(op_context);
1452
1453 Ok(false)
1454 }
1455 AppCommand::Shutdown => {
1456 info!(target: "handle_app_command", "Received Shutdown command.");
1457 app.cancel_current_processing().await;
1458 approval_queue.cancel_all();
1459 *active_agent_event_rx = None;
1460 Ok(true)
1461 }
1462 AppCommand::GetCurrentConversation => {
1463 warn!(target:"handle_app_command", "GetCurrentConversation command received - this should use the sync RPC instead");
1466 Ok(false)
1467 }
1468 AppCommand::RequestToolApprovalInternal {
1469 tool_call,
1470 responder,
1471 } => {
1472 let tool_id = tool_call.id.clone();
1473 let tool_name = tool_call.name.clone();
1474
1475 info!(target: "handle_app_command", "Received internal request for tool approval: '{}' (ID: {})", tool_name, tool_id);
1476
1477 approval_queue.add_request(tool_id, tool_call, responder);
1478 process_next_approval_request(app, approval_queue).await;
1479 Ok(false)
1480 }
1481
1482 AppCommand::ExecuteCommand(cmd) => {
1483 warn!(target: "handle_app_command", "Received ExecuteCommand: {}", cmd.as_command_str());
1484 if active_agent_event_rx.is_some() {
1485 warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to ExecuteCommand.");
1486 *active_agent_event_rx = None;
1487 }
1488 handle_slash_command(app, cmd).await;
1489 Ok(false)
1490 }
1491
1492 AppCommand::RequestWorkspaceFiles => {
1493 info!(target: "app.handle_app_command", "Received RequestWorkspaceFiles command");
1494 app.emit_workspace_files().await;
1495 Ok(false)
1496 }
1497 }
1498}
1499
1500async fn handle_slash_command(app: &mut App, command: AppCommandType) {
1502 let command_str = command.as_command_str();
1503 match app.handle_command(command.clone()).await {
1504 Ok(response_option) => {
1505 if let Some(response) = response_option {
1506 app.emit_event(AppEvent::CommandResponse {
1507 command,
1508 response,
1509 id: format!("cmd_resp_{}", uuid::Uuid::new_v4()),
1510 });
1511 }
1512 }
1513 Err(e) => {
1514 error!(target: "handle_slash_command", "Error running command '{}': {}", command_str, e);
1515 app.emit_event(AppEvent::Error {
1516 message: format!("Command failed: {e}"),
1517 });
1518 }
1519 }
1520}
1521async fn handle_agent_event(app: &mut App, event: AgentEvent) {
1523 debug!(target: "handle_agent_event", "Handling event: {:?}", event);
1524
1525 match event {
1526 AgentEvent::MessageFinal(message) => match message.data {
1527 MessageData::Tool {
1528 ref tool_use_id,
1529 ref result,
1530 } => {
1531 let conversation_guard = app.conversation.lock().await;
1532 let tool_name = conversation_guard
1533 .find_tool_name_by_id(tool_use_id)
1534 .unwrap_or_else(|| "unknown_tool".to_string());
1535 drop(conversation_guard);
1536
1537 let is_error = matches!(result, ToolResult::Error(_));
1538 if is_error {
1539 if let steer_tools::result::ToolResult::Error(e) = result {
1540 let error_message = match e {
1542 steer_tools::ToolError::Execution { message, .. } => message.clone(),
1543 steer_tools::ToolError::Io { message, .. } => message.clone(),
1544 steer_tools::ToolError::InvalidParams(_, msg) => msg.clone(),
1545 _ => e.to_string(),
1546 };
1547
1548 app.emit_event(AppEvent::ToolCallFailed {
1549 id: tool_use_id.clone(),
1550 name: tool_name.clone(),
1551 error: error_message,
1552 model: app.current_model.clone(),
1553 });
1554 }
1555 } else {
1556 app.emit_event(AppEvent::ToolCallCompleted {
1557 id: tool_use_id.clone(),
1558 name: tool_name.clone(),
1559 result: result.clone(),
1560 model: app.current_model.clone(),
1561 });
1562
1563 let mutating_tools =
1564 ["edit", "replace", "bash", "write_file", "multi_edit_file"];
1565 if mutating_tools.contains(&tool_name.as_str()) {
1566 app.emit_event(AppEvent::WorkspaceChanged);
1567 app.emit_workspace_files().await;
1568 }
1569 }
1570 app.add_message(message.clone()).await;
1571 }
1572 MessageData::Assistant { .. } => {
1573 app.add_message(message).await;
1574 }
1575 MessageData::User { .. } => {
1576 warn!(target: "handle_agent_event", "Received MessageFinal for user message. This should not happen.");
1577 }
1578 },
1579 AgentEvent::ExecutingTool {
1580 tool_call_id,
1581 name,
1582 parameters,
1583 } => {
1584 app.emit_event(AppEvent::ToolCallStarted {
1585 id: tool_call_id,
1586 name,
1587 parameters,
1588 model: app.current_model.clone(),
1589 });
1590 }
1591 }
1592}
1593
1594async fn handle_task_outcome(app: &mut App, task_outcome: TaskOutcome) {
1596 match task_outcome {
1597 TaskOutcome::AgentOperationComplete { result } => {
1598 info!(target: "handle_task_outcome", "Standard agent operation task completed processing.");
1599
1600 match result {
1601 Ok(_) => {
1602 info!(target: "handle_task_outcome", "Agent operation task reported success.");
1603 }
1604 Err(e) => {
1605 error!(target: "handle_task_outcome", "Agent operation task reported failure: {}", e);
1606 if !matches!(e, AgentExecutorError::Cancelled) {
1608 app.emit_event(AppEvent::Error {
1609 message: e.to_string(),
1610 });
1611 }
1612 }
1613 }
1614
1615 debug!(target: "handle_task_outcome", "Clearing OpContext for completed standard operation.");
1617 app.current_op_context = None;
1618 }
1619 TaskOutcome::DispatchAgentResult { result } => {
1620 info!(target: "handle_task_outcome", "Dispatch agent operation task completed.");
1621
1622 match result {
1623 Ok(response_text) => {
1624 info!(target: "handle_task_outcome", "Dispatch agent successful.");
1625
1626 app.add_message_from_data(MessageData::Assistant {
1627 content: vec![AssistantContent::Text {
1628 text: format!("Dispatch Agent Result:\n{response_text}"),
1629 }],
1630 })
1631 .await;
1632 }
1633 Err(e) => {
1634 error!(target: "handle_task_outcome", "Dispatch agent failed: {}", e);
1635 app.emit_event(AppEvent::Error {
1636 message: e.to_string(),
1637 });
1638 }
1639 }
1640
1641 debug!(target: "handle_task_outcome", "Clearing OpContext and signaling ProcessingCompleted for dispatch operation.");
1643 app.current_op_context = None;
1644 }
1645 TaskOutcome::BashCommandComplete {
1646 op_id,
1647 command,
1648 start_time,
1649 result,
1650 } => {
1651 info!(target: "handle_task_outcome", "Bash command task completed.");
1652
1653 let elapsed = start_time.elapsed();
1654
1655 match result {
1656 Ok(output) => {
1657 let output_str = output.llm_format();
1659
1660 let (stdout, stderr, exit_code) = parse_bash_output(&output_str);
1662
1663 app.add_message_from_data(MessageData::User {
1664 content: vec![UserContent::CommandExecution {
1665 command: command.clone(),
1666 stdout,
1667 stderr,
1668 exit_code,
1669 }],
1670 })
1671 .await;
1672
1673 app.emit_event(AppEvent::Finished {
1675 id: op_id,
1676 outcome: OperationOutcome::Bash {
1677 elapsed,
1678 result: Ok(()),
1679 },
1680 });
1681
1682 app.emit_event(AppEvent::WorkspaceChanged);
1684 app.emit_workspace_files().await;
1685 }
1686 Err(e) => {
1687 error!(target: "handle_task_outcome", "Failed to execute bash command: {}", e);
1688
1689 if matches!(e, steer_tools::ToolError::Cancelled(_)) {
1691 app.emit_event(AppEvent::Finished {
1693 id: op_id,
1694 outcome: OperationOutcome::Bash {
1695 elapsed,
1696 result: Err(BashError {
1697 exit_code: -130, stderr: "Cancelled".to_string(),
1699 }),
1700 },
1701 });
1702 } else {
1703 let parent_id = app.get_active_message_id().await;
1705 let error_message = format!("Error executing command: {e}");
1706 let message = Message {
1707 data: MessageData::User {
1708 content: vec![UserContent::CommandExecution {
1709 command: command.clone(),
1710 stdout: String::new(),
1711 stderr: error_message.clone(),
1712 exit_code: -1,
1713 }],
1714 },
1715 timestamp: Message::current_timestamp(),
1716 id: Message::generate_id("user", Message::current_timestamp()),
1717 parent_message_id: parent_id,
1718 };
1719 app.add_message(message).await;
1720
1721 app.emit_event(AppEvent::Finished {
1723 id: op_id,
1724 outcome: OperationOutcome::Bash {
1725 elapsed,
1726 result: Err(BashError {
1727 exit_code: -1,
1728 stderr: error_message,
1729 }),
1730 },
1731 });
1732
1733 app.emit_event(AppEvent::WorkspaceChanged);
1735 app.emit_workspace_files().await;
1736 }
1737 }
1738 }
1739
1740 debug!(target: "handle_task_outcome", "Clearing OpContext and signaling ProcessingCompleted for bash operation.");
1741 app.current_op_context = None;
1742 }
1743 }
1744}
1745
1746async fn create_system_prompt_with_workspace(
1747 model: Option<ModelId>,
1748 workspace: &dyn crate::workspace::Workspace,
1749) -> Result<String> {
1750 let env_info = workspace.environment().await?;
1751
1752 let system_prompt_body = if let Some(model_id) = model {
1754 get_model_system_prompt(model_id)
1755 } else {
1756 crate::prompts::default_system_prompt()
1757 };
1758
1759 let prompt = format!(
1760 r#"{}
1761
1762{}"#,
1763 system_prompt_body,
1764 env_info.as_context()
1765 );
1766 Ok(prompt)
1767}
1768
1769fn get_model_system_prompt(model_id: ModelId) -> String {
1770 use crate::config::model::builtin;
1771
1772 if model_id == builtin::gpt_5_2025_08_07() {
1774 crate::prompts::gpt5_system_prompt()
1775 } else if model_id == builtin::o3_2025_04_16()
1776 || model_id == builtin::gpt_4_1_2025_04_14()
1777 || model_id == builtin::o4_mini_2025_04_16()
1778 || model_id == builtin::grok_4_0709()
1779 {
1780 crate::prompts::o3_system_prompt()
1781 } else if model_id.0 == crate::config::provider::google()
1782 && (model_id.1.starts_with("gemini-2.5") || model_id.1.starts_with("gemini-"))
1783 {
1784 crate::prompts::gemini_system_prompt()
1785 } else if model_id == builtin::claude_sonnet_4_20250514()
1786 || model_id == builtin::claude_opus_4_20250514()
1787 || model_id == builtin::claude_opus_4_1_20250805()
1788 {
1789 crate::prompts::claude_system_prompt()
1790 } else {
1791 crate::prompts::default_system_prompt()
1792 }
1793}
1794
1795fn parse_bash_output(output: &str) -> (String, String, i32) {
1796 if output.starts_with("Command failed with exit code") {
1798 let mut stdout = String::new();
1801 let mut stderr = String::new();
1802 let mut exit_code = -1;
1803
1804 let lines: Vec<&str> = output.lines().collect();
1805 let mut i = 0;
1806
1807 if let Some(first_line) = lines.first() {
1809 if let Some(code_str) = first_line.strip_prefix("Command failed with exit code ") {
1810 exit_code = code_str.parse().unwrap_or(-1);
1811 }
1812 }
1813
1814 while i < lines.len() {
1816 if lines[i] == "--- STDOUT ---" {
1817 i += 1;
1818 while i < lines.len() && lines[i] != "--- STDERR ---" {
1819 if !stdout.is_empty() {
1820 stdout.push('\n');
1821 }
1822 stdout.push_str(lines[i]);
1823 i += 1;
1824 }
1825 } else if lines[i] == "--- STDERR ---" {
1826 i += 1;
1827 while i < lines.len() {
1828 if !stderr.is_empty() {
1829 stderr.push('\n');
1830 }
1831 stderr.push_str(lines[i]);
1832 i += 1;
1833 }
1834 } else {
1835 i += 1;
1836 }
1837 }
1838
1839 (stdout, stderr, exit_code)
1840 } else {
1841 (output.to_string(), String::new(), 0)
1843 }
1844}
1845
1846#[cfg(test)]
1847mod pattern_tests {
1848 use super::*;
1849
1850 #[test]
1851 fn test_matches_any_pattern_exact_match() {
1852 let patterns = vec!["git status".to_string(), "git log".to_string()];
1853
1854 assert!(matches_any_pattern("git status", &patterns));
1855 assert!(matches_any_pattern("git log", &patterns));
1856 assert!(!matches_any_pattern("git push", &patterns));
1857 }
1858
1859 #[test]
1860 fn test_matches_any_pattern_glob_patterns() {
1861 let patterns = vec![
1862 "git *".to_string(),
1863 "npm run*".to_string(),
1864 "cargo test*".to_string(),
1865 ];
1866
1867 assert!(matches_any_pattern("git status", &patterns));
1869 assert!(matches_any_pattern("git log -10", &patterns));
1870 assert!(matches_any_pattern("npm run test", &patterns));
1871 assert!(matches_any_pattern("npm run build", &patterns));
1872 assert!(matches_any_pattern("cargo test", &patterns));
1873 assert!(matches_any_pattern("cargo test --all", &patterns));
1874
1875 assert!(!matches_any_pattern("npm install", &patterns));
1877 assert!(!matches_any_pattern("cargo build", &patterns));
1878 assert!(!matches_any_pattern("ls -la", &patterns));
1879 }
1880
1881 #[test]
1882 fn test_matches_any_pattern_complex_patterns() {
1883 let patterns = vec![
1884 "docker run*".to_string(),
1885 "kubectl apply -f*".to_string(),
1886 "terraform *".to_string(),
1887 ];
1888
1889 assert!(matches_any_pattern("docker run nginx", &patterns));
1891 assert!(matches_any_pattern("docker run -it ubuntu bash", &patterns));
1892 assert!(matches_any_pattern(
1893 "kubectl apply -f deployment.yaml",
1894 &patterns
1895 ));
1896 assert!(matches_any_pattern("terraform plan", &patterns));
1897 assert!(matches_any_pattern("terraform apply", &patterns));
1898
1899 assert!(!matches_any_pattern("docker ps", &patterns));
1901 assert!(!matches_any_pattern("kubectl get pods", &patterns));
1902 assert!(!matches_any_pattern("git status", &patterns));
1903 }
1904
1905 #[test]
1906 fn test_matches_any_pattern_empty_patterns() {
1907 let patterns: Vec<String> = vec![];
1908 assert!(!matches_any_pattern("git status", &patterns));
1909 }
1910
1911 #[test]
1912 fn test_matches_any_pattern_special_chars() {
1913 let patterns = vec![
1914 "echo \"hello world\"".to_string(),
1915 "python -c 'print(\"test\")'".to_string(),
1916 "ls | grep .txt".to_string(),
1917 ];
1918
1919 assert!(matches_any_pattern("echo \"hello world\"", &patterns));
1921 assert!(matches_any_pattern(
1922 "python -c 'print(\"test\")'",
1923 &patterns
1924 ));
1925 assert!(matches_any_pattern("ls | grep .txt", &patterns));
1926
1927 assert!(!matches_any_pattern("echo hello world", &patterns));
1929 }
1930}