1mod config_watch;
35mod middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::collections::VecDeque;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::time::Instant;
43
44use tokio::sync::mpsc;
45
46use crate::app::{Config, MemoryConfig};
47use crate::domain::{
48 Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
49};
50use crate::models::{ModelError, TokenUsage};
51use crate::providers::ctx::{ExecContext, StreamContext};
52use crate::providers::model::ModelProvider;
53use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
54
55pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
56pub use turn_scope::TurnScope;
57
58#[cfg(not(test))]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
60#[cfg(test)]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
62
63const CANCELLED_TOMBSTONE_CAP: usize = 256;
69
70pub type MsgSender = mpsc::Sender<Msg>;
76
77pub const MSG_CHANNEL_CAPACITY: usize = 512;
83
84pub struct EffectRunner {
87 msg_tx: MsgSender,
88 scopes: HashMap<TurnId, TurnScope>,
94 cancelled_turns: VecDeque<TurnId>,
100 detached: tokio::task::JoinSet<()>,
104 workdir: PathBuf,
108 providers: Option<Arc<ProviderFactory>>,
113 tools: Option<Arc<ToolRegistry>>,
116 task_id: Option<String>,
118 terminal_title_enabled: bool,
122 approval: Option<crate::providers::ApprovalBroker>,
126 config_watch: Option<tokio::task::AbortHandle>,
130}
131
132impl EffectRunner {
133 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
135 Self {
136 msg_tx,
137 scopes: HashMap::new(),
138 cancelled_turns: VecDeque::new(),
139 detached: tokio::task::JoinSet::new(),
140 workdir,
141 providers: None,
142 tools: None,
143 task_id: None,
144 terminal_title_enabled: true,
145 approval: None,
146 config_watch: None,
147 }
148 }
149
150 pub fn with_interactive_approvals(mut self) -> Self {
154 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
155 self
156 }
157
158 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
164 let handle = self.detached.spawn(config_watch::config_watcher(
165 self.msg_tx.clone(),
166 cwd,
167 memory,
168 ));
169 self.config_watch = Some(handle);
170 }
171
172 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
175 self.task_id = task_id;
176 self
177 }
178
179 pub fn without_terminal_title(mut self) -> Self {
181 self.terminal_title_enabled = false;
182 self
183 }
184
185 pub fn with_bindings(
190 mut self,
191 providers: Arc<ProviderFactory>,
192 tools: Arc<ToolRegistry>,
193 ) -> Self {
194 self.providers = Some(providers);
195 self.tools = Some(tools);
196 self
197 }
198
199 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
203 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
204 (Self::new(tx, workdir), rx)
205 }
206
207 pub fn pair_with_bindings(
210 workdir: PathBuf,
211 config: Config,
212 tools: Arc<ToolRegistry>,
213 ) -> (Self, mpsc::Receiver<Msg>) {
214 let providers = Arc::new(ProviderFactory::new(config));
215 Self::pair_from(workdir, providers, tools)
216 }
217
218 pub fn pair_from(
223 workdir: PathBuf,
224 providers: Arc<ProviderFactory>,
225 tools: Arc<ToolRegistry>,
226 ) -> (Self, mpsc::Receiver<Msg>) {
227 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
228 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
229 }
230
231 pub fn pair_from_with_task(
232 workdir: PathBuf,
233 providers: Arc<ProviderFactory>,
234 tools: Arc<ToolRegistry>,
235 task_id: Option<String>,
236 ) -> (Self, mpsc::Receiver<Msg>) {
237 let (runner, rx) = Self::pair_from(workdir, providers, tools);
238 (runner.with_task_id(task_id), rx)
239 }
240
241 pub fn new_child(
246 msg_tx: MsgSender,
247 workdir: PathBuf,
248 providers: Arc<ProviderFactory>,
249 tools: Arc<ToolRegistry>,
250 ) -> Self {
251 Self::new(msg_tx, workdir)
256 .with_bindings(providers, tools)
257 .without_terminal_title()
258 }
259
260 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
264 self.scopes
265 .entry(turn)
266 .or_insert_with(|| TurnScope::new(turn))
267 }
268
269 fn tombstone_turn(&mut self, turn: TurnId) {
273 if self.cancelled_turns.contains(&turn) {
274 return;
275 }
276 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
277 self.cancelled_turns.pop_front();
278 }
279 self.cancelled_turns.push_back(turn);
280 }
281
282 fn is_tombstoned(&self, turn: TurnId) -> bool {
285 self.cancelled_turns.contains(&turn)
286 }
287
288 fn drop_scope(&mut self, turn: TurnId) {
298 self.tombstone_turn(turn);
303 if let Some(mut scope) = self.scopes.remove(&turn) {
304 scope.cancel();
305 let tx = self.msg_tx.clone();
306 self.detached.spawn(async move {
307 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
308 .await
309 .is_err()
310 {
311 tracing::warn!(
312 turn = %turn,
313 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
314 "cancel drain timed out; aborting remaining scoped tasks"
315 );
316 }
317 let _ = tx.send(Msg::TurnCancelled(turn)).await;
318 });
319 } else {
320 let tx = self.msg_tx.clone();
327 self.detached.spawn(async move {
328 let _ = tx.send(Msg::TurnCancelled(turn)).await;
329 });
330 }
331 }
332
333 pub fn scope_count(&self) -> usize {
336 self.scopes.len()
337 }
338
339 fn reap_empty_scopes(&mut self) {
348 self.reap_detached();
349 self.scopes.retain(|_, scope| {
350 scope.drain_completed();
351 !scope.is_empty()
352 });
353 }
354
355 fn reap_detached(&mut self) {
360 while let Some(result) = self.detached.try_join_next() {
361 if let Err(e) = result
362 && !e.is_cancelled()
363 {
364 tracing::warn!(error = %e, "effect: detached task panicked");
365 }
366 }
367 }
368
369 pub fn dispatch(&mut self, cmd: Cmd) {
373 self.reap_empty_scopes();
376 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
377
378 if let Some(turn) = cmd.scope_turn()
386 && self.is_tombstoned(turn)
387 {
388 tracing::debug!(
389 cmd = %cmd.summary(),
390 turn = %turn,
391 "effect: dropping turn-scoped cmd for an already-cancelled turn"
392 );
393 return;
394 }
395
396 match cmd {
397 Cmd::CallModel { turn, mut request } => {
398 let tx = self.msg_tx.clone();
399 let providers = self.providers.clone();
400 if let Some(tools) = &self.tools {
407 let mut enriched = tools.describe_all();
408 let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
412 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
418 tracing::debug!(
419 error = %e,
420 "effect: dropped builtin tool-schema token estimate (channel full); \
421 /context preview may be briefly stale"
422 );
423 }
424 enriched.append(&mut request.tools);
425 request.tools = enriched;
426 }
427 self.detached.spawn(fire_plugin_hooks(
430 "prompt_submit",
431 serde_json::json!({
432 "turn_id": turn.0,
433 "model_id": request.model_id.clone(),
434 "message_count": request.messages.len(),
435 "tool_count": request.tools.len(),
436 }),
437 ));
438 let scope = self.scope_mut(turn);
439 let token = scope.token();
440 scope.spawn(async move {
441 use futures::FutureExt;
442 let fallback_tx = tx.clone();
443 if std::panic::AssertUnwindSafe(dispatch_call_model(
444 tx, providers, turn, request, token,
445 ))
446 .catch_unwind()
447 .await
448 .is_err()
449 {
450 tracing::error!(turn = %turn, "dispatch_call_model panicked");
455 let _ = fallback_tx
456 .send(Msg::UpstreamError {
457 turn,
458 error: crate::models::UserFacingError {
459 summary: "Internal error".to_string(),
460 message: "The model dispatch task panicked unexpectedly."
461 .to_string(),
462 suggestion: "This is a bug. Please retry; if it persists, \
463 check the logs."
464 .to_string(),
465 category: crate::models::ErrorCategory::Internal,
466 recoverable: true,
467 },
468 })
469 .await;
470 }
471 });
472 },
473 Cmd::CompactConversation { turn, mut request } => {
474 let tx = self.msg_tx.clone();
475 let providers = self.providers.clone();
476 if let Some(tools) = &self.tools {
477 let mut enriched = tools.describe_all();
478 enriched.append(&mut request.chat.tools);
479 request.chat.tools = enriched;
480 }
481 let trigger = request.trigger;
484 let scope = self.scope_mut(turn);
485 let token = scope.token();
486 scope.spawn(async move {
487 use futures::FutureExt;
488 let fallback_tx = tx.clone();
489 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
490 tx, providers, turn, request, token,
491 ))
492 .catch_unwind()
493 .await
494 .is_err()
495 {
496 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
502 let _ = fallback_tx
503 .send(Msg::CompactionFailed {
504 turn,
505 trigger,
506 message: "the compaction task panicked unexpectedly".to_string(),
507 kind: crate::domain::StatusKind::Error,
508 })
509 .await;
510 }
511 });
512 },
513 Cmd::ExecuteTool {
514 turn,
515 call_id,
516 source,
517 model_id,
518 safety_mode,
519 intent,
520 } => {
521 let tx = self.msg_tx.clone();
522 let tools = self.tools.clone();
523 let workdir = self.workdir.clone();
524 let config = self
529 .providers
530 .as_ref()
531 .map(|p| Arc::new(p.config().clone()))
532 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
533 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
538 if safety_mode == crate::runtime::SafetyMode::Auto {
539 self.providers.as_ref().map(|p| {
540 let model = config
541 .safety
542 .auto_classifier_model
543 .clone()
544 .unwrap_or_else(|| model_id.clone());
545 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
546 as Arc<dyn crate::providers::AutoClassifier>
547 })
548 } else {
549 None
550 };
551 let task_id = self.task_id.clone();
552 let approval = self.approval.clone();
553 let scope = self.scope_mut(turn);
554 let token = scope.token();
555 let background = scope.background_token();
556 scope.spawn(async move {
557 use futures::FutureExt;
558 let fallback_tx = tx.clone();
559 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
560 tx,
561 tools,
562 workdir,
563 turn,
564 call_id,
565 source,
566 token,
567 background,
568 config,
569 model_id,
570 task_id,
571 safety_mode,
572 intent,
573 classifier,
574 approval,
575 ))
576 .catch_unwind()
577 .await
578 .is_err()
579 {
580 tracing::error!(
585 turn = %turn,
586 call_id = call_id.0,
587 "dispatch_execute_tool panicked"
588 );
589 let _ = fallback_tx
590 .send(Msg::ToolFinished {
591 turn,
592 call_id,
593 outcome: crate::domain::ToolOutcome::error(
594 "internal error: the tool execution task panicked".to_string(),
595 0.0,
596 ),
597 })
598 .await;
599 }
600 });
601 },
602 Cmd::ResolveApproval { call_id, decision } => {
603 if let Some(broker) = &self.approval {
606 broker.resolve(call_id, decision.into());
607 }
608 },
609 Cmd::CancelScope(turn) => {
610 self.drop_scope(turn);
611 },
612 Cmd::BackgroundScope(turn) => {
613 self.scope_mut(turn).background();
617 },
618 Cmd::SaveConversation(history) => {
619 let tx = self.msg_tx.clone();
620 let workdir = self.workdir.clone();
621 self.detached.spawn(async move {
622 if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
623 && manager.save_conversation(&history).is_ok()
624 {
625 let _ = tx.send(Msg::SessionSaved).await;
626 } else {
627 tracing::warn!("SaveConversation: failed to write to disk");
628 }
629 });
630 },
631 Cmd::SaveCompactionArchive {
632 archive,
633 record,
634 conversation,
635 } => {
636 let tx = self.msg_tx.clone();
637 let workdir = self.workdir.clone();
638 let task_id = self.task_id.clone();
639 self.detached.spawn(async move {
640 let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
641 return;
642 };
643 match manager.save_compaction_archive(&archive) {
648 Ok(path) => {
649 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
650 let compaction_id = record.id.clone();
651 let conversation_id = archive.conversation_id.clone();
652 let _ =
653 store.compactions().create(crate::runtime::NewCompaction {
654 id: Some(record.id),
655 task_id: task_id.clone(),
656 session_id: Some(archive.conversation_id),
657 source_token_estimate: Some(record.before_tokens as i64),
658 summary_token_count: Some(record.summary_tokens as i64),
659 preserved_turns: Some(record.preserved_message_count as i64),
660 archive_path: Some(path.display().to_string()),
661 verification_status: Some("verified".to_string()),
662 });
663 fire_plugin_hooks(
664 "compaction",
665 serde_json::json!({
666 "id": compaction_id,
667 "task_id": task_id.clone(),
668 "session_id": conversation_id,
669 "archive_path": path.display().to_string(),
670 }),
671 )
672 .await;
673 }
674 if manager.save_conversation(&conversation).is_ok() {
675 let _ = tx.send(Msg::SessionSaved).await;
676 } else {
677 tracing::warn!(
678 "SaveCompactionArchive: archive persisted but conversation save failed"
679 );
680 }
681 },
682 Err(err) => {
683 tracing::warn!(
684 error = %err,
685 "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
686 );
687 },
688 }
689 });
690 },
691 Cmd::SaveProcess(process) => {
692 let task_id = self.task_id.clone();
693 self.detached.spawn(async move {
694 let status = match process.status {
695 crate::domain::ManagedProcessStatus::Running => {
696 crate::runtime::ProcessStatus::Running
697 },
698 crate::domain::ManagedProcessStatus::Exited => {
699 crate::runtime::ProcessStatus::Exited
700 },
701 crate::domain::ManagedProcessStatus::Unknown => {
702 crate::runtime::ProcessStatus::Unknown
703 },
704 };
705 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
706 let _ = store.processes().upsert(crate::runtime::NewProcess {
707 id: Some(process.id),
708 task_id,
709 pid: process.pid,
710 command: process.command,
711 cwd: process.cwd,
712 log_path: Some(process.log_path),
713 detected_url: process.detected_url,
714 status,
715 health: None,
716 });
717 }
718 });
719 },
720 Cmd::PersistLastModel(model) => {
721 self.detached.spawn(async move {
722 let _ = crate::app::persist_last_model(&model);
723 });
724 },
725 Cmd::PersistReasoningFor { model_id, level } => {
726 self.detached.spawn(async move {
727 let _ = crate::app::persist_reasoning_for_model(&model_id, level);
728 });
729 },
730 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
731 self.detached.spawn(async move {
732 let _ = crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx);
733 });
734 },
735 Cmd::PersistOllamaOffload(enabled) => {
736 self.detached.spawn(async move {
737 let _ = crate::app::persist_ollama_allow_ram_offload(enabled);
738 });
739 },
740 Cmd::RefreshInstructions => {
741 let tx = self.msg_tx.clone();
742 let workdir = self.workdir.clone();
743 self.detached.spawn(async move {
744 let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
745 let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
746 });
747 },
748 Cmd::RefreshMemory => {
749 let tx = self.msg_tx.clone();
750 let workdir = self.workdir.clone();
751 self.detached.spawn(async move {
752 let cfg = crate::app::load_config().unwrap_or_default().memory;
753 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
754 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
755 });
756 },
757 Cmd::ListMemory => {
758 let tx = self.msg_tx.clone();
759 let workdir = self.workdir.clone();
760 self.detached.spawn(async move {
761 let cfg = crate::app::load_config().unwrap_or_default().memory;
762 let text = match crate::app::memory::load(&workdir, &cfg) {
763 Some(mem) => mem.index,
764 None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
765 };
766 let _ = tx.send(Msg::RuntimeText(text)).await;
767 });
768 },
769 Cmd::RememberMemory { text } => {
770 let tx = self.msg_tx.clone();
771 let workdir = self.workdir.clone();
772 self.detached.spawn(async move {
773 let cfg = crate::app::load_config().unwrap_or_default().memory;
774 let name = memory_title_from_text(&text);
775 let (status, kind) = match crate::app::memory::write_memory(
776 &workdir,
777 crate::app::memory::MemoryScope::ProjectPrivate,
778 &name,
779 &text,
780 &[],
781 &text,
782 ) {
783 Ok(_) => (
784 format!("Remembered: {name}"),
785 crate::domain::StatusKind::Info,
786 ),
787 Err(e) => (
788 format!("Couldn't save memory: {e}"),
789 crate::domain::StatusKind::Error,
790 ),
791 };
792 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
793 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
794 let _ = tx
795 .send(Msg::TransientStatus {
796 text: status,
797 kind,
798 dismiss_ms: 3_000,
799 })
800 .await;
801 });
802 },
803 Cmd::ForgetMemory { id } => {
804 let tx = self.msg_tx.clone();
805 let workdir = self.workdir.clone();
806 self.detached.spawn(async move {
807 let cfg = crate::app::load_config().unwrap_or_default().memory;
808 let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
809 Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
810 Ok(None) => (
811 format!("No memory named '{id}'"),
812 crate::domain::StatusKind::Info,
813 ),
814 Err(e) => (
815 format!("Couldn't forget memory: {e}"),
816 crate::domain::StatusKind::Error,
817 ),
818 };
819 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
820 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
821 let _ = tx
822 .send(Msg::TransientStatus {
823 text: status,
824 kind,
825 dismiss_ms: 3_000,
826 })
827 .await;
828 });
829 },
830 Cmd::ConsolidateMemory { model_id } => {
831 let tx = self.msg_tx.clone();
832 let workdir = self.workdir.clone();
833 let providers = self.providers.clone();
834 self.detached.spawn(async move {
835 consolidate_memory(tx, providers, workdir, model_id).await;
836 });
837 },
838 Cmd::LoadConversation(id) => {
839 let tx = self.msg_tx.clone();
840 let workdir = self.workdir.clone();
841 self.detached.spawn(async move {
842 match crate::session::ConversationManager::new(&workdir) {
843 Ok(mgr) => match mgr.load_conversation(&id) {
844 Ok(history) => {
845 let _ = tx.send(Msg::ConversationLoaded(history)).await;
846 },
847 Err(e) => {
848 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
849 },
850 },
851 Err(e) => {
852 tracing::warn!(error = %e, "ConversationManager init failed");
853 },
854 }
855 });
856 },
857 Cmd::ListConversations => {
858 let tx = self.msg_tx.clone();
859 let workdir = self.workdir.clone();
860 self.detached.spawn(async move {
861 let summaries = match crate::session::ConversationManager::new(&workdir) {
862 Ok(mgr) => mgr
863 .list_conversations()
864 .unwrap_or_default()
865 .into_iter()
866 .map(|h| crate::domain::ConversationSummary {
867 id: h.id.clone(),
868 title: h.title.clone(),
869 message_count: h.messages.len(),
870 updated_at: h.updated_at.to_rfc3339(),
871 })
872 .collect(),
873 Err(_) => Vec::new(),
874 };
875 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
876 });
877 },
878 Cmd::ListRuntimeTasks { limit } => {
879 let tx = self.msg_tx.clone();
880 self.detached.spawn_blocking(move || {
883 let tasks = crate::runtime::RuntimeClient::auto()
884 .list_tasks(limit)
885 .map(|read| read.value)
886 .unwrap_or_default();
887 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
888 });
889 },
890 Cmd::LoadRuntimeTask { id } => {
891 let tx = self.msg_tx.clone();
892 self.detached.spawn_blocking(move || {
893 let (task, events) = crate::runtime::RuntimeClient::auto()
894 .task_detail(&id)
895 .map(|read| (Some(read.value.task), read.value.events))
896 .unwrap_or((None, Vec::new()));
897 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
898 });
899 },
900 Cmd::ListRuntimeProcesses { limit } => {
901 let tx = self.msg_tx.clone();
902 self.detached.spawn_blocking(move || {
903 let processes = crate::runtime::RuntimeClient::auto()
904 .list_processes(limit)
905 .map(|read| read.value)
906 .unwrap_or_default();
907 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
908 });
909 },
910 Cmd::ShowRuntimeProcessLogs { id } => {
911 let tx = self.msg_tx.clone();
912 self.detached.spawn_blocking(move || {
913 let text = crate::runtime::RuntimeClient::auto()
914 .process_log(&id, None)
915 .map(|log| format!("Process log {}\n\n{}", id, log.content))
916 .unwrap_or_else(|err| format!("Process log error: {}", err));
917 let _ = tx.blocking_send(Msg::RuntimeText(text));
918 });
919 },
920 Cmd::StopRuntimeProcess { id } => {
921 let tx = self.msg_tx.clone();
922 self.detached.spawn_blocking(move || {
923 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
924 Ok(response) => Msg::TransientStatus {
925 text: format!("Stopped process {} (pid {})", id, response.item.pid),
926 kind: crate::domain::StatusKind::Info,
927 dismiss_ms: 3_000,
928 },
929 Err(err) => Msg::TransientStatus {
930 text: format!("Process stop failed: {}", err),
931 kind: crate::domain::StatusKind::Warn,
932 dismiss_ms: 5_000,
933 },
934 };
935 let _ = tx.blocking_send(msg);
936 });
937 },
938 Cmd::RestartRuntimeProcess { id } => {
939 let tx = self.msg_tx.clone();
940 self.detached.spawn_blocking(move || {
941 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
942 Ok(response) => Msg::TransientStatus {
943 text: format!("Restarted process {} (pid {})", id, response.item.pid),
944 kind: crate::domain::StatusKind::Info,
945 dismiss_ms: 3_000,
946 },
947 Err(err) => Msg::TransientStatus {
948 text: format!("Process restart failed: {}", err),
949 kind: crate::domain::StatusKind::Warn,
950 dismiss_ms: 5_000,
951 },
952 };
953 let _ = tx.blocking_send(msg);
954 });
955 },
956 Cmd::OpenRuntimeTarget { target } => {
957 self.detached.spawn_blocking(move || {
958 let resolved = crate::runtime::RuntimeService::open_default()
959 .and_then(|service| service.resolve_open_target(&target))
960 .unwrap_or(target);
961 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
965 tracing::warn!(error = %err, "refusing to open runtime target");
966 return;
967 }
968 crate::utils::open_file(resolved);
969 });
970 },
971 Cmd::ShowRuntimePorts => {
972 let tx = self.msg_tx.clone();
973 self.detached.spawn_blocking(move || {
974 let text = crate::runtime::RuntimeClient::auto()
975 .ports()
976 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
977 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
978 let _ = tx.blocking_send(Msg::RuntimeText(text));
979 });
980 },
981 Cmd::ListRuntimeApprovals => {
982 let tx = self.msg_tx.clone();
983 self.detached.spawn_blocking(move || {
984 let approvals = crate::runtime::RuntimeClient::auto()
985 .list_approvals()
986 .map(|read| read.value)
987 .unwrap_or_default();
988 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
989 });
990 },
991 Cmd::DecideRuntimeApproval { id, decision } => {
992 let tx = self.msg_tx.clone();
993 self.detached.spawn_blocking(move || {
994 let result = if decision == "approved" {
995 crate::runtime::RuntimeClient::auto().approve(&id)
996 } else {
997 crate::runtime::RuntimeClient::auto().deny(&id)
998 };
999 let msg = match result {
1000 Ok(result) => Msg::TransientStatus {
1001 text: if result.replayed {
1002 format!("Approval {} {}: {}", id, decision, result.summary)
1003 } else {
1004 format!("Approval {} {}", id, decision)
1005 },
1006 kind: crate::domain::StatusKind::Info,
1007 dismiss_ms: 3_000,
1008 },
1009 Err(err) => Msg::TransientStatus {
1010 text: format!("Approval update failed: {}", err),
1011 kind: crate::domain::StatusKind::Warn,
1012 dismiss_ms: 5_000,
1013 },
1014 };
1015 let _ = tx.blocking_send(msg);
1016 });
1017 },
1018 Cmd::ListRuntimeCheckpoints { limit } => {
1019 let tx = self.msg_tx.clone();
1020 self.detached.spawn_blocking(move || {
1021 let checkpoints = crate::runtime::RuntimeClient::auto()
1022 .list_checkpoints(limit)
1023 .map(|read| read.value)
1024 .unwrap_or_default();
1025 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1026 });
1027 },
1028 Cmd::ListRuntimePlugins => {
1029 let tx = self.msg_tx.clone();
1030 self.detached.spawn_blocking(move || {
1031 let plugins = crate::runtime::RuntimeClient::auto()
1032 .list_plugins()
1033 .map(|read| read.value)
1034 .unwrap_or_default();
1035 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1036 });
1037 },
1038 Cmd::UpdateRuntimeTaskStatus {
1039 id,
1040 status,
1041 final_report,
1042 } => {
1043 let tx = self.msg_tx.clone();
1044 self.detached.spawn_blocking(move || {
1045 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1046 store
1047 .tasks()
1048 .update_status(&id, status, final_report.as_deref())
1049 }) {
1050 Ok(()) => Msg::TransientStatus {
1051 text: format!("Task {} -> {}", id, status),
1052 kind: crate::domain::StatusKind::Info,
1053 dismiss_ms: 3_000,
1054 },
1055 Err(err) => Msg::TransientStatus {
1056 text: format!("Task update failed: {}", err),
1057 kind: crate::domain::StatusKind::Warn,
1058 dismiss_ms: 4_000,
1059 },
1060 };
1061 let _ = tx.blocking_send(msg);
1062 });
1063 },
1064 Cmd::CreateRuntimeCheckpoint { paths } => {
1065 let tx = self.msg_tx.clone();
1066 let workdir = self.workdir.clone();
1067 self.detached.spawn_blocking(move || {
1068 let pending_action = Some(serde_json::json!({
1069 "source": "tui",
1070 "command": "checkpoint",
1071 }));
1072 let msg =
1073 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1074 Ok(manifest) => Msg::TransientStatus {
1075 text: format!(
1076 "Checkpoint {} created for {} path(s)",
1077 manifest.id,
1078 manifest.files.len()
1079 ),
1080 kind: crate::domain::StatusKind::Info,
1081 dismiss_ms: 4_000,
1082 },
1083 Err(err) => Msg::TransientStatus {
1084 text: format!("Checkpoint failed: {}", err),
1085 kind: crate::domain::StatusKind::Warn,
1086 dismiss_ms: 5_000,
1087 },
1088 };
1089 let _ = tx.blocking_send(msg);
1090 });
1091 },
1092 Cmd::RestoreRuntimeCheckpoint { id } => {
1093 let tx = self.msg_tx.clone();
1094 self.detached.spawn_blocking(move || {
1095 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1096 Ok(result) => Msg::TransientStatus {
1097 text: format!(
1098 "Restored checkpoint {} ({} file(s)){}",
1099 result.checkpoint.id,
1100 result.checkpoint.files.len(),
1101 if result.checkpoint.pending_action.is_some() {
1102 "; pending action available in checkpoint manifest"
1103 } else {
1104 ""
1105 }
1106 ),
1107 kind: crate::domain::StatusKind::Info,
1108 dismiss_ms: 4_000,
1109 },
1110 Err(err) => Msg::TransientStatus {
1111 text: format!("Restore failed: {}", err),
1112 kind: crate::domain::StatusKind::Warn,
1113 dismiss_ms: 5_000,
1114 },
1115 };
1116 let _ = tx.blocking_send(msg);
1117 });
1118 },
1119 Cmd::ShowRuntimeModelInfo { model } => {
1120 let tx = self.msg_tx.clone();
1121 self.detached.spawn_blocking(move || {
1122 let text = runtime_model_info_text(&model);
1123 let _ = tx.blocking_send(Msg::RuntimeText(text));
1124 });
1125 },
1126 Cmd::InitMcpServers(configs) => {
1127 let tx = self.msg_tx.clone();
1128 self.detached.spawn(async move {
1129 if configs.is_empty() {
1130 return;
1131 }
1132 crate::mcp::manager_ref::mark_init_started();
1133 let manager =
1134 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1135 for (name, _cfg) in configs.iter() {
1139 let server_tools: Vec<crate::domain::McpToolSpec> = manager
1140 .get_all_tools()
1141 .iter()
1142 .filter(|(server, _)| server == name)
1143 .map(|(_, def)| crate::domain::McpToolSpec {
1144 name: def.name.clone(),
1145 description: def.description.clone(),
1146 input_schema: def.input_schema.clone(),
1147 })
1148 .collect();
1149 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1150 let _ = tx.send(msg).await;
1151 }
1152 crate::mcp::manager_ref::set_manager(manager);
1153 crate::mcp::manager_ref::mark_init_complete();
1154 });
1155 },
1156 Cmd::StopMcpServer { name } => {
1157 let tx = self.msg_tx.clone();
1158 self.detached.spawn(async move {
1159 if let Some(mgr) = crate::mcp::manager_ref::get() {
1162 mgr.stop_server(&name).await;
1163 }
1164 let _ = tx.send(Msg::McpServerStopped { name }).await;
1165 });
1166 },
1167 Cmd::PullOllamaModel { model } => {
1168 let tx = self.msg_tx.clone();
1169 self.detached.spawn(async move {
1170 dispatch_pull_ollama_model(tx, model).await;
1171 });
1172 },
1173 Cmd::OpenInSystem(path) => {
1174 self.detached.spawn(async move {
1175 let _ = tokio::task::spawn_blocking(move || {
1176 crate::utils::open_file(&path);
1177 })
1178 .await;
1179 });
1180 },
1181 Cmd::DismissStatusAfter { ms } => {
1182 let tx = self.msg_tx.clone();
1183 self.detached.spawn(async move {
1184 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1185 let _ = tx.send(Msg::StatusDismiss).await;
1186 });
1187 },
1188 Cmd::WriteImageToTemp {
1189 path,
1190 bytes,
1191 format: _,
1192 } => {
1193 self.detached.spawn(async move {
1194 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1195 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1196 }
1197 });
1198 },
1199 Cmd::ReadClipboard => {
1200 let tx = self.msg_tx.clone();
1201 self.detached.spawn(async move {
1202 dispatch_read_clipboard(tx).await;
1203 });
1204 },
1205 Cmd::CopyToClipboard(text) => {
1206 let tx = self.msg_tx.clone();
1207 self.detached.spawn(async move {
1208 dispatch_copy_to_clipboard(text, tx).await;
1209 });
1210 },
1211 Cmd::Exit => {
1212 },
1217 Cmd::SetTerminalTitle(title) => {
1218 if !self.terminal_title_enabled {
1219 return;
1220 }
1221 self.detached.spawn_blocking(move || {
1227 use std::io::Write;
1228 let seq = format!("\x1b]2;{}\x07", title);
1229 let mut stdout = std::io::stdout();
1230 let _ = stdout.write_all(seq.as_bytes());
1231 let _ = stdout.flush();
1232 });
1233 },
1234 }
1235 }
1236
1237 pub async fn shutdown(mut self) {
1241 for (id, scope) in self.scopes.iter() {
1242 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1243 scope.cancel();
1244 }
1245
1246 if let Some(handle) = self.config_watch.take() {
1249 handle.abort();
1250 }
1251
1252 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1254
1255 let drain = async {
1256 let _ = tokio::time::timeout(
1261 std::time::Duration::from_secs(2),
1262 crate::mcp::manager_ref::wait_ready(),
1263 )
1264 .await;
1265 if let Some(mgr) = crate::mcp::manager_ref::get() {
1271 mgr.shutdown().await;
1272 }
1273 for (id, mut scope) in self.scopes.drain() {
1279 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1280 .await
1281 .is_err()
1282 {
1283 tracing::warn!(
1284 turn = %id,
1285 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1286 "shutdown: scope drain timed out; aborting its remaining tasks"
1287 );
1288 }
1289 }
1290 while let Some(result) = self.detached.join_next().await {
1291 if let Err(e) = result
1292 && !e.is_cancelled()
1293 {
1294 tracing::warn!(error = %e, "shutdown: detached task panic");
1295 }
1296 }
1297 };
1298
1299 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1300 }
1301
1302 #[doc(hidden)]
1305 pub fn msg_sender(&self) -> MsgSender {
1306 self.msg_tx.clone()
1307 }
1308}
1309
1310async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1319 if let Err(e) = handle.await
1320 && !e.is_cancelled()
1321 {
1322 tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1323 }
1324}
1325
1326struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
1333
1334impl AbortOnDrop {
1335 fn take(mut self) -> tokio::task::JoinHandle<()> {
1336 self.0.take().expect("AbortOnDrop::take called once")
1337 }
1338}
1339
1340impl Drop for AbortOnDrop {
1341 fn drop(&mut self) {
1342 if let Some(handle) = &self.0 {
1343 handle.abort();
1344 }
1345 }
1346}
1347
1348fn spawn_guarded<F>(fut: F) -> AbortOnDrop
1350where
1351 F: std::future::Future<Output = ()> + Send + 'static,
1352{
1353 AbortOnDrop(Some(tokio::spawn(fut)))
1354}
1355
1356async fn dispatch_call_model(
1357 msg_tx: MsgSender,
1358 providers: Option<Arc<ProviderFactory>>,
1359 turn: TurnId,
1360 mut request: crate::domain::ChatRequest,
1361 token: tokio_util::sync::CancellationToken,
1362) {
1363 use crate::models::UserFacingError;
1364
1365 let Some(factory) = providers else {
1366 let error = UserFacingError {
1367 summary: "not wired".to_string(),
1368 message: "EffectRunner has no ProviderFactory bound".to_string(),
1369 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1370 category: crate::models::ErrorCategory::Internal,
1371 recoverable: false,
1372 };
1373 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1374 return;
1375 };
1376
1377 let provider = match factory.resolve(&request.model_id).await {
1379 Ok(p) => p,
1380 Err(e) => {
1381 let error = classify_error_for_ui(&e);
1382 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1383 return;
1384 },
1385 };
1386 {
1387 let model_id = request.model_id.clone();
1391 let caps = provider.capabilities().clone();
1392 if let Err(e) =
1399 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1400 .await
1401 {
1402 tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1403 }
1404 }
1405 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1406 let _ = msg_tx
1407 .send(Msg::TransientStatus {
1408 text: format!(
1409 "{} does not advertise tool support; Mermaid will send the turn without tools",
1410 request.model_id
1411 ),
1412 kind: crate::domain::StatusKind::Warn,
1413 dismiss_ms: 6_000,
1414 })
1415 .await;
1416 request.tools.clear();
1417 }
1418
1419 let sizing = provider.resolve_context_window(&request).await;
1425 let max_context_tokens = sizing.effective.or_else(|| {
1426 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1427 });
1428 let _ = msg_tx
1432 .send(Msg::ProviderContextResolved {
1433 model_id: request.model_id.clone(),
1434 model_max: sizing.model_max,
1435 effective: sizing.effective,
1436 source: sizing.source,
1437 })
1438 .await;
1439 let context_snapshot =
1440 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1441 let _ = msg_tx
1442 .send(Msg::ContextUsageEstimated {
1443 turn,
1444 snapshot: context_snapshot.clone(),
1445 })
1446 .await;
1447
1448 let policy = CompactionPolicy::default();
1449 let mut compacted_before_stream = false;
1450 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1451 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1452 if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1455 match run_compaction(
1456 Arc::clone(&provider),
1457 turn,
1458 compaction,
1459 prepared,
1460 context_snapshot.clone(),
1461 max_context_tokens,
1462 token.clone(),
1463 )
1464 .await
1465 {
1466 Ok(result) => {
1467 request.messages = result.replacement_messages.clone();
1468 compacted_before_stream = true;
1469 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1470 },
1471 Err(err) => {
1472 if token.is_cancelled() {
1480 return;
1481 }
1482 tracing::warn!(
1483 turn = %turn,
1484 error = %err,
1485 "auto-compaction failed; proceeding with the un-compacted request",
1486 );
1487 let _ = msg_tx
1488 .send(Msg::CompactionFailed {
1489 turn,
1490 trigger: CompactionTrigger::AutoThreshold,
1491 message: err.to_string(),
1492 kind: crate::domain::StatusKind::Warn,
1493 })
1494 .await;
1495 },
1496 }
1497 }
1498 }
1499
1500 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1503 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1504
1505 let relay_tx = msg_tx.clone();
1512 let relay_token = token.clone();
1513 let relay = spawn_guarded(async move {
1514 loop {
1515 let event = tokio::select! {
1516 biased;
1517 _ = relay_token.cancelled() => {
1518 while let Ok(buffered) = stream_rx.try_recv() {
1525 if let StreamEvent::Done {
1526 usage,
1527 thinking_signature,
1528 stop_reason,
1529 } = buffered
1530 {
1531 let _ = relay_tx
1532 .send(Msg::StreamDone {
1533 turn,
1534 usage,
1535 thinking_signature,
1536 stop_reason,
1537 })
1538 .await;
1539 }
1540 }
1541 break;
1542 },
1543 ev = stream_rx.recv() => match ev {
1544 Some(ev) => ev,
1545 None => break,
1546 },
1547 };
1548 let msg = match event {
1549 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1550 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1551 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1552 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1554 usage,
1555 thinking_signature,
1556 stop_reason,
1557 } => Msg::StreamDone {
1558 turn,
1559 usage,
1560 thinking_signature,
1561 stop_reason,
1562 },
1563 };
1564 if relay_tx.send(msg).await.is_err() {
1565 break;
1566 }
1567 }
1568 });
1569
1570 let mut completed_ok = false;
1579 match provider.chat(request.clone(), ctx).await {
1580 Ok(_final_response) => {
1581 completed_ok = true;
1583 },
1584 Err(crate::models::ModelError::Cancelled) => {
1585 },
1587 Err(e) => {
1588 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1589 if retry_context_limit {
1590 let latest_snapshot =
1591 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1592 let compaction =
1593 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1594 if let Ok(prepared) =
1597 crate::domain::prepare_compaction(&compaction, max_context_tokens)
1598 {
1599 match run_compaction(
1600 Arc::clone(&provider),
1601 turn,
1602 compaction,
1603 prepared,
1604 latest_snapshot,
1605 max_context_tokens,
1606 token.clone(),
1607 )
1608 .await
1609 {
1610 Ok(result) => {
1611 let mut retry_request = request;
1612 retry_request.messages = result.replacement_messages.clone();
1613 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1614 join_logged(relay.take(), "stream_relay").await;
1615 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1616 .await;
1617 return;
1618 },
1619 Err(compact_err) => {
1620 let _ = msg_tx
1621 .send(Msg::CompactionFailed {
1622 turn,
1623 trigger: CompactionTrigger::ContextLimitRetry,
1624 message: compact_err.to_string(),
1625 kind: crate::domain::StatusKind::Error,
1626 })
1627 .await;
1628 },
1629 }
1630 }
1631 }
1632 let error = classify_error_for_ui(&e);
1633 run_provider_error_hook(&request.model_id, &error).await;
1634 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1635 },
1636 }
1637
1638 join_logged(relay.take(), "stream_relay").await;
1639
1640 if completed_ok
1645 && request.ollama_allow_ram_offload != Some(true)
1646 && let Some(p) = provider.verify_placement(sizing.effective).await
1647 {
1648 tracing::debug!(
1649 size_vram_bytes = p.size_vram_bytes,
1650 total_bytes = p.total_bytes,
1651 offloaded = p.size_vram_bytes < p.total_bytes,
1652 suggested_num_ctx = ?p.suggested_num_ctx,
1653 "Ollama placement"
1654 );
1655 let _ = msg_tx
1656 .send(Msg::OllamaPlacementResolved {
1657 model_id: request.model_id.clone(),
1658 size_vram_bytes: p.size_vram_bytes,
1659 total_bytes: p.total_bytes,
1660 suggested_num_ctx: p.suggested_num_ctx,
1661 })
1662 .await;
1663 }
1664}
1665
1666async fn dispatch_provider_stream(
1667 msg_tx: MsgSender,
1668 provider: Arc<dyn ModelProvider>,
1669 turn: TurnId,
1670 request: crate::domain::ChatRequest,
1671 token: tokio_util::sync::CancellationToken,
1672) {
1673 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1674 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1675 let relay_tx = msg_tx.clone();
1676 let relay_token = token.clone();
1677 let relay = spawn_guarded(async move {
1678 loop {
1679 let event = tokio::select! {
1680 biased;
1681 _ = relay_token.cancelled() => {
1682 while let Ok(buffered) = stream_rx.try_recv() {
1689 if let StreamEvent::Done {
1690 usage,
1691 thinking_signature,
1692 stop_reason,
1693 } = buffered
1694 {
1695 let _ = relay_tx
1696 .send(Msg::StreamDone {
1697 turn,
1698 usage,
1699 thinking_signature,
1700 stop_reason,
1701 })
1702 .await;
1703 }
1704 }
1705 break;
1706 },
1707 ev = stream_rx.recv() => match ev {
1708 Some(ev) => ev,
1709 None => break,
1710 },
1711 };
1712 let msg = match event {
1713 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1714 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1715 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1716 StreamEvent::ThinkingSignature(_) => continue,
1717 StreamEvent::Done {
1718 usage,
1719 thinking_signature,
1720 stop_reason,
1721 } => Msg::StreamDone {
1722 turn,
1723 usage,
1724 thinking_signature,
1725 stop_reason,
1726 },
1727 };
1728 if relay_tx.send(msg).await.is_err() {
1729 break;
1730 }
1731 }
1732 });
1733
1734 let model_id = request.model_id.clone();
1735 match provider.chat(request, ctx).await {
1736 Ok(_) | Err(ModelError::Cancelled) => {},
1737 Err(e) => {
1738 let error = classify_error_for_ui(&e);
1739 run_provider_error_hook(&model_id, &error).await;
1740 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1741 },
1742 }
1743
1744 join_logged(relay.take(), "stream_relay").await;
1745}
1746
1747async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1753 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1754 .await;
1755}
1756
1757async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1758 fire_plugin_hooks(
1759 "provider_error",
1760 serde_json::json!({
1761 "model_id": model_id,
1762 "summary": &error.summary,
1763 "message": &error.message,
1764 "category": format!("{:?}", error.category),
1765 "recoverable": error.recoverable,
1766 }),
1767 )
1768 .await;
1769}
1770
1771fn memory_title_from_text(text: &str) -> String {
1775 let first = text
1776 .lines()
1777 .find(|l| !l.trim().is_empty())
1778 .unwrap_or("memory")
1779 .trim();
1780 let title: String = first
1781 .split_whitespace()
1782 .take(8)
1783 .collect::<Vec<_>>()
1784 .join(" ")
1785 .chars()
1786 .take(60)
1787 .collect();
1788 if title.trim().is_empty() {
1789 "memory".to_string()
1790 } else {
1791 title
1792 }
1793}
1794
1795const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
1796
1797#[derive(Debug)]
1798struct PrunePlan {
1799 prune: Vec<String>,
1800 reason: String,
1801}
1802
1803fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1806 let start = text.find('{')?;
1807 let end = text.rfind('}')?;
1808 if end < start {
1809 return None;
1810 }
1811 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1812 let prune = json
1813 .get("prune")?
1814 .as_array()?
1815 .iter()
1816 .filter_map(|v| v.as_str().map(str::to_string))
1817 .collect();
1818 let reason = json
1819 .get("reason")
1820 .and_then(|v| v.as_str())
1821 .unwrap_or("")
1822 .to_string();
1823 Some(PrunePlan { prune, reason })
1824}
1825
1826async fn consolidate_memory(
1830 tx: MsgSender,
1831 providers: Option<Arc<ProviderFactory>>,
1832 workdir: std::path::PathBuf,
1833 model_id: String,
1834) {
1835 let items = crate::app::memory::entries_with_bodies(&workdir);
1836 if items.len() < 2 {
1837 let _ = tx
1838 .send(Msg::RuntimeText(format!(
1839 "Nothing to consolidate — {} memor{} saved.",
1840 items.len(),
1841 if items.len() == 1 { "y" } else { "ies" }
1842 )))
1843 .await;
1844 return;
1845 }
1846 let Some(factory) = providers else {
1847 let _ = tx
1848 .send(Msg::RuntimeText(
1849 "Memory consolidation needs a model provider, which isn't bound in this session."
1850 .to_string(),
1851 ))
1852 .await;
1853 return;
1854 };
1855
1856 let mut listing = String::new();
1857 for (entry, body) in &items {
1858 let id = entry
1859 .path
1860 .file_stem()
1861 .and_then(|s| s.to_str())
1862 .unwrap_or(entry.name.as_str());
1863 listing.push_str(&format!(
1864 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1865 entry.scope.as_str(),
1866 entry.description,
1867 body.replace('\n', " ").trim(),
1868 ));
1869 }
1870 let user = format!(
1871 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1872 items.len(),
1873 listing
1874 );
1875 let request = crate::domain::ChatRequest {
1876 model_id: model_id.clone(),
1877 messages: vec![crate::models::ChatMessage::user(user)],
1878 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1879 instructions: None,
1880 reasoning: crate::models::ReasoningLevel::None,
1881 temperature: 0.0,
1882 max_tokens: 1024,
1883 tools: Vec::new(),
1884 ollama_num_ctx: None,
1885 ollama_allow_ram_offload: None,
1886 };
1887
1888 let provider = match factory.resolve(&model_id).await {
1889 Ok(p) => p,
1890 Err(e) => {
1891 let _ = tx
1892 .send(Msg::RuntimeText(format!(
1893 "Memory consolidation failed: {e}"
1894 )))
1895 .await;
1896 return;
1897 },
1898 };
1899 let token = tokio_util::sync::CancellationToken::new();
1900 let text =
1901 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1902 Ok((t, _)) => t,
1903 Err(e) => {
1904 let _ = tx
1905 .send(Msg::RuntimeText(format!(
1906 "Memory consolidation failed: {e}"
1907 )))
1908 .await;
1909 return;
1910 },
1911 };
1912
1913 let Some(plan) = parse_prune_plan(&text) else {
1914 let _ = tx
1915 .send(Msg::RuntimeText(
1916 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1917 .to_string(),
1918 ))
1919 .await;
1920 return;
1921 };
1922 if plan.prune.is_empty() {
1923 let reason = if plan.reason.is_empty() {
1924 String::new()
1925 } else {
1926 format!(" {}", plan.reason)
1927 };
1928 let _ = tx
1929 .send(Msg::RuntimeText(format!(
1930 "Memory consolidation: nothing to prune.{reason}"
1931 )))
1932 .await;
1933 return;
1934 }
1935
1936 let paths: Vec<std::path::PathBuf> = plan
1942 .prune
1943 .iter()
1944 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1945 .collect();
1946 if !paths.is_empty()
1947 && let Err(e) = crate::runtime::create_checkpoint(
1948 &workdir,
1949 &paths,
1950 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1951 )
1952 {
1953 let _ = tx
1954 .send(Msg::RuntimeText(format!(
1955 "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
1956 paths.len(),
1957 if paths.len() == 1 { "" } else { "s" },
1958 )))
1959 .await;
1960 return;
1961 }
1962
1963 let mut pruned = Vec::new();
1964 for id in &plan.prune {
1965 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1966 pruned.push(id.clone());
1967 }
1968 }
1969
1970 let cfg = crate::app::load_config().unwrap_or_default().memory;
1971 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1972 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1973
1974 let report = if pruned.is_empty() {
1975 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1976 .to_string()
1977 } else {
1978 format!(
1979 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1980 pruned.len(),
1981 if pruned.len() == 1 { "" } else { "s" },
1982 pruned.join(", "),
1983 if plan.reason.is_empty() {
1984 String::new()
1985 } else {
1986 format!(" Reason: {}.", plan.reason)
1987 },
1988 )
1989 };
1990 let _ = tx.send(Msg::RuntimeText(report)).await;
1991}
1992
1993async fn dispatch_compact_conversation(
1994 msg_tx: MsgSender,
1995 providers: Option<Arc<ProviderFactory>>,
1996 turn: TurnId,
1997 request: CompactionRequest,
1998 token: tokio_util::sync::CancellationToken,
1999) {
2000 let Some(factory) = providers else {
2001 let _ = msg_tx
2002 .send(Msg::CompactionFailed {
2003 turn,
2004 trigger: request.trigger,
2005 message: "EffectRunner has no ProviderFactory bound".to_string(),
2006 kind: crate::domain::StatusKind::Error,
2007 })
2008 .await;
2009 return;
2010 };
2011
2012 let provider = match factory.resolve(&request.chat.model_id).await {
2013 Ok(provider) => provider,
2014 Err(err) => {
2015 let _ = msg_tx
2016 .send(Msg::CompactionFailed {
2017 turn,
2018 trigger: request.trigger,
2019 message: err.to_string(),
2020 kind: crate::domain::StatusKind::Error,
2021 })
2022 .await;
2023 return;
2024 },
2025 };
2026
2027 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
2028 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
2029 });
2030 let before_snapshot =
2031 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
2032
2033 let trigger = request.trigger;
2034 let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
2039 Ok(prepared) => prepared,
2040 Err(skip) => {
2041 let _ = msg_tx
2042 .send(Msg::CompactionFailed {
2043 turn,
2044 trigger,
2045 message: skip.to_string(),
2046 kind: crate::domain::StatusKind::Info,
2047 })
2048 .await;
2049 return;
2050 },
2051 };
2052 match run_compaction(
2053 provider,
2054 turn,
2055 request,
2056 prepared,
2057 before_snapshot,
2058 max_context_tokens,
2059 token,
2060 )
2061 .await
2062 {
2063 Ok(result) => {
2064 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2065 },
2066 Err(err) => {
2067 let _ = msg_tx
2068 .send(Msg::CompactionFailed {
2069 turn,
2070 trigger,
2071 message: err.to_string(),
2072 kind: crate::domain::StatusKind::Error,
2073 })
2074 .await;
2075 },
2076 }
2077}
2078
2079async fn run_compaction(
2080 provider: Arc<dyn ModelProvider>,
2081 turn: TurnId,
2082 request: CompactionRequest,
2083 prepared: crate::domain::PreparedCompaction,
2084 before_snapshot: crate::domain::ContextUsageSnapshot,
2085 max_context_tokens: Option<usize>,
2086 token: tokio_util::sync::CancellationToken,
2087) -> Result<CompactionResult, ModelError> {
2088 let started = Instant::now();
2089
2090 let summary_request = crate::domain::build_summary_request(
2091 &request.chat,
2092 &prepared,
2093 request.instructions.as_deref(),
2094 request.policy,
2095 );
2096 let (draft, draft_usage) =
2097 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2098 .await?;
2099 let draft_summary = crate::domain::normalize_summary(&draft);
2100 if draft_summary.trim().is_empty() {
2101 return Err(ModelError::InvalidRequest(
2102 "compaction produced an empty summary".to_string(),
2103 ));
2104 }
2105
2106 let verify_request = crate::domain::build_verification_request(
2107 &request.chat,
2108 &prepared,
2109 &draft_summary,
2110 request.instructions.as_deref(),
2111 request.policy,
2112 );
2113 let (final_summary, verify_usage, verified, verification_error) =
2114 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2115 Ok((verified_text, verify_usage)) => {
2116 let verified_summary = crate::domain::normalize_summary(&verified_text);
2117 if verified_summary.trim().is_empty() {
2118 (
2119 draft_summary,
2120 verify_usage,
2121 false,
2122 Some("verification returned an empty summary".to_string()),
2123 )
2124 } else {
2125 (verified_summary, verify_usage, true, None)
2126 }
2127 },
2128 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2129 Err(err) => (draft_summary, None, false, Some(err.to_string())),
2130 };
2131
2132 let id = format!(
2133 "compact_{}",
2134 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2135 );
2136 let mut record = crate::domain::CompactionRecord {
2137 id,
2138 trigger: request.trigger,
2139 created_at: chrono::Local::now(),
2140 before_tokens: before_snapshot.used_tokens,
2141 after_tokens: 0,
2142 archived_message_count: prepared.archived_messages.len(),
2143 preserved_message_count: prepared.preserved_messages.len(),
2144 summary_tokens: final_summary.len().div_ceil(4),
2145 duration_secs: started.elapsed().as_secs_f64(),
2146 verified,
2147 verification_error,
2148 focus: request.instructions.clone(),
2149 archive_path: None,
2150 };
2151
2152 let mut replacement =
2153 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2154 let mut compacted_request = request.chat.clone();
2155 compacted_request.messages = replacement.clone();
2156 let mut after_snapshot =
2157 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2158 record.after_tokens = after_snapshot.used_tokens;
2159 record.duration_secs = started.elapsed().as_secs_f64();
2160 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2161 compacted_request.messages = replacement.clone();
2162 after_snapshot =
2163 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2164 record.after_tokens = after_snapshot.used_tokens;
2165
2166 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2167 return Err(ModelError::InvalidRequest(format!(
2168 "compaction did not reduce context ({} -> {} tokens)",
2169 before_snapshot.used_tokens, after_snapshot.used_tokens
2170 )));
2171 }
2172
2173 if crate::domain::context_exceeds_hard_limit(
2174 &after_snapshot,
2175 &compacted_request,
2176 request.policy,
2177 ) {
2178 return Err(ModelError::InvalidRequest(format!(
2179 "compacted context still exceeds response reserve ({} tokens used)",
2180 after_snapshot.used_tokens
2181 )));
2182 }
2183
2184 Ok(CompactionResult {
2185 record,
2186 replacement_messages: replacement,
2187 archived_messages: prepared.archived_messages,
2188 before_snapshot,
2189 after_snapshot,
2190 usage: crate::domain::combine_usage(draft_usage, verify_usage),
2191 })
2192}
2193
2194async fn collect_compaction_text(
2195 provider: Arc<dyn ModelProvider>,
2196 turn: TurnId,
2197 request: crate::domain::ChatRequest,
2198 token: tokio_util::sync::CancellationToken,
2199) -> Result<(String, Option<TokenUsage>), ModelError> {
2200 crate::providers::model::collect_text(provider, turn, request, token).await
2203}
2204
2205fn record_provider_capabilities(
2206 model_id: &str,
2207 caps: &crate::providers::capabilities::Capabilities,
2208) {
2209 let (provider, model) = split_model_id(model_id);
2210 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2211 for (key, value) in [
2212 ("tools_support", caps.supports_tools.to_string()),
2213 ("vision_support", caps.supports_vision.to_string()),
2214 (
2215 "context_limit",
2216 caps.max_context_tokens
2217 .map(|v| v.to_string())
2218 .unwrap_or_else(|| "unknown".to_string()),
2219 ),
2220 (
2221 "reasoning_parameter_shape",
2222 format!("{:?}", caps.supports_reasoning),
2223 ),
2224 (
2225 "streaming_usage_available",
2226 "provider_dependent".to_string(),
2227 ),
2228 ("token_usage_field_shape", "normalized".to_string()),
2229 ] {
2230 let _ = store
2231 .provider_probes()
2232 .upsert(crate::runtime::NewProviderProbe {
2233 provider: provider.clone(),
2234 model_id: model.clone(),
2235 capability_key: key.to_string(),
2236 capability_value: value,
2237 confidence: "verified".to_string(),
2238 error: None,
2239 });
2240 }
2241 }
2242}
2243
2244fn split_model_id(model_id: &str) -> (String, String) {
2245 match model_id.split_once('/') {
2246 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2247 (provider.to_ascii_lowercase(), model.to_string())
2248 },
2249 _ => ("ollama".to_string(), model_id.to_string()),
2250 }
2251}
2252
2253fn is_context_limit_error(error: &ModelError) -> bool {
2254 let text = error.to_string().to_lowercase();
2255 text.contains("context")
2256 && (text.contains("too large")
2257 || text.contains("exceed")
2258 || text.contains("maximum")
2259 || text.contains("token"))
2260}
2261
2262#[allow(clippy::too_many_arguments)]
2264async fn dispatch_execute_tool(
2265 msg_tx: MsgSender,
2266 tools: Option<Arc<ToolRegistry>>,
2267 workdir: PathBuf,
2268 turn: TurnId,
2269 call_id: crate::domain::ToolCallId,
2270 source: crate::models::tool_call::ToolCall,
2271 token: tokio_util::sync::CancellationToken,
2272 background: tokio_util::sync::CancellationToken,
2273 config: Arc<crate::app::Config>,
2274 model_id: String,
2275 task_id: Option<String>,
2276 safety_mode: crate::runtime::SafetyMode,
2277 intent: Option<String>,
2278 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2279 approval: Option<crate::providers::ApprovalBroker>,
2280) {
2281 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2282
2283 let Some(registry) = tools else {
2284 let _ = msg_tx
2285 .send(Msg::ToolFinished {
2286 turn,
2287 call_id,
2288 outcome: crate::domain::ToolOutcome::error(
2289 "EffectRunner has no ToolRegistry bound",
2290 0.0,
2291 ),
2292 })
2293 .await;
2294 return;
2295 };
2296
2297 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2301 let rest = &source.function.name[5..];
2302 if let Some((server, tool)) = rest.split_once("__") {
2303 (
2304 "mcp_proxy",
2305 serde_json::json!({
2306 "server_name": server,
2307 "tool_name": tool,
2308 "arguments": source.function.arguments.clone(),
2309 }),
2310 )
2311 } else {
2312 let _ = msg_tx
2313 .send(Msg::ToolFinished {
2314 turn,
2315 call_id,
2316 outcome: crate::domain::ToolOutcome::error(
2317 format!("invalid MCP tool name: {}", source.function.name),
2318 0.0,
2319 ),
2320 })
2321 .await;
2322 return;
2323 }
2324 } else {
2325 (
2326 source.function.name.as_str(),
2327 source.function.arguments.clone(),
2328 )
2329 };
2330 let tool_run_id =
2331 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2332
2333 let Some(tool) = registry.get(tool_key) else {
2334 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2335 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2336 let _ = msg_tx
2337 .send(Msg::ToolFinished {
2338 turn,
2339 call_id,
2340 outcome,
2341 })
2342 .await;
2343 return;
2344 };
2345
2346 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2352 let relay_tx = msg_tx.clone();
2353 let relay_token = token.clone();
2354 let progress_relay = spawn_guarded(async move {
2355 loop {
2356 let event = tokio::select! {
2357 biased;
2358 _ = relay_token.cancelled() => break,
2359 ev = progress_rx.recv() => match ev {
2360 Some(ev) => ev,
2361 None => break,
2362 },
2363 };
2364 if relay_tx
2365 .send(Msg::ToolProgress {
2366 turn,
2367 call_id,
2368 event,
2369 })
2370 .await
2371 .is_err()
2372 {
2373 break;
2374 }
2375 }
2376 });
2377
2378 let mut ctx = ExecContext::new(
2379 token,
2380 progress_tx,
2381 call_id,
2382 turn,
2383 workdir,
2384 config,
2385 model_id,
2386 task_id,
2387 safety_mode,
2388 intent,
2389 classifier,
2390 approval,
2391 );
2392 ctx.background = background;
2393 let before_payload = serde_json::json!({
2394 "turn_id": turn.0,
2395 "call_id": call_id.0,
2396 "tool": tool_key,
2397 });
2398 fire_plugin_hooks("before_tool_use", before_payload).await;
2399 let outcome = tool.execute(args, ctx).await;
2400 let after_payload = serde_json::json!({
2401 "turn_id": turn.0,
2402 "call_id": call_id.0,
2403 "tool": tool_key,
2404 "status": tool_status_label(outcome.status),
2405 "summary": &outcome.summary,
2406 });
2407 fire_plugin_hooks("after_tool_use", after_payload).await;
2408 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2409 join_logged(progress_relay.take(), "tool_progress_relay").await;
2410 let _ = msg_tx
2411 .send(Msg::ToolFinished {
2412 turn,
2413 call_id,
2414 outcome,
2415 })
2416 .await;
2417}
2418
2419async fn start_runtime_tool_run(
2420 task_id: Option<&str>,
2421 turn: TurnId,
2422 call_id: crate::domain::ToolCallId,
2423 tool_name: &str,
2424 args: &serde_json::Value,
2425) -> Option<String> {
2426 let task_id = task_id.map(str::to_string);
2430 let tool_name = tool_name.to_string();
2431 let args_json = serde_json::to_string(args).ok();
2432 tokio::task::spawn_blocking(move || {
2433 crate::runtime::RuntimeStore::open_default()
2434 .and_then(|store| {
2435 store.tool_runs().start(crate::runtime::NewToolRun {
2436 id: None,
2437 task_id,
2438 turn_id: Some(turn.0.to_string()),
2439 call_id: Some(call_id.0.to_string()),
2440 tool_name,
2441 args_json,
2442 })
2443 })
2444 .map(|record| record.id)
2445 .ok()
2446 })
2447 .await
2448 .ok()
2449 .flatten()
2450}
2451
2452fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2453 let Some(tool_run_id) = tool_run_id else {
2454 return;
2455 };
2456 let tool_run_id = tool_run_id.to_string();
2457 let status = tool_status_label(outcome.status).to_string();
2458 let output_json = serde_json::to_string(&serde_json::json!({
2459 "status": tool_status_label(outcome.status),
2460 "summary": &outcome.summary,
2461 "model_content": &outcome.model_content,
2462 "error": &outcome.error,
2463 "metadata": &outcome.metadata,
2464 "artifacts": &outcome.artifacts,
2465 "duration_secs": outcome.duration_secs,
2466 }))
2467 .ok();
2468 tokio::task::spawn_blocking(move || {
2471 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2472 let _ = store
2473 .tool_runs()
2474 .finish(&tool_run_id, &status, output_json.as_deref());
2475 }
2476 });
2477}
2478
2479fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2480 match status {
2481 crate::domain::ToolStatus::Success => "success",
2482 crate::domain::ToolStatus::Error => "error",
2483 crate::domain::ToolStatus::Cancelled => "cancelled",
2484 }
2485}
2486
2487fn runtime_model_info_text(model: &str) -> String {
2488 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2489 let mut lines = vec![
2490 format!("Model info: {}", model),
2491 format!("- provider: {}", snapshot.provider),
2492 format!("- model: {}", snapshot.model),
2493 format!("- supports tools: {}", snapshot.supports_tools),
2494 format!("- supports vision: {}", snapshot.supports_vision),
2495 format!("- reasoning: {}", snapshot.reasoning),
2496 format!(
2497 "- context limit: {}",
2498 snapshot
2499 .max_context_tokens
2500 .map(|value: usize| value.to_string())
2501 .unwrap_or_else(|| "unknown".to_string())
2502 ),
2503 ];
2504 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2505 && let Ok(probes) = store
2506 .provider_probes()
2507 .list(Some(&snapshot.provider), Some(&snapshot.model))
2508 && !probes.is_empty()
2509 {
2510 lines.push(String::new());
2511 lines.push("Cached provider reality records:".to_string());
2512 for probe in probes {
2513 lines.push(format!(
2514 "- {} = {} ({})",
2515 probe.capability_key, probe.capability_value, probe.confidence
2516 ));
2517 }
2518 }
2519 lines.join("\n")
2520}
2521
2522async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2527 use tokio::io::{AsyncBufReadExt, BufReader};
2528 use tokio::process::Command;
2529
2530 let mut cmd = Command::new("ollama");
2531 cmd.arg("pull")
2532 .arg(&model)
2533 .stdin(std::process::Stdio::null())
2534 .stdout(std::process::Stdio::piped())
2535 .stderr(std::process::Stdio::piped())
2536 .kill_on_drop(true);
2537
2538 let mut child = match cmd.spawn() {
2539 Ok(c) => c,
2540 Err(e) => {
2541 let _ = tx
2542 .send(Msg::ModelPullProgress(format!(
2543 "ollama pull failed to start: {}",
2544 e
2545 )))
2546 .await;
2547 return;
2548 },
2549 };
2550
2551 let reader_handle = child.stdout.take().map(|stdout| {
2555 let tx_inner = tx.clone();
2556 tokio::spawn(async move {
2557 let mut reader = BufReader::new(stdout).lines();
2558 while let Ok(Some(line)) = reader.next_line().await {
2559 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2560 }
2561 })
2562 });
2563
2564 match child.wait().await {
2565 Ok(status) if status.success() => {
2566 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2567 },
2568 Ok(status) => {
2569 let _ = tx
2570 .send(Msg::ModelPullProgress(format!(
2571 "ollama pull exited with status {}",
2572 status.code().unwrap_or(-1)
2573 )))
2574 .await;
2575 },
2576 Err(e) => {
2577 let _ = tx
2578 .send(Msg::ModelPullProgress(format!(
2579 "ollama pull wait error: {}",
2580 e
2581 )))
2582 .await;
2583 },
2584 }
2585
2586 if let Some(handle) = reader_handle {
2589 join_logged(handle, "ollama_pull_reader").await;
2590 }
2591}
2592
2593fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2594 if started {
2595 Msg::McpServerReady {
2596 name: name.to_string(),
2597 tools,
2598 }
2599 } else {
2600 Msg::McpServerErrored {
2601 name: name.to_string(),
2602 reason: "server failed to start or initialize".to_string(),
2603 }
2604 }
2605}
2606
2607async fn dispatch_read_clipboard(tx: MsgSender) {
2617 use crate::domain::{Paste, StatusKind};
2618
2619 enum Outcome {
2620 Image { bytes: Vec<u8>, format: String },
2621 Text(String),
2622 Empty,
2623 Error(String),
2624 }
2625
2626 let outcome = tokio::task::spawn_blocking(|| {
2627 if crate::clipboard::has_image() {
2628 match crate::clipboard::read_image_bytes() {
2629 Ok((bytes, format)) => Outcome::Image { bytes, format },
2630 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2631 }
2632 } else {
2633 match crate::clipboard::read_text() {
2634 Ok(t) if !t.is_empty() => Outcome::Text(t),
2635 Ok(_) => Outcome::Empty,
2636 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2637 }
2638 }
2639 })
2640 .await
2641 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2642
2643 let msg = match outcome {
2644 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2645 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2646 Outcome::Empty => Msg::TransientStatus {
2647 text: "Clipboard is empty".to_string(),
2648 kind: StatusKind::Info,
2649 dismiss_ms: 2_000,
2650 },
2651 Outcome::Error(text) => Msg::TransientStatus {
2652 text,
2653 kind: StatusKind::Warn,
2654 dismiss_ms: 4_000,
2655 },
2656 };
2657 let _ = tx.send(msg).await;
2658}
2659
2660async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2663 use crate::domain::StatusKind;
2664
2665 let char_count = text.chars().count();
2666 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2667 .await
2668 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2669
2670 let msg = match result {
2671 Ok(()) => Msg::TransientStatus {
2672 text: format!("Copied {char_count} chars to clipboard"),
2673 kind: StatusKind::Info,
2674 dismiss_ms: 2_000,
2675 },
2676 Err(e) => Msg::TransientStatus {
2677 text: format!("Copy failed: {e}"),
2678 kind: StatusKind::Warn,
2679 dismiss_ms: 4_000,
2680 },
2681 };
2682 let _ = tx.send(msg).await;
2683}
2684
2685fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2686 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2687 match e {
2688 ModelError::Backend(b) => UserFacingError {
2689 summary: "Backend error".to_string(),
2690 message: b.to_string(),
2691 suggestion: "Check the provider endpoint / API key.".to_string(),
2692 category: ErrorCategory::Connection,
2693 recoverable: true,
2694 },
2695 ModelError::Authentication(msg) => UserFacingError {
2696 summary: "Auth error".to_string(),
2697 message: msg.clone(),
2698 suggestion: "Set the env var the provider expects.".to_string(),
2699 category: ErrorCategory::Auth,
2700 recoverable: false,
2701 },
2702 ModelError::RateLimit { retry_after } => UserFacingError {
2703 summary: "Rate limit".to_string(),
2704 message: format!("retry after {:?}", retry_after),
2705 suggestion: "Wait and try again.".to_string(),
2706 category: ErrorCategory::Temporary,
2707 recoverable: true,
2708 },
2709 ModelError::StreamError(msg) => UserFacingError {
2710 summary: "Stream error".to_string(),
2711 message: msg.clone(),
2712 suggestion: "Retry the request.".to_string(),
2713 category: ErrorCategory::Connection,
2714 recoverable: true,
2715 },
2716 other => UserFacingError {
2717 summary: "Model error".to_string(),
2718 message: other.to_string(),
2719 suggestion: String::new(),
2720 category: ErrorCategory::Internal,
2721 recoverable: false,
2722 },
2723 }
2724}
2725
2726#[cfg(test)]
2727mod tests {
2728 use super::*;
2729 use crate::domain::ToolCallId;
2730 use std::time::Duration;
2731
2732 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2733 EffectRunner::pair(PathBuf::from("/tmp"))
2734 }
2735
2736 #[test]
2737 fn new_child_suppresses_terminal_title() {
2738 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2742 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2743 let tools = Arc::new(ToolRegistry::new());
2744 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2745 assert!(
2746 !child.terminal_title_enabled,
2747 "subagent child runner must suppress terminal-title escapes"
2748 );
2749 }
2750
2751 #[test]
2752 fn parse_prune_plan_extracts_json_amid_prose() {
2753 let plan = parse_prune_plan(
2754 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2755 )
2756 .expect("should parse");
2757 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2758 assert_eq!(plan.reason, "dupes");
2759 }
2760
2761 #[test]
2762 fn parse_prune_plan_handles_empty_and_garbage() {
2763 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2764 .expect("empty plan parses");
2765 assert!(empty.prune.is_empty());
2766 assert!(parse_prune_plan("no json here").is_none());
2767 }
2768
2769 #[test]
2770 fn memory_title_from_text_is_short_and_nonempty() {
2771 assert_eq!(
2772 memory_title_from_text("prefer ripgrep over grep"),
2773 "prefer ripgrep over grep"
2774 );
2775 assert_eq!(memory_title_from_text(" "), "memory");
2776 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2777 assert!(long.split_whitespace().count() <= 8);
2778 }
2779
2780 #[tokio::test]
2781 async fn dispatch_exit_is_noop_on_runner_state() {
2782 let (mut r, _rx) = runner();
2783 r.dispatch(Cmd::Exit);
2784 assert_eq!(r.scope_count(), 0);
2785 }
2786
2787 #[tokio::test]
2788 async fn dispatch_save_emits_session_saved() {
2789 let (mut r, mut rx) = runner();
2790 r.dispatch(Cmd::SaveConversation(
2791 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2792 ));
2793 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2794 .await
2795 .expect("sender emits")
2796 .expect("channel alive");
2797 assert!(matches!(msg, Msg::SessionSaved));
2798 }
2799
2800 #[tokio::test]
2801 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2802 let (mut r, mut rx) = runner();
2803 let t0 = std::time::Instant::now();
2804 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2805 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2806 .await
2807 .expect("sender emits")
2808 .expect("channel alive");
2809 assert!(matches!(msg, Msg::StatusDismiss));
2810 assert!(t0.elapsed() >= Duration::from_millis(25));
2811 }
2812
2813 #[test]
2814 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2815 let msg = mcp_startup_msg("empty", true, Vec::new());
2816 assert!(matches!(
2817 msg,
2818 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2819 ));
2820 }
2821
2822 #[test]
2823 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2824 let msg = mcp_startup_msg("bad", false, Vec::new());
2825 assert!(matches!(
2826 msg,
2827 Msg::McpServerErrored { name, reason }
2828 if name == "bad" && reason.contains("failed to start")
2829 ));
2830 }
2831
2832 #[tokio::test]
2833 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2834 let (mut r, mut rx) = runner();
2835 let turn = TurnId(77);
2836 {
2837 let scope = r.scope_mut(turn);
2838 scope.spawn(async {
2839 std::future::pending::<()>().await;
2840 });
2841 }
2842 assert_eq!(r.scope_count(), 1);
2843
2844 let start = std::time::Instant::now();
2845 r.dispatch(Cmd::CancelScope(turn));
2846 assert_eq!(r.scope_count(), 0);
2847 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2848 .await
2849 .expect("bounded cancel should emit terminal message")
2850 .expect("channel alive");
2851 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2852 assert!(
2853 start.elapsed() < Duration::from_millis(500),
2854 "cancel terminal message took {:?}",
2855 start.elapsed()
2856 );
2857 }
2858
2859 #[tokio::test]
2860 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2861 let (mut r, mut rx) = runner();
2867 let turn = TurnId(88);
2868 {
2869 let scope = r.scope_mut(turn);
2870 scope.spawn(async {}); }
2872 assert_eq!(r.scope_count(), 1);
2873
2874 tokio::time::sleep(Duration::from_millis(20)).await;
2876 r.dispatch(Cmd::Exit);
2877 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2878
2879 r.dispatch(Cmd::CancelScope(turn));
2882 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2883 .await
2884 .expect("cancel on a reaped scope must still emit a terminal message")
2885 .expect("channel alive");
2886 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2887 }
2888
2889 #[tokio::test]
2890 async fn dispatch_call_model_creates_scope() {
2891 let (mut r, _rx) = runner();
2892 let turn = TurnId(7);
2893 let request = crate::domain::ChatRequest {
2894 model_id: "test/m".to_string(),
2895 messages: vec![],
2896 system_prompt: String::new(),
2897 instructions: None,
2898 reasoning: crate::models::ReasoningLevel::Medium,
2899 temperature: 0.7,
2900 max_tokens: 4096,
2901 tools: vec![],
2902
2903 ollama_num_ctx: None,
2904 ollama_allow_ram_offload: None,
2905 };
2906 r.dispatch(Cmd::CallModel { turn, request });
2907 assert_eq!(r.scope_count(), 1);
2908 }
2909
2910 #[tokio::test]
2914 async fn empty_scopes_are_reaped_on_next_dispatch() {
2915 let (mut r, mut rx) = runner();
2916 let turn = TurnId(42);
2917 let request = crate::domain::ChatRequest {
2918 model_id: "test/m".to_string(),
2919 messages: vec![],
2920 system_prompt: String::new(),
2921 instructions: None,
2922 reasoning: crate::models::ReasoningLevel::Medium,
2923 temperature: 0.7,
2924 max_tokens: 4096,
2925 tools: vec![],
2926
2927 ollama_num_ctx: None,
2928 ollama_allow_ram_offload: None,
2929 };
2930 r.dispatch(Cmd::CallModel { turn, request });
2931 assert_eq!(r.scope_count(), 1);
2932
2933 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2938 .await
2939 .expect("upstream error arrived")
2940 .expect("channel alive");
2941 assert!(matches!(msg, Msg::UpstreamError { .. }));
2942
2943 tokio::task::yield_now().await;
2945
2946 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2948 assert_eq!(
2949 r.scope_count(),
2950 0,
2951 "completed scope must be reaped on next dispatch"
2952 );
2953 }
2954
2955 #[tokio::test]
2956 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2957 let (mut r, mut rx) = runner();
2958 let turn = TurnId(7);
2959 let call_id = ToolCallId(1);
2960 let source = crate::models::tool_call::ToolCall {
2961 id: Some("c1".to_string()),
2962 function: crate::models::tool_call::FunctionCall {
2963 name: "read_file".to_string(),
2964 arguments: serde_json::json!({"path": "x"}),
2965 },
2966 };
2967 r.dispatch(Cmd::ExecuteTool {
2968 turn,
2969 call_id,
2970 source,
2971 model_id: "ollama/test".to_string(),
2972 safety_mode: crate::runtime::SafetyMode::Ask,
2973 intent: None,
2974 });
2975 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2976 .await
2977 .expect("some msg")
2978 .expect("channel alive");
2979 assert!(matches!(
2980 first,
2981 Msg::ToolStarted {
2982 turn: t,
2983 call_id: c,
2984 } if t == turn && c == call_id
2985 ));
2986 }
2987
2988 #[tokio::test]
2989 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2990 let (mut r, _rx) = runner();
2991 let turn = TurnId(9);
2992 r.dispatch(Cmd::CallModel {
2993 turn,
2994 request: crate::domain::ChatRequest {
2995 model_id: "m".to_string(),
2996 messages: vec![],
2997 system_prompt: String::new(),
2998 instructions: None,
2999 reasoning: crate::models::ReasoningLevel::Medium,
3000 temperature: 0.7,
3001 max_tokens: 4096,
3002 tools: vec![],
3003
3004 ollama_num_ctx: None,
3005 ollama_allow_ram_offload: None,
3006 },
3007 });
3008 assert_eq!(r.scope_count(), 1);
3009
3010 r.dispatch(Cmd::CancelScope(turn));
3011 assert_eq!(r.scope_count(), 0);
3012 }
3013
3014 #[tokio::test]
3015 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
3016 let (mut r, _rx) = runner();
3022 let req = || crate::domain::ChatRequest {
3023 model_id: "test/m".to_string(),
3024 messages: vec![],
3025 system_prompt: String::new(),
3026 instructions: None,
3027 reasoning: crate::models::ReasoningLevel::Medium,
3028 temperature: 0.7,
3029 max_tokens: 4096,
3030 tools: vec![],
3031 ollama_num_ctx: None,
3032 ollama_allow_ram_offload: None,
3033 };
3034 let turn = TurnId(123);
3035
3036 r.dispatch(Cmd::CallModel {
3037 turn,
3038 request: req(),
3039 });
3040 assert_eq!(r.scope_count(), 1);
3041
3042 r.dispatch(Cmd::CancelScope(turn));
3044 assert_eq!(r.scope_count(), 0);
3045
3046 r.dispatch(Cmd::CallModel {
3048 turn,
3049 request: req(),
3050 });
3051 assert_eq!(
3052 r.scope_count(),
3053 0,
3054 "a cancelled turn must not be resurrected by a late scoped Cmd"
3055 );
3056
3057 r.dispatch(Cmd::CallModel {
3059 turn: TurnId(124),
3060 request: req(),
3061 });
3062 assert_eq!(
3063 r.scope_count(),
3064 1,
3065 "a fresh turn must still create its scope normally"
3066 );
3067 }
3068
3069 #[tokio::test]
3070 async fn shutdown_drains_pending_saves() {
3071 let (mut r, _rx) = runner();
3072 for _ in 0..5 {
3073 r.dispatch(Cmd::SaveConversation(
3074 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
3075 ));
3076 }
3077 let start = std::time::Instant::now();
3079 r.shutdown().await;
3080 assert!(start.elapsed() < Duration::from_secs(2));
3081 }
3082}