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