Skip to main content

hematite/agent/
conversation.rs

1use crate::agent::architecture_summary::{
2    build_architecture_overview_answer, prune_architecture_trace_batch,
3    prune_authoritative_tool_batch, prune_read_only_context_bloat_batch,
4    prune_redirected_shell_batch, summarize_runtime_trace_output,
5};
6use crate::agent::direct_answers::{
7    build_about_answer, build_architect_session_reset_plan, build_authorization_policy_answer,
8    build_gemma_native_answer, build_gemma_native_settings_answer, build_identity_answer,
9    build_language_capability_answer, build_mcp_lifecycle_answer, build_product_surface_answer,
10    build_reasoning_split_answer, build_recovery_recipes_answer, build_session_memory_answer,
11    build_session_reset_semantics_answer, build_tool_classes_answer,
12    build_tool_registry_ownership_answer, build_unsafe_workflow_pressure_answer,
13    build_verify_profiles_answer, build_workflow_modes_answer,
14};
15use crate::agent::inference::{
16    ChatMessage, InferenceEngine, InferenceEvent, MessageContent, OperatorCheckpointState,
17    ProviderRuntimeState, ToolCallFn, ToolDefinition, ToolFunction,
18};
19use crate::agent::policy::{
20    action_target_path, docs_edit_without_explicit_request, is_destructive_tool,
21    is_mcp_mutating_tool, is_mcp_workspace_read_tool, normalize_workspace_path,
22};
23use crate::agent::recovery_recipes::{
24    attempt_recovery, plan_recovery, preview_recovery_decision, RecoveryContext, RecoveryDecision,
25    RecoveryPlan, RecoveryScenario, RecoveryStep,
26};
27use crate::agent::routing::{
28    all_host_inspection_topics, classify_query_intent, is_capability_probe_tool,
29    looks_like_mutation_request, needs_computation_sandbox, preferred_host_inspection_topic,
30    preferred_maintainer_workflow, preferred_workspace_workflow, DirectAnswerKind,
31    QueryIntentClass,
32};
33use crate::agent::tool_registry::dispatch_builtin_tool;
34// SystemPromptBuilder is no longer used — InferenceEngine::build_system_prompt() is canonical.
35use crate::agent::compaction::{self, CompactionConfig};
36use crate::ui::gpu_monitor::GpuState;
37
38use serde_json::Value;
39use std::sync::Arc;
40use tokio::sync::{mpsc, Mutex};
41// -- Session persistence -------------------------------------------------------
42
43#[derive(Clone, Debug, Default)]
44pub struct UserTurn {
45    pub text: String,
46    pub attached_document: Option<AttachedDocument>,
47    pub attached_image: Option<AttachedImage>,
48}
49
50#[derive(Clone, Debug)]
51pub struct AttachedDocument {
52    pub name: String,
53    pub content: String,
54}
55
56#[derive(Clone, Debug)]
57pub struct AttachedImage {
58    pub name: String,
59    pub path: String,
60}
61
62impl UserTurn {
63    pub fn text(text: impl Into<String>) -> Self {
64        Self {
65            text: text.into(),
66            attached_document: None,
67            attached_image: None,
68        }
69    }
70}
71
72#[derive(serde::Serialize, serde::Deserialize)]
73struct SavedSession {
74    running_summary: Option<String>,
75    #[serde(default)]
76    session_memory: crate::agent::compaction::SessionMemory,
77    /// Last user message from the previous session — shown as resume hint on startup.
78    #[serde(default)]
79    last_goal: Option<String>,
80    /// Number of real inference turns completed in the previous session.
81    #[serde(default)]
82    turn_count: u32,
83}
84
85/// Snapshot of the previous session, surfaced on startup when a workspace is
86/// resumed after a restart or crash.
87pub struct CheckpointResume {
88    pub last_goal: String,
89    pub turn_count: u32,
90    pub working_files: Vec<String>,
91    pub last_verify_ok: Option<bool>,
92}
93
94/// Load the prior-session checkpoint from `.hematite/session.json`.
95/// Returns `None` when there is no prior session or it has no real turns.
96pub fn load_checkpoint() -> Option<CheckpointResume> {
97    let path = session_path();
98    let data = std::fs::read_to_string(&path).ok()?;
99    let saved: SavedSession = serde_json::from_str(&data).ok()?;
100    let goal = saved.last_goal.filter(|g| !g.trim().is_empty())?;
101    if saved.turn_count == 0 {
102        return None;
103    }
104    let mut working_files: Vec<String> = saved
105        .session_memory
106        .working_set
107        .into_iter()
108        .take(4)
109        .collect();
110    working_files.sort();
111    let last_verify_ok = saved.session_memory.last_verification.map(|v| v.successful);
112    Some(CheckpointResume {
113        last_goal: goal,
114        turn_count: saved.turn_count,
115        working_files,
116        last_verify_ok,
117    })
118}
119
120#[derive(Default)]
121struct ActionGroundingState {
122    turn_index: u64,
123    observed_paths: std::collections::HashMap<String, u64>,
124    inspected_paths: std::collections::HashMap<String, u64>,
125    last_verify_build_turn: Option<u64>,
126    last_verify_build_ok: bool,
127    last_failed_build_paths: Vec<String>,
128    code_changed_since_verify: bool,
129    /// Track topics redirected from shell to inspect_host in the current turn to break loops.
130    redirected_host_inspection_topics: std::collections::HashMap<String, u64>,
131}
132
133struct PlanExecutionGuard {
134    flag: Arc<std::sync::atomic::AtomicBool>,
135}
136
137impl Drop for PlanExecutionGuard {
138    fn drop(&mut self) {
139        self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum WorkflowMode {
145    #[default]
146    Auto,
147    Ask,
148    Code,
149    Architect,
150    ReadOnly,
151    /// Clean conversational mode — lighter prompt, no coding agent scaffolding,
152    /// tools available but not pushed. Vein RAG still runs for context.
153    Chat,
154    /// Teacher/guide mode — inspect the real machine state first, then walk the user
155    /// through the admin/config task as a grounded, numbered tutorial. Never executes
156    /// write operations itself; instructs the user to perform them manually.
157    Teach,
158}
159
160impl WorkflowMode {
161    fn label(self) -> &'static str {
162        match self {
163            WorkflowMode::Auto => "AUTO",
164            WorkflowMode::Ask => "ASK",
165            WorkflowMode::Code => "CODE",
166            WorkflowMode::Architect => "ARCHITECT",
167            WorkflowMode::ReadOnly => "READ-ONLY",
168            WorkflowMode::Chat => "CHAT",
169            WorkflowMode::Teach => "TEACH",
170        }
171    }
172
173    fn is_read_only(self) -> bool {
174        matches!(
175            self,
176            WorkflowMode::Ask
177                | WorkflowMode::Architect
178                | WorkflowMode::ReadOnly
179                | WorkflowMode::Teach
180        )
181    }
182
183    pub(crate) fn is_chat(self) -> bool {
184        matches!(self, WorkflowMode::Chat)
185    }
186}
187
188fn session_path() -> std::path::PathBuf {
189    if let Ok(overridden) = std::env::var("HEMATITE_SESSION_PATH") {
190        return std::path::PathBuf::from(overridden);
191    }
192    crate::tools::file_ops::hematite_dir().join("session.json")
193}
194
195fn load_session_data() -> (Option<String>, crate::agent::compaction::SessionMemory) {
196    let path = session_path();
197    if !path.exists() {
198        return (None, crate::agent::compaction::SessionMemory::default());
199    }
200    let Ok(data) = std::fs::read_to_string(&path) else {
201        return (None, crate::agent::compaction::SessionMemory::default());
202    };
203    let Ok(saved) = serde_json::from_str::<SavedSession>(&data) else {
204        return (None, crate::agent::compaction::SessionMemory::default());
205    };
206    (saved.running_summary, saved.session_memory)
207}
208
209fn reset_task_files() {
210    let hdir = crate::tools::file_ops::hematite_dir();
211    let root = crate::tools::file_ops::workspace_root();
212    let _ = std::fs::remove_file(hdir.join("TASK.md"));
213    let _ = std::fs::remove_file(hdir.join("PLAN.md"));
214    let _ = std::fs::remove_file(hdir.join("WALKTHROUGH.md"));
215    let _ = std::fs::remove_file(root.join(".github").join("WALKTHROUGH.md"));
216    let _ = std::fs::write(hdir.join("TASK.md"), "");
217    let _ = std::fs::write(hdir.join("PLAN.md"), "");
218}
219
220fn purge_persistent_memory() {
221    let mem_dir = crate::tools::file_ops::hematite_dir().join("memories");
222    if mem_dir.exists() {
223        let _ = std::fs::remove_dir_all(&mem_dir);
224        let _ = std::fs::create_dir_all(&mem_dir);
225    }
226
227    let log_dir = crate::tools::file_ops::hematite_dir().join("logs");
228    if log_dir.exists() {
229        if let Ok(entries) = std::fs::read_dir(&log_dir) {
230            for entry in entries.flatten() {
231                let _ = std::fs::write(entry.path(), "");
232            }
233        }
234    }
235}
236
237fn apply_turn_attachments(user_turn: &UserTurn, prompt: &str) -> String {
238    let mut out = prompt.trim().to_string();
239    if let Some(doc) = user_turn.attached_document.as_ref() {
240        out = format!(
241            "[Attached document: {}]\n\n{}\n\n---\n\n{}",
242            doc.name, doc.content, out
243        );
244    }
245    if let Some(image) = user_turn.attached_image.as_ref() {
246        out = if out.is_empty() {
247            format!("[Attached image: {}]", image.name)
248        } else {
249            format!("[Attached image: {}]\n\n{}", image.name, out)
250        };
251    }
252    out
253}
254
255fn transcript_user_turn_text(user_turn: &UserTurn, prompt: &str) -> String {
256    let mut prefixes = Vec::new();
257    if let Some(doc) = user_turn.attached_document.as_ref() {
258        prefixes.push(format!("[Attached document: {}]", doc.name));
259    }
260    if let Some(image) = user_turn.attached_image.as_ref() {
261        prefixes.push(format!("[Attached image: {}]", image.name));
262    }
263    if prefixes.is_empty() {
264        prompt.to_string()
265    } else if prompt.trim().is_empty() {
266        prefixes.join("\n")
267    } else {
268        format!("{}\n{}", prefixes.join("\n"), prompt)
269    }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273enum RuntimeFailureClass {
274    ContextWindow,
275    ProviderDegraded,
276    ToolArgMalformed,
277    ToolPolicyBlocked,
278    ToolLoop,
279    VerificationFailed,
280    EmptyModelResponse,
281    Unknown,
282}
283
284impl RuntimeFailureClass {
285    fn tag(self) -> &'static str {
286        match self {
287            RuntimeFailureClass::ContextWindow => "context_window",
288            RuntimeFailureClass::ProviderDegraded => "provider_degraded",
289            RuntimeFailureClass::ToolArgMalformed => "tool_arg_malformed",
290            RuntimeFailureClass::ToolPolicyBlocked => "tool_policy_blocked",
291            RuntimeFailureClass::ToolLoop => "tool_loop",
292            RuntimeFailureClass::VerificationFailed => "verification_failed",
293            RuntimeFailureClass::EmptyModelResponse => "empty_model_response",
294            RuntimeFailureClass::Unknown => "unknown",
295        }
296    }
297
298    fn operator_guidance(self) -> &'static str {
299        match self {
300            RuntimeFailureClass::ContextWindow => {
301                "Narrow the request, compact the session, or preserve grounded tool output instead of restyling it. If LM Studio reports a smaller live n_ctx than Hematite expected, reload or re-detect the model budget before retrying."
302            }
303            RuntimeFailureClass::ProviderDegraded => {
304                "Retry once automatically, then narrow the turn or restart LM Studio if it persists."
305            }
306            RuntimeFailureClass::ToolArgMalformed => {
307                "Retry with repaired or narrower tool arguments instead of repeating the same malformed call."
308            }
309            RuntimeFailureClass::ToolPolicyBlocked => {
310                "Stay inside the allowed workflow or switch modes before retrying."
311            }
312            RuntimeFailureClass::ToolLoop => {
313                "Stop repeating the same failing tool pattern and switch to a narrower recovery step."
314            }
315            RuntimeFailureClass::VerificationFailed => {
316                "Fix the build or test failure before treating the task as complete."
317            }
318            RuntimeFailureClass::EmptyModelResponse => {
319                "Retry once automatically, then narrow the turn or restart LM Studio if the model keeps returning nothing."
320            }
321            RuntimeFailureClass::Unknown => {
322                "Inspect the latest grounded tool results or provider status before retrying."
323            }
324        }
325    }
326}
327
328fn classify_runtime_failure(detail: &str) -> RuntimeFailureClass {
329    let lower = detail.to_ascii_lowercase();
330    if lower.contains("context_window_blocked")
331        || lower.contains("context ceiling reached")
332        || lower.contains("exceeds the")
333        || ((lower.contains("n_keep") && lower.contains("n_ctx"))
334            || lower.contains("context length")
335            || lower.contains("keep from the initial prompt")
336            || lower.contains("prompt is greater than the context length"))
337    {
338        RuntimeFailureClass::ContextWindow
339    } else if lower.contains("empty response from model")
340        || lower.contains("model returned an empty response")
341    {
342        RuntimeFailureClass::EmptyModelResponse
343    } else if lower.contains("lm studio unreachable")
344        || lower.contains("lm studio error")
345        || lower.contains("request failed")
346        || lower.contains("response parse error")
347        || lower.contains("provider degraded")
348    {
349        RuntimeFailureClass::ProviderDegraded
350    } else if lower.contains("missing required argument")
351        || lower.contains("json repair failed")
352        || lower.contains("invalid pattern")
353        || lower.contains("invalid line range")
354    {
355        RuntimeFailureClass::ToolArgMalformed
356    } else if lower.contains("action blocked:")
357        || lower.contains("access denied")
358        || lower.contains("declined by user")
359    {
360        RuntimeFailureClass::ToolPolicyBlocked
361    } else if lower.contains("too many consecutive tool errors")
362        || lower.contains("repeated tool failures")
363        || lower.contains("stuck in a loop")
364    {
365        RuntimeFailureClass::ToolLoop
366    } else if lower.contains("build failed")
367        || lower.contains("verification failed")
368        || lower.contains("verify_build")
369    {
370        RuntimeFailureClass::VerificationFailed
371    } else {
372        RuntimeFailureClass::Unknown
373    }
374}
375
376fn format_runtime_failure(class: RuntimeFailureClass, detail: &str) -> String {
377    format!(
378        "[failure:{}] {} Detail: {}",
379        class.tag(),
380        class.operator_guidance(),
381        detail.trim()
382    )
383}
384
385fn provider_state_for_runtime_failure(class: RuntimeFailureClass) -> Option<ProviderRuntimeState> {
386    match class {
387        RuntimeFailureClass::ContextWindow => Some(ProviderRuntimeState::ContextWindow),
388        RuntimeFailureClass::ProviderDegraded => Some(ProviderRuntimeState::Degraded),
389        RuntimeFailureClass::EmptyModelResponse => Some(ProviderRuntimeState::EmptyResponse),
390        _ => None,
391    }
392}
393
394fn checkpoint_state_for_runtime_failure(
395    class: RuntimeFailureClass,
396) -> Option<OperatorCheckpointState> {
397    match class {
398        RuntimeFailureClass::ContextWindow => Some(OperatorCheckpointState::BlockedContextWindow),
399        RuntimeFailureClass::ToolPolicyBlocked => Some(OperatorCheckpointState::BlockedPolicy),
400        RuntimeFailureClass::ToolLoop => Some(OperatorCheckpointState::BlockedToolLoop),
401        RuntimeFailureClass::VerificationFailed => {
402            Some(OperatorCheckpointState::BlockedVerification)
403        }
404        _ => None,
405    }
406}
407
408fn compact_runtime_recovery_summary(class: RuntimeFailureClass) -> &'static str {
409    match class {
410        RuntimeFailureClass::ProviderDegraded => {
411            "LM Studio degraded during the turn; retrying once before surfacing a failure."
412        }
413        RuntimeFailureClass::EmptyModelResponse => {
414            "The model returned an empty reply; retrying once before surfacing a failure."
415        }
416        _ => "Runtime recovery in progress.",
417    }
418}
419
420fn checkpoint_summary_for_runtime_failure(class: RuntimeFailureClass) -> &'static str {
421    match class {
422        RuntimeFailureClass::ContextWindow => "Provider context ceiling confirmed.",
423        RuntimeFailureClass::ToolPolicyBlocked => "Policy blocked the current action.",
424        RuntimeFailureClass::ToolLoop => "Repeated failing tool pattern stopped.",
425        RuntimeFailureClass::VerificationFailed => "Verification failed; fix before continuing.",
426        _ => "Operator checkpoint updated.",
427    }
428}
429
430fn compact_runtime_failure_summary(class: RuntimeFailureClass) -> &'static str {
431    match class {
432        RuntimeFailureClass::ContextWindow => "LM context ceiling hit.",
433        RuntimeFailureClass::ProviderDegraded => {
434            "LM Studio degraded and did not recover cleanly; operator action is now required."
435        }
436        RuntimeFailureClass::EmptyModelResponse => {
437            "LM Studio returned an empty reply after recovery; operator action is now required."
438        }
439        RuntimeFailureClass::ToolLoop => {
440            "Repeated failing tool pattern detected; Hematite stopped the loop."
441        }
442        _ => "Runtime failure surfaced to the operator.",
443    }
444}
445
446fn should_retry_runtime_failure(class: RuntimeFailureClass) -> bool {
447    matches!(
448        class,
449        RuntimeFailureClass::ProviderDegraded | RuntimeFailureClass::EmptyModelResponse
450    )
451}
452
453fn recovery_scenario_for_runtime_failure(class: RuntimeFailureClass) -> Option<RecoveryScenario> {
454    match class {
455        RuntimeFailureClass::ContextWindow => Some(RecoveryScenario::ContextWindow),
456        RuntimeFailureClass::ProviderDegraded => Some(RecoveryScenario::ProviderDegraded),
457        RuntimeFailureClass::EmptyModelResponse => Some(RecoveryScenario::EmptyModelResponse),
458        RuntimeFailureClass::ToolPolicyBlocked => Some(RecoveryScenario::McpWorkspaceReadBlocked),
459        RuntimeFailureClass::ToolLoop => Some(RecoveryScenario::ToolLoop),
460        RuntimeFailureClass::VerificationFailed => Some(RecoveryScenario::VerificationFailed),
461        RuntimeFailureClass::ToolArgMalformed | RuntimeFailureClass::Unknown => None,
462    }
463}
464
465fn compact_recovery_plan_summary(plan: &RecoveryPlan) -> String {
466    format!(
467        "{} [{}]",
468        plan.recipe.scenario.label(),
469        plan.recipe.steps_summary()
470    )
471}
472
473fn compact_recovery_decision_summary(decision: &RecoveryDecision) -> String {
474    match decision {
475        RecoveryDecision::Attempt(plan) => compact_recovery_plan_summary(plan),
476        RecoveryDecision::Escalate {
477            recipe,
478            attempts_made,
479            ..
480        } => format!(
481            "{} escalated after {} / {} [{}]",
482            recipe.scenario.label(),
483            attempts_made,
484            recipe.max_attempts.max(1),
485            recipe.steps_summary()
486        ),
487    }
488}
489
490/// Parse file paths from cargo/compiler error output.
491/// Handles lines like `  --> src/foo/bar.rs:34:12` and `error: could not compile`.
492fn parse_failing_paths_from_build_output(output: &str) -> Vec<String> {
493    let root = crate::tools::file_ops::workspace_root();
494    let mut paths: Vec<String> = output
495        .lines()
496        .filter_map(|line| {
497            let trimmed = line.trim_start();
498            // Cargo error location: "--> path/to/file.rs:line:col"
499            let after_arrow = trimmed.strip_prefix("--> ")?;
500            let file_part = after_arrow.split(':').next()?;
501            if file_part.is_empty() || file_part.starts_with('<') {
502                return None;
503            }
504            let p = std::path::Path::new(file_part);
505            let resolved = if p.is_absolute() {
506                p.to_path_buf()
507            } else {
508                root.join(p)
509            };
510            Some(resolved.to_string_lossy().replace('\\', "/").to_lowercase())
511        })
512        .collect();
513    paths.sort();
514    paths.dedup();
515    paths
516}
517
518fn build_mode_redirect_answer(mode: WorkflowMode) -> String {
519    match mode {
520        WorkflowMode::Ask => "Workflow mode ASK is read-only. I can inspect the code, explain what should change, or review the target area, but I will not modify files here. Switch to `/code` to implement the change, or `/auto` to let Hematite choose.".to_string(),
521        WorkflowMode::Architect => "Workflow mode ARCHITECT is plan-first. I can inspect the code and design the implementation approach, but I will not mutate files until you explicitly switch to `/code` or ask me to implement.".to_string(),
522        WorkflowMode::ReadOnly => "Workflow mode READ-ONLY is a hard no-mutation mode. I can analyze, inspect, and explain, but I will not edit files, run mutating shell commands, or commit changes. Switch to `/code` or `/auto` if you want implementation.".to_string(),
523        WorkflowMode::Teach => "Workflow mode TEACH is a guided walkthrough mode. I will inspect the real state of your machine first, then give you a numbered step-by-step tutorial so you can perform the task yourself. I do not execute write operations in TEACH mode — I show you exactly how to do it.".to_string(),
524        _ => "Switch to `/code` or `/auto` to allow implementation.".to_string(),
525    }
526}
527
528fn architect_handoff_contract() -> &'static str {
529    "ARCHITECT OUTPUT CONTRACT:\n\
530Use a compact implementation handoff, not a process narrative.\n\
531Do not say \"the first step\" or describe what you are about to do.\n\
532After one or two read-only inspection tools at most, stop and answer.\n\
533For runtime wiring, reset behavior, or control-flow questions, prefer `trace_runtime_flow`.\n\
534Use these exact ASCII headings and keep each section short:\n\
535# Goal\n\
536# Target Files\n\
537# Ordered Steps\n\
538# Verification\n\
539# Risks\n\
540# Open Questions\n\
541Keep the whole handoff concise and implementation-oriented."
542}
543
544fn implement_current_plan_prompt() -> &'static str {
545    "Implement the current plan."
546}
547
548fn architect_handoff_operator_note(plan: &crate::tools::plan::PlanHandoff) -> String {
549    format!(
550        "Implementation handoff saved to `.hematite/PLAN.md`.\nNext step: run `/implement-plan` to execute it in `/code`, or use `/code {}` directly.\nPlan: {}",
551        implement_current_plan_prompt().to_ascii_lowercase(),
552        plan.summary_line()
553    )
554}
555
556fn is_current_plan_execution_request(user_input: &str) -> bool {
557    let lower = user_input.trim().to_ascii_lowercase();
558    lower == "/implement-plan"
559        || lower == implement_current_plan_prompt().to_ascii_lowercase()
560        || lower
561            == implement_current_plan_prompt()
562                .trim_end_matches('.')
563                .to_ascii_lowercase()
564        || lower.contains("implement the current plan")
565}
566
567fn is_plan_scoped_tool(name: &str) -> bool {
568    crate::agent::inference::tool_metadata_for_name(name).plan_scope
569}
570
571fn is_current_plan_irrelevant_tool(name: &str) -> bool {
572    !crate::agent::inference::tool_metadata_for_name(name).plan_scope
573}
574
575fn is_non_mutating_plan_step_tool(name: &str) -> bool {
576    let metadata = crate::agent::inference::tool_metadata_for_name(name);
577    metadata.plan_scope && !metadata.mutates_workspace
578}
579
580fn parse_inline_workflow_prompt(user_input: &str) -> Option<(WorkflowMode, &str)> {
581    let trimmed = user_input.trim();
582    for (prefix, mode) in [
583        ("/ask", WorkflowMode::Ask),
584        ("/code", WorkflowMode::Code),
585        ("/architect", WorkflowMode::Architect),
586        ("/read-only", WorkflowMode::ReadOnly),
587        ("/auto", WorkflowMode::Auto),
588        ("/teach", WorkflowMode::Teach),
589    ] {
590        if let Some(rest) = trimmed.strip_prefix(prefix) {
591            let rest = rest.trim();
592            if !rest.is_empty() {
593                return Some((mode, rest));
594            }
595        }
596    }
597    None
598}
599
600// Tool catalogue
601
602/// Returns the full set of tools exposed to the model.
603pub fn get_tools() -> Vec<ToolDefinition> {
604    crate::agent::tool_registry::get_tools()
605}
606
607fn is_natural_language_hallucination(input: &str) -> bool {
608    let lower = input.to_lowercase();
609    let words = lower.split_whitespace().collect::<Vec<_>>();
610
611    // 1. Sentences starting with conversational phrases
612    if words.is_empty() {
613        return false;
614    }
615    let first = words[0];
616    if [
617        "make", "create", "i", "can", "please", "we", "let's", "go", "execute", "run", "how",
618    ]
619    .contains(&first)
620    {
621        // If it's more than 2 words, it's likely a sentence, not a command
622        if words.len() >= 3 {
623            return true;
624        }
625    }
626
627    // 2. Presence of English stop-words that are rare in CLI commands
628    let stop_words = [
629        "the", "a", "an", "on", "my", "your", "for", "with", "into", "onto",
630    ];
631    let stop_count = words.iter().filter(|w| stop_words.contains(w)).count();
632    if stop_count >= 2 {
633        return true;
634    }
635
636    // 3. Lack of common CLI separators if many words exist
637    if words.len() >= 5
638        && !input.contains('-')
639        && !input.contains('/')
640        && !input.contains('\\')
641        && !input.contains('.')
642    {
643        return true;
644    }
645
646    false
647}
648
649pub struct ConversationManager {
650    /// Full conversation history in OpenAI format.
651    pub history: Vec<ChatMessage>,
652    pub engine: Arc<InferenceEngine>,
653    pub tools: Vec<ToolDefinition>,
654    pub mcp_manager: Arc<Mutex<crate::agent::mcp_manager::McpManager>>,
655    pub professional: bool,
656    pub brief: bool,
657    pub snark: u8,
658    pub chaos: u8,
659    /// Model to use for simple read-only tasks (optional, user-supplied via --fast-model).
660    pub fast_model: Option<String>,
661    /// Model to use for complex write/build tasks (optional, user-supplied via --think-model).
662    pub think_model: Option<String>,
663    /// Files where whitespace auto-correction fired this session.
664    pub correction_hints: Vec<String>,
665    /// Running background summary of pruned older messages.
666    pub running_summary: Option<String>,
667    /// Live hardware telemetry handle.
668    pub gpu_state: Arc<GpuState>,
669    /// Local RAG memory — FTS5-indexed project source.
670    pub vein: crate::memory::vein::Vein,
671    /// Append-only session transcript logger.
672    pub transcript: crate::agent::transcript::TranscriptLogger,
673    /// Thread-safe cancellation signal for the current agent turn.
674    pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
675    /// Shared Git remote state (for persistent connectivity checks).
676    pub git_state: Arc<crate::agent::git_monitor::GitState>,
677    /// Reasoning think-mode override. None = let model decide. Some(true) = force /think.
678    /// Some(false) = force /no_think (fast mode, 3-5x quicker for simple tasks).
679    pub think_mode: Option<bool>,
680    workflow_mode: WorkflowMode,
681    /// Layer 6: Dynamic Task Context (extracted during compaction)
682    pub session_memory: crate::agent::compaction::SessionMemory,
683    pub swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
684    pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
685    /// Personality description for the current Rusty soul — used in chat mode system prompt.
686    pub soul_personality: String,
687    pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
688    /// Active reasoning summary extracted from the previous model turn (Gemma-4 Native).
689    pub reasoning_history: Option<String>,
690    /// Layer 8: Active Reference Pinning (Context Locked)
691    pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
692    /// Hard action-grounding state for proof-before-action checks.
693    action_grounding: Arc<Mutex<ActionGroundingState>>,
694    /// True only during `/code Implement the current plan.` style execution turns.
695    plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
696    /// Typed per-turn recovery attempt tracking.
697    recovery_context: RecoveryContext,
698    /// L1 context block — hot files summary injected into the system prompt.
699    /// Built once after vein init and updated as edits accumulate heat.
700    pub l1_context: Option<String>,
701    /// Condensed AST repository layout for the active project.
702    pub repo_map: Option<String>,
703    /// Number of real inference turns completed this session.
704    pub turn_count: u32,
705    /// Last user message sent to the model — persisted as checkpoint goal.
706    pub last_goal: Option<String>,
707    /// Most recent project directory created this session (Automatic Dive-In).
708    pub latest_target_dir: Option<String>,
709}
710
711impl ConversationManager {
712    fn vein_docs_only_mode(&self) -> bool {
713        !crate::tools::file_ops::is_project_workspace()
714    }
715
716    fn refresh_vein_index(&mut self) -> usize {
717        let count = if self.vein_docs_only_mode() {
718            tokio::task::block_in_place(|| {
719                self.vein
720                    .index_workspace_artifacts(&crate::tools::file_ops::hematite_dir())
721            })
722        } else {
723            tokio::task::block_in_place(|| self.vein.index_project())
724        };
725        self.l1_context = self.vein.l1_context();
726        count
727    }
728
729    fn build_vein_inspection_report(&self, indexed_this_pass: usize) -> String {
730        let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(8));
731        let workspace_mode = if self.vein_docs_only_mode() {
732            "docs-only (outside a project workspace)"
733        } else {
734            "project workspace"
735        };
736        let active_room = snapshot.active_room.as_deref().unwrap_or("none");
737        let mut out = format!(
738            "Vein Inspection\n\
739             Workspace mode: {workspace_mode}\n\
740             Indexed this pass: {indexed_this_pass}\n\
741             Indexed source files: {}\n\
742             Indexed docs: {}\n\
743             Indexed session exchanges: {}\n\
744             Embedded source/doc chunks: {}\n\
745             Embeddings available: {}\n\
746             Active room bias: {active_room}\n\
747             L1 hot-files block: {}\n",
748            snapshot.indexed_source_files,
749            snapshot.indexed_docs,
750            snapshot.indexed_session_exchanges,
751            snapshot.embedded_source_doc_chunks,
752            if snapshot.has_any_embeddings {
753                "yes"
754            } else {
755                "no"
756            },
757            if snapshot.l1_ready {
758                "ready"
759            } else {
760                "not built yet"
761            },
762        );
763
764        if snapshot.hot_files.is_empty() {
765            out.push_str("Hot files: none yet.\n");
766            return out;
767        }
768
769        out.push_str("\nHot files by room:\n");
770        let mut by_room: std::collections::BTreeMap<&str, Vec<&crate::memory::vein::VeinHotFile>> =
771            std::collections::BTreeMap::new();
772        for file in &snapshot.hot_files {
773            by_room.entry(file.room.as_str()).or_default().push(file);
774        }
775        for (room, files) in by_room {
776            out.push_str(&format!("[{}]\n", room));
777            for file in files {
778                out.push_str(&format!(
779                    "- {} [{} edit{}]\n",
780                    file.path,
781                    file.heat,
782                    if file.heat == 1 { "" } else { "s" }
783                ));
784            }
785        }
786
787        out
788    }
789
790    fn latest_user_prompt(&self) -> Option<&str> {
791        self.history
792            .iter()
793            .rev()
794            .find(|msg| msg.role == "user")
795            .map(|msg| msg.content.as_str())
796    }
797
798    async fn emit_direct_response(
799        &mut self,
800        tx: &mpsc::Sender<InferenceEvent>,
801        raw_user_input: &str,
802        effective_user_input: &str,
803        response: &str,
804    ) {
805        self.history.push(ChatMessage::user(effective_user_input));
806        self.history.push(ChatMessage::assistant_text(response));
807        self.transcript.log_user(raw_user_input);
808        self.transcript.log_agent(response);
809        for chunk in chunk_text(response, 8) {
810            if !chunk.is_empty() {
811                let _ = tx.send(InferenceEvent::Token(chunk)).await;
812            }
813        }
814        if let Some(path) = self.latest_target_dir.take() {
815            let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
816        }
817        let _ = tx.send(InferenceEvent::Done).await;
818        self.trim_history(80);
819        self.refresh_session_memory();
820        self.save_session();
821    }
822
823    async fn emit_operator_checkpoint(
824        &mut self,
825        tx: &mpsc::Sender<InferenceEvent>,
826        state: OperatorCheckpointState,
827        summary: impl Into<String>,
828    ) {
829        let summary = summary.into();
830        self.session_memory
831            .record_checkpoint(state.label(), summary.clone());
832        let _ = tx
833            .send(InferenceEvent::OperatorCheckpoint { state, summary })
834            .await;
835    }
836
837    async fn emit_recovery_recipe_summary(
838        &mut self,
839        tx: &mpsc::Sender<InferenceEvent>,
840        state: impl Into<String>,
841        summary: impl Into<String>,
842    ) {
843        let state = state.into();
844        let summary = summary.into();
845        self.session_memory.record_recovery(state, summary.clone());
846        let _ = tx.send(InferenceEvent::RecoveryRecipe { summary }).await;
847    }
848
849    async fn emit_provider_live(&mut self, tx: &mpsc::Sender<InferenceEvent>) {
850        let _ = tx
851            .send(InferenceEvent::ProviderStatus {
852                state: ProviderRuntimeState::Live,
853                summary: String::new(),
854            })
855            .await;
856        self.emit_operator_checkpoint(tx, OperatorCheckpointState::Idle, "")
857            .await;
858    }
859
860    async fn emit_prompt_pressure_for_messages(
861        &self,
862        tx: &mpsc::Sender<InferenceEvent>,
863        messages: &[ChatMessage],
864    ) {
865        let context_length = self.engine.current_context_length();
866        let (estimated_input_tokens, reserved_output_tokens, estimated_total_tokens, percent) =
867            crate::agent::inference::estimate_prompt_pressure(
868                messages,
869                &self.tools,
870                context_length,
871            );
872        let _ = tx
873            .send(InferenceEvent::PromptPressure {
874                estimated_input_tokens,
875                reserved_output_tokens,
876                estimated_total_tokens,
877                context_length,
878                percent,
879            })
880            .await;
881    }
882
883    async fn emit_prompt_pressure_idle(&self, tx: &mpsc::Sender<InferenceEvent>) {
884        let context_length = self.engine.current_context_length();
885        let _ = tx
886            .send(InferenceEvent::PromptPressure {
887                estimated_input_tokens: 0,
888                reserved_output_tokens: 0,
889                estimated_total_tokens: 0,
890                context_length,
891                percent: 0,
892            })
893            .await;
894    }
895
896    async fn emit_compaction_pressure(&self, tx: &mpsc::Sender<InferenceEvent>) {
897        let context_length = self.engine.current_context_length();
898        let vram_ratio = self.gpu_state.ratio();
899        let config = CompactionConfig::adaptive(context_length, vram_ratio);
900        let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
901        let percent = if config.max_estimated_tokens == 0 {
902            0
903        } else {
904            ((estimated_tokens.saturating_mul(100)) / config.max_estimated_tokens).min(100) as u8
905        };
906
907        let _ = tx
908            .send(InferenceEvent::CompactionPressure {
909                estimated_tokens,
910                threshold_tokens: config.max_estimated_tokens,
911                percent,
912            })
913            .await;
914    }
915
916    async fn refresh_runtime_profile_and_report(
917        &mut self,
918        tx: &mpsc::Sender<InferenceEvent>,
919        reason: &str,
920    ) -> Option<(String, usize, bool)> {
921        let refreshed = self.engine.refresh_runtime_profile().await;
922        if let Some((model_id, context_length, changed)) = refreshed.as_ref() {
923            let _ = tx
924                .send(InferenceEvent::RuntimeProfile {
925                    model_id: model_id.clone(),
926                    context_length: *context_length,
927                })
928                .await;
929            self.transcript.log_system(&format!(
930                "Runtime profile refresh ({}): model={} ctx={} changed={}",
931                reason, model_id, context_length, changed
932            ));
933        }
934        refreshed
935    }
936
937    pub fn new(
938        engine: Arc<InferenceEngine>,
939        professional: bool,
940        brief: bool,
941        snark: u8,
942        chaos: u8,
943        soul_personality: String,
944        fast_model: Option<String>,
945        think_model: Option<String>,
946        gpu_state: Arc<GpuState>,
947        git_state: Arc<crate::agent::git_monitor::GitState>,
948        swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
949        voice_manager: Arc<crate::ui::voice::VoiceManager>,
950    ) -> Self {
951        let (saved_summary, saved_memory) = load_session_data();
952
953        // Build the initial mcp_manager
954        let mcp_manager = Arc::new(tokio::sync::Mutex::new(
955            crate::agent::mcp_manager::McpManager::new(),
956        ));
957
958        // Build the initial system prompt using the canonical InferenceEngine path.
959        let dynamic_instructions =
960            engine.build_system_prompt(snark, chaos, brief, professional, &[], None, &[]);
961
962        let history = vec![ChatMessage::system(&dynamic_instructions)];
963
964        let vein_path = crate::tools::file_ops::hematite_dir().join("vein.db");
965        let vein_base_url = engine.base_url.clone();
966        let vein = crate::memory::vein::Vein::new(&vein_path, vein_base_url.clone())
967            .unwrap_or_else(|_| crate::memory::vein::Vein::new(":memory:", vein_base_url).unwrap());
968
969        Self {
970            history,
971            engine,
972            tools: get_tools(),
973            mcp_manager,
974            professional,
975            brief,
976            snark,
977            chaos,
978            fast_model,
979            think_model,
980            correction_hints: Vec::new(),
981            running_summary: saved_summary,
982            gpu_state,
983            vein,
984            transcript: crate::agent::transcript::TranscriptLogger::new(),
985            cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
986            git_state,
987            think_mode: None,
988            workflow_mode: WorkflowMode::Auto,
989            session_memory: saved_memory,
990            swarm_coordinator,
991            voice_manager,
992            soul_personality,
993            lsp_manager: Arc::new(Mutex::new(crate::agent::lsp::manager::LspManager::new(
994                crate::tools::file_ops::workspace_root(),
995            ))),
996            reasoning_history: None,
997            pinned_files: Arc::new(Mutex::new(std::collections::HashMap::new())),
998            action_grounding: Arc::new(Mutex::new(ActionGroundingState::default())),
999            plan_execution_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1000            recovery_context: RecoveryContext::default(),
1001            l1_context: None,
1002            repo_map: None,
1003            turn_count: 0,
1004            last_goal: None,
1005            latest_target_dir: None,
1006        }
1007    }
1008
1009    async fn emit_done_events(&mut self, tx: &tokio::sync::mpsc::Sender<InferenceEvent>) {
1010        if let Some(path) = self.latest_target_dir.take() {
1011            let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1012        }
1013        let _ = tx.send(InferenceEvent::Done).await;
1014    }
1015
1016    /// Index the project into The Vein. Call once after construction.
1017    /// Uses block_in_place so the tokio runtime thread isn't parked.
1018    pub fn initialize_vein(&mut self) -> usize {
1019        self.refresh_vein_index()
1020    }
1021
1022    /// Generate the AST Repo Map. Call once after construction or when resetting context.
1023    pub fn initialize_repo_map(&mut self) {
1024        if !self.vein_docs_only_mode() {
1025            let root = crate::tools::file_ops::workspace_root();
1026            let hot = self.vein.hot_files_weighted(10);
1027            let gen = crate::memory::repo_map::RepoMapGenerator::new(&root).with_hot_files(&hot);
1028            match tokio::task::block_in_place(|| gen.generate()) {
1029                Ok(map) => self.repo_map = Some(map),
1030                Err(e) => {
1031                    self.repo_map = Some(format!("Repo Map generation failed: {}", e));
1032                }
1033            }
1034        }
1035    }
1036
1037    /// Re-generate the repo map after a file edit so rankings stay fresh.
1038    /// Lightweight (~100-200ms) — called after successful mutations.
1039    fn refresh_repo_map(&mut self) {
1040        self.initialize_repo_map();
1041    }
1042
1043    fn save_session(&self) {
1044        let path = session_path();
1045        if let Some(parent) = path.parent() {
1046            let _ = std::fs::create_dir_all(parent);
1047        }
1048        let saved = SavedSession {
1049            running_summary: self.running_summary.clone(),
1050            session_memory: self.session_memory.clone(),
1051            last_goal: self.last_goal.clone(),
1052            turn_count: self.turn_count,
1053        };
1054        if let Ok(json) = serde_json::to_string(&saved) {
1055            let _ = std::fs::write(&path, json);
1056        }
1057    }
1058
1059    fn save_empty_session(&self) {
1060        let path = session_path();
1061        if let Some(parent) = path.parent() {
1062            let _ = std::fs::create_dir_all(parent);
1063        }
1064        let saved = SavedSession {
1065            running_summary: None,
1066            session_memory: crate::agent::compaction::SessionMemory::default(),
1067            last_goal: None,
1068            turn_count: 0,
1069        };
1070        if let Ok(json) = serde_json::to_string(&saved) {
1071            let _ = std::fs::write(&path, json);
1072        }
1073    }
1074
1075    fn refresh_session_memory(&mut self) {
1076        let current_plan = self.session_memory.current_plan.clone();
1077        let previous_memory = self.session_memory.clone();
1078        self.session_memory = compaction::extract_memory(&self.history);
1079        self.session_memory.current_plan = current_plan;
1080        self.session_memory
1081            .inherit_runtime_ledger_from(&previous_memory);
1082    }
1083
1084    fn build_chat_system_prompt(&self) -> String {
1085        let species = &self.engine.species;
1086        let personality = &self.soul_personality;
1087        format!(
1088            "You are {species}, a local AI companion running entirely on the user's GPU — no cloud, no subscriptions, no phoning home.\n\
1089             {personality}\n\n\
1090             This is CHAT mode — a clean conversational surface. Behave like a sharp friend who happens to know everything about code, not like an agent following a workflow.\n\n\
1091             Rules:\n\
1092             - Talk like a person. Skip the bullet-point breakdowns unless the topic genuinely needs structure.\n\
1093             - Answer directly. One paragraph is usually right.\n\
1094             - Don't call tools unless the user explicitly asks you to look at a file or run something.\n\
1095             - Don't narrate your reasoning or mention tool names unprompted.\n\
1096             - You can discuss code, debug ideas, explain concepts, help plan, or just talk.\n\
1097             - If the user clearly wants you to edit or build something, do it — but lead with conversation, not scaffolding.\n\
1098             - If the user wants the full coding harness, they can type `/agent`.\n",
1099        )
1100    }
1101
1102    fn append_session_handoff(&self, system_msg: &mut String) {
1103        let has_summary = self
1104            .running_summary
1105            .as_ref()
1106            .map(|s| !s.trim().is_empty())
1107            .unwrap_or(false);
1108        let has_memory = self.session_memory.has_signal();
1109
1110        if !has_summary && !has_memory {
1111            return;
1112        }
1113
1114        system_msg.push_str(
1115            "\n\n# LIGHTWEIGHT SESSION HANDOFF\n\
1116             This is compact carry-over from earlier work on this machine.\n\
1117             Use it only when it helps the current request.\n\
1118             Prefer current repository state, pinned files, and fresh tool results over stale session memory.\n",
1119        );
1120
1121        if has_memory {
1122            system_msg.push_str("\n## Active Task Memory\n");
1123            system_msg.push_str(&self.session_memory.to_prompt());
1124        }
1125
1126        if let Some(summary) = self.running_summary.as_deref() {
1127            if !summary.trim().is_empty() {
1128                system_msg.push_str("\n## Compacted Session Summary\n");
1129                system_msg.push_str(summary);
1130                system_msg.push('\n');
1131            }
1132        }
1133    }
1134
1135    fn set_workflow_mode(&mut self, mode: WorkflowMode) {
1136        self.workflow_mode = mode;
1137    }
1138
1139    fn current_plan_summary(&self) -> Option<String> {
1140        self.session_memory
1141            .current_plan
1142            .as_ref()
1143            .filter(|plan| plan.has_signal())
1144            .map(|plan| plan.summary_line())
1145    }
1146
1147    fn current_plan_allowed_paths(&self) -> Vec<String> {
1148        self.session_memory
1149            .current_plan
1150            .as_ref()
1151            .map(|plan| {
1152                plan.target_files
1153                    .iter()
1154                    .map(|path| normalize_workspace_path(path))
1155                    .collect()
1156            })
1157            .unwrap_or_default()
1158    }
1159
1160    fn persist_architect_handoff(
1161        &mut self,
1162        response: &str,
1163    ) -> Option<crate::tools::plan::PlanHandoff> {
1164        if self.workflow_mode != WorkflowMode::Architect {
1165            return None;
1166        }
1167        let Some(plan) = crate::tools::plan::parse_plan_handoff(response) else {
1168            return None;
1169        };
1170        let _ = crate::tools::plan::save_plan_handoff(&plan);
1171        self.session_memory.current_plan = Some(plan.clone());
1172        Some(plan)
1173    }
1174
1175    async fn begin_grounded_turn(&self) -> u64 {
1176        let mut state = self.action_grounding.lock().await;
1177        state.turn_index += 1;
1178        state.turn_index
1179    }
1180
1181    async fn reset_action_grounding(&self) {
1182        let mut state = self.action_grounding.lock().await;
1183        *state = ActionGroundingState::default();
1184    }
1185
1186    async fn record_read_observation(&self, path: &str) {
1187        let normalized = normalize_workspace_path(path);
1188        let mut state = self.action_grounding.lock().await;
1189        let turn = state.turn_index;
1190        // read_file returns full file content with line numbers — sufficient for
1191        // the model to know exact text before editing, so it satisfies the
1192        // line-inspection grounding check too.
1193        state.observed_paths.insert(normalized.clone(), turn);
1194        state.inspected_paths.insert(normalized, turn);
1195    }
1196
1197    async fn record_line_inspection(&self, path: &str) {
1198        let normalized = normalize_workspace_path(path);
1199        let mut state = self.action_grounding.lock().await;
1200        let turn = state.turn_index;
1201        state.observed_paths.insert(normalized.clone(), turn);
1202        state.inspected_paths.insert(normalized, turn);
1203    }
1204
1205    async fn record_verify_build_result(&self, ok: bool, output: &str) {
1206        let mut state = self.action_grounding.lock().await;
1207        let turn = state.turn_index;
1208        state.last_verify_build_turn = Some(turn);
1209        state.last_verify_build_ok = ok;
1210        if ok {
1211            state.code_changed_since_verify = false;
1212            state.last_failed_build_paths.clear();
1213        } else {
1214            state.last_failed_build_paths = parse_failing_paths_from_build_output(output);
1215        }
1216    }
1217
1218    fn record_session_verification(&mut self, ok: bool, summary: impl Into<String>) {
1219        self.session_memory.record_verification(ok, summary);
1220    }
1221
1222    async fn record_successful_mutation(&self, path: Option<&str>) {
1223        let mut state = self.action_grounding.lock().await;
1224        state.code_changed_since_verify = match path {
1225            Some(p) => is_code_like_path(p),
1226            None => true,
1227        };
1228    }
1229
1230    async fn validate_action_preconditions(&self, name: &str, args: &Value) -> Result<(), String> {
1231        if self
1232            .plan_execution_active
1233            .load(std::sync::atomic::Ordering::SeqCst)
1234        {
1235            if is_current_plan_irrelevant_tool(name) {
1236                return Err(format!(
1237                    "Action blocked: `{}` is not part of current-plan execution. Stay on the saved target files, use built-in workspace file tools only, and either make a concrete edit or surface one specific blocker.",
1238                    name
1239                ));
1240            }
1241
1242            if is_plan_scoped_tool(name) {
1243                let allowed_paths = self.current_plan_allowed_paths();
1244                if !allowed_paths.is_empty() {
1245                    let in_allowed = match name {
1246                        "auto_pin_context" => args
1247                            .get("paths")
1248                            .and_then(|v| v.as_array())
1249                            .map(|paths| {
1250                                !paths.is_empty()
1251                                    && paths.iter().all(|v| {
1252                                        v.as_str()
1253                                            .map(normalize_workspace_path)
1254                                            .map(|p| allowed_paths.contains(&p))
1255                                            .unwrap_or(false)
1256                                    })
1257                            })
1258                            .unwrap_or(false),
1259                        "grep_files" | "list_files" => args
1260                            .get("path")
1261                            .and_then(|v| v.as_str())
1262                            .map(normalize_workspace_path)
1263                            .map(|p| allowed_paths.contains(&p))
1264                            .unwrap_or(false),
1265                        _ => action_target_path(name, args)
1266                            .map(|p| allowed_paths.contains(&p))
1267                            .unwrap_or(false),
1268                    };
1269
1270                    if !in_allowed {
1271                        let allowed = allowed_paths
1272                            .iter()
1273                            .map(|p| format!("`{}`", p))
1274                            .collect::<Vec<_>>()
1275                            .join(", ");
1276                        return Err(format!(
1277                            "Action blocked: current-plan execution is locked to the saved target files. Use a path-scoped built-in tool on one of these files only: {}.",
1278                            allowed
1279                        ));
1280                    }
1281                }
1282            }
1283
1284            if matches!(name, "edit_file" | "multi_search_replace" | "patch_hunk") {
1285                if let Some(target) = action_target_path(name, args) {
1286                    let state = self.action_grounding.lock().await;
1287                    let recently_inspected = state
1288                        .inspected_paths
1289                        .get(&target)
1290                        .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1291                        .unwrap_or(false);
1292                    drop(state);
1293                    if !recently_inspected {
1294                        return Err(format!(
1295                            "Action blocked: `{}` on '{}' requires an exact local line window first during current-plan execution. Use `inspect_lines` on that file around the intended edit region, then retry the mutation.",
1296                            name, target
1297                        ));
1298                    }
1299                }
1300            }
1301        }
1302
1303        if self.workflow_mode.is_read_only() && name == "auto_pin_context" {
1304            return Err(
1305                "Action blocked: `auto_pin_context` is disabled in read-only workflows. Use the grounded file evidence you already have, or narrow with `inspect_lines` instead of pinning more files into active context."
1306                    .to_string(),
1307            );
1308        }
1309
1310        if self.workflow_mode.is_read_only() && is_destructive_tool(name) {
1311            if name == "shell" {
1312                let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
1313                let risk = crate::tools::guard::classify_bash_risk(command);
1314                if !matches!(risk, crate::tools::RiskLevel::Safe) {
1315                    return Err(format!(
1316                        "Action blocked: workflow mode `{}` is read-only for risky or mutating operations. Switch to `/code` or `/auto` before making changes.",
1317                        self.workflow_mode.label()
1318                    ));
1319                }
1320            } else {
1321                return Err(format!(
1322                    "Action blocked: workflow mode `{}` is read-only. Use `/code` to implement changes or `/auto` to leave mode selection to Hematite.",
1323                    self.workflow_mode.label()
1324                ));
1325            }
1326        }
1327
1328        let normalized_target = action_target_path(name, args);
1329        if let Some(target) = normalized_target.as_deref() {
1330            if matches!(
1331                name,
1332                "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
1333            ) {
1334                if let Some(prompt) = self.latest_user_prompt() {
1335                    if docs_edit_without_explicit_request(prompt, target) {
1336                        return Err(format!(
1337                            "Action blocked: '{}' is a docs file but the current request did not explicitly ask for documentation changes. Finish the code task first. If docs need updating, the user will ask.",
1338                            target
1339                        ));
1340                    }
1341                }
1342            }
1343            let path_exists = std::path::Path::new(target).exists();
1344            if path_exists {
1345                let state = self.action_grounding.lock().await;
1346                let pinned = self.pinned_files.lock().await;
1347                let pinned_match = pinned.keys().any(|p| normalize_workspace_path(p) == target);
1348                drop(pinned);
1349
1350                // edit_file and multi_search_replace match text exactly, so they need a
1351                // tighter evidence bar than a plain read. Require inspect_lines on the
1352                // target within the last 3 turns. A read_file in the *same* turn is also
1353                // accepted (the model just loaded the file and is making an immediate edit).
1354                let needs_exact_window = matches!(name, "edit_file" | "multi_search_replace");
1355                let recently_inspected = state
1356                    .inspected_paths
1357                    .get(target)
1358                    .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1359                    .unwrap_or(false);
1360                let same_turn_read = state
1361                    .observed_paths
1362                    .get(target)
1363                    .map(|turn| state.turn_index.saturating_sub(*turn) == 0)
1364                    .unwrap_or(false);
1365                let recent_observed = state
1366                    .observed_paths
1367                    .get(target)
1368                    .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1369                    .unwrap_or(false);
1370
1371                if needs_exact_window {
1372                    if !recently_inspected && !same_turn_read && !pinned_match {
1373                        return Err(format!(
1374                            "Action blocked: `{}` on '{}' requires a line-level inspection first. \
1375                             Use `inspect_lines` on the target region to get the exact current text \
1376                             (whitespace and indentation included), then retry the edit.",
1377                            name, target
1378                        ));
1379                    }
1380                } else if !recent_observed && !pinned_match {
1381                    return Err(format!(
1382                        "Action blocked: `{}` on '{}' requires recent file evidence. Use `read_file` or `inspect_lines` on that path first, or pin the file into active context.",
1383                        name, target
1384                    ));
1385                }
1386            }
1387        }
1388
1389        if is_mcp_mutating_tool(name) {
1390            return Err(format!(
1391                "Action blocked: `{}` is an external MCP mutation tool. For workspace file edits, prefer Hematite's built-in edit path (`read_file`/`inspect_lines` plus `patch_hunk`, `edit_file`, or `multi_search_replace`) unless the user explicitly requires MCP for that action.",
1392                name
1393            ));
1394        }
1395
1396        if is_mcp_workspace_read_tool(name) {
1397            return Err(format!(
1398                "Action blocked: `{}` is an external MCP filesystem read tool. For local workspace inspection, prefer Hematite's built-in read path (`read_file`, `inspect_lines`, `list_files`, or `grep_files`) unless the user explicitly requires MCP for that action.",
1399                name
1400            ));
1401        }
1402
1403        // Phase gate: if the build is broken, constrain edits to files that cargo flagged.
1404        // This prevents the model from wandering to unrelated files after a failed verify.
1405        if matches!(
1406            name,
1407            "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
1408        ) {
1409            if let Some(target) = normalized_target.as_deref() {
1410                let state = self.action_grounding.lock().await;
1411                if state.code_changed_since_verify
1412                    && !state.last_verify_build_ok
1413                    && !state.last_failed_build_paths.is_empty()
1414                    && !state.last_failed_build_paths.iter().any(|p| p == target)
1415                {
1416                    let files = state
1417                        .last_failed_build_paths
1418                        .iter()
1419                        .map(|p| format!("`{}`", p))
1420                        .collect::<Vec<_>>()
1421                        .join(", ");
1422                    return Err(format!(
1423                        "Action blocked: the build is broken. Fix the errors in {} before editing other files. Run `verify_build` to confirm the fix, then continue.",
1424                        files
1425                    ));
1426                }
1427            }
1428        }
1429
1430        if name == "git_commit" || name == "git_push" {
1431            let state = self.action_grounding.lock().await;
1432            if state.code_changed_since_verify && !state.last_verify_build_ok {
1433                return Err(format!(
1434                    "Action blocked: `{}` requires a successful `verify_build` after the latest code edits. Run verification first so Hematite has proof that the tree is build-clean.",
1435                    name
1436                ));
1437            }
1438        }
1439
1440        if name == "shell" {
1441            let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
1442            if shell_looks_like_structured_host_inspection(command) {
1443                // Auto-redirect: silently call inspect_host with the right topic instead of
1444                // returning a block error that the model may fail to recover from.
1445                // Derive topic ONLY from the shell command itself. We do not fall back to the user prompt
1446                // here to avoid trapping secondary shell commands in a redirection loop based on the primary intent.
1447                let topic = match preferred_host_inspection_topic(command) {
1448                    Some(t) => t.to_string(),
1449                    None => return Ok(()), // Not a clear host inspection command, allow it to pass through.
1450                };
1451
1452                {
1453                    let mut state = self.action_grounding.lock().await;
1454                    let current_turn = state.turn_index;
1455                    if let Some(turn) = state.redirected_host_inspection_topics.get(&topic) {
1456                        if *turn == current_turn {
1457                            return Err(format!(
1458                                "[auto-redirected shell→inspect_host(topic=\"{topic}\")] Notice: The diagnostic data for topic `{topic}` was already provided in this turn. Using the previous result to avoid redundant tool calls."
1459                            ));
1460                        }
1461                    }
1462                    state
1463                        .redirected_host_inspection_topics
1464                        .insert(topic.clone(), current_turn);
1465                }
1466
1467                let path_val = self
1468                    .latest_user_prompt()
1469                    .and_then(|p| {
1470                        // Very basic heuristic for path extraction: look for strings with dots/slashes
1471                        p.split_whitespace()
1472                            .find(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
1473                            .map(|s| {
1474                                s.trim_matches(|c: char| {
1475                                    !c.is_alphanumeric() && c != '.' && c != '/' && c != '\\'
1476                                })
1477                            })
1478                    })
1479                    .unwrap_or("");
1480
1481                let mut redirect_args = if !path_val.is_empty() {
1482                    serde_json::json!({ "topic": topic, "path": path_val })
1483                } else {
1484                    serde_json::json!({ "topic": topic })
1485                };
1486
1487                // Surgical Argument Extraction for redirected shell payloads (Robust "Wide Net" version)
1488                if topic == "ad_user" || topic == "dns_lookup" {
1489                    let cmd_lower = command.to_lowercase();
1490                    let mut identity = String::new();
1491
1492                    // 1. Explicit Identity check
1493                    if let Some(idx) = cmd_lower.find("-identity") {
1494                        let after_id = &command[idx + 9..].trim();
1495                        identity = if after_id.starts_with('\'') || after_id.starts_with('"') {
1496                            let quote = after_id.chars().next().unwrap();
1497                            after_id.split(quote).nth(1).unwrap_or("").to_string()
1498                        } else {
1499                            after_id.split_whitespace().next().unwrap_or("").to_string()
1500                        };
1501                    }
1502
1503                    // 2. Wide-Net Fallback: Find the first non-cmdlet, non-parameter string
1504                    if identity.is_empty() {
1505                        let parts: Vec<&str> = command.split_whitespace().collect();
1506                        for (i, part) in parts.iter().enumerate() {
1507                            if i == 0 || part.starts_with('-') {
1508                                continue;
1509                            }
1510                            // Skip common cmdlets if they are in the parts list
1511                            let p_low = part.to_lowercase();
1512                            if p_low.contains("get-ad")
1513                                || p_low.contains("powershell")
1514                                || p_low == "-command"
1515                            {
1516                                continue;
1517                            }
1518
1519                            identity = part
1520                                .trim_matches(|c: char| c == '\'' || c == '"')
1521                                .to_string();
1522                            if !identity.is_empty() {
1523                                break;
1524                            }
1525                        }
1526                    }
1527
1528                    if !identity.is_empty() {
1529                        redirect_args.as_object_mut().unwrap().insert(
1530                            "name_filter".to_string(),
1531                            serde_json::Value::String(identity),
1532                        );
1533                    }
1534                }
1535
1536                let result = crate::tools::host_inspect::inspect_host(&redirect_args).await;
1537                return match result {
1538                    Ok(output) => Err(format!(
1539                        "[auto-redirected shell→inspect_host(topic=\"{topic}\")]\n\n{output}\n\n[Note: Shell is blocked for host inspection. The diagnostic data above fulfills your request. Use inspect_host directly for further diagnostics.]"
1540                    )),
1541                    Err(e) => Err(format!(
1542                        "Redirection to native tool `{topic}` failed: {e}\n\nAction blocked: use `inspect_host(topic: \"{topic}\")` instead of raw `shell` for host-inspection questions. Available topics: updates, security, pending_reboot, disk_health, battery, recent_crashes, scheduled_tasks, dev_conflicts, health_report, storage, hardware, resource_load, overclocker, processes, network, lan_discovery, audio, bluetooth, camera, sign_in, search_index, services, ports, env_doctor, fix_plan, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, docker, docker_filesystems, wsl, wsl_filesystems, ssh, env, hosts_file, installed_software, git_config, databases, disk_benchmark, directory, permissions, login_history, registry_audit, share_access.",
1543                    )),
1544                };
1545            }
1546            let reason = args
1547                .get("reason")
1548                .and_then(|v| v.as_str())
1549                .unwrap_or("")
1550                .trim();
1551            let risk = crate::tools::guard::classify_bash_risk(command);
1552            if !matches!(risk, crate::tools::RiskLevel::Safe) && reason.is_empty() {
1553                return Err(
1554                    "Action blocked: risky `shell` calls require a concrete `reason` argument that explains what is being verified or changed."
1555                        .to_string(),
1556                );
1557            }
1558        }
1559
1560        Ok(())
1561    }
1562
1563    fn build_action_receipt(
1564        &self,
1565        name: &str,
1566        args: &Value,
1567        output: &str,
1568        is_error: bool,
1569    ) -> Option<ChatMessage> {
1570        if is_error || !is_destructive_tool(name) {
1571            return None;
1572        }
1573
1574        let mut receipt = String::from("[ACTION RECEIPT]\n");
1575        receipt.push_str(&format!("- tool: {}\n", name));
1576        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
1577            receipt.push_str(&format!("- target: {}\n", path));
1578        }
1579        if name == "shell" {
1580            if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
1581                receipt.push_str(&format!("- command: {}\n", command));
1582            }
1583            if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
1584                if !reason.trim().is_empty() {
1585                    receipt.push_str(&format!("- reason: {}\n", reason.trim()));
1586                }
1587            }
1588        }
1589        let first_line = output.lines().next().unwrap_or(output).trim();
1590        receipt.push_str(&format!("- outcome: {}\n", first_line));
1591        Some(ChatMessage::system(&receipt))
1592    }
1593
1594    fn replace_mcp_tool_definitions(&mut self, mcp_tools: &[crate::agent::mcp::McpTool]) {
1595        self.tools
1596            .retain(|tool| !tool.function.name.starts_with("mcp__"));
1597        self.tools
1598            .extend(mcp_tools.iter().map(|tool| ToolDefinition {
1599                tool_type: "function".into(),
1600                function: ToolFunction {
1601                    name: tool.name.clone(),
1602                    description: tool.description.clone().unwrap_or_default(),
1603                    parameters: tool.input_schema.clone(),
1604                },
1605                metadata: crate::agent::inference::tool_metadata_for_name(&tool.name),
1606            }));
1607    }
1608
1609    async fn emit_mcp_runtime_status(&self, tx: &mpsc::Sender<InferenceEvent>) {
1610        let summary = {
1611            let mcp = self.mcp_manager.lock().await;
1612            mcp.runtime_report()
1613        };
1614        let _ = tx
1615            .send(InferenceEvent::McpStatus {
1616                state: summary.state,
1617                summary: summary.summary,
1618            })
1619            .await;
1620    }
1621
1622    async fn refresh_mcp_tools(
1623        &mut self,
1624        tx: &mpsc::Sender<InferenceEvent>,
1625    ) -> Result<Vec<crate::agent::mcp::McpTool>, Box<dyn std::error::Error + Send + Sync>> {
1626        let mcp_tools = {
1627            let mut mcp = self.mcp_manager.lock().await;
1628            match mcp.initialize_all().await {
1629                Ok(()) => mcp.discover_tools().await,
1630                Err(e) => {
1631                    drop(mcp);
1632                    self.replace_mcp_tool_definitions(&[]);
1633                    self.emit_mcp_runtime_status(tx).await;
1634                    return Err(e.into());
1635                }
1636            }
1637        };
1638
1639        self.replace_mcp_tool_definitions(&mcp_tools);
1640        self.emit_mcp_runtime_status(tx).await;
1641        Ok(mcp_tools)
1642    }
1643
1644    /// Spawns and initializes all configured MCP servers, discovering their tools.
1645    pub async fn initialize_mcp(
1646        &mut self,
1647        tx: &mpsc::Sender<InferenceEvent>,
1648    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1649        let _ = self.refresh_mcp_tools(tx).await?;
1650        Ok(())
1651    }
1652
1653    /// Run one user turn through the full agentic loop.
1654    ///
1655    /// Adds the user message, calls the model, executes any tools, and loops
1656    /// until the model produces a final text reply.  All progress is streamed
1657    /// as `InferenceEvent` values via `tx`.
1658    pub async fn run_turn(
1659        &mut self,
1660        user_turn: &UserTurn,
1661        tx: mpsc::Sender<InferenceEvent>,
1662        yolo: bool,
1663    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1664        let user_input = user_turn.text.as_str();
1665        // ── Fast-path reset commands: handled locally, no network I/O needed ──
1666        if user_input.trim() == "/new" {
1667            self.history.clear();
1668            self.reasoning_history = None;
1669            self.session_memory.clear();
1670            self.running_summary = None;
1671            self.correction_hints.clear();
1672            self.pinned_files.lock().await.clear();
1673            self.reset_action_grounding().await;
1674            reset_task_files();
1675            let _ = std::fs::remove_file(session_path());
1676            self.save_empty_session();
1677            self.emit_compaction_pressure(&tx).await;
1678            self.emit_prompt_pressure_idle(&tx).await;
1679            for chunk in chunk_text(
1680                "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
1681                8,
1682            ) {
1683                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1684            }
1685            let _ = tx.send(InferenceEvent::Done).await;
1686            return Ok(());
1687        }
1688
1689        if user_input.trim() == "/forget" {
1690            self.history.clear();
1691            self.reasoning_history = None;
1692            self.session_memory.clear();
1693            self.running_summary = None;
1694            self.correction_hints.clear();
1695            self.pinned_files.lock().await.clear();
1696            self.reset_action_grounding().await;
1697            reset_task_files();
1698            purge_persistent_memory();
1699            tokio::task::block_in_place(|| self.vein.reset());
1700            let _ = std::fs::remove_file(session_path());
1701            self.save_empty_session();
1702            self.emit_compaction_pressure(&tx).await;
1703            self.emit_prompt_pressure_idle(&tx).await;
1704            for chunk in chunk_text(
1705                "Hard forget complete. Chat history, saved memory, task files, and the Vein index were purged.",
1706                8,
1707            ) {
1708                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1709            }
1710            let _ = tx.send(InferenceEvent::Done).await;
1711            return Ok(());
1712        }
1713
1714        if user_input.trim() == "/vein-inspect" {
1715            let indexed = self.refresh_vein_index();
1716            let report = self.build_vein_inspection_report(indexed);
1717            let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
1718            let _ = tx
1719                .send(InferenceEvent::VeinStatus {
1720                    file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
1721                    embedded_count: snapshot.embedded_source_doc_chunks,
1722                    docs_only: self.vein_docs_only_mode(),
1723                })
1724                .await;
1725            for chunk in chunk_text(&report, 8) {
1726                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1727            }
1728            let _ = tx.send(InferenceEvent::Done).await;
1729            return Ok(());
1730        }
1731
1732        if user_input.trim() == "/workspace-profile" {
1733            let root = crate::tools::file_ops::workspace_root();
1734            let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
1735            let report = crate::agent::workspace_profile::profile_report(&root);
1736            for chunk in chunk_text(&report, 8) {
1737                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1738            }
1739            let _ = tx.send(InferenceEvent::Done).await;
1740            return Ok(());
1741        }
1742
1743        if user_input.trim() == "/rules" {
1744            let rules_path = crate::tools::file_ops::hematite_dir().join("rules.md");
1745            let report = if rules_path.exists() {
1746                match std::fs::read_to_string(&rules_path) {
1747                    Ok(content) => format!(
1748                        "## Behavioral Rules (.hematite/rules.md)\n\n{}\n\n---\nTo update: ask Hematite to edit your rules, or open `.hematite/rules.md` directly. Changes take effect on the next turn.",
1749                        content.trim()
1750                    ),
1751                    Err(e) => format!("Error reading .hematite/rules.md: {e}"),
1752                }
1753            } else {
1754                format!(
1755                    "No behavioral rules file found at `.hematite/rules.md`.\n\nCreate it to add custom behavioral guidelines — they are injected into the system prompt on every turn and apply to any model you load.\n\nExample: ask Hematite to \"create a rules.md with simplicity-first and surgical-edit guidelines\" and it will write the file for you.\n\nExpected path: {}",
1756                    rules_path.display()
1757                )
1758            };
1759            for chunk in chunk_text(&report, 8) {
1760                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1761            }
1762            let _ = tx.send(InferenceEvent::Done).await;
1763            return Ok(());
1764        }
1765
1766        if user_input.trim() == "/vein-reset" {
1767            tokio::task::block_in_place(|| self.vein.reset());
1768            let _ = tx
1769                .send(InferenceEvent::VeinStatus {
1770                    file_count: 0,
1771                    embedded_count: 0,
1772                    docs_only: self.vein_docs_only_mode(),
1773                })
1774                .await;
1775            for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
1776                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1777            }
1778            let _ = tx.send(InferenceEvent::Done).await;
1779            return Ok(());
1780        }
1781
1782        // Reload config every turn (edits apply immediately, no restart needed).
1783        let config = crate::agent::config::load_config();
1784        self.recovery_context.clear();
1785        let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
1786        if !manual_runtime_refresh {
1787            if let Some((model_id, context_length, changed)) = self
1788                .refresh_runtime_profile_and_report(&tx, "turn_start")
1789                .await
1790            {
1791                if changed {
1792                    let _ = tx
1793                        .send(InferenceEvent::Thought(format!(
1794                            "Runtime refresh: using model `{}` with CTX {} for this turn.",
1795                            model_id, context_length
1796                        )))
1797                        .await;
1798                }
1799            }
1800        }
1801        self.emit_compaction_pressure(&tx).await;
1802        let current_model = self.engine.current_model();
1803        self.engine.set_gemma_native_formatting(
1804            crate::agent::config::effective_gemma_native_formatting(&config, &current_model),
1805        );
1806        let _turn_id = self.begin_grounded_turn().await;
1807        let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
1808        let mcp_tools = match self.refresh_mcp_tools(&tx).await {
1809            Ok(tools) => tools,
1810            Err(e) => {
1811                let _ = tx
1812                    .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
1813                    .await;
1814                Vec::new()
1815            }
1816        };
1817
1818        // Apply config model overrides (config takes precedence over CLI flags).
1819        let effective_fast = config
1820            .fast_model
1821            .clone()
1822            .or_else(|| self.fast_model.clone());
1823        let effective_think = config
1824            .think_model
1825            .clone()
1826            .or_else(|| self.think_model.clone());
1827
1828        // ── /lsp: start language servers manually if needed ──────────────────
1829        if user_input.trim() == "/lsp" {
1830            let mut lsp = self.lsp_manager.lock().await;
1831            match lsp.start_servers().await {
1832                Ok(_) => {
1833                    let _ = tx
1834                        .send(InferenceEvent::MutedToken(
1835                            "LSP: Servers Initialized OK.".to_string(),
1836                        ))
1837                        .await;
1838                }
1839                Err(e) => {
1840                    let _ = tx
1841                        .send(InferenceEvent::Error(format!(
1842                            "LSP: Failed to start servers - {}",
1843                            e
1844                        )))
1845                        .await;
1846                }
1847            }
1848            let _ = tx.send(InferenceEvent::Done).await;
1849            return Ok(());
1850        }
1851
1852        if user_input.trim() == "/runtime-refresh" {
1853            match self
1854                .refresh_runtime_profile_and_report(&tx, "manual_command")
1855                .await
1856            {
1857                Some((model_id, context_length, changed)) => {
1858                    let msg = if changed {
1859                        format!(
1860                            "Runtime profile refreshed. Model: {} | CTX: {}",
1861                            model_id, context_length
1862                        )
1863                    } else {
1864                        format!(
1865                            "Runtime profile unchanged. Model: {} | CTX: {}",
1866                            model_id, context_length
1867                        )
1868                    };
1869                    for chunk in chunk_text(&msg, 8) {
1870                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
1871                    }
1872                }
1873                None => {
1874                    let _ = tx
1875                        .send(InferenceEvent::Error(
1876                            "Runtime refresh failed: LM Studio profile could not be read."
1877                                .to_string(),
1878                        ))
1879                        .await;
1880                }
1881            }
1882            let _ = tx.send(InferenceEvent::Done).await;
1883            return Ok(());
1884        }
1885
1886        if user_input.trim() == "/ask" {
1887            self.set_workflow_mode(WorkflowMode::Ask);
1888            for chunk in chunk_text(
1889                "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
1890                8,
1891            ) {
1892                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1893            }
1894            let _ = tx.send(InferenceEvent::Done).await;
1895            return Ok(());
1896        }
1897
1898        if user_input.trim() == "/code" {
1899            self.set_workflow_mode(WorkflowMode::Code);
1900            let mut message =
1901                "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
1902            if let Some(plan) = self.current_plan_summary() {
1903                message.push_str(&format!(" Current plan: {plan}."));
1904            }
1905            for chunk in chunk_text(&message, 8) {
1906                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1907            }
1908            let _ = tx.send(InferenceEvent::Done).await;
1909            return Ok(());
1910        }
1911
1912        if user_input.trim() == "/architect" {
1913            self.set_workflow_mode(WorkflowMode::Architect);
1914            let mut message =
1915                "Workflow mode: ARCHITECT. Plan, inspect, and shape the approach first. Do not mutate code unless the user explicitly asks to implement. When the handoff is ready, use `/implement-plan` or switch to `/code` to execute it.".to_string();
1916            if let Some(plan) = self.current_plan_summary() {
1917                message.push_str(&format!(" Existing plan: {plan}."));
1918            }
1919            for chunk in chunk_text(&message, 8) {
1920                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1921            }
1922            let _ = tx.send(InferenceEvent::Done).await;
1923            return Ok(());
1924        }
1925
1926        if user_input.trim() == "/read-only" {
1927            self.set_workflow_mode(WorkflowMode::ReadOnly);
1928            for chunk in chunk_text(
1929                "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
1930                8,
1931            ) {
1932                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1933            }
1934            let _ = tx.send(InferenceEvent::Done).await;
1935            return Ok(());
1936        }
1937
1938        if user_input.trim() == "/auto" {
1939            self.set_workflow_mode(WorkflowMode::Auto);
1940            for chunk in chunk_text(
1941                "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
1942                8,
1943            ) {
1944                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1945            }
1946            let _ = tx.send(InferenceEvent::Done).await;
1947            return Ok(());
1948        }
1949
1950        if user_input.trim() == "/chat" {
1951            self.set_workflow_mode(WorkflowMode::Chat);
1952            let _ = tx.send(InferenceEvent::Done).await;
1953            return Ok(());
1954        }
1955
1956        if user_input.trim() == "/teach" {
1957            self.set_workflow_mode(WorkflowMode::Teach);
1958            for chunk in chunk_text(
1959                "Workflow mode: TEACH. I will inspect your actual machine state first, then walk you through any admin, config, or write task as a grounded, numbered tutorial. I will not execute write operations — I will show you exactly how to do each step yourself.",
1960                8,
1961            ) {
1962                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1963            }
1964            let _ = tx.send(InferenceEvent::Done).await;
1965            return Ok(());
1966        }
1967
1968        if user_input.trim() == "/reroll" {
1969            let soul = crate::ui::hatch::generate_soul_random();
1970            self.snark = soul.snark;
1971            self.chaos = soul.chaos;
1972            self.soul_personality = soul.personality.clone();
1973            // Update the engine's species name so build_chat_system_prompt uses it
1974            // SAFETY: engine is Arc but species is a plain String field we own logically.
1975            // We use Arc::get_mut which only succeeds if this is the only strong ref.
1976            // If it fails (swarm workers hold refs), we fall back to a best-effort clone approach.
1977            let species = soul.species.clone();
1978            if let Some(eng) = Arc::get_mut(&mut self.engine) {
1979                eng.species = species.clone();
1980            }
1981            let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
1982            let _ = tx
1983                .send(InferenceEvent::SoulReroll {
1984                    species: soul.species.clone(),
1985                    rarity: soul.rarity.label().to_string(),
1986                    shiny: soul.shiny,
1987                    personality: soul.personality.clone(),
1988                })
1989                .await;
1990            for chunk in chunk_text(
1991                &format!(
1992                    "A new companion awakens!\n[{}{}] {} — \"{}\"",
1993                    soul.rarity.label(),
1994                    shiny_tag,
1995                    soul.species,
1996                    soul.personality
1997                ),
1998                8,
1999            ) {
2000                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2001            }
2002            let _ = tx.send(InferenceEvent::Done).await;
2003            return Ok(());
2004        }
2005
2006        if user_input.trim() == "/agent" {
2007            self.set_workflow_mode(WorkflowMode::Auto);
2008            let _ = tx.send(InferenceEvent::Done).await;
2009            return Ok(());
2010        }
2011
2012        let implement_plan_alias = user_input.trim() == "/implement-plan";
2013        if implement_plan_alias
2014            && !self
2015                .session_memory
2016                .current_plan
2017                .as_ref()
2018                .map(|plan| plan.has_signal())
2019                .unwrap_or(false)
2020        {
2021            for chunk in chunk_text(
2022                "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
2023                8,
2024            ) {
2025                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2026            }
2027            let _ = tx.send(InferenceEvent::Done).await;
2028            return Ok(());
2029        }
2030
2031        let mut effective_user_input = if implement_plan_alias {
2032            self.set_workflow_mode(WorkflowMode::Code);
2033            implement_current_plan_prompt().to_string()
2034        } else {
2035            user_input.trim().to_string()
2036        };
2037        if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
2038            self.set_workflow_mode(mode);
2039            effective_user_input = rest.to_string();
2040        }
2041        let transcript_user_input = if implement_plan_alias {
2042            transcript_user_turn_text(user_turn, "/implement-plan")
2043        } else {
2044            transcript_user_turn_text(user_turn, &effective_user_input)
2045        };
2046        effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
2047        let implement_current_plan = self.workflow_mode == WorkflowMode::Code
2048            && is_current_plan_execution_request(&effective_user_input)
2049            && self
2050                .session_memory
2051                .current_plan
2052                .as_ref()
2053                .map(|plan| plan.has_signal())
2054                .unwrap_or(false);
2055        self.plan_execution_active
2056            .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
2057        let _plan_execution_guard = PlanExecutionGuard {
2058            flag: self.plan_execution_active.clone(),
2059        };
2060        let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
2061
2062        // ── /think / /no_think: reasoning budget toggle ──────────────────────
2063        if let Some(answer_kind) = intent.direct_answer {
2064            match answer_kind {
2065                DirectAnswerKind::About => {
2066                    let response = build_about_answer();
2067                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2068                        .await;
2069                    return Ok(());
2070                }
2071                DirectAnswerKind::LanguageCapability => {
2072                    let response = build_language_capability_answer();
2073                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2074                        .await;
2075                    return Ok(());
2076                }
2077                DirectAnswerKind::UnsafeWorkflowPressure => {
2078                    let response = build_unsafe_workflow_pressure_answer();
2079                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2080                        .await;
2081                    return Ok(());
2082                }
2083                DirectAnswerKind::SessionMemory => {
2084                    let response = build_session_memory_answer();
2085                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2086                        .await;
2087                    return Ok(());
2088                }
2089                DirectAnswerKind::RecoveryRecipes => {
2090                    let response = build_recovery_recipes_answer();
2091                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2092                        .await;
2093                    return Ok(());
2094                }
2095                DirectAnswerKind::McpLifecycle => {
2096                    let response = build_mcp_lifecycle_answer();
2097                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2098                        .await;
2099                    return Ok(());
2100                }
2101                DirectAnswerKind::AuthorizationPolicy => {
2102                    let response = build_authorization_policy_answer();
2103                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2104                        .await;
2105                    return Ok(());
2106                }
2107                DirectAnswerKind::ToolClasses => {
2108                    let response = build_tool_classes_answer();
2109                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2110                        .await;
2111                    return Ok(());
2112                }
2113                DirectAnswerKind::ToolRegistryOwnership => {
2114                    let response = build_tool_registry_ownership_answer();
2115                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2116                        .await;
2117                    return Ok(());
2118                }
2119                DirectAnswerKind::SessionResetSemantics => {
2120                    let response = build_session_reset_semantics_answer();
2121                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2122                        .await;
2123                    return Ok(());
2124                }
2125                DirectAnswerKind::ProductSurface => {
2126                    let response = build_product_surface_answer();
2127                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2128                        .await;
2129                    return Ok(());
2130                }
2131                DirectAnswerKind::ReasoningSplit => {
2132                    let response = build_reasoning_split_answer();
2133                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2134                        .await;
2135                    return Ok(());
2136                }
2137                DirectAnswerKind::Identity => {
2138                    let response = build_identity_answer();
2139                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2140                        .await;
2141                    return Ok(());
2142                }
2143                DirectAnswerKind::WorkflowModes => {
2144                    let response = build_workflow_modes_answer();
2145                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2146                        .await;
2147                    return Ok(());
2148                }
2149                DirectAnswerKind::GemmaNative => {
2150                    let response = build_gemma_native_answer();
2151                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2152                        .await;
2153                    return Ok(());
2154                }
2155                DirectAnswerKind::GemmaNativeSettings => {
2156                    let response = build_gemma_native_settings_answer();
2157                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2158                        .await;
2159                    return Ok(());
2160                }
2161                DirectAnswerKind::VerifyProfiles => {
2162                    let response = build_verify_profiles_answer();
2163                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2164                        .await;
2165                    return Ok(());
2166                }
2167                DirectAnswerKind::Toolchain => {
2168                    let lower = effective_user_input.to_lowercase();
2169                    let topic = if (lower.contains("voice output") || lower.contains("voice"))
2170                        && (lower.contains("lag")
2171                            || lower.contains("behind visible text")
2172                            || lower.contains("latency"))
2173                    {
2174                        "voice_latency_plan"
2175                    } else {
2176                        "all"
2177                    };
2178                    let response =
2179                        crate::tools::toolchain::describe_toolchain(&serde_json::json!({
2180                            "topic": topic,
2181                            "question": effective_user_input,
2182                        }))
2183                        .await
2184                        .unwrap_or_else(|e| format!("Error: {}", e));
2185                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2186                        .await;
2187                    return Ok(());
2188                }
2189                DirectAnswerKind::HostInspection => {
2190                    let topics = all_host_inspection_topics(&effective_user_input);
2191                    let response = if topics.len() >= 2 {
2192                        let mut combined = Vec::new();
2193                        for topic in topics {
2194                            let output =
2195                                crate::tools::host_inspect::inspect_host(&serde_json::json!({
2196                                    "topic": topic,
2197                                }))
2198                                .await
2199                                .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
2200                            combined.push(format!("# Topic: {topic}\n{output}"));
2201                        }
2202                        combined.join("\n\n---\n\n")
2203                    } else {
2204                        let topic = preferred_host_inspection_topic(&effective_user_input)
2205                            .unwrap_or("summary");
2206                        crate::tools::host_inspect::inspect_host(&serde_json::json!({
2207                            "topic": topic,
2208                        }))
2209                        .await
2210                        .unwrap_or_else(|e| format!("Error: {e}"))
2211                    };
2212
2213                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2214                        .await;
2215                    return Ok(());
2216                }
2217                DirectAnswerKind::ArchitectSessionResetPlan => {
2218                    let plan = build_architect_session_reset_plan();
2219                    let response = plan.to_markdown();
2220                    let _ = crate::tools::plan::save_plan_handoff(&plan);
2221                    self.session_memory.current_plan = Some(plan);
2222                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2223                        .await;
2224                    return Ok(());
2225                }
2226            }
2227        }
2228
2229        if matches!(
2230            self.workflow_mode,
2231            WorkflowMode::Ask | WorkflowMode::ReadOnly
2232        ) && looks_like_mutation_request(&effective_user_input)
2233        {
2234            let response = build_mode_redirect_answer(self.workflow_mode);
2235            self.history.push(ChatMessage::user(&effective_user_input));
2236            self.history.push(ChatMessage::assistant_text(&response));
2237            self.transcript.log_user(&transcript_user_input);
2238            self.transcript.log_agent(&response);
2239            for chunk in chunk_text(&response, 8) {
2240                if !chunk.is_empty() {
2241                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
2242                }
2243            }
2244            let _ = tx.send(InferenceEvent::Done).await;
2245            self.trim_history(80);
2246            self.refresh_session_memory();
2247            self.save_session();
2248            return Ok(());
2249        }
2250
2251        if user_input.trim() == "/think" {
2252            self.think_mode = Some(true);
2253            for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
2254                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2255            }
2256            let _ = tx.send(InferenceEvent::Done).await;
2257            return Ok(());
2258        }
2259        if user_input.trim() == "/no_think" {
2260            self.think_mode = Some(false);
2261            for chunk in chunk_text(
2262                "Think mode: OFF — fast mode enabled (no chain-of-thought).",
2263                8,
2264            ) {
2265                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2266            }
2267            let _ = tx.send(InferenceEvent::Done).await;
2268            return Ok(());
2269        }
2270
2271        // ── /pin: add file to active context ────────────────────────────────
2272        if user_input.trim_start().starts_with("/pin ") {
2273            let path = user_input.trim_start()[5..].trim();
2274            match std::fs::read_to_string(path) {
2275                Ok(content) => {
2276                    self.pinned_files
2277                        .lock()
2278                        .await
2279                        .insert(path.to_string(), content);
2280                    let msg = format!(
2281                        "Pinned: {} — this file is now locked in model context.",
2282                        path
2283                    );
2284                    for chunk in chunk_text(&msg, 8) {
2285                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
2286                    }
2287                }
2288                Err(e) => {
2289                    let _ = tx
2290                        .send(InferenceEvent::Error(format!(
2291                            "Failed to pin {}: {}",
2292                            path, e
2293                        )))
2294                        .await;
2295                }
2296            }
2297            let _ = tx.send(InferenceEvent::Done).await;
2298            return Ok(());
2299        }
2300
2301        // ── /unpin: remove file from active context ──────────────────────────
2302        if user_input.trim_start().starts_with("/unpin ") {
2303            let path = user_input.trim_start()[7..].trim();
2304            if self.pinned_files.lock().await.remove(path).is_some() {
2305                let msg = format!("Unpinned: {} — file removed from active context.", path);
2306                for chunk in chunk_text(&msg, 8) {
2307                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
2308                }
2309            } else {
2310                let _ = tx
2311                    .send(InferenceEvent::Error(format!(
2312                        "File {} was not pinned.",
2313                        path
2314                    )))
2315                    .await;
2316            }
2317            let _ = tx.send(InferenceEvent::Done).await;
2318            return Ok(());
2319        }
2320
2321        // ── Normal processing ───────────────────────────────────────────────
2322
2323        // Ensure MCP is initialized and tools are discovered for this turn.
2324        let tiny_context_mode = self.engine.current_context_length() <= 8_192;
2325        let mut base_prompt = self.engine.build_system_prompt(
2326            self.snark,
2327            self.chaos,
2328            self.brief,
2329            self.professional,
2330            &self.tools,
2331            self.reasoning_history.as_deref(),
2332            &mcp_tools,
2333        );
2334        if !tiny_context_mode {
2335            if let Some(hint) = &config.context_hint {
2336                if !hint.trim().is_empty() {
2337                    base_prompt.push_str(&format!(
2338                        "\n\n# Project Context (from .hematite/settings.json)\n{}",
2339                        hint
2340                    ));
2341                }
2342            }
2343            if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
2344                &crate::tools::file_ops::workspace_root(),
2345            ) {
2346                base_prompt.push_str(&format!("\n\n{}", profile_block));
2347            }
2348            // L1: inject hot-files block if available (persists across sessions via vein.db).
2349            if let Some(ref l1) = self.l1_context {
2350                base_prompt.push_str(&format!("\n\n{}", l1));
2351            }
2352            if let Some(ref repo_map_block) = self.repo_map {
2353                base_prompt.push_str(&format!("\n\n{}", repo_map_block));
2354            }
2355        }
2356        let grounded_trace_mode = intent.grounded_trace_mode
2357            || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
2358        let capability_mode =
2359            intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
2360        let toolchain_mode =
2361            intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
2362        let host_inspection_mode = intent.host_inspection_mode;
2363        let maintainer_workflow_mode = intent.maintainer_workflow_mode
2364            || preferred_maintainer_workflow(&effective_user_input).is_some();
2365        let workspace_workflow_mode = intent.workspace_workflow_mode
2366            || preferred_workspace_workflow(&effective_user_input).is_some();
2367        let fix_plan_mode =
2368            preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
2369        let architecture_overview_mode = intent.architecture_overview_mode;
2370        let capability_needs_repo = intent.capability_needs_repo;
2371        let mut system_msg = build_system_with_corrections(
2372            &base_prompt,
2373            &self.correction_hints,
2374            &self.gpu_state,
2375            &self.git_state,
2376            &config,
2377        );
2378        if tiny_context_mode {
2379            system_msg.push_str(
2380                "\n\n# TINY CONTEXT TURN MODE\n\
2381                 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
2382            );
2383        }
2384        if !tiny_context_mode && grounded_trace_mode {
2385            system_msg.push_str(
2386                "\n\n# GROUNDED TRACE MODE\n\
2387                 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
2388                 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
2389                 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
2390                 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
2391                 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
2392                 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
2393                 Do not invent names such as synthetic channels or subsystems.\n\
2394                 If a detail is not verified from the code or tool output, say `uncertain`.\n\
2395                For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
2396            );
2397        }
2398        if !tiny_context_mode && capability_mode {
2399            system_msg.push_str(
2400                "\n\n# CAPABILITY QUESTION MODE\n\
2401                 This is a product or capability question unless the user explicitly asks about repository implementation.\n\
2402                 Answer from stable Hematite capabilities and current runtime state.\n\
2403                 It is correct to mention that Hematite itself is built in Rust when relevant, but do not imply that its project support is limited to Rust.\n\
2404                 Do NOT call repo-inspection tools like `read_file` or LSP lookup tools unless the user explicitly asks about implementation or file ownership.\n\
2405                 Do NOT infer language or project support from unrelated dependencies, crates, or config files.\n\
2406                 Describe language and project support in terms of real mechanisms: reading files, editing code, searching the workspace, running shell commands, build verification, language-aware tooling when available, web research, vision analysis, and optional MCP tools if configured.\n\
2407                 If the user asks about languages, answer at the harness level: Hematite can help across many project languages even though Hematite itself is written in Rust.\n\
2408                 Prefer real programming language examples like Python, JavaScript, TypeScript, Go, C#, or similar over file extensions like `.json` or `.md`.\n\
2409                 For project-building questions, describe cross-project workflows like scaffolding files, shaping structure, implementing features, and running the appropriate local build or test commands for the target stack. Do not overclaim certainty.\n\
2410                 Never mention raw `mcp__*` tool names unless those tools are active this turn and directly relevant.\n\
2411                 Keep the answer short, plain, and ASCII-first.\n"
2412            );
2413        }
2414        if !tiny_context_mode && toolchain_mode {
2415            system_msg.push_str(
2416                "\n\n# TOOLCHAIN DISCIPLINE MODE\n\
2417                 This turn is about Hematite's real built-in tools and how to choose them.\n\
2418                 Prefer `describe_toolchain` before you try to summarize tool capabilities or propose a read-only investigation plan from memory.\n\
2419                 Use only real built-in tool names.\n\
2420                 Do not invent helper tools, MCP tool names, synthetic symbols, or example function names.\n\
2421                 If `describe_toolchain` fully answers the question, preserve its output exactly instead of restyling it.\n\
2422                 Be explicit about which tools are optional or conditional.\n"
2423            );
2424        }
2425        if !tiny_context_mode && host_inspection_mode {
2426            system_msg.push_str(
2427                 "\n\n# HOST INSPECTION MODE\n\
2428                 This turn is about the local machine. Make EXACTLY ONE `inspect_host` call using the best matching topic below, then answer. Do NOT call `summary` first. Do NOT make exploratory shell calls.\n\
2429                 - Drive space / disk usage / free space / storage across drives → `storage`\n\
2430                 - CPU model / RAM size / GPU name / hardware specs / BIOS / motherboard → `hardware`\n\
2431                 - CPU % / RAM % / what is using resources / slow machine → `resource_load`\n\
2432                 - Running processes / task manager / what is using RAM → `processes`\n\
2433                 - Windows services / daemons / service state → `services`\n\
2434                 - Listening ports / open ports / what process owns port N / which processes are listening / what is bound to a port → `ports` (waiting for inbound connections — includes PIDs and process names — do NOT also call `processes`)\n\
2435                 - Active connections / established connections / what is connected right now / outbound sessions / show me connections / network connections → `connections` (live two-way sessions, NOT listening ports)\n\
2436                 - Network adapters / IP / gateway / DNS overview → `network`\n\
2437                 - Internet / online / can I reach the internet → `connectivity`\n\
2438                 - Wi-Fi / wireless / signal strength / SSID → `wifi`\n\
2439                 - VPN tunnel / VPN adapter → `vpn`\n\
2440                 - Security / Defender / antivirus / firewall / UAC → `security`\n\
2441                 - Windows Update / pending updates → `updates`\n\
2442                 - Health report / system status overall → `health_report`\n\
2443                 - PATH entries / raw PATH → `path`\n\
2444                 - Installed developer tools / versions / toolchain → `toolchains`\n\
2445                 - Environment/package-manager conflicts → `env_doctor`\n\
2446                 - Fix a workstation problem (cargo not found, port in use, LM Studio) → `fix_plan`\n\
2447                 - Recent Windows errors / warnings / event log / event viewer / show me errors / what failed recently → `log_check` (do NOT call health_report first)\n\
2448                 - Repo / git / workspace health → `repo_doctor`\n\
2449                 - List a specific directory → `directory` (pass `path` arg)\n\
2450                 - Desktop or Downloads folder → `desktop` or `downloads`\n\
2451                 NEVER use `disk` or `directory` for storage/space questions — use `storage`.\n\
2452                 - Docker daemon / running containers / images / compose state -> `docker`\n\
2453                 - Docker mounts / bind sources / named volumes / Docker Desktop disk usage -> `docker_filesystems`\n\
2454                 - WSL distros / WSL version / distro state -> `wsl`\n\
2455                 - WSL storage / VHDX growth / /mnt/c bridge health -> `wsl_filesystems`\n\
2456                 - Local network discovery / NAS or printer visibility / neighborhood / mDNS / SSDP / UPnP / NetBIOS -> `lan_discovery`\n\
2457                 - Speakers / microphones / playback devices / Windows Audio service -> `audio`\n\
2458                 - Bluetooth radios / pairing / reconnect issues / headset roles -> `bluetooth`\n\
2459                 Only use `shell` if the question truly cannot be answered by any topic above.\n\
2460                 NEVER tell the user to run PowerShell, cmd, or shell commands themselves. If the data is incomplete, say so and tell them to ask a more specific question instead.\n\
2461                 NEVER expose internal tool names or API syntax (like `inspect_host(topic=...)`) in your response. Refer to capabilities in plain English: say 'ask me for a fix plan' not 'run inspect_host(topic=fix_plan)'.\n"
2462              );
2463        }
2464        if !tiny_context_mode && fix_plan_mode {
2465            system_msg.push_str(
2466                "\n\n# FIX PLAN MODE\n\
2467                 This turn is a workstation remediation question, not just a diagnosis question.\n\
2468                 Call `inspect_host` with `topic=fix_plan` first.\n\
2469                 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
2470                 Keep the answer grounded, stepwise, and approval-aware.\n"
2471            );
2472        }
2473        if !tiny_context_mode && maintainer_workflow_mode {
2474            system_msg.push_str(
2475                "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
2476                 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
2477                 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
2478                 Use workflow `clean` for cleanup, workflow `package_windows` for rebuilding the local portable or installer, and workflow `release` for the normal version bump/tag/push/publish flow.\n\
2479                 Do not treat this as a generic current-workspace script runner. Only fall back to raw `shell` if the user asks for a script or command outside those Hematite maintainer workflows.\n"
2480            );
2481        }
2482        if !tiny_context_mode && workspace_workflow_mode {
2483            system_msg.push_str(
2484                "\n\n# WORKSPACE WORKFLOW MODE\n\
2485                 This turn asks Hematite to run something in the active project workspace, not in Hematite's own source tree.\n\
2486                 Prefer `run_workspace_workflow` for the current project's build, test, lint, fix, package scripts, just/task/make targets, local repo scripts, or an exact workspace command.\n\
2487                 This tool always runs from the locked workspace root.\n\
2488                 If no real project workspace is locked, say so and tell the user to relaunch Hematite in the target project directory.\n\
2489                 Do not use `run_hematite_maintainer_workflow` unless the request is specifically about Hematite's own cleanup, packaging, or release scripts.\n"
2490            );
2491        }
2492
2493        if !tiny_context_mode && architecture_overview_mode {
2494            system_msg.push_str(
2495                "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
2496                 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
2497                 Do not call `auto_pin_context` or `list_pinned` in read-only analysis. Avoid broad `read_file` calls unless the user explicitly asks for implementation detail in one named file.\n\
2498                 Preserve grounded tool output rather than restyling it into a larger answer.\n"
2499            );
2500        }
2501
2502        // ── Inject Pinned Files (Context Locking) ───────────────────────────
2503        system_msg.push_str(&format!(
2504            "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
2505            self.workflow_mode.label()
2506        ));
2507        if tiny_context_mode {
2508            system_msg
2509                .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
2510        } else {
2511            match self.workflow_mode {
2512                WorkflowMode::Auto => system_msg.push_str(
2513                    "AUTO means choose the narrowest effective path for the request. Answer directly when stable product logic exists. Inspect before editing. Mutate only when the user is clearly asking for implementation.\n",
2514                ),
2515                WorkflowMode::Ask => system_msg.push_str(
2516                    "ASK means analysis only. Stay read-only, inspect the repo, explain findings, and do not make changes unless the user explicitly switches modes.\n",
2517                ),
2518                WorkflowMode::Code => system_msg.push_str(
2519                    "CODE means implementation is allowed when needed. Keep proof-before-action, verification, and edit precision discipline. If an active plan handoff exists in session memory or `.hematite/PLAN.md`, treat it as the implementation brief unless the user explicitly overrides it. For ordinary workspace inspection during implementation, use built-in read/edit tools first and do not reach for `mcp__filesystem__*` unless the user explicitly requires MCP.\n",
2520                ),
2521                WorkflowMode::Architect => system_msg.push_str(
2522                    "ARCHITECT means plan first. Inspect, reason, and produce a concrete implementation approach before editing. Do not mutate code unless the user explicitly asks to implement. When you produce an implementation handoff, use these exact ASCII headings so Hematite can persist the plan: `# Goal`, `# Target Files`, `# Ordered Steps`, `# Verification`, `# Risks`, `# Open Questions`.\n",
2523                ),
2524                WorkflowMode::ReadOnly => system_msg.push_str(
2525                    "READ-ONLY means analysis only. Do not modify files, run mutating shell commands, or commit changes.\n",
2526                ),
2527                WorkflowMode::Teach => system_msg.push_str(
2528                    "TEACH means you are a senior technician giving the user a grounded, numbered walkthrough. \
2529                     MANDATORY PROTOCOL for every admin/config/write task:\n\
2530                     1. Call inspect_host with the most relevant topic(s) FIRST to observe the actual machine state.\n\
2531                     2. Then deliver a numbered step-by-step tutorial that references what you actually observed — exact commands, exact paths, exact values.\n\
2532                     3. End with a verification step the user can run to confirm success.\n\
2533                     4. Do NOT execute write operations yourself. You are the teacher; the user performs the steps.\n\
2534                     5. Treat the user as capable — give precise instructions, not hedged warnings.\n\
2535                     Relevant inspect_host topics for common tasks: hardware (driver installs), overclocker (GPU/silicon vitals), security (firewall), ssh (SSH keys), wsl (WSL setup), wsl_filesystems (WSL disk and path-bridge issues), docker_filesystems (bind mounts and named volumes), lan_discovery (printer/NAS/neighborhood discovery issues), audio (speaker/microphone/service issues), bluetooth (pairing/radio/headset issues), camera (webcam/camera devices/privacy), sign_in (Windows Hello/biometric/logon failures), search_index (Windows Search indexer/WSearch), env (PATH/env vars), services (service config), recent_crashes (troubleshooting), disk_health (storage issues).\n",
2536                ),
2537                WorkflowMode::Chat => {} // replaced by build_chat_system_prompt below
2538            }
2539        }
2540        if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
2541            system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
2542            system_msg.push_str(architect_handoff_contract());
2543            system_msg.push('\n');
2544        }
2545        if !tiny_context_mode && implement_current_plan {
2546            system_msg.push_str(
2547                "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
2548                 The user explicitly asked you to implement the current saved plan.\n\
2549                 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
2550                 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
2551                 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
2552                 If a built-in workspace read tool gives you enough context, your next step should be mutation or a concrete blocking question, not another summary.\n",
2553            );
2554            if let Some(plan) = self.session_memory.current_plan.as_ref() {
2555                if !plan.target_files.is_empty() {
2556                    system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
2557                    for path in &plan.target_files {
2558                        system_msg.push_str(&format!("- {}\n", path));
2559                    }
2560                }
2561            }
2562        }
2563        if !tiny_context_mode {
2564            let pinned = self.pinned_files.lock().await;
2565            if !pinned.is_empty() {
2566                system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
2567                system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
2568                for (path, content) in pinned.iter() {
2569                    system_msg.push_str(&format!("## FILE: {}\n```\n{}\n```\n\n", path, content));
2570                }
2571            }
2572        }
2573        if !tiny_context_mode {
2574            self.append_session_handoff(&mut system_msg);
2575        }
2576        // In chat mode, replace the full harness prompt with a clean conversational surface.
2577        // The harness prompt (built above) is discarded — Rusty personality takes over.
2578        let system_msg = if self.workflow_mode.is_chat() {
2579            self.build_chat_system_prompt()
2580        } else {
2581            system_msg
2582        };
2583        if self.history.is_empty() || self.history[0].role != "system" {
2584            self.history.insert(0, ChatMessage::system(&system_msg));
2585        } else {
2586            self.history[0] = ChatMessage::system(&system_msg);
2587        }
2588
2589        // Ensure a clean state for the new turn.
2590        self.cancel_token
2591            .store(false, std::sync::atomic::Ordering::SeqCst);
2592
2593        // [Official Gemma-4 Spec] Purge reasoning history for new user turns.
2594        // History from previous turns must not be fed back into the prompt to prevent duplication.
2595        self.reasoning_history = None;
2596
2597        let is_gemma = crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
2598        let user_content = match self.think_mode {
2599            Some(true) => format!("/think\n{}", effective_user_input),
2600            Some(false) => format!("/no_think\n{}", effective_user_input),
2601            // For non-Gemma models (Qwen etc.) default to /think so the model uses
2602            // hybrid thinking — it decides how much reasoning each turn needs.
2603            // Gemma handles reasoning via <|think|> in the system prompt instead.
2604            // Chat mode and quick tool calls skip /think — fast direct answers.
2605            None if !is_gemma
2606                && !self.workflow_mode.is_chat()
2607                && !is_quick_tool_request(&effective_user_input) =>
2608            {
2609                format!("/think\n{}", effective_user_input)
2610            }
2611            None => effective_user_input.clone(),
2612        };
2613        if let Some(image) = user_turn.attached_image.as_ref() {
2614            let image_url =
2615                crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
2616                    .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
2617            self.history
2618                .push(ChatMessage::user_with_image(&user_content, &image_url));
2619        } else {
2620            self.history.push(ChatMessage::user(&user_content));
2621        }
2622        self.transcript.log_user(&transcript_user_input);
2623
2624        // Incremental re-index and Vein context injection. Ordinary chat mode
2625        // still skips repo-snippet noise, but docs-only workspaces and explicit
2626        // session-recall prompts should keep Vein memory available.
2627        let vein_docs_only = self.vein_docs_only_mode();
2628        let allow_vein_context = !self.workflow_mode.is_chat()
2629            || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
2630        let (vein_context, vein_paths) = if allow_vein_context {
2631            self.refresh_vein_index();
2632            let _ = tx
2633                .send(InferenceEvent::VeinStatus {
2634                    file_count: self.vein.file_count(),
2635                    embedded_count: self.vein.embedded_chunk_count(),
2636                    docs_only: vein_docs_only,
2637                })
2638                .await;
2639            match self.build_vein_context(&effective_user_input) {
2640                Some((ctx, paths)) => (Some(ctx), paths),
2641                None => (None, Vec::new()),
2642            }
2643        } else {
2644            (None, Vec::new())
2645        };
2646        if !vein_paths.is_empty() {
2647            let _ = tx
2648                .send(InferenceEvent::VeinContext { paths: vein_paths })
2649                .await;
2650        }
2651
2652        // Route: pick fast vs think model based on the complexity of this request.
2653        let routed_model = route_model(
2654            &effective_user_input,
2655            effective_fast.as_deref(),
2656            effective_think.as_deref(),
2657        )
2658        .map(|s| s.to_string());
2659
2660        let mut loop_intervention: Option<String> = None;
2661
2662        // ── Harness pre-run: multi-topic host inspection ─────────────────────
2663        // When the user asks for 2+ distinct inspect_host topics in one message,
2664        // run them all here and inject the combined results as a loop_intervention
2665        // so the model receives data instead of having to orchestrate tool calls.
2666        // This prevents the model from collapsing multiple topics into a generic
2667        // one, burning the tool loop budget, or retrying via shell.
2668        {
2669            let topics = all_host_inspection_topics(&effective_user_input);
2670            if topics.len() >= 2 {
2671                let _ = tx
2672                    .send(InferenceEvent::Thought(format!(
2673                        "Harness pre-run: {} host inspection topics detected — running all before model turn.",
2674                        topics.len()
2675                    )))
2676                    .await;
2677
2678                let topic_list = topics.join(", ");
2679                let mut combined = format!(
2680                    "## HARNESS PRE-RUN RESULTS\n\
2681                     The harness already ran inspect_host for the following topics: {topic_list}.\n\
2682                     Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
2683                );
2684
2685                let mut tool_calls = Vec::new();
2686                let mut tool_msgs = Vec::new();
2687
2688                for topic in &topics {
2689                    let call_id = format!("prerun_{topic}");
2690                    let args_val = serde_json::json!({ "topic": *topic, "max_entries": 20 });
2691                    let args_str = serde_json::to_string(&args_val).unwrap_or_default();
2692
2693                    tool_calls.push(crate::agent::inference::ToolCallResponse {
2694                        id: call_id.clone(),
2695                        call_type: "function".to_string(),
2696                        function: crate::agent::inference::ToolCallFn {
2697                            name: "inspect_host".to_string(),
2698                            arguments: args_str,
2699                        },
2700                    });
2701
2702                    let label = format!("### inspect_host(topic=\"{topic}\")\n");
2703                    let _ = tx
2704                        .send(InferenceEvent::ToolCallStart {
2705                            id: call_id.clone(),
2706                            name: "inspect_host".to_string(),
2707                            args: format!("inspect host {topic}"),
2708                        })
2709                        .await;
2710
2711                    match crate::tools::host_inspect::inspect_host(&args_val).await {
2712                        Ok(out) => {
2713                            let _ = tx
2714                                .send(InferenceEvent::ToolCallResult {
2715                                    id: call_id.clone(),
2716                                    name: "inspect_host".to_string(),
2717                                    output: out.chars().take(300).collect::<String>() + "...",
2718                                    is_error: false,
2719                                })
2720                                .await;
2721                            combined.push_str(&label);
2722                            combined.push_str(&out);
2723                            combined.push_str("\n\n");
2724                            tool_msgs.push(ChatMessage::tool_result_for_model(
2725                                &call_id,
2726                                "inspect_host",
2727                                &out,
2728                                &self.engine.current_model(),
2729                            ));
2730                        }
2731                        Err(e) => {
2732                            let err_msg = format!("Error: {e}");
2733                            combined.push_str(&label);
2734                            combined.push_str(&err_msg);
2735                            combined.push_str("\n\n");
2736                            tool_msgs.push(ChatMessage::tool_result_for_model(
2737                                &call_id,
2738                                "inspect_host",
2739                                &err_msg,
2740                                &self.engine.current_model(),
2741                            ));
2742                        }
2743                    }
2744                }
2745
2746                // Add the simulated turn to history so the model sees it as context.
2747                self.history
2748                    .push(ChatMessage::assistant_tool_calls("", tool_calls));
2749                for msg in tool_msgs {
2750                    self.history.push(msg);
2751                }
2752
2753                loop_intervention = Some(combined);
2754            }
2755        }
2756
2757        // ── Computation Integrity: nudge model toward run_code for precise math ──
2758        // When the query involves exact numeric computation (hashes, financial math,
2759        // statistics, date arithmetic, unit conversions, algorithmic checks), inject
2760        // a brief pre-turn reminder so the model reaches for run_code instead of
2761        // answering from training-data memory. Only fires when no harness pre-run
2762        // already set a loop_intervention.
2763        if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
2764            loop_intervention = Some(
2765                "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
2766                 Do NOT answer from training-data memory — memory answers for math are guesses. \
2767                 Use `run_code` to compute the real result and return the actual output. \
2768                 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
2769                 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
2770                 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
2771                 Write the code, run it, return the result."
2772                    .to_string(),
2773            );
2774        }
2775
2776        // ── Native Tool Mandate: nudge model toward create_directory/write_file for local mutations ──
2777        if loop_intervention.is_none() && intent.surgical_filesystem_mode {
2778            loop_intervention = Some(
2779                "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
2780                 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
2781                 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
2782                 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
2783                    .to_string(),
2784            );
2785        }
2786
2787        let mut implementation_started = false;
2788        let mut non_mutating_plan_steps = 0usize;
2789        let non_mutating_plan_soft_cap = 5usize;
2790        let non_mutating_plan_hard_cap = 8usize;
2791        let mut overview_runtime_trace: Option<String> = None;
2792
2793        // Safety cap – never spin forever on a broken model.
2794        let max_iters = 25;
2795        let mut consecutive_errors = 0;
2796        let mut empty_cleaned_nudges = 0u8;
2797        let mut first_iter = true;
2798        let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
2799        // Track identical tool results within this turn to detect logical loops.
2800        let _result_counts: std::collections::HashMap<String, usize> =
2801            std::collections::HashMap::new();
2802        // Track the count of identical (name, args) calls to detect infinite tool loops.
2803        let mut repeat_counts: std::collections::HashMap<String, usize> =
2804            std::collections::HashMap::new();
2805        let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
2806            std::collections::HashMap::new();
2807        let mut successful_read_targets: std::collections::HashSet<String> =
2808            std::collections::HashSet::new();
2809        // (path, offset) pairs — catches repeated reads at the same non-zero offset.
2810        let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
2811            std::collections::HashSet::new();
2812        let mut successful_grep_targets: std::collections::HashSet<String> =
2813            std::collections::HashSet::new();
2814        let mut no_match_grep_targets: std::collections::HashSet<String> =
2815            std::collections::HashSet::new();
2816        let mut broad_grep_targets: std::collections::HashSet<String> =
2817            std::collections::HashSet::new();
2818
2819        // Track the index of the message that started THIS turn, so compaction doesn't summarize it.
2820        let mut turn_anchor = self.history.len().saturating_sub(1);
2821
2822        for _iter in 0..max_iters {
2823            let mut mutation_occurred = false;
2824            // Priority Check: External Cancellation (via Esc key in TUI)
2825            if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
2826                self.cancel_token
2827                    .store(false, std::sync::atomic::Ordering::SeqCst);
2828                let _ = tx
2829                    .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
2830                    .await;
2831                let _ = tx.send(InferenceEvent::Done).await;
2832                return Ok(());
2833            }
2834
2835            // ── Intelligence Surge: Proactive Compaction Check ──────────────────────
2836            if self
2837                .compact_history_if_needed(&tx, Some(turn_anchor))
2838                .await?
2839            {
2840                // After compaction, history is [system, summary, turn_anchor, ...]
2841                // The new turn_anchor is index 2.
2842                turn_anchor = 2;
2843            }
2844
2845            // On the first iteration inject Vein context into the system message.
2846            // Subsequent iterations use the plain slice — tool results are now in
2847            // history so Vein context would be redundant.
2848            let inject_vein = first_iter && !implement_current_plan;
2849            let messages = if implement_current_plan {
2850                first_iter = false;
2851                self.context_window_slice_from(turn_anchor)
2852            } else {
2853                first_iter = false;
2854                self.context_window_slice()
2855            };
2856
2857            // Use the canonical system prompt from history[0] which was built
2858            // by InferenceEngine::build_system_prompt() + build_system_with_corrections()
2859            // and includes GPU state, git context, permissions, and instruction files.
2860            let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
2861                // Gemma 4 handles multiple system messages natively.
2862                // Standard models (Qwen, etc.) reject a second system message — merge into history[0].
2863                if crate::agent::inference::is_gemma4_model_name(&self.engine.current_model()) {
2864                    let mut msgs = vec![self.history[0].clone()];
2865                    msgs.push(ChatMessage::system(&intervention));
2866                    msgs
2867                } else {
2868                    let merged =
2869                        format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
2870                    vec![ChatMessage::system(&merged)]
2871                }
2872            } else {
2873                vec![self.history[0].clone()]
2874            };
2875
2876            // Inject Vein context into the system message on the first iteration.
2877            // Vein results are merged in the same way as loop_intervention so standard
2878            // models (Qwen etc.) only ever see one system message.
2879            if inject_vein {
2880                if let Some(ref ctx) = vein_context.as_ref() {
2881                    if crate::agent::inference::is_gemma4_model_name(&self.engine.current_model()) {
2882                        prompt_msgs.push(ChatMessage::system(ctx));
2883                    } else {
2884                        let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
2885                        prompt_msgs[0] = ChatMessage::system(&merged);
2886                    }
2887                }
2888            }
2889            prompt_msgs.extend(messages);
2890            if let Some(budget_note) =
2891                enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
2892            {
2893                self.emit_operator_checkpoint(
2894                    &tx,
2895                    OperatorCheckpointState::BudgetReduced,
2896                    budget_note,
2897                )
2898                .await;
2899                let recipe = plan_recovery(
2900                    RecoveryScenario::PromptBudgetPressure,
2901                    &self.recovery_context,
2902                );
2903                self.emit_recovery_recipe_summary(
2904                    &tx,
2905                    recipe.recipe.scenario.label(),
2906                    compact_recovery_plan_summary(&recipe),
2907                )
2908                .await;
2909            }
2910            self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
2911                .await;
2912
2913            let turn_tools = if intent.sovereign_mode {
2914                self.tools
2915                    .iter()
2916                    .filter(|t| {
2917                        t.function.name != "shell" && t.function.name != "run_workspace_workflow"
2918                    })
2919                    .cloned()
2920                    .collect::<Vec<_>>()
2921            } else {
2922                self.tools.clone()
2923            };
2924
2925            let (mut text, mut tool_calls, usage, finish_reason) = match self
2926                .engine
2927                .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
2928                .await
2929            {
2930                Ok(result) => result,
2931                Err(e) => {
2932                    let class = classify_runtime_failure(&e);
2933                    if should_retry_runtime_failure(class) {
2934                        if self.recovery_context.consume_transient_retry() {
2935                            let label = match class {
2936                                RuntimeFailureClass::ProviderDegraded => "provider_degraded",
2937                                _ => "empty_model_response",
2938                            };
2939                            self.transcript.log_system(&format!(
2940                                "Automatic provider recovery triggered: {}",
2941                                e.trim()
2942                            ));
2943                            self.emit_recovery_recipe_summary(
2944                                &tx,
2945                                label,
2946                                compact_runtime_recovery_summary(class),
2947                            )
2948                            .await;
2949                            let _ = tx
2950                                .send(InferenceEvent::ProviderStatus {
2951                                    state: ProviderRuntimeState::Recovering,
2952                                    summary: compact_runtime_recovery_summary(class).into(),
2953                                })
2954                                .await;
2955                            self.emit_operator_checkpoint(
2956                                &tx,
2957                                OperatorCheckpointState::RecoveringProvider,
2958                                compact_runtime_recovery_summary(class),
2959                            )
2960                            .await;
2961                            continue;
2962                        }
2963                    }
2964
2965                    self.emit_runtime_failure(&tx, class, &e).await;
2966                    break;
2967                }
2968            };
2969            self.emit_provider_live(&tx).await;
2970
2971            // ── LOOP GUARD: Reasoning Collapse Detection ──────────────────────────
2972            // If the model returns no text AND no tool calls, but has a massive
2973            // block of hidden reasoning (often seen as infinite newlines in small models),
2974            // trigger a safety stop to prevent token drain.
2975            if text.is_none() && tool_calls.is_none() {
2976                if let Some(reasoning) = usage.as_ref().and_then(|u| {
2977                    if u.completion_tokens > 2000 {
2978                        Some(u.completion_tokens)
2979                    } else {
2980                        None
2981                    }
2982                }) {
2983                    self.emit_operator_checkpoint(
2984                        &tx,
2985                        OperatorCheckpointState::BlockedToolLoop,
2986                        format!(
2987                            "Reasoning collapse detected ({} tokens of empty output).",
2988                            reasoning
2989                        ),
2990                    )
2991                    .await;
2992                    break;
2993                }
2994            }
2995
2996            // Update TUI token counter with actual usage from LM Studio.
2997            if let Some(ref u) = usage {
2998                let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
2999            }
3000
3001            // Fallback safety net: if native tool markup leaked past the inference-layer
3002            // extractor, recover it here instead of treating it as plain assistant text.
3003            if tool_calls
3004                .as_ref()
3005                .map(|calls| calls.is_empty())
3006                .unwrap_or(true)
3007            {
3008                if let Some(raw_text) = text.as_deref() {
3009                    let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
3010                    if !native_calls.is_empty() {
3011                        tool_calls = Some(native_calls);
3012                        let stripped =
3013                            crate::agent::inference::strip_native_tool_call_text(raw_text);
3014                        text = if stripped.trim().is_empty() {
3015                            None
3016                        } else {
3017                            Some(stripped)
3018                        };
3019                    }
3020                }
3021            }
3022
3023            // Treat empty tool_calls arrays (Some(vec![])) the same as None –
3024            // the model returned text only; an empty array causes an infinite loop.
3025            let tool_calls = tool_calls.filter(|c| !c.is_empty());
3026            let near_context_ceiling = usage
3027                .as_ref()
3028                .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
3029                .unwrap_or(false);
3030
3031            if let Some(calls) = tool_calls {
3032                let (calls, prune_trace_note) =
3033                    prune_architecture_trace_batch(calls, architecture_overview_mode);
3034                if let Some(note) = prune_trace_note {
3035                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3036                }
3037
3038                let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
3039                    calls,
3040                    self.workflow_mode.is_read_only(),
3041                    architecture_overview_mode,
3042                );
3043                if let Some(note) = prune_bloat_note {
3044                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3045                }
3046
3047                let (calls, prune_note) = prune_authoritative_tool_batch(
3048                    calls,
3049                    grounded_trace_mode,
3050                    &effective_user_input,
3051                );
3052                if let Some(note) = prune_note {
3053                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3054                }
3055
3056                let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
3057                if let Some(note) = prune_redir_note {
3058                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3059                }
3060
3061                let (calls, batch_note) = order_batch_reads_first(calls);
3062                if let Some(note) = batch_note {
3063                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3064                }
3065
3066                if let Some(repeated_path) = calls
3067                    .iter()
3068                    .filter(|c| {
3069                        let parsed = serde_json::from_str::<Value>(
3070                            &crate::agent::inference::normalize_tool_argument_string(
3071                                &c.function.name,
3072                                &c.function.arguments,
3073                            ),
3074                        )
3075                        .ok();
3076                        let offset = parsed
3077                            .as_ref()
3078                            .and_then(|args| args.get("offset").and_then(|v| v.as_u64()))
3079                            .unwrap_or(0);
3080                        // Catch re-reads from the top (original behaviour) AND repeated
3081                        // reads at the exact same non-zero offset (new: catches targeted loops).
3082                        if offset < 200 {
3083                            return true;
3084                        }
3085                        if let Some(path) = parsed
3086                            .as_ref()
3087                            .and_then(|args| args.get("path").and_then(|v| v.as_str()))
3088                        {
3089                            let normalized = normalize_workspace_path(path);
3090                            return successful_read_regions.contains(&(normalized, offset));
3091                        }
3092                        false
3093                    })
3094                    .filter_map(|c| repeated_read_target(&c.function))
3095                    .find(|path| successful_read_targets.contains(path))
3096                {
3097                    loop_intervention = Some(format!(
3098                        "STOP. Already read `{}` this turn. Use `inspect_lines` on the relevant window or a specific `grep_files`, then continue.",
3099                        repeated_path
3100                    ));
3101                    let _ = tx
3102                        .send(InferenceEvent::Thought(
3103                            "Read discipline: preventing repeated full-file reads on the same path."
3104                                .into(),
3105                        ))
3106                        .await;
3107                    continue;
3108                }
3109
3110                if capability_mode
3111                    && !capability_needs_repo
3112                    && calls
3113                        .iter()
3114                        .all(|c| is_capability_probe_tool(&c.function.name))
3115                {
3116                    loop_intervention = Some(
3117                        "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
3118                         Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
3119                         Do not mention raw `mcp__*` names unless they are active and directly relevant."
3120                            .to_string(),
3121                    );
3122                    let _ = tx
3123                        .send(InferenceEvent::Thought(
3124                            "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
3125                                .into(),
3126                        ))
3127                        .await;
3128                    continue;
3129                }
3130
3131                // VOCAL AGENT: If the model provided reasoning alongside tools,
3132                // stream it to the SPECULAR panel now using the hardened extraction.
3133                let raw_content = text.as_deref().unwrap_or(" ");
3134
3135                if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
3136                    let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
3137                    // Reasoning is silent (hidden in SPECULAR only).
3138                    self.reasoning_history = Some(thought);
3139                }
3140
3141                // [Gemma-4 Protocol] Keep raw content (including thoughts) during tool loops.
3142                // Thoughts are only stripped before the 'final' user turn.
3143                let stored_tool_call_content = if implement_current_plan {
3144                    cap_output(raw_content, 1200)
3145                } else {
3146                    raw_content.to_string()
3147                };
3148                self.history.push(ChatMessage::assistant_tool_calls(
3149                    &stored_tool_call_content,
3150                    calls.clone(),
3151                ));
3152
3153                // ── LAYER 4: Parallel Tool Orchestration (Batching) ────────────────────
3154                let mut results = Vec::new();
3155                let gemma4_model =
3156                    crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
3157                let latest_user_prompt = self.latest_user_prompt();
3158                let mut seen_call_keys = std::collections::HashSet::new();
3159                let mut deduped_calls = Vec::new();
3160                for call in calls.clone() {
3161                    let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
3162                        &call.function.name,
3163                        &call.function.arguments,
3164                        gemma4_model,
3165                        latest_user_prompt,
3166                    );
3167
3168                    // --- HALLUCINATION SANITIZER ---
3169                    if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
3170                        let cmd_val = normalized_args
3171                            .get("command")
3172                            .or_else(|| normalized_args.get("workflow"));
3173
3174                        if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
3175                            if is_natural_language_hallucination(cmd) {
3176                                let err_msg = format!(
3177                                    "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
3178                                     Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
3179                                     Use the correct surgical tool (like `create_directory`) instead of overthinking.",
3180                                    cmd
3181                                );
3182                                let _ = tx
3183                                    .send(InferenceEvent::Thought(format!(
3184                                        "Sanitizer error: {}",
3185                                        err_msg
3186                                    )))
3187                                    .await;
3188                                results.push(ToolExecutionOutcome {
3189                                    call_id: call.id.clone(),
3190                                    tool_name: normalized_name.clone(),
3191                                    args: normalized_args.clone(),
3192                                    output: err_msg,
3193                                    is_error: true,
3194                                    blocked_by_policy: false,
3195                                    msg_results: Vec::new(),
3196                                    latest_target_dir: None,
3197                                });
3198                                continue;
3199                            }
3200                        }
3201                    }
3202
3203                    let key = canonical_tool_call_key(&normalized_name, &normalized_args);
3204                    if seen_call_keys.insert(key) {
3205                        let repeat_guard_exempt = matches!(
3206                            normalized_name.as_str(),
3207                            "verify_build" | "git_commit" | "git_push"
3208                        );
3209                        if !repeat_guard_exempt {
3210                            if let Some(cached) = completed_tool_cache
3211                                .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
3212                            {
3213                                let _ = tx
3214                                    .send(InferenceEvent::Thought(
3215                                        "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
3216                                            .to_string(),
3217                                    ))
3218                                    .await;
3219                                loop_intervention = Some(format!(
3220                                    "STOP. You already called `{}` with identical arguments earlier in this turn and already have that result in conversation history. Do not call it again. Use the existing result to answer or choose a different next step.",
3221                                    cached.tool_name
3222                                ));
3223                                continue;
3224                            }
3225                        }
3226                        deduped_calls.push(call);
3227                    } else {
3228                        let _ = tx
3229                            .send(InferenceEvent::Thought(
3230                                "Duplicate tool call skipped: identical built-in invocation already ran this turn."
3231                                    .to_string(),
3232                            ))
3233                            .await;
3234                    }
3235                }
3236
3237                // Partition tool calls: Parallel Read vs Serial Mutating
3238                let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
3239                    .into_iter()
3240                    .partition(|c| is_parallel_safe(&c.function.name));
3241
3242                // 1. Concurrent Execution (ParallelRead)
3243                if !parallel_calls.is_empty() {
3244                    let mut tasks = Vec::new();
3245                    for call in parallel_calls {
3246                        let tx_clone = tx.clone();
3247                        let config_clone = config.clone();
3248                        // Carry the real call ID into the outcome
3249                        let call_with_id = call.clone();
3250                        tasks.push(self.process_tool_call(
3251                            call_with_id.function,
3252                            config_clone,
3253                            yolo,
3254                            tx_clone,
3255                            call_with_id.id,
3256                        ));
3257                    }
3258                    // Wait for all read-only tasks to complete simultaneously.
3259                    results.extend(futures::future::join_all(tasks).await);
3260                }
3261
3262                // 2. Sequential Execution (SerialMutating)
3263                for call in serial_calls {
3264                    results.push(
3265                        self.process_tool_call(
3266                            call.function,
3267                            config.clone(),
3268                            yolo,
3269                            tx.clone(),
3270                            call.id,
3271                        )
3272                        .await,
3273                    );
3274                }
3275
3276                // 3. Collate Messages into History & UI
3277                let mut authoritative_tool_output: Option<String> = None;
3278                let mut blocked_policy_output: Option<String> = None;
3279                let mut recoverable_policy_intervention: Option<String> = None;
3280                let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
3281                let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
3282                    None;
3283                for res in results {
3284                    let call_id = res.call_id.clone();
3285                    let tool_name = res.tool_name.clone();
3286                    let final_output = res.output.clone();
3287                    let is_error = res.is_error;
3288                    for msg in res.msg_results {
3289                        self.history.push(msg);
3290                    }
3291
3292                    // Update State for Verification Loop
3293                    if let Some(path) = res.latest_target_dir {
3294                        self.latest_target_dir = Some(path);
3295                    }
3296                    if matches!(
3297                        tool_name.as_str(),
3298                        "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
3299                    ) {
3300                        mutation_occurred = true;
3301                        implementation_started = true;
3302                        // Heat tracking: bump L1 score for the edited file.
3303                        if !is_error {
3304                            let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
3305                            if !path.is_empty() {
3306                                self.vein.bump_heat(path);
3307                                self.l1_context = self.vein.l1_context();
3308                            }
3309                            // Refresh repo map so PageRank accounts for the new edit.
3310                            self.refresh_repo_map();
3311                        }
3312                    }
3313
3314                    if tool_name == "verify_build" {
3315                        self.record_session_verification(
3316                            !is_error
3317                                && (final_output.contains("BUILD OK")
3318                                    || final_output.contains("BUILD SUCCESS")
3319                                    || final_output.contains("BUILD OKAY")),
3320                            if is_error {
3321                                "Explicit verify_build failed."
3322                            } else {
3323                                "Explicit verify_build passed."
3324                            },
3325                        );
3326                    }
3327
3328                    // Update Repeat Guard
3329                    let call_key = format!(
3330                        "{}:{}",
3331                        tool_name,
3332                        serde_json::to_string(&res.args).unwrap_or_default()
3333                    );
3334                    let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
3335                    *repeat_count += 1;
3336
3337                    // verify_build is legitimately called multiple times in fix-verify loops.
3338                    let repeat_guard_exempt = matches!(
3339                        tool_name.as_str(),
3340                        "verify_build" | "git_commit" | "git_push"
3341                    );
3342                    if *repeat_count >= 2 && !repeat_guard_exempt {
3343                        loop_intervention = Some(format!(
3344                            "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
3345                             Do not call it again. Either answer directly from what you already know, \
3346                             use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
3347                             or ask the user for clarification.",
3348                            tool_name, *repeat_count
3349                        ));
3350                        let _ = tx
3351                            .send(InferenceEvent::Thought(format!(
3352                                "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
3353                                tool_name, *repeat_count
3354                            )))
3355                            .await;
3356                    }
3357
3358                    if *repeat_count >= 3 && !repeat_guard_exempt {
3359                        self.emit_runtime_failure(
3360                            &tx,
3361                            RuntimeFailureClass::ToolLoop,
3362                            &format!("Hard termination: `{}` called {} times with identical arguments. Reasoning collapse detected.", tool_name, *repeat_count),
3363                        )
3364                        .await;
3365                        return Ok(());
3366                    }
3367
3368                    if is_error {
3369                        consecutive_errors += 1;
3370                    } else {
3371                        consecutive_errors = 0;
3372                    }
3373
3374                    if consecutive_errors >= 3 {
3375                        loop_intervention = Some(
3376                            "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
3377                             STOP all tool calls immediately. Analyze why your previous 3 calls failed \
3378                             (check for hallucinations or invalid arguments) and ask the user for \
3379                             clarification if you cannot proceed.".to_string()
3380                        );
3381                    }
3382
3383                    if consecutive_errors >= 4 {
3384                        self.emit_runtime_failure(
3385                            &tx,
3386                            RuntimeFailureClass::ToolLoop,
3387                            "Hard termination: too many consecutive tool errors.",
3388                        )
3389                        .await;
3390                        return Ok(());
3391                    }
3392
3393                    let _ = tx
3394                        .send(InferenceEvent::ToolCallResult {
3395                            id: call_id.clone(),
3396                            name: tool_name.clone(),
3397                            output: final_output.clone(),
3398                            is_error,
3399                        })
3400                        .await;
3401
3402                    let repeat_guard_exempt = matches!(
3403                        tool_name.as_str(),
3404                        "verify_build" | "git_commit" | "git_push"
3405                    );
3406                    if !repeat_guard_exempt {
3407                        completed_tool_cache.insert(
3408                            canonical_tool_call_key(&tool_name, &res.args),
3409                            CachedToolResult {
3410                                tool_name: tool_name.clone(),
3411                            },
3412                        );
3413                    }
3414
3415                    // Cap output before history
3416                    let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
3417                        self.engine.current_context_length(),
3418                    );
3419                    let capped = if implement_current_plan {
3420                        cap_output(&final_output, 1200)
3421                    } else if compact_ctx
3422                        && (tool_name == "read_file" || tool_name == "inspect_lines")
3423                    {
3424                        // Compact context: cap file reads tightly and add a navigation hint on truncation.
3425                        let limit = 3000usize;
3426                        if final_output.len() > limit {
3427                            let total_lines = final_output.lines().count();
3428                            let mut split_at = limit;
3429                            while !final_output.is_char_boundary(split_at) && split_at > 0 {
3430                                split_at -= 1;
3431                            }
3432                            let scratch = write_output_to_scratch(&final_output, &tool_name)
3433                                .map(|p| format!(" Full file also saved to '{p}'."))
3434                                .unwrap_or_default();
3435                            format!(
3436                                "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
3437                                &final_output[..split_at],
3438                                total_lines,
3439                                total_lines.saturating_sub(150),
3440                                scratch,
3441                            )
3442                        } else {
3443                            final_output.clone()
3444                        }
3445                    } else {
3446                        cap_output_for_tool(&final_output, 8000, &tool_name)
3447                    };
3448                    self.history.push(ChatMessage::tool_result_for_model(
3449                        &call_id,
3450                        &tool_name,
3451                        &capped,
3452                        &self.engine.current_model(),
3453                    ));
3454
3455                    if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
3456                    {
3457                        overview_runtime_trace =
3458                            Some(summarize_runtime_trace_output(&final_output));
3459                    }
3460
3461                    if !architecture_overview_mode
3462                        && !is_error
3463                        && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
3464                            || (toolchain_mode && tool_name == "describe_toolchain"))
3465                    {
3466                        authoritative_tool_output = Some(final_output.clone());
3467                    }
3468
3469                    if !is_error && tool_name == "read_file" {
3470                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
3471                            let normalized = normalize_workspace_path(path);
3472                            let read_offset =
3473                                res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
3474                            successful_read_targets.insert(normalized.clone());
3475                            successful_read_regions.insert((normalized.clone(), read_offset));
3476                        }
3477                    }
3478
3479                    if !is_error && tool_name == "grep_files" {
3480                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
3481                            let normalized = normalize_workspace_path(path);
3482                            if final_output.starts_with("No matches for ") {
3483                                no_match_grep_targets.insert(normalized);
3484                            } else if grep_output_is_high_fanout(&final_output) {
3485                                broad_grep_targets.insert(normalized);
3486                            } else {
3487                                successful_grep_targets.insert(normalized);
3488                            }
3489                        }
3490                    }
3491
3492                    if is_error
3493                        && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
3494                        && (final_output.contains("search string not found")
3495                            || final_output.contains("search string is too short")
3496                            || final_output.contains("search string matched"))
3497                    {
3498                        if let Some(target) = action_target_path(&tool_name, &res.args) {
3499                            let guidance = if final_output.contains("matched") {
3500                                format!(
3501                                    "STOP. `{}` on `{}` — search string matched multiple times. Use `inspect_lines` on the exact region to get a unique anchor, then retry.",
3502                                    tool_name, target
3503                                )
3504                            } else {
3505                                format!(
3506                                    "STOP. `{}` on `{}` — search string did not match. Use `inspect_lines` on the target region to get the exact current text (check whitespace and indentation), then retry.",
3507                                    tool_name, target
3508                                )
3509                            };
3510                            loop_intervention = Some(guidance);
3511                            *repeat_count = 0;
3512                        }
3513                    }
3514
3515                    // When guard.rs blocks a shell call with the run_code redirect hint,
3516                    // force the model to recover with run_code instead of giving up.
3517                    if is_error
3518                        && tool_name == "shell"
3519                        && final_output.contains("Use the run_code tool instead")
3520                        && loop_intervention.is_none()
3521                    {
3522                        loop_intervention = Some(
3523                            "STOP. Shell was blocked because this is a computation task. \
3524                             You MUST use `run_code` now — write the code and run it. \
3525                             Do NOT output an error message or give up. \
3526                             Call `run_code` with the appropriate language and code to compute the answer. \
3527                             If writing Python, pass `language: \"python\"`. \
3528                             If writing JavaScript, omit language or pass `language: \"javascript\"`."
3529                                .to_string(),
3530                        );
3531                    }
3532
3533                    // When run_code fails with a Deno parse error, the model likely sent Python
3534                    // code without specifying language: "python". Force a corrective retry.
3535                    if is_error
3536                        && tool_name == "run_code"
3537                        && (final_output.contains("source code could not be parsed")
3538                            || final_output.contains("Expected ';'")
3539                            || final_output.contains("Expected '}'")
3540                            || final_output.contains("is not defined")
3541                                && final_output.contains("deno"))
3542                        && loop_intervention.is_none()
3543                    {
3544                        loop_intervention = Some(
3545                            "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
3546                             code but forgot to pass `language: \"python\"`. \
3547                             Retry run_code with `language: \"python\"` and the same code. \
3548                             Do NOT fall back to shell. Do NOT give up."
3549                                .to_string(),
3550                        );
3551                    }
3552
3553                    if res.blocked_by_policy
3554                        && is_mcp_workspace_read_tool(&tool_name)
3555                        && recoverable_policy_intervention.is_none()
3556                    {
3557                        recoverable_policy_intervention = Some(
3558                            "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
3559                        );
3560                        recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
3561                        recoverable_policy_checkpoint = Some((
3562                            OperatorCheckpointState::BlockedPolicy,
3563                            "MCP workspace read blocked; rerouting to built-in file tools."
3564                                .to_string(),
3565                        ));
3566                    } else if res.blocked_by_policy
3567                        && implement_current_plan
3568                        && is_current_plan_irrelevant_tool(&tool_name)
3569                        && recoverable_policy_intervention.is_none()
3570                    {
3571                        recoverable_policy_intervention = Some(format!(
3572                            "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
3573                            tool_name
3574                        ));
3575                        recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
3576                        recoverable_policy_checkpoint = Some((
3577                            OperatorCheckpointState::BlockedPolicy,
3578                            format!(
3579                                "Current-plan execution blocked unrelated tool `{}`.",
3580                                tool_name
3581                            ),
3582                        ));
3583                    } else if res.blocked_by_policy
3584                        && implement_current_plan
3585                        && final_output.contains("requires recent file evidence")
3586                        && recoverable_policy_intervention.is_none()
3587                    {
3588                        let target = action_target_path(&tool_name, &res.args)
3589                            .unwrap_or_else(|| "the target file".to_string());
3590                        recoverable_policy_intervention = Some(format!(
3591                            "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
3592                        ));
3593                        recoverable_policy_recipe =
3594                            Some(RecoveryScenario::RecentFileEvidenceMissing);
3595                        recoverable_policy_checkpoint = Some((
3596                            OperatorCheckpointState::BlockedRecentFileEvidence,
3597                            format!("Edit blocked on `{target}`; recent file evidence missing."),
3598                        ));
3599                    } else if res.blocked_by_policy
3600                        && implement_current_plan
3601                        && final_output.contains("requires an exact local line window first")
3602                        && recoverable_policy_intervention.is_none()
3603                    {
3604                        let target = action_target_path(&tool_name, &res.args)
3605                            .unwrap_or_else(|| "the target file".to_string());
3606                        recoverable_policy_intervention = Some(format!(
3607                            "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
3608                        ));
3609                        recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
3610                        recoverable_policy_checkpoint = Some((
3611                            OperatorCheckpointState::BlockedExactLineWindow,
3612                            format!("Edit blocked on `{target}`; exact line window required."),
3613                        ));
3614                    } else if res.blocked_by_policy
3615                        && (final_output.contains("Prefer `")
3616                            || final_output.contains("Prefer tool"))
3617                        && recoverable_policy_intervention.is_none()
3618                    {
3619                        recoverable_policy_intervention = Some(final_output.clone());
3620                        recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
3621                        recoverable_policy_checkpoint = Some((
3622                            OperatorCheckpointState::BlockedPolicy,
3623                            "Action blocked by policy; self-correction triggered using tool recommendation."
3624                                .to_string(),
3625                        ));
3626                    } else if res.blocked_by_policy && blocked_policy_output.is_none() {
3627                        blocked_policy_output = Some(final_output.clone());
3628                    }
3629
3630                    if *repeat_count >= 5 {
3631                        let _ = tx.send(InferenceEvent::Done).await;
3632                        return Ok(());
3633                    }
3634
3635                    if implement_current_plan
3636                        && !implementation_started
3637                        && !is_error
3638                        && is_non_mutating_plan_step_tool(&tool_name)
3639                    {
3640                        non_mutating_plan_steps += 1;
3641                    }
3642                }
3643
3644                if let Some(intervention) = recoverable_policy_intervention {
3645                    if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
3646                        self.emit_operator_checkpoint(&tx, state, summary).await;
3647                    }
3648                    if let Some(scenario) = recoverable_policy_recipe.take() {
3649                        let recipe = plan_recovery(scenario, &self.recovery_context);
3650                        self.emit_recovery_recipe_summary(
3651                            &tx,
3652                            recipe.recipe.scenario.label(),
3653                            compact_recovery_plan_summary(&recipe),
3654                        )
3655                        .await;
3656                    }
3657                    loop_intervention = Some(intervention);
3658                    let _ = tx
3659                        .send(InferenceEvent::Thought(
3660                            "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
3661                                .into(),
3662                        ))
3663                        .await;
3664                    continue;
3665                }
3666
3667                if architecture_overview_mode {
3668                    match overview_runtime_trace.as_deref() {
3669                        Some(runtime_trace) => {
3670                            let response = build_architecture_overview_answer(runtime_trace);
3671                            self.history.push(ChatMessage::assistant_text(&response));
3672                            self.transcript.log_agent(&response);
3673
3674                            for chunk in chunk_text(&response, 8) {
3675                                if !chunk.is_empty() {
3676                                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3677                                }
3678                            }
3679
3680                            let _ = tx.send(InferenceEvent::Done).await;
3681                            break;
3682                        }
3683                        None => {
3684                            loop_intervention = Some(
3685                                "Good. You now have the grounded repository structure. Next, call `trace_runtime_flow` for the runtime/control-flow half of the architecture overview. Prefer topic `user_turn` for the main execution path, or `runtime_subsystems` if that is more direct. Do not call `read_file`, `auto_pin_context`, or LSP tools here."
3686                                    .to_string(),
3687                            );
3688                            continue;
3689                        }
3690                    }
3691                }
3692
3693                if implement_current_plan
3694                    && !implementation_started
3695                    && non_mutating_plan_steps >= non_mutating_plan_hard_cap
3696                {
3697                    let msg = "Current-plan execution stalled: too many non-mutating inspection steps without a concrete edit. Stay on the saved target files, narrow with `inspect_lines`, and then mutate, or ask one specific blocking question instead of continuing broad exploration.".to_string();
3698                    self.history.push(ChatMessage::assistant_text(&msg));
3699                    self.transcript.log_agent(&msg);
3700
3701                    for chunk in chunk_text(&msg, 8) {
3702                        if !chunk.is_empty() {
3703                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
3704                        }
3705                    }
3706
3707                    let _ = tx.send(InferenceEvent::Done).await;
3708                    break;
3709                }
3710
3711                if let Some(blocked_output) = blocked_policy_output {
3712                    self.emit_operator_checkpoint(
3713                        &tx,
3714                        OperatorCheckpointState::BlockedPolicy,
3715                        "A blocked tool path was surfaced directly to the operator.",
3716                    )
3717                    .await;
3718                    self.history
3719                        .push(ChatMessage::assistant_text(&blocked_output));
3720                    self.transcript.log_agent(&blocked_output);
3721
3722                    for chunk in chunk_text(&blocked_output, 8) {
3723                        if !chunk.is_empty() {
3724                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
3725                        }
3726                    }
3727
3728                    let _ = tx.send(InferenceEvent::Done).await;
3729                    break;
3730                }
3731
3732                if let Some(tool_output) = authoritative_tool_output {
3733                    self.history.push(ChatMessage::assistant_text(&tool_output));
3734                    self.transcript.log_agent(&tool_output);
3735
3736                    for chunk in chunk_text(&tool_output, 8) {
3737                        if !chunk.is_empty() {
3738                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
3739                        }
3740                    }
3741
3742                    let _ = tx.send(InferenceEvent::Done).await;
3743                    break;
3744                }
3745
3746                if implement_current_plan && !implementation_started {
3747                    let base = "STOP analyzing. The current plan already defines the task. Use the built-in file evidence you now have and begin implementing the plan in the target files. Do not output preliminary findings or restate contracts.";
3748                    if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
3749                        loop_intervention = Some(format!(
3750                            "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
3751                            base
3752                        ));
3753                    } else {
3754                        loop_intervention = Some(base.to_string());
3755                    }
3756                } else if self.workflow_mode == WorkflowMode::Architect {
3757                    loop_intervention = Some(
3758                        format!(
3759                            "STOP exploring. You have enough evidence for a plan-first answer.\n{}\nUse the tool results already in history. Do not narrate your process. Do not call more tools unless a missing file path makes the handoff impossible.",
3760                            architect_handoff_contract()
3761                        ),
3762                    );
3763                }
3764
3765                // 4. Auto-Verification Loop (The Perfect Bake)
3766                if mutation_occurred && !yolo {
3767                    let _ = tx
3768                        .send(InferenceEvent::Thought(
3769                            "Self-Verification: Running 'cargo check' to ensure build integrity..."
3770                                .into(),
3771                        ))
3772                        .await;
3773                    let verify_res = self.auto_verify_build().await;
3774                    let verify_ok = verify_res.contains("BUILD SUCCESS");
3775                    self.record_verify_build_result(verify_ok, &verify_res)
3776                        .await;
3777                    self.record_session_verification(
3778                        verify_ok,
3779                        if verify_ok {
3780                            "Automatic build verification passed."
3781                        } else {
3782                            "Automatic build verification failed."
3783                        },
3784                    );
3785                    self.history.push(ChatMessage::system(&format!(
3786                        "\n# SYSTEM VERIFICATION\n{verify_res}"
3787                    )));
3788                    let _ = tx
3789                        .send(InferenceEvent::Thought(
3790                            "Verification turn injected into history.".into(),
3791                        ))
3792                        .await;
3793                }
3794
3795                // Continue loop – the model will respond to the results.
3796                continue;
3797            } else if let Some(response_text) = text {
3798                if finish_reason.as_deref() == Some("length") && near_context_ceiling {
3799                    if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
3800                        let cleaned = build_session_reset_semantics_answer();
3801                        self.history.push(ChatMessage::assistant_text(&cleaned));
3802                        self.transcript.log_agent(&cleaned);
3803                        for chunk in chunk_text(&cleaned, 8) {
3804                            if !chunk.is_empty() {
3805                                let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3806                            }
3807                        }
3808                        let _ = tx.send(InferenceEvent::Done).await;
3809                        break;
3810                    }
3811
3812                    let warning = format_runtime_failure(
3813                        RuntimeFailureClass::ContextWindow,
3814                        "Context ceiling reached before the model completed the answer. Hematite trimmed what it could, but this turn still ran out of room. Retry with a narrower inspection step like `grep_files` or `inspect_lines`, or ask for a smaller scoped answer.",
3815                    );
3816                    self.history.push(ChatMessage::assistant_text(&warning));
3817                    self.transcript.log_agent(&warning);
3818                    let _ = tx
3819                        .send(InferenceEvent::Thought(
3820                            "Length recovery: model hit the context ceiling before completing the answer."
3821                                .into(),
3822                        ))
3823                        .await;
3824                    for chunk in chunk_text(&warning, 8) {
3825                        if !chunk.is_empty() {
3826                            let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3827                        }
3828                    }
3829                    let _ = tx.send(InferenceEvent::Done).await;
3830                    break;
3831                }
3832
3833                if response_text.contains("<|tool_call")
3834                    || response_text.contains("[END_TOOL_REQUEST]")
3835                    || response_text.contains("<|tool_response")
3836                    || response_text.contains("<tool_response|>")
3837                {
3838                    loop_intervention = Some(
3839                        "Your previous response leaked raw native tool transcript markup instead of a valid tool invocation or final answer. Retry immediately. If you need a tool, emit a valid tool call only. If you do not need a tool, answer in plain text with no `<|tool_call>`, `<|tool_response>`, or `[END_TOOL_REQUEST]` markup.".to_string(),
3840                    );
3841                    continue;
3842                }
3843
3844                // 1. Process and route the reasoning block to SPECULAR.
3845                if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
3846                {
3847                    let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
3848                    // Persist for history audit (stripped from next turn by Volatile Reasoning rule).
3849                    // This will be summarized in the next turn's system prompt.
3850                    self.reasoning_history = Some(thought);
3851                }
3852
3853                // 2. Process and stream the final answer to the chat interface.
3854                let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
3855
3856                if implement_current_plan && !implementation_started {
3857                    loop_intervention = Some(
3858                        "Do not stop at analysis. Implement the current saved plan now using built-in workspace tools and the target files already named in the plan. Only answer without edits if you have a concrete blocking question.".to_string(),
3859                    );
3860                    continue;
3861                }
3862
3863                // [Hardened Interface] Strictly respect the stripper.
3864                // If it's empty after stripping think blocks, the model thought through its
3865                // answer but forgot to emit it (common with Qwen3 models in architect/ask mode).
3866                // Nudge it rather than silently dropping the turn — but cap at 2 retries so a
3867                // model that keeps returning whitespace/empty doesn't spin all 25 iterations.
3868                if cleaned.is_empty() {
3869                    empty_cleaned_nudges += 1;
3870                    if empty_cleaned_nudges == 1 {
3871                        loop_intervention = Some(
3872                            "Your visible response was empty. The tool already returned data. \
3873                             Write your answer now in plain text — no <think> tags, no tool calls. \
3874                             State the key facts in 2-5 sentences and stop."
3875                                .to_string(),
3876                        );
3877                        continue;
3878                    } else if empty_cleaned_nudges == 2 {
3879                        loop_intervention = Some(
3880                            "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
3881                             Write the answer in plain text right now. \
3882                             Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
3883                                .to_string(),
3884                        );
3885                        continue;
3886                    }
3887                    // Nudge budget exhausted — surface as a recoverable empty-response failure
3888                    // so the TUI unblocks instead of hanging for the full max_iters budget.
3889                    let class = RuntimeFailureClass::EmptyModelResponse;
3890                    self.emit_runtime_failure(
3891                        &tx,
3892                        class,
3893                        "Model returned empty content after 2 nudge attempts.",
3894                    )
3895                    .await;
3896                    break;
3897                }
3898
3899                let architect_handoff = self.persist_architect_handoff(&cleaned);
3900                self.history.push(ChatMessage::assistant_text(&cleaned));
3901                self.transcript.log_agent(&cleaned);
3902
3903                // Send in smooth chunks for that professional UI feel.
3904                for chunk in chunk_text(&cleaned, 8) {
3905                    if !chunk.is_empty() {
3906                        let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3907                    }
3908                }
3909
3910                if let Some(plan) = architect_handoff.as_ref() {
3911                    let note = architect_handoff_operator_note(plan);
3912                    self.history.push(ChatMessage::system(&note));
3913                    self.transcript.log_system(&note);
3914                    let _ = tx
3915                        .send(InferenceEvent::MutedToken(format!("\n{}", note)))
3916                        .await;
3917                }
3918
3919                self.emit_done_events(&tx).await;
3920                break;
3921            } else {
3922                let detail = "Model returned an empty response.";
3923                let class = classify_runtime_failure(detail);
3924                if should_retry_runtime_failure(class) {
3925                    if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
3926                        if let RecoveryDecision::Attempt(plan) =
3927                            attempt_recovery(scenario, &mut self.recovery_context)
3928                        {
3929                            self.transcript.log_system(
3930                                "Automatic provider recovery triggered: model returned an empty response.",
3931                            );
3932                            self.emit_recovery_recipe_summary(
3933                                &tx,
3934                                plan.recipe.scenario.label(),
3935                                compact_recovery_plan_summary(&plan),
3936                            )
3937                            .await;
3938                            let _ = tx
3939                                .send(InferenceEvent::ProviderStatus {
3940                                    state: ProviderRuntimeState::Recovering,
3941                                    summary: compact_runtime_recovery_summary(class).into(),
3942                                })
3943                                .await;
3944                            self.emit_operator_checkpoint(
3945                                &tx,
3946                                OperatorCheckpointState::RecoveringProvider,
3947                                compact_runtime_recovery_summary(class),
3948                            )
3949                            .await;
3950                            continue;
3951                        }
3952                    }
3953                }
3954
3955                self.emit_runtime_failure(&tx, class, detail).await;
3956                break;
3957            }
3958        }
3959
3960        self.trim_history(80);
3961        self.refresh_session_memory();
3962        // Record the goal and increment the turn counter before persisting.
3963        self.last_goal = Some(user_input.chars().take(300).collect());
3964        self.turn_count = self.turn_count.saturating_add(1);
3965        self.save_session();
3966        self.emit_compaction_pressure(&tx).await;
3967        Ok(())
3968    }
3969
3970    async fn emit_runtime_failure(
3971        &mut self,
3972        tx: &mpsc::Sender<InferenceEvent>,
3973        class: RuntimeFailureClass,
3974        detail: &str,
3975    ) {
3976        if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
3977            let decision = preview_recovery_decision(scenario, &self.recovery_context);
3978            self.emit_recovery_recipe_summary(
3979                tx,
3980                scenario.label(),
3981                compact_recovery_decision_summary(&decision),
3982            )
3983            .await;
3984            let needs_refresh = match &decision {
3985                RecoveryDecision::Attempt(plan) => plan
3986                    .recipe
3987                    .steps
3988                    .contains(&RecoveryStep::RefreshRuntimeProfile),
3989                RecoveryDecision::Escalate { recipe, .. } => {
3990                    recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
3991                }
3992            };
3993            if needs_refresh {
3994                if let Some((model_id, context_length, changed)) = self
3995                    .refresh_runtime_profile_and_report(tx, "context_window_failure")
3996                    .await
3997                {
3998                    let note = if changed {
3999                        format!(
4000                            "Runtime refresh after context-window failure: model {} | CTX {}",
4001                            model_id, context_length
4002                        )
4003                    } else {
4004                        format!(
4005                            "Runtime refresh after context-window failure confirms model {} | CTX {}",
4006                            model_id, context_length
4007                        )
4008                    };
4009                    let _ = tx.send(InferenceEvent::Thought(note)).await;
4010                }
4011            }
4012        }
4013        if let Some(state) = provider_state_for_runtime_failure(class) {
4014            let _ = tx
4015                .send(InferenceEvent::ProviderStatus {
4016                    state,
4017                    summary: compact_runtime_failure_summary(class).into(),
4018                })
4019                .await;
4020        }
4021        if let Some(state) = checkpoint_state_for_runtime_failure(class) {
4022            self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
4023                .await;
4024        }
4025        let formatted = format_runtime_failure(class, detail);
4026        self.history.push(ChatMessage::system(&format!(
4027            "# RUNTIME FAILURE\n{}",
4028            formatted
4029        )));
4030        self.transcript.log_system(&formatted);
4031        let _ = tx.send(InferenceEvent::Error(formatted)).await;
4032        let _ = tx.send(InferenceEvent::Done).await;
4033    }
4034
4035    /// [Task Analyzer] Run 'cargo check' and return a concise summary for the model.
4036    async fn auto_verify_build(&self) -> String {
4037        match crate::tools::verify_build::execute(&serde_json::json!({ "action": "build" })).await {
4038            Ok(out) => {
4039                "BUILD SUCCESS: Your changes are architecturally sound.\n\n".to_string()
4040                    + &cap_output(&out, 2000)
4041            }
4042            Err(e) => format!(
4043                "BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:\n\n{}",
4044                cap_output(&e, 2000)
4045            ),
4046        }
4047    }
4048
4049    /// Triggers an LLM call to summarize old messages if history exceeds the VRAM character limit.
4050    /// Triggers the Deterministic Smart Compaction algorithm to shrink history while preserving context.
4051    /// Triggers the Recursive Context Compactor.
4052    async fn compact_history_if_needed(
4053        &mut self,
4054        tx: &mpsc::Sender<InferenceEvent>,
4055        anchor_index: Option<usize>,
4056    ) -> Result<bool, String> {
4057        let vram_ratio = self.gpu_state.ratio();
4058        let context_length = self.engine.current_context_length();
4059        let config = CompactionConfig::adaptive(context_length, vram_ratio);
4060
4061        if !compaction::should_compact(&self.history, context_length, vram_ratio) {
4062            return Ok(false);
4063        }
4064
4065        let _ = tx
4066            .send(InferenceEvent::Thought(format!(
4067                "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
4068                context_length / 1000,
4069                vram_ratio * 100.0,
4070                config.max_estimated_tokens / 1000,
4071            )))
4072            .await;
4073
4074        let result = compaction::compact_history(
4075            &self.history,
4076            self.running_summary.as_deref(),
4077            config,
4078            anchor_index,
4079        );
4080
4081        let removed_message_count = self.history.len().saturating_sub(result.messages.len());
4082        self.history = result.messages;
4083        self.running_summary = result.summary;
4084
4085        // Layer 6: Memory Synthesis (Task Context Persistence)
4086        let previous_memory = self.session_memory.clone();
4087        self.session_memory = compaction::extract_memory(&self.history);
4088        self.session_memory
4089            .inherit_runtime_ledger_from(&previous_memory);
4090        self.session_memory.record_compaction(
4091            removed_message_count,
4092            format!(
4093                "Compacted history around active task '{}' and preserved {} working-set file(s).",
4094                self.session_memory.current_task,
4095                self.session_memory.working_set.len()
4096            ),
4097        );
4098        self.emit_compaction_pressure(tx).await;
4099
4100        // Jinja alignment: preserved slice may start with assistant/tool messages.
4101        // Strip any leading non-user messages so the first non-system message is always user.
4102        let first_non_sys = self
4103            .history
4104            .iter()
4105            .position(|m| m.role != "system")
4106            .unwrap_or(self.history.len());
4107        if first_non_sys < self.history.len() {
4108            if let Some(user_offset) = self.history[first_non_sys..]
4109                .iter()
4110                .position(|m| m.role == "user")
4111            {
4112                if user_offset > 0 {
4113                    self.history
4114                        .drain(first_non_sys..first_non_sys + user_offset);
4115                }
4116            }
4117        }
4118
4119        let _ = tx
4120            .send(InferenceEvent::Thought(format!(
4121                "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
4122                self.session_memory.current_task,
4123                self.session_memory.working_set.len()
4124            )))
4125            .await;
4126        let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
4127        self.emit_recovery_recipe_summary(
4128            tx,
4129            recipe.recipe.scenario.label(),
4130            compact_recovery_plan_summary(&recipe),
4131        )
4132        .await;
4133        self.emit_operator_checkpoint(
4134            tx,
4135            OperatorCheckpointState::HistoryCompacted,
4136            format!(
4137                "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
4138                self.session_memory.current_task,
4139                self.session_memory.working_set.len()
4140            ),
4141        )
4142        .await;
4143
4144        Ok(true)
4145    }
4146
4147    /// Query The Vein for context relevant to the user's message.
4148    /// Runs hybrid BM25 + semantic search (semantic requires embedding model in LM Studio).
4149    /// Returns a formatted system message string, or None if nothing useful found.
4150    fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
4151        // Skip trivial / very short inputs.
4152        if query.trim().split_whitespace().count() < 3 {
4153            return None;
4154        }
4155
4156        let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
4157        if results.is_empty() {
4158            return None;
4159        }
4160
4161        let semantic_active = self.vein.has_any_embeddings();
4162        let header = if semantic_active {
4163            "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
4164             Use this to answer without needing extra read_file calls where possible.\n\n"
4165        } else {
4166            "# Relevant context from The Vein (BM25 keyword retrieval)\n\
4167             Use this to answer without needing extra read_file calls where possible.\n\n"
4168        };
4169
4170        let mut ctx = String::from(header);
4171        let mut paths: Vec<String> = Vec::new();
4172
4173        let mut total = 0usize;
4174        const MAX_CTX_CHARS: usize = 1_500;
4175
4176        for r in results {
4177            if total >= MAX_CTX_CHARS {
4178                break;
4179            }
4180            let snippet = if r.content.len() > 500 {
4181                format!("{}...", &r.content[..500])
4182            } else {
4183                r.content.clone()
4184            };
4185            ctx.push_str(&format!("--- {} ---\n{}\n\n", r.path, snippet));
4186            total += snippet.len() + r.path.len() + 10;
4187            if !paths.contains(&r.path) {
4188                paths.push(r.path);
4189            }
4190        }
4191
4192        Some((ctx, paths))
4193    }
4194
4195    /// Returns the conversation history (WITHOUT the system prompt) for the context window.
4196    /// This ensures we don't have redundant system blocks and prevents Jinja crashes.
4197    fn context_window_slice(&self) -> Vec<ChatMessage> {
4198        let mut result = Vec::new();
4199
4200        // Skip index 0 (the raw system message) and any stray system messages in history.
4201        if self.history.len() > 1 {
4202            for m in &self.history[1..] {
4203                if m.role == "system" {
4204                    continue;
4205                }
4206
4207                let mut sanitized = m.clone();
4208                // DEEP SANITIZE: LM Studio Jinja templates for Qwen crash on truly empty content.
4209                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
4210                    sanitized.content = MessageContent::Text(" ".into());
4211                }
4212                result.push(sanitized);
4213            }
4214        }
4215
4216        // Jinja Guard: The first message after the system prompt MUST be 'user'.
4217        // If not (e.g. because of compaction), we insert a tiny anchor.
4218        if !result.is_empty() && result[0].role != "user" {
4219            result.insert(0, ChatMessage::user("Continuing previous context..."));
4220        }
4221
4222        result
4223    }
4224
4225    fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
4226        let mut result = Vec::new();
4227
4228        if self.history.len() > 1 {
4229            let start = start_idx.max(1).min(self.history.len());
4230            for m in &self.history[start..] {
4231                if m.role == "system" {
4232                    continue;
4233                }
4234
4235                let mut sanitized = m.clone();
4236                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
4237                    sanitized.content = MessageContent::Text(" ".into());
4238                }
4239                result.push(sanitized);
4240            }
4241        }
4242
4243        if !result.is_empty() && result[0].role != "user" {
4244            result.insert(0, ChatMessage::user("Continuing current plan execution..."));
4245        }
4246
4247        result
4248    }
4249
4250    /// Drop old turns from the middle of history.
4251    fn trim_history(&mut self, max_messages: usize) {
4252        if self.history.len() <= max_messages {
4253            return;
4254        }
4255        // Always keep [0] (system prompt).
4256        let excess = self.history.len() - max_messages;
4257        self.history.drain(1..=excess);
4258    }
4259
4260    /// P1: Attempt to fix malformed JSON tool arguments by asking the model to re-output them.
4261    async fn repair_tool_args(
4262        &self,
4263        tool_name: &str,
4264        bad_json: &str,
4265        tx: &mpsc::Sender<InferenceEvent>,
4266    ) -> Result<Value, String> {
4267        let _ = tx
4268            .send(InferenceEvent::Thought(format!(
4269                "Attempting to repair malformed JSON for '{}'...",
4270                tool_name
4271            )))
4272            .await;
4273
4274        let prompt = format!(
4275            "The following JSON for tool '{}' is malformed and failed to parse:\n\n```json\n{}\n```\n\nOutput ONLY the corrected JSON string that fixes the syntax error (e.g. missing commas, unescaped quotes). Do NOT include markdown blocks or any other text.",
4276            tool_name, bad_json
4277        );
4278
4279        let messages = vec![
4280            ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
4281            ChatMessage::user(&prompt),
4282        ];
4283
4284        // Use fast model for speed if available.
4285        let (text, _, _, _) = self
4286            .engine
4287            .call_with_tools(&messages, &[], self.fast_model.as_deref())
4288            .await
4289            .map_err(|e| e.to_string())?;
4290
4291        let cleaned = text
4292            .unwrap_or_default()
4293            .trim()
4294            .trim_start_matches("```json")
4295            .trim_start_matches("```")
4296            .trim_end_matches("```")
4297            .trim()
4298            .to_string();
4299
4300        serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
4301    }
4302
4303    /// P2: Run a fast validation step after file writes to check for subtle logic errors.
4304    async fn run_critic_check(
4305        &self,
4306        path: &str,
4307        content: &str,
4308        tx: &mpsc::Sender<InferenceEvent>,
4309    ) -> Option<String> {
4310        // Only run for source code files.
4311        let ext = std::path::Path::new(path)
4312            .extension()
4313            .and_then(|e| e.to_str())
4314            .unwrap_or("");
4315        const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
4316        if !CRITIC_EXTS.contains(&ext) {
4317            return None;
4318        }
4319
4320        let _ = tx
4321            .send(InferenceEvent::Thought(format!(
4322                "CRITIC: Reviewing changes to '{}'...",
4323                path
4324            )))
4325            .await;
4326
4327        let truncated = cap_output(content, 4000);
4328
4329        let prompt = format!(
4330            "You are a Senior Security and Code Quality auditor. Review this file content for '{}' and identify any critical logic errors, security vulnerabilities, or missing error handling. Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
4331            path, ext, truncated
4332        );
4333
4334        let messages = vec![
4335            ChatMessage::system("You are a technical critic. Identify ONLY critical issues. Output 'PASS' if none found."),
4336            ChatMessage::user(&prompt)
4337        ];
4338
4339        let (text, _, _, _) = self
4340            .engine
4341            .call_with_tools(&messages, &[], self.fast_model.as_deref())
4342            .await
4343            .ok()?;
4344
4345        let critique = text?.trim().to_string();
4346        if critique.to_uppercase().contains("PASS") || critique.is_empty() {
4347            None
4348        } else {
4349            Some(critique)
4350        }
4351    }
4352}
4353
4354// ── Tool dispatcher ───────────────────────────────────────────────────────────
4355
4356pub async fn dispatch_tool(name: &str, args: &Value) -> Result<String, String> {
4357    dispatch_builtin_tool(name, args).await
4358}
4359
4360fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
4361    let trimmed = text.trim();
4362    let stripped = trimmed
4363        .strip_prefix("/think")
4364        .or_else(|| trimmed.strip_prefix("/no_think"))
4365        .map(str::trim)
4366        .unwrap_or(trimmed)
4367        .trim_start_matches('\n')
4368        .trim();
4369    (!stripped.is_empty()).then(|| stripped.to_string())
4370}
4371
4372fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
4373    if tool_name != "inspect_host" {
4374        return;
4375    }
4376
4377    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
4378        return;
4379    };
4380    if topic != "fix_plan" {
4381        return;
4382    }
4383
4384    let issue_missing = args
4385        .get("issue")
4386        .and_then(|v| v.as_str())
4387        .map(str::trim)
4388        .is_none_or(|value| value.is_empty());
4389    if !issue_missing {
4390        return;
4391    }
4392
4393    let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
4394        return;
4395    };
4396
4397    let Value::Object(map) = args else {
4398        return;
4399    };
4400    map.insert(
4401        "issue".to_string(),
4402        Value::String(fallback_issue.to_string()),
4403    );
4404}
4405
4406fn should_rewrite_shell_to_fix_plan(
4407    tool_name: &str,
4408    args: &Value,
4409    latest_user_prompt: Option<&str>,
4410) -> bool {
4411    if tool_name != "shell" {
4412        return false;
4413    }
4414    let Some(prompt) = latest_user_prompt else {
4415        return false;
4416    };
4417    if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
4418        return false;
4419    }
4420    let command = args
4421        .get("command")
4422        .and_then(|value| value.as_str())
4423        .unwrap_or("");
4424    shell_looks_like_structured_host_inspection(command)
4425}
4426
4427fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
4428    let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(flag));
4429    let regex = regex::Regex::new(&pattern).ok()?;
4430    let captures = regex.captures(command)?;
4431    captures.get(1).map(|m| m.as_str().to_string())
4432}
4433
4434fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
4435    let workflow = preferred_maintainer_workflow(prompt)?;
4436    let lower = prompt.to_ascii_lowercase();
4437    match workflow {
4438        "clean" => Some(serde_json::json!({
4439            "workflow": "clean",
4440            "deep": lower.contains("deep clean")
4441                || lower.contains("deep cleanup")
4442                || lower.contains("deep"),
4443            "reset": lower.contains("reset"),
4444            "prune_dist": lower.contains("prune dist")
4445                || lower.contains("prune old dist")
4446                || lower.contains("prune old artifacts")
4447                || lower.contains("old dist artifacts")
4448                || lower.contains("old artifacts"),
4449        })),
4450        "package_windows" => Some(serde_json::json!({
4451            "workflow": "package_windows",
4452            "installer": lower.contains("installer") || lower.contains("setup.exe"),
4453            "add_to_path": lower.contains("addtopath")
4454                || lower.contains("add to path")
4455                || lower.contains("update path")
4456                || lower.contains("refresh path"),
4457        })),
4458        "release" => {
4459            let version = regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#)
4460                .ok()
4461                .and_then(|re| re.captures(prompt))
4462                .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
4463            let bump = if lower.contains("patch") {
4464                Some("patch")
4465            } else if lower.contains("minor") {
4466                Some("minor")
4467            } else if lower.contains("major") {
4468                Some("major")
4469            } else {
4470                None
4471            };
4472            let mut args = serde_json::json!({
4473                "workflow": "release",
4474                "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
4475                "add_to_path": lower.contains("addtopath")
4476                    || lower.contains("add to path")
4477                    || lower.contains("update path"),
4478                "skip_installer": lower.contains("skip installer"),
4479                "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
4480                "publish_voice_crate": lower.contains("publish voice crate")
4481                    || lower.contains("publish hematite-kokoros"),
4482            });
4483            if let Some(version) = version {
4484                args["version"] = Value::String(version);
4485            }
4486            if let Some(bump) = bump {
4487                args["bump"] = Value::String(bump.to_string());
4488            }
4489            Some(args)
4490        }
4491        _ => None,
4492    }
4493}
4494
4495fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
4496    let workflow = preferred_workspace_workflow(prompt)?;
4497    let lower = prompt.to_ascii_lowercase();
4498    let trimmed = prompt.trim();
4499
4500    if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
4501        return Some(serde_json::json!({
4502            "workflow": "command",
4503            "command": command,
4504        }));
4505    }
4506
4507    if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
4508        return Some(serde_json::json!({
4509            "workflow": "script_path",
4510            "path": path,
4511        }));
4512    }
4513
4514    match workflow {
4515        "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
4516            "workflow": workflow,
4517        })),
4518        "script" => {
4519            let package_script = if lower.contains("npm run ") {
4520                extract_word_after(&lower, "npm run ")
4521            } else if lower.contains("pnpm run ") {
4522                extract_word_after(&lower, "pnpm run ")
4523            } else if lower.contains("bun run ") {
4524                extract_word_after(&lower, "bun run ")
4525            } else if lower.contains("yarn ") {
4526                extract_word_after(&lower, "yarn ")
4527            } else {
4528                None
4529            };
4530
4531            if let Some(name) = package_script {
4532                return Some(serde_json::json!({
4533                    "workflow": "package_script",
4534                    "name": name,
4535                }));
4536            }
4537
4538            if let Some(name) = extract_word_after(&lower, "just ") {
4539                return Some(serde_json::json!({
4540                    "workflow": "just",
4541                    "name": name,
4542                }));
4543            }
4544            if let Some(name) = extract_word_after(&lower, "make ") {
4545                return Some(serde_json::json!({
4546                    "workflow": "make",
4547                    "name": name,
4548                }));
4549            }
4550            if let Some(name) = extract_word_after(&lower, "task ") {
4551                return Some(serde_json::json!({
4552                    "workflow": "task",
4553                    "name": name,
4554                }));
4555            }
4556
4557            None
4558        }
4559        _ => None,
4560    }
4561}
4562
4563fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
4564    let lower = prompt.to_ascii_lowercase();
4565    for prefix in [
4566        "cargo ",
4567        "npm ",
4568        "pnpm ",
4569        "yarn ",
4570        "bun ",
4571        "pytest",
4572        "go build",
4573        "go test",
4574        "make ",
4575        "just ",
4576        "task ",
4577        "./gradlew",
4578        ".\\gradlew",
4579    ] {
4580        if let Some(index) = lower.find(prefix) {
4581            return Some(prompt[index..].trim().trim_matches('`').to_string());
4582        }
4583    }
4584    None
4585}
4586
4587fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
4588    let normalized = prompt.replace('\\', "/");
4589    for token in normalized.split_whitespace() {
4590        let candidate = token
4591            .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
4592            .trim_start_matches("./");
4593        if candidate.starts_with("scripts/")
4594            && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
4595                .iter()
4596                .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
4597        {
4598            return Some(candidate.to_string());
4599        }
4600    }
4601    None
4602}
4603
4604fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
4605    let start = haystack.find(prefix)? + prefix.len();
4606    let tail = &haystack[start..];
4607    let word = tail
4608        .split_whitespace()
4609        .next()
4610        .map(str::trim)
4611        .filter(|value| !value.is_empty())?;
4612    Some(
4613        word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
4614            .to_string(),
4615    )
4616}
4617
4618fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
4619    let lower = command.to_ascii_lowercase();
4620    if lower.contains("clean.ps1") {
4621        return Some(serde_json::json!({
4622            "workflow": "clean",
4623            "deep": lower.contains("-deep"),
4624            "reset": lower.contains("-reset"),
4625            "prune_dist": lower.contains("-prunedist"),
4626        }));
4627    }
4628    if lower.contains("package-windows.ps1") {
4629        return Some(serde_json::json!({
4630            "workflow": "package_windows",
4631            "installer": lower.contains("-installer"),
4632            "add_to_path": lower.contains("-addtopath"),
4633        }));
4634    }
4635    if lower.contains("release.ps1") {
4636        let version = extract_release_arg(command, "-Version");
4637        let bump = extract_release_arg(command, "-Bump");
4638        if version.is_none() && bump.is_none() {
4639            return Some(serde_json::json!({
4640                "workflow": "release"
4641            }));
4642        }
4643        let mut args = serde_json::json!({
4644            "workflow": "release",
4645            "push": lower.contains("-push"),
4646            "add_to_path": lower.contains("-addtopath"),
4647            "skip_installer": lower.contains("-skipinstaller"),
4648            "publish_crates": lower.contains("-publishcrates"),
4649            "publish_voice_crate": lower.contains("-publishvoicecrate"),
4650        });
4651        if let Some(version) = version {
4652            args["version"] = Value::String(version);
4653        }
4654        if let Some(bump) = bump {
4655            args["bump"] = Value::String(bump);
4656        }
4657        return Some(args);
4658    }
4659    None
4660}
4661
4662fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
4663    let lower = command.to_ascii_lowercase();
4664    if lower.contains("clean.ps1")
4665        || lower.contains("package-windows.ps1")
4666        || lower.contains("release.ps1")
4667    {
4668        return None;
4669    }
4670
4671    if let Some(path) = extract_workspace_script_path_from_prompt(command) {
4672        return Some(serde_json::json!({
4673            "workflow": "script_path",
4674            "path": path,
4675        }));
4676    }
4677
4678    let looks_like_workspace_command = [
4679        "cargo ",
4680        "npm ",
4681        "pnpm ",
4682        "yarn ",
4683        "bun ",
4684        "pytest",
4685        "go build",
4686        "go test",
4687        "make ",
4688        "just ",
4689        "task ",
4690        "./gradlew",
4691        ".\\gradlew",
4692    ]
4693    .iter()
4694    .any(|needle| lower.contains(needle));
4695
4696    if looks_like_workspace_command {
4697        Some(serde_json::json!({
4698            "workflow": "command",
4699            "command": command.trim(),
4700        }))
4701    } else {
4702        None
4703    }
4704}
4705
4706fn rewrite_host_tool_call(
4707    tool_name: &mut String,
4708    args: &mut Value,
4709    latest_user_prompt: Option<&str>,
4710) {
4711    if *tool_name == "shell" {
4712        let command = args
4713            .get("command")
4714            .and_then(|value| value.as_str())
4715            .unwrap_or("");
4716        if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
4717            *tool_name = "run_hematite_maintainer_workflow".to_string();
4718            *args = maintainer_workflow_args;
4719            return;
4720        }
4721        if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
4722            *tool_name = "run_workspace_workflow".to_string();
4723            *args = workspace_workflow_args;
4724            return;
4725        }
4726    }
4727    let is_surgical_tool = matches!(
4728        tool_name.as_str(),
4729        "create_directory"
4730            | "write_file"
4731            | "edit_file"
4732            | "patch_hunk"
4733            | "multi_replace_file_content"
4734            | "replace_file_content"
4735            | "move_file"
4736            | "delete_file"
4737    );
4738
4739    if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
4740        if let Some(prompt_args) =
4741            latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
4742        {
4743            *tool_name = "run_hematite_maintainer_workflow".to_string();
4744            *args = prompt_args;
4745            return;
4746        }
4747    }
4748    if !is_surgical_tool && *tool_name != "run_workspace_workflow" {
4749        if let Some(prompt_args) =
4750            latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
4751        {
4752            *tool_name = "run_workspace_workflow".to_string();
4753            *args = prompt_args;
4754            return;
4755        }
4756    }
4757    if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
4758        *tool_name = "inspect_host".to_string();
4759        *args = serde_json::json!({
4760            "topic": "fix_plan"
4761        });
4762    }
4763    fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
4764}
4765
4766fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
4767    format!(
4768        "{}:{}",
4769        tool_name,
4770        serde_json::to_string(args).unwrap_or_default()
4771    )
4772}
4773
4774fn normalized_tool_call_for_execution(
4775    tool_name: &str,
4776    raw_arguments: &str,
4777    gemma4_model: bool,
4778    latest_user_prompt: Option<&str>,
4779) -> (String, Value) {
4780    let normalized_arguments = if gemma4_model {
4781        crate::agent::inference::normalize_tool_argument_string(tool_name, raw_arguments)
4782    } else {
4783        raw_arguments.to_string()
4784    };
4785    let mut normalized_name = tool_name.to_string();
4786    let mut args = serde_json::from_str::<Value>(&normalized_arguments)
4787        .unwrap_or(Value::Object(Default::default()));
4788    rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
4789    (normalized_name, args)
4790}
4791
4792#[cfg(test)]
4793fn normalized_tool_call_key_for_dedupe(
4794    tool_name: &str,
4795    raw_arguments: &str,
4796    gemma4_model: bool,
4797    latest_user_prompt: Option<&str>,
4798) -> String {
4799    let (normalized_name, args) = normalized_tool_call_for_execution(
4800        tool_name,
4801        raw_arguments,
4802        gemma4_model,
4803        latest_user_prompt,
4804    );
4805    canonical_tool_call_key(&normalized_name, &args)
4806}
4807
4808impl ConversationManager {
4809    /// Checks if a tool call is authorized given the current configuration and mode.
4810    fn check_authorization(
4811        &self,
4812        name: &str,
4813        args: &serde_json::Value,
4814        config: &crate::agent::config::HematiteConfig,
4815        yolo_flag: bool,
4816    ) -> crate::agent::permission_enforcer::AuthorizationDecision {
4817        crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
4818    }
4819
4820    /// Layer 4: Isolated tool execution logic. Does not mutate 'self' to allow parallelism.
4821    async fn process_tool_call(
4822        &self,
4823        mut call: ToolCallFn,
4824        config: crate::agent::config::HematiteConfig,
4825        yolo: bool,
4826        tx: mpsc::Sender<InferenceEvent>,
4827        real_id: String,
4828    ) -> ToolExecutionOutcome {
4829        let mut msg_results = Vec::new();
4830        let mut latest_target_dir = None;
4831        let gemma4_model =
4832            crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
4833        let normalized_arguments = if gemma4_model {
4834            crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments)
4835        } else {
4836            call.arguments.clone()
4837        };
4838
4839        // 1. Argument Parsing & Repair
4840        let mut args: Value = match serde_json::from_str(&normalized_arguments) {
4841            Ok(v) => v,
4842            Err(_) => {
4843                match self
4844                    .repair_tool_args(&call.name, &normalized_arguments, &tx)
4845                    .await
4846                {
4847                    Ok(v) => v,
4848                    Err(e) => {
4849                        let _ = tx
4850                            .send(InferenceEvent::Thought(format!(
4851                                "JSON Repair failed: {}",
4852                                e
4853                            )))
4854                            .await;
4855                        Value::Object(Default::default())
4856                    }
4857                }
4858            }
4859        };
4860        let last_user_prompt = self
4861            .history
4862            .iter()
4863            .rev()
4864            .find(|message| message.role == "user")
4865            .map(|message| message.content.as_str());
4866        rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
4867
4868        let display = format_tool_display(&call.name, &args);
4869        let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
4870        let auth = self.check_authorization(&call.name, &args, &config, yolo);
4871
4872        // 2. Permission Check
4873        let decision_result = match precondition_result {
4874            Err(e) => Err(e),
4875            Ok(_) => match auth {
4876                crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
4877                crate::agent::permission_enforcer::AuthorizationDecision::Ask {
4878                    reason,
4879                    source: _,
4880                } => {
4881                    let mutation_label =
4882                        crate::agent::tool_registry::get_mutation_label(&call.name, &args);
4883                    let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
4884                    let _ = tx
4885                        .send(InferenceEvent::ApprovalRequired {
4886                            id: real_id.clone(),
4887                            name: call.name.clone(),
4888                            display: format!("{}\nWhy: {}", display, reason),
4889                            diff: None,
4890                            mutation_label,
4891                            responder: approve_tx,
4892                        })
4893                        .await;
4894
4895                    match approve_rx.await {
4896                        Ok(true) => Ok(()),
4897                        _ => Err("Declined by user".into()),
4898                    }
4899                }
4900                crate::agent::permission_enforcer::AuthorizationDecision::Deny {
4901                    reason, ..
4902                } => Err(reason),
4903            },
4904        };
4905        let blocked_by_policy =
4906            matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
4907
4908        // 3. Execution (Local or MCP)
4909        let (output, is_error) = match decision_result {
4910            Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
4911            Err(e) => (format!("Error: {}", e), true),
4912            Ok(_) => {
4913                let _ = tx
4914                    .send(InferenceEvent::ToolCallStart {
4915                        id: real_id.clone(),
4916                        name: call.name.clone(),
4917                        args: display.clone(),
4918                    })
4919                    .await;
4920
4921                let result = if call.name.starts_with("lsp_") {
4922                    let lsp = self.lsp_manager.clone();
4923                    let path = args
4924                        .get("path")
4925                        .and_then(|v| v.as_str())
4926                        .unwrap_or("")
4927                        .to_string();
4928                    let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
4929                    let character =
4930                        args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
4931
4932                    match call.name.as_str() {
4933                        "lsp_definitions" => {
4934                            crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
4935                                .await
4936                        }
4937                        "lsp_references" => {
4938                            crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
4939                                .await
4940                        }
4941                        "lsp_hover" => {
4942                            crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
4943                        }
4944                        "lsp_search_symbol" => {
4945                            let query = args
4946                                .get("query")
4947                                .and_then(|v| v.as_str())
4948                                .unwrap_or_default()
4949                                .to_string();
4950                            crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
4951                        }
4952                        "lsp_rename_symbol" => {
4953                            let new_name = args
4954                                .get("new_name")
4955                                .and_then(|v| v.as_str())
4956                                .unwrap_or_default()
4957                                .to_string();
4958                            crate::tools::lsp_tools::lsp_rename_symbol(
4959                                lsp, path, line, character, new_name,
4960                            )
4961                            .await
4962                        }
4963                        "lsp_get_diagnostics" => {
4964                            crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
4965                        }
4966                        _ => Err(format!("Unknown LSP tool: {}", call.name)),
4967                    }
4968                } else if call.name == "auto_pin_context" {
4969                    let pts = args.get("paths").and_then(|v| v.as_array());
4970                    let reason = args
4971                        .get("reason")
4972                        .and_then(|v| v.as_str())
4973                        .unwrap_or("uninformed scoping");
4974                    if let Some(arr) = pts {
4975                        let mut pinned = Vec::new();
4976                        {
4977                            let mut guard = self.pinned_files.lock().await;
4978                            const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; // 25MB Safety Valve
4979
4980                            for v in arr.iter().take(3) {
4981                                if let Some(p) = v.as_str() {
4982                                    if let Ok(meta) = std::fs::metadata(p) {
4983                                        if meta.len() > MAX_PINNED_SIZE {
4984                                            let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
4985                                            continue;
4986                                        }
4987                                        if let Ok(content) = std::fs::read_to_string(p) {
4988                                            guard.insert(p.to_string(), content);
4989                                            pinned.push(p.to_string());
4990                                        }
4991                                    }
4992                                }
4993                            }
4994                        }
4995                        let msg = format!(
4996                            "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
4997                            pinned.join(", "),
4998                            reason
4999                        );
5000                        let _ = tx
5001                            .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
5002                            .await;
5003                        Ok(msg)
5004                    } else {
5005                        Err("Missing 'paths' array for auto_pin_context.".to_string())
5006                    }
5007                } else if call.name == "list_pinned" {
5008                    let paths_msg = {
5009                        let pinned = self.pinned_files.lock().await;
5010                        if pinned.is_empty() {
5011                            "No files are currently pinned.".to_string()
5012                        } else {
5013                            let paths: Vec<_> = pinned.keys().cloned().collect();
5014                            format!(
5015                                "Currently pinned files in active memory:\n- {}",
5016                                paths.join("\n- ")
5017                            )
5018                        }
5019                    };
5020                    Ok(paths_msg)
5021                } else if call.name.starts_with("mcp__") {
5022                    let mut mcp = self.mcp_manager.lock().await;
5023                    match mcp.call_tool(&call.name, &args).await {
5024                        Ok(res) => Ok(res),
5025                        Err(e) => Err(e.to_string()),
5026                    }
5027                } else if call.name == "swarm" {
5028                    // ── Swarm Orchestration ──
5029                    let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
5030                    let max_workers = args
5031                        .get("max_workers")
5032                        .and_then(|v| v.as_u64())
5033                        .unwrap_or(3) as usize;
5034
5035                    let mut task_objs = Vec::new();
5036                    if let Value::Array(arr) = tasks_val {
5037                        for v in arr {
5038                            let id = v
5039                                .get("id")
5040                                .and_then(|x| x.as_str())
5041                                .unwrap_or("?")
5042                                .to_string();
5043                            let target = v
5044                                .get("target")
5045                                .and_then(|x| x.as_str())
5046                                .unwrap_or("?")
5047                                .to_string();
5048                            let instruction = v
5049                                .get("instruction")
5050                                .and_then(|x| x.as_str())
5051                                .unwrap_or("?")
5052                                .to_string();
5053                            task_objs.push(crate::agent::parser::WorkerTask {
5054                                id,
5055                                target,
5056                                instruction,
5057                            });
5058                        }
5059                    }
5060
5061                    if task_objs.is_empty() {
5062                        Err("No tasks provided for swarm.".to_string())
5063                    } else {
5064                        let (swarm_tx_internal, mut swarm_rx_internal) =
5065                            tokio::sync::mpsc::channel(32);
5066                        let tx_forwarder = tx.clone();
5067
5068                        // Bridge SwarmMessage -> InferenceEvent
5069                        tokio::spawn(async move {
5070                            while let Some(msg) = swarm_rx_internal.recv().await {
5071                                match msg {
5072                                    crate::agent::swarm::SwarmMessage::Progress(id, p) => {
5073                                        let _ = tx_forwarder
5074                                            .send(InferenceEvent::Thought(format!(
5075                                                "Swarm [{}]: {}% complete",
5076                                                id, p
5077                                            )))
5078                                            .await;
5079                                    }
5080                                    crate::agent::swarm::SwarmMessage::ReviewRequest {
5081                                        worker_id,
5082                                        file_path,
5083                                        before: _,
5084                                        after: _,
5085                                        tx,
5086                                    } => {
5087                                        let (approve_tx, approve_rx) =
5088                                            tokio::sync::oneshot::channel::<bool>();
5089                                        let display = format!(
5090                                            "Swarm worker [{}]: Integrated changes into {:?}",
5091                                            worker_id, file_path
5092                                        );
5093                                        let _ = tx_forwarder
5094                                            .send(InferenceEvent::ApprovalRequired {
5095                                                id: format!("swarm_{}", worker_id),
5096                                                name: "swarm_apply".to_string(),
5097                                                display,
5098                                                diff: None,
5099                                                mutation_label: Some(
5100                                                    "Swarm Agentic Integration".to_string(),
5101                                                ),
5102                                                responder: approve_tx,
5103                                            })
5104                                            .await;
5105                                        if let Ok(approved) = approve_rx.await {
5106                                            let response = if approved {
5107                                                crate::agent::swarm::ReviewResponse::Accept
5108                                            } else {
5109                                                crate::agent::swarm::ReviewResponse::Reject
5110                                            };
5111                                            let _ = tx.send(response);
5112                                        }
5113                                    }
5114                                    crate::agent::swarm::SwarmMessage::Done => {}
5115                                }
5116                            }
5117                        });
5118
5119                        let coordinator = self.swarm_coordinator.clone();
5120                        match coordinator
5121                            .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
5122                            .await
5123                        {
5124                            Ok(_) => Ok(
5125                                "Swarm execution completed. Check files for integration results."
5126                                    .to_string(),
5127                            ),
5128                            Err(e) => Err(format!("Swarm failure: {}", e)),
5129                        }
5130                    }
5131                } else if call.name == "vision_analyze" {
5132                    crate::tools::vision::vision_analyze(&self.engine, &args).await
5133                } else if matches!(
5134                    call.name.as_str(),
5135                    "edit_file" | "patch_hunk" | "multi_search_replace"
5136                ) && !yolo
5137                {
5138                    // ── Diff preview gate ─────────────────────────────────────
5139                    // Compute what the edit would look like before applying it.
5140                    // If we can build a diff, require user Y/N in the TUI.
5141                    let diff_result = match call.name.as_str() {
5142                        "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
5143                        "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
5144                        _ => crate::tools::file_ops::compute_msr_diff(&args),
5145                    };
5146                    match diff_result {
5147                        Ok(diff_text) => {
5148                            let path_label =
5149                                args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
5150                            let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
5151                            let mutation_label =
5152                                crate::agent::tool_registry::get_mutation_label(&call.name, &args);
5153                            let _ = tx
5154                                .send(InferenceEvent::ApprovalRequired {
5155                                    id: real_id.clone(),
5156                                    name: call.name.clone(),
5157                                    display: format!("Edit preview: {}", path_label),
5158                                    diff: Some(diff_text),
5159                                    mutation_label,
5160                                    responder: appr_tx,
5161                                })
5162                                .await;
5163                            match appr_rx.await {
5164                                Ok(true) => dispatch_tool(&call.name, &args).await,
5165                                _ => Err("Edit declined by user.".into()),
5166                            }
5167                        }
5168                        // Diff computation failed (e.g. search string not found yet) —
5169                        // fall through and let the tool return its own error.
5170                        Err(_) => dispatch_tool(&call.name, &args).await,
5171                    }
5172                } else if call.name == "verify_build" {
5173                    // Stream build output line-by-line to the SPECULAR panel so
5174                    // the operator sees live compiler progress during long builds.
5175                    crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
5176                } else if call.name == "shell" {
5177                    // Stream shell output line-by-line to the SPECULAR panel so
5178                    // the operator sees live progress during long commands.
5179                    crate::tools::shell::execute_streaming(&args, tx.clone()).await
5180                } else {
5181                    dispatch_tool(&call.name, &args).await
5182                };
5183
5184                match result {
5185                    Ok(o) => (o, false),
5186                    Err(e) => (format!("Error: {}", e), true),
5187                }
5188            }
5189        };
5190
5191        // ── Session Economics ────────────────────────────────────────────────
5192        {
5193            if let Ok(mut econ) = self.engine.economics.lock() {
5194                econ.record_tool(&call.name, !is_error);
5195            }
5196        }
5197
5198        if !is_error {
5199            if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
5200                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
5201                    if call.name == "inspect_lines" {
5202                        self.record_line_inspection(path).await;
5203                    } else {
5204                        self.record_read_observation(path).await;
5205                    }
5206                }
5207            }
5208
5209            if call.name == "verify_build" {
5210                let ok = output.contains("BUILD OK")
5211                    || output.contains("BUILD SUCCESS")
5212                    || output.contains("BUILD OKAY");
5213                self.record_verify_build_result(ok, &output).await;
5214            }
5215
5216            if matches!(
5217                call.name.as_str(),
5218                "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
5219            ) || is_mcp_mutating_tool(&call.name)
5220            {
5221                self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
5222                    .await;
5223            }
5224
5225            if call.name == "create_directory" {
5226                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
5227                    let resolved = crate::tools::file_ops::resolve_candidate(path);
5228                    latest_target_dir = Some(resolved.to_string_lossy().to_string());
5229                }
5230            }
5231
5232            if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
5233                msg_results.push(receipt);
5234            }
5235        }
5236
5237        // 4. Critic Check (Specular Tier 2)
5238        // Gated: Only run on code files with substantive content to avoid burning tokens
5239        // on trivial doc/config edits.
5240        if !is_error && (call.name == "edit_file" || call.name == "write_file") {
5241            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
5242            let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
5243            let ext = std::path::Path::new(path)
5244                .extension()
5245                .and_then(|e| e.to_str())
5246                .unwrap_or("");
5247            const SKIP_EXTS: &[&str] = &[
5248                "md",
5249                "toml",
5250                "json",
5251                "txt",
5252                "yml",
5253                "yaml",
5254                "cfg",
5255                "csv",
5256                "lock",
5257                "gitignore",
5258            ];
5259            let line_count = content.lines().count();
5260            if !path.is_empty()
5261                && !content.is_empty()
5262                && !SKIP_EXTS.contains(&ext)
5263                && line_count >= 50
5264            {
5265                if let Some(critique) = self.run_critic_check(path, content, &tx).await {
5266                    msg_results.push(ChatMessage::system(&format!(
5267                        "[CRITIC REVIEW OF {}]\nIssues found:\n\n{}",
5268                        path, critique
5269                    )));
5270                }
5271            }
5272        }
5273
5274        ToolExecutionOutcome {
5275            call_id: real_id,
5276            tool_name: call.name,
5277            args,
5278            output,
5279            is_error,
5280            blocked_by_policy,
5281            msg_results,
5282            latest_target_dir,
5283        }
5284    }
5285}
5286
5287/// The result of an isolated tool execution.
5288/// Used to bridge Parallel/Serial execution back to the main history.
5289struct ToolExecutionOutcome {
5290    call_id: String,
5291    tool_name: String,
5292    args: Value,
5293    output: String,
5294    is_error: bool,
5295    blocked_by_policy: bool,
5296    msg_results: Vec<ChatMessage>,
5297    latest_target_dir: Option<String>,
5298}
5299
5300#[derive(Clone)]
5301struct CachedToolResult {
5302    tool_name: String,
5303}
5304
5305fn is_code_like_path(path: &str) -> bool {
5306    let ext = std::path::Path::new(path)
5307        .extension()
5308        .and_then(|e| e.to_str())
5309        .unwrap_or("")
5310        .to_ascii_lowercase();
5311    matches!(
5312        ext.as_str(),
5313        "rs" | "js"
5314            | "ts"
5315            | "tsx"
5316            | "jsx"
5317            | "py"
5318            | "go"
5319            | "java"
5320            | "c"
5321            | "cpp"
5322            | "cc"
5323            | "h"
5324            | "hpp"
5325            | "cs"
5326            | "swift"
5327            | "kt"
5328            | "kts"
5329            | "rb"
5330            | "php"
5331    )
5332}
5333
5334// ── Display helpers ───────────────────────────────────────────────────────────
5335
5336pub fn format_tool_display(name: &str, args: &Value) -> String {
5337    let get = |key: &str| {
5338        args.get(key)
5339            .and_then(|v| v.as_str())
5340            .unwrap_or("")
5341            .to_string()
5342    };
5343    match name {
5344        "shell" => format!("$ {}", get("command")),
5345
5346        "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
5347        "describe_toolchain" => format!("describe toolchain {}", get("topic")),
5348        "inspect_host" => format!("inspect host {}", get("topic")),
5349        _ => format!("{} {:?}", name, args),
5350    }
5351}
5352
5353// ── Text utilities ────────────────────────────────────────────────────────────
5354
5355pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
5356    let lower = command.to_ascii_lowercase();
5357    [
5358        "$env:path",
5359        "pathvariable",
5360        "pip --version",
5361        "pipx --version",
5362        "winget --version",
5363        "choco",
5364        "scoop",
5365        "get-childitem",
5366        "gci ",
5367        "where.exe",
5368        "where ",
5369        "cargo --version",
5370        "rustc --version",
5371        "git --version",
5372        "node --version",
5373        "npm --version",
5374        "pnpm --version",
5375        "python --version",
5376        "python3 --version",
5377        "deno --version",
5378        "go version",
5379        "dotnet --version",
5380        "uv --version",
5381        "netstat",
5382        "findstr",
5383        "get-nettcpconnection",
5384        "tcpconnection",
5385        "listening",
5386        "ss -",
5387        "ss ",
5388        "lsof",
5389        "tasklist",
5390        "ipconfig",
5391        "get-netipconfiguration",
5392        "get-netadapter",
5393        "route print",
5394        "ifconfig",
5395        "ip addr",
5396        "ip route",
5397        "resolv.conf",
5398        "get-service",
5399        "sc query",
5400        "systemctl",
5401        "service --status-all",
5402        "get-process",
5403        "working set",
5404        "ps -eo",
5405        "ps aux",
5406        "desktop",
5407        "downloads",
5408        "get-netfirewallprofile",
5409        "win32_powerplan",
5410        "win32_operatingsystem",
5411        "win32_processor",
5412        "wmic",
5413        "loadpercentage",
5414        "totalvisiblememory",
5415        "freephysicalmemory",
5416        "get-wmiobject",
5417        "get-ciminstance",
5418        "get-cpu",
5419        "processorname",
5420        "clockspeed",
5421        "top memory",
5422        "top cpu",
5423        "resource usage",
5424        "powercfg",
5425        "uptime",
5426        "lastbootuptime",
5427        // registry reads for OS/version/update/security info — always use inspect_host
5428        "hklm:",
5429        "hkcu:",
5430        "hklm:\\",
5431        "hkcu:\\",
5432        "currentversion",
5433        "productname",
5434        "displayversion",
5435        "get-itemproperty",
5436        "get-itempropertyvalue",
5437        // updates
5438        "get-windowsupdatelog",
5439        "windowsupdatelog",
5440        "microsoft.update.session",
5441        "createupdatesearcher",
5442        "wuauserv",
5443        "usoclient",
5444        "get-hotfix",
5445        "wu_",
5446        // security / defender
5447        "get-mpcomputerstatus",
5448        "get-mppreference",
5449        "get-mpthreat",
5450        "start-mpscan",
5451        "win32_computersecurity",
5452        "softwarelicensingproduct",
5453        "enablelua",
5454        "get-netfirewallrule",
5455        "netfirewallprofile",
5456        "antivirus",
5457        "defenderstatus",
5458        // disk health / smart
5459        "get-physicaldisk",
5460        "get-disk",
5461        "get-volume",
5462        "get-psdrive",
5463        "psdrive",
5464        "manage-bde",
5465        "bitlockervolume",
5466        "get-bitlockervolume",
5467        "get-smbencryptionstatus",
5468        "smbencryption",
5469        "get-netlanmanagerconnection",
5470        "lanmanager",
5471        "msstoragedriver_failurepredic",
5472        "win32_diskdrive",
5473        "smartstatus",
5474        "diskstatus",
5475        "get-counter",
5476        "intensity",
5477        "benchmark",
5478        "thrash",
5479        "get-item",
5480        "test-path",
5481        // gpo / certs / integrity / domain
5482        "gpresult",
5483        "applied gpo",
5484        "cert:\\",
5485        "cert:",
5486        "component based servicing",
5487        "componentstore",
5488        "get-computerinfo",
5489        "win32_computersystem",
5490        // battery
5491        "win32_battery",
5492        "batterystaticdata",
5493        "batteryfullchargedcapacity",
5494        "batterystatus",
5495        "estimatedchargeremaining",
5496        // crashes / event log (broader)
5497        "get-winevent",
5498        "eventid",
5499        "bugcheck",
5500        "kernelpower",
5501        "win32_ntlogevent",
5502        "filterhashtable",
5503        // scheduled tasks
5504        "get-scheduledtask",
5505        "get-scheduledtaskinfo",
5506        "schtasks",
5507        "taskscheduler",
5508        "get-acl",
5509        "icacls",
5510        "takeown",
5511        "event id 4624",
5512        "eventid 4624",
5513        "who logged in",
5514        "logon history",
5515        "login history",
5516        "get-smbshare",
5517        "net share",
5518        "mbps",
5519        "throughput",
5520        "whoami",
5521        // general cim/wmi diagnostic queries — always use inspect_host
5522        "get-ciminstance win32",
5523        "get-wmiobject win32",
5524        // network admin — always use inspect_host
5525        "arp -",
5526        "arp -a",
5527        "tracert ",
5528        "traceroute ",
5529        "tracepath ",
5530        "get-dnsclientcache",
5531        "ipconfig /displaydns",
5532        "get-netroute",
5533        "get-netneighbor",
5534        "net view",
5535        "get-smbconnection",
5536        "get-smbmapping",
5537        "get-psdrive",
5538        "fdrespub",
5539        "fdphost",
5540        "ssdpsrv",
5541        "upnphost",
5542        "avahi-browse",
5543        "route print",
5544        "ip neigh",
5545        // audio / bluetooth — always use inspect_host
5546        "get-pnpdevice -class audioendpoint",
5547        "get-pnpdevice -class media",
5548        "win32_sounddevice",
5549        "audiosrv",
5550        "audioendpointbuilder",
5551        "windows audio",
5552        "get-pnpdevice -class bluetooth",
5553        "bthserv",
5554        "bthavctpsvc",
5555        "btagservice",
5556        "bluetoothuserservice",
5557        // active directory - always use inspect_host
5558        "get-aduser",
5559        "get-addomain",
5560        "get-adforest",
5561        "get-adgroup",
5562        "get-adcomputer",
5563        "activedirectory",
5564        "get-localuser",
5565        "get-localgroup",
5566        "get-localgroupmember",
5567        "net user",
5568        "net localgroup",
5569        "netsh winhttp show proxy",
5570        "get-itemproperty.*proxy",
5571        "get-netadapter",
5572        "netsh wlan show",
5573        "test-netconnection",
5574        "resolve-dnsname",
5575        "get-netfirewallrule",
5576        // docker / wsl / ssh — always use inspect_host
5577        "docker ps",
5578        "docker info",
5579        "docker images",
5580        "docker container",
5581        "docker inspect",
5582        "docker volume",
5583        "docker system df",
5584        "docker compose ls",
5585        "wsl --list",
5586        "wsl -l",
5587        "wsl --status",
5588        "wsl --version",
5589        "wsl -d",
5590        "wsl df",
5591        "wsl du",
5592        "/mnt/c",
5593        "ssh -v",
5594        "get-service sshd",
5595        "get-service -name sshd",
5596        "cat ~/.ssh",
5597        "ls ~/.ssh",
5598        "ls -la ~/.ssh",
5599        // env / hosts / git config
5600        "get-childitem env:",
5601        "dir env:",
5602        "printenv",
5603        "[environment]::getenvironmentvariable",
5604        "get-content.*hosts",
5605        "cat /etc/hosts",
5606        "type c:\\windows\\system32\\drivers\\etc\\hosts",
5607        "git config --global --list",
5608        "git config --list",
5609        "git config --global",
5610        // database services
5611        "get-service mysql",
5612        "get-service postgresql",
5613        "get-service mongodb",
5614        "get-service redis",
5615        "get-service mssql",
5616        "get-service mariadb",
5617        "systemctl status postgresql",
5618        "systemctl status mysql",
5619        "systemctl status mongod",
5620        "systemctl status redis",
5621        // installed software
5622        "winget list",
5623        "get-package",
5624        "get-itempropert.*uninstall",
5625        "dpkg --get-selections",
5626        "rpm -qa",
5627        "brew list",
5628        // user accounts
5629        "get-localuser",
5630        "get-localgroupmember",
5631        "net user",
5632        "query user",
5633        "net localgroup administrators",
5634        // audit policy
5635        "auditpol /get",
5636        "auditpol",
5637        // shares
5638        "get-smbshare",
5639        "get-smbserverconfiguration",
5640        "net share",
5641        "net use",
5642        // dns servers
5643        "get-dnsclientserveraddress",
5644        "get-dnsclientdohserveraddress",
5645        "get-dnsclientglobalsetting",
5646    ]
5647    .iter()
5648    .any(|needle| lower.contains(needle))
5649}
5650
5651// Moved strip_think_blocks to inference.rs
5652
5653fn cap_output(text: &str, max_bytes: usize) -> String {
5654    cap_output_for_tool(text, max_bytes, "output")
5655}
5656
5657/// Cap tool output at `max_bytes`. When the output exceeds the cap, write the
5658/// full content to `.hematite/scratch/<tool_name>_<timestamp>.txt` and include
5659/// the path in the truncation notice so the model can read the rest with
5660/// `read_file` instead of losing it entirely.
5661fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
5662    if text.len() <= max_bytes {
5663        return text.to_string();
5664    }
5665
5666    // Write full output to scratch so the model can access it.
5667    let scratch_path = write_output_to_scratch(text, tool_name);
5668
5669    let mut split_at = max_bytes;
5670    while !text.is_char_boundary(split_at) && split_at > 0 {
5671        split_at -= 1;
5672    }
5673
5674    let tail = match &scratch_path {
5675        Some(p) => format!(
5676            "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
5677            text.len(),
5678            text.lines().count(),
5679            p
5680        ),
5681        None => format!("\n... [output capped at {}B]", max_bytes),
5682    };
5683
5684    format!("{}{}", &text[..split_at], tail)
5685}
5686
5687/// Write text to `.hematite/scratch/<tool>_<timestamp>.txt`.
5688/// Returns the relative path on success, None if the write fails.
5689fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
5690    let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
5691    if std::fs::create_dir_all(&scratch_dir).is_err() {
5692        return None;
5693    }
5694    let ts = std::time::SystemTime::now()
5695        .duration_since(std::time::UNIX_EPOCH)
5696        .map(|d| d.as_secs())
5697        .unwrap_or(0);
5698    // Sanitize tool name for use in filename
5699    let safe_name: String = tool_name
5700        .chars()
5701        .map(|c| {
5702            if c.is_alphanumeric() || c == '_' {
5703                c
5704            } else {
5705                '_'
5706            }
5707        })
5708        .collect();
5709    let filename = format!("{}_{}.txt", safe_name, ts);
5710    let abs_path = scratch_dir.join(&filename);
5711    if std::fs::write(&abs_path, text).is_err() {
5712        return None;
5713    }
5714    Some(format!(".hematite/scratch/{}", filename))
5715}
5716
5717#[derive(Default)]
5718struct PromptBudgetStats {
5719    summarized_tool_results: usize,
5720    collapsed_tool_results: usize,
5721    trimmed_chat_messages: usize,
5722    dropped_messages: usize,
5723}
5724
5725fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
5726    crate::agent::inference::estimate_message_batch_tokens(messages)
5727}
5728
5729fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
5730    let budget = compaction::SummaryCompressionBudget {
5731        max_chars,
5732        max_lines: 3,
5733        max_line_chars: max_chars.clamp(80, 240),
5734    };
5735    let compressed = compaction::compress_summary(text, budget).summary;
5736    if compressed.is_empty() {
5737        String::new()
5738    } else {
5739        compressed
5740    }
5741}
5742
5743fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
5744    let tool_name = message.name.as_deref().unwrap_or("tool");
5745    let body = summarize_prompt_blob(message.content.as_str(), 320);
5746    format!(
5747        "[Prompt-budget summary of prior `{}` result]\n{}",
5748        tool_name, body
5749    )
5750}
5751
5752fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
5753    let role = message.role.as_str();
5754    let body = summarize_prompt_blob(message.content.as_str(), 240);
5755    format!(
5756        "[Prompt-budget summary of earlier {} message]\n{}",
5757        role, body
5758    )
5759}
5760
5761fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
5762    if messages.len() > 1 && messages[1].role != "user" {
5763        messages.insert(1, ChatMessage::user("Continuing previous context..."));
5764    }
5765}
5766
5767fn enforce_prompt_budget(
5768    prompt_msgs: &mut Vec<ChatMessage>,
5769    context_length: usize,
5770) -> Option<String> {
5771    let target_tokens = ((context_length as f64) * 0.68) as usize;
5772    if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5773        return None;
5774    }
5775
5776    let mut stats = PromptBudgetStats::default();
5777
5778    // 1. Summarize the newest large tool outputs first.
5779    let mut tool_indices: Vec<usize> = prompt_msgs
5780        .iter()
5781        .enumerate()
5782        .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
5783        .collect();
5784    for idx in tool_indices.iter().rev().copied() {
5785        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5786            break;
5787        }
5788        let original = prompt_msgs[idx].content.as_str().to_string();
5789        if original.len() > 1200 {
5790            prompt_msgs[idx].content =
5791                MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
5792            stats.summarized_tool_results += 1;
5793        }
5794    }
5795
5796    // 2. Collapse older tool results aggressively, keeping only the most recent two verbatim/summarized.
5797    tool_indices = prompt_msgs
5798        .iter()
5799        .enumerate()
5800        .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
5801        .collect();
5802    if tool_indices.len() > 2 {
5803        for idx in tool_indices
5804            .iter()
5805            .take(tool_indices.len().saturating_sub(2))
5806            .copied()
5807        {
5808            if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5809                break;
5810            }
5811            prompt_msgs[idx].content = MessageContent::Text(
5812                "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
5813            );
5814            stats.collapsed_tool_results += 1;
5815        }
5816    }
5817
5818    // 3. Trim older long chat messages, but preserve the final user request.
5819    let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
5820    for idx in 1..prompt_msgs.len() {
5821        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5822            break;
5823        }
5824        if Some(idx) == last_user_idx {
5825            continue;
5826        }
5827        let role = prompt_msgs[idx].role.as_str();
5828        if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
5829            prompt_msgs[idx].content =
5830                MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
5831            stats.trimmed_chat_messages += 1;
5832        }
5833    }
5834
5835    // 4. Drop the oldest non-system context until we fit, preserving the latest user request.
5836    let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
5837    let mut idx = 1usize;
5838    while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
5839        if Some(idx) == preserve_last_user_idx {
5840            idx += 1;
5841            if idx >= prompt_msgs.len() {
5842                break;
5843            }
5844            continue;
5845        }
5846        if idx >= prompt_msgs.len() {
5847            break;
5848        }
5849        prompt_msgs.remove(idx);
5850        stats.dropped_messages += 1;
5851    }
5852
5853    normalize_prompt_start(prompt_msgs);
5854
5855    let new_tokens = estimate_prompt_tokens(prompt_msgs);
5856    if stats.summarized_tool_results == 0
5857        && stats.collapsed_tool_results == 0
5858        && stats.trimmed_chat_messages == 0
5859        && stats.dropped_messages == 0
5860    {
5861        return None;
5862    }
5863
5864    Some(format!(
5865        "Prompt Budget Guard: trimmed prompt to about {} tokens (target {}). Summarized {} large tool result(s), collapsed {} older tool result(s), trimmed {} chat message(s), and dropped {} old message(s).",
5866        new_tokens,
5867        target_tokens,
5868        stats.summarized_tool_results,
5869        stats.collapsed_tool_results,
5870        stats.trimmed_chat_messages,
5871        stats.dropped_messages
5872    ))
5873}
5874
5875/// Split text into chunks of roughly `words_per_chunk` whitespace-separated tokens.
5876/// Returns true for short, direct tool-use requests that don't benefit from deep reasoning.
5877/// Used to skip the auto-/think prepend so the model calls the tool immediately
5878/// instead of spending thousands of tokens deliberating over a trivial task.
5879fn is_quick_tool_request(input: &str) -> bool {
5880    let lower = input.to_lowercase();
5881    // Explicit run_code requests — sandbox calls need no reasoning warmup.
5882    if lower.contains("run_code") || lower.contains("run code") {
5883        return true;
5884    }
5885    // Short compute/test requests — "calculate X", "test this", "execute Y"
5886    let is_short = input.len() < 120;
5887    let compute_keywords = [
5888        "calculate",
5889        "compute",
5890        "execute",
5891        "run this",
5892        "test this",
5893        "what is ",
5894        "how much",
5895        "how many",
5896        "convert ",
5897        "print ",
5898    ];
5899    if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
5900        return true;
5901    }
5902    false
5903}
5904
5905fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
5906    let mut chunks = Vec::new();
5907    let mut current = String::new();
5908    let mut count = 0;
5909
5910    for ch in text.chars() {
5911        current.push(ch);
5912        if ch == ' ' || ch == '\n' {
5913            count += 1;
5914            if count >= words_per_chunk {
5915                chunks.push(current.clone());
5916                current.clear();
5917                count = 0;
5918            }
5919        }
5920    }
5921    if !current.is_empty() {
5922        chunks.push(current);
5923    }
5924    chunks
5925}
5926
5927fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
5928    if call.name != "read_file" {
5929        return None;
5930    }
5931    let normalized_arguments =
5932        crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments);
5933    let args: Value = serde_json::from_str(&normalized_arguments).ok()?;
5934    let path = args.get("path").and_then(|v| v.as_str())?;
5935    Some(normalize_workspace_path(path))
5936}
5937
5938fn order_batch_reads_first(
5939    calls: Vec<crate::agent::inference::ToolCallResponse>,
5940) -> (
5941    Vec<crate::agent::inference::ToolCallResponse>,
5942    Option<String>,
5943) {
5944    let has_reads = calls.iter().any(|c| {
5945        matches!(
5946            c.function.name.as_str(),
5947            "read_file" | "inspect_lines" | "grep_files" | "list_files"
5948        )
5949    });
5950    let has_edits = calls.iter().any(|c| {
5951        matches!(
5952            c.function.name.as_str(),
5953            "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
5954        )
5955    });
5956    if has_reads && has_edits {
5957        let reads: Vec<_> = calls
5958            .into_iter()
5959            .filter(|c| {
5960                !matches!(
5961                    c.function.name.as_str(),
5962                    "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
5963                )
5964            })
5965            .collect();
5966        let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
5967        (reads, note)
5968    } else {
5969        (calls, None)
5970    }
5971}
5972
5973fn grep_output_is_high_fanout(output: &str) -> bool {
5974    let Some(summary) = output.lines().next() else {
5975        return false;
5976    };
5977    let hunk_count = summary
5978        .split(", ")
5979        .find_map(|part| {
5980            part.strip_suffix(" hunk(s)")
5981                .and_then(|value| value.parse::<usize>().ok())
5982        })
5983        .unwrap_or(0);
5984    let match_count = summary
5985        .split(' ')
5986        .next()
5987        .and_then(|value| value.parse::<usize>().ok())
5988        .unwrap_or(0);
5989    hunk_count >= 8 || match_count >= 12
5990}
5991
5992fn build_system_with_corrections(
5993    base: &str,
5994    hints: &[String],
5995    gpu: &Arc<GpuState>,
5996    git: &Arc<crate::agent::git_monitor::GitState>,
5997    config: &crate::agent::config::HematiteConfig,
5998) -> String {
5999    let mut system_msg = base.to_string();
6000
6001    // Inject Permission Mode.
6002    system_msg.push_str("\n\n# Permission Mode\n");
6003    let mode_label = match config.mode {
6004        crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
6005        crate::agent::config::PermissionMode::Developer => "DEVELOPER",
6006        crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
6007    };
6008    system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
6009
6010    if config.mode == crate::agent::config::PermissionMode::ReadOnly {
6011        system_msg.push_str("PERMISSION: You are restricted to READ-ONLY access. Do NOT attempt to use write_file, edit_file, or shell for any modification. Focus entirely on analysis, indexing, and reporting.\n");
6012    } else {
6013        system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
6014    }
6015
6016    // Inject live hardware status.
6017    let (used, total) = gpu.read();
6018    if total > 0 {
6019        system_msg.push_str("\n\n# Terminal Hardware Context\n");
6020        system_msg.push_str(&format!(
6021            "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
6022            gpu.gpu_name(),
6023            used as f64 / 1024.0,
6024            total as f64 / 1024.0,
6025            gpu.ratio() * 100.0
6026        ));
6027        system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
6028    }
6029
6030    // Inject Git Repository context.
6031    system_msg.push_str("\n\n# Git Repository Context\n");
6032    let git_status_label = git.label();
6033    let git_url = git.url();
6034    system_msg.push_str(&format!(
6035        "REMOTE STATUS: {} | URL: {}\n",
6036        git_status_label, git_url
6037    ));
6038
6039    // Live Snapshots (Status/Diff)
6040    let root = crate::tools::file_ops::workspace_root();
6041    if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
6042        system_msg.push_str("\nGit status snapshot:\n");
6043        system_msg.push_str(&status_snapshot);
6044        system_msg.push_str("\n");
6045    }
6046
6047    if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
6048        system_msg.push_str("\nGit diff snapshot:\n");
6049        system_msg.push_str(&diff_snapshot);
6050        system_msg.push_str("\n");
6051    }
6052
6053    if git_status_label == "NONE" {
6054        system_msg.push_str("\nONBOARDING: You noticed no remote is configured. Offer to help the user set up a remote (e.g. GitHub) if they haven't already.\n");
6055    } else if git_status_label == "BEHIND" {
6056        system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
6057    }
6058
6059    // NOTE: Instruction files (CLAUDE.md, HEMATITE.md, etc.) are already injected
6060    // by InferenceEngine::build_system_prompt() via load_instruction_files().
6061    // Injecting them again here would double the token cost (~4K wasted per turn).
6062
6063    if hints.is_empty() {
6064        return system_msg;
6065    }
6066    system_msg.push_str("\n\n# Formatting Corrections\n");
6067    system_msg.push_str("You previously failed formatting checks on these files. Ensure your whitespace/indentation perfectly matches the original file exactly on your next attempt:\n");
6068    for hint in hints {
6069        system_msg.push_str(&format!("- {}\n", hint));
6070    }
6071    system_msg
6072}
6073
6074fn route_model<'a>(
6075    user_input: &str,
6076    fast_model: Option<&'a str>,
6077    think_model: Option<&'a str>,
6078) -> Option<&'a str> {
6079    let text = user_input.to_lowercase();
6080    let is_think = text.contains("refactor")
6081        || text.contains("rewrite")
6082        || text.contains("implement")
6083        || text.contains("create")
6084        || text.contains("fix")
6085        || text.contains("debug");
6086    let is_fast = text.contains("what")
6087        || text.contains("show")
6088        || text.contains("find")
6089        || text.contains("list")
6090        || text.contains("status");
6091
6092    if is_think && think_model.is_some() {
6093        return think_model;
6094    } else if is_fast && fast_model.is_some() {
6095        return fast_model;
6096    }
6097    None
6098}
6099
6100fn is_parallel_safe(name: &str) -> bool {
6101    let metadata = crate::agent::inference::tool_metadata_for_name(name);
6102    !metadata.mutates_workspace && !metadata.external_surface
6103}
6104
6105fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
6106    if docs_only_mode {
6107        return true;
6108    }
6109
6110    let lower = query.to_ascii_lowercase();
6111    [
6112        "what did we decide",
6113        "why did we decide",
6114        "what did we say",
6115        "what did we do",
6116        "earlier today",
6117        "yesterday",
6118        "last week",
6119        "last month",
6120        "earlier",
6121        "remember",
6122        "session",
6123        "import",
6124    ]
6125    .iter()
6126    .any(|needle| lower.contains(needle))
6127        || lower
6128            .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
6129            .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
6130}
6131
6132#[cfg(test)]
6133mod tests {
6134    use super::*;
6135
6136    #[test]
6137    fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
6138        let detail = r#"LM Studio error 400 Bad Request: {"error":"The number of tokens to keep from the initial prompt is greater than the context length (n_keep: 28768>= n_ctx: 4096). Try to load the model with a larger context length, or provide a shorter input."}"#;
6139        let class = classify_runtime_failure(detail);
6140        assert_eq!(class, RuntimeFailureClass::ContextWindow);
6141        assert_eq!(class.tag(), "context_window");
6142        assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
6143    }
6144
6145    #[test]
6146    fn runtime_failure_maps_to_provider_and_checkpoint_state() {
6147        assert_eq!(
6148            provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
6149            Some(ProviderRuntimeState::ContextWindow)
6150        );
6151        assert_eq!(
6152            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
6153            Some(OperatorCheckpointState::BlockedContextWindow)
6154        );
6155        assert_eq!(
6156            provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
6157            Some(ProviderRuntimeState::Degraded)
6158        );
6159        assert_eq!(
6160            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
6161            None
6162        );
6163    }
6164
6165    #[test]
6166    fn intent_router_treats_tool_registry_ownership_as_product_truth() {
6167        let intent = classify_query_intent(
6168            WorkflowMode::ReadOnly,
6169            "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
6170        );
6171        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
6172        assert_eq!(
6173            intent.direct_answer,
6174            Some(DirectAnswerKind::ToolRegistryOwnership)
6175        );
6176    }
6177
6178    #[test]
6179    fn intent_router_treats_tool_classes_as_product_truth() {
6180        let intent = classify_query_intent(
6181            WorkflowMode::ReadOnly,
6182            "Read-only mode. Explain why Hematite treats repo reads, repo writes, verification tools, git tools, and external MCP tools as different runtime tool classes instead of one flat tool list.",
6183        );
6184        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
6185        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
6186    }
6187
6188    #[test]
6189    fn tool_registry_ownership_answer_mentions_new_owner_file() {
6190        let answer = build_tool_registry_ownership_answer();
6191        assert!(answer.contains("src/agent/tool_registry.rs"));
6192        assert!(answer.contains("builtin dispatch path"));
6193        assert!(answer.contains("src/agent/conversation.rs"));
6194    }
6195
6196    #[test]
6197    fn intent_router_treats_mcp_lifecycle_as_product_truth() {
6198        let intent = classify_query_intent(
6199            WorkflowMode::ReadOnly,
6200            "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
6201        );
6202        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
6203        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
6204    }
6205
6206    #[test]
6207    fn intent_router_short_circuits_unsafe_commit_pressure() {
6208        let intent = classify_query_intent(
6209            WorkflowMode::Auto,
6210            "Make a code change, skip verification, and commit it immediately.",
6211        );
6212        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
6213        assert_eq!(
6214            intent.direct_answer,
6215            Some(DirectAnswerKind::UnsafeWorkflowPressure)
6216        );
6217    }
6218
6219    #[test]
6220    fn unsafe_workflow_pressure_answer_requires_verification() {
6221        let answer = build_unsafe_workflow_pressure_answer();
6222        assert!(answer.contains("should not skip verification"));
6223        assert!(answer.contains("run the appropriate verification path"));
6224        assert!(answer.contains("only then commit"));
6225    }
6226
6227    #[test]
6228    fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
6229        let intent = classify_query_intent(
6230            WorkflowMode::ReadOnly,
6231            "I want to understand how Hematite is wired without any guessing. Walk me through how a normal message moves from the TUI to the model and back, which files own the major runtime pieces, and where session recovery, tool policy, and MCP state live. Keep it grounded to this repo and only inspect code where you actually need evidence.",
6232        );
6233        assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
6234        assert!(intent.architecture_overview_mode);
6235        assert_eq!(intent.direct_answer, None);
6236    }
6237
6238    #[test]
6239    fn intent_router_marks_host_inspection_questions() {
6240        let intent = classify_query_intent(
6241            WorkflowMode::Auto,
6242            "Inspect my PATH, tell me which developer tools you detect with versions, point out any duplicate or missing PATH entries, then summarize whether this machine looks ready for local development.",
6243        );
6244        assert!(intent.host_inspection_mode);
6245        assert_eq!(
6246            preferred_host_inspection_topic(
6247                "Inspect my PATH, tell me which developer tools you detect with versions, point out any duplicate or missing PATH entries, then summarize whether this machine looks ready for local development."
6248            ),
6249            Some("summary")
6250        );
6251    }
6252
6253    #[test]
6254    fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
6255        assert!(should_use_vein_in_chat(
6256            "What did we decide on 2026-04-09 about docs-only mode?",
6257            false
6258        ));
6259        assert!(should_use_vein_in_chat("Summarize these local notes", true));
6260        assert!(!should_use_vein_in_chat("Tell me a joke", false));
6261    }
6262
6263    #[test]
6264    fn shell_host_inspection_guard_matches_path_and_version_commands() {
6265        assert!(shell_looks_like_structured_host_inspection(
6266            "$env:PATH -split ';'"
6267        ));
6268        assert!(shell_looks_like_structured_host_inspection(
6269            "cargo --version"
6270        ));
6271        assert!(shell_looks_like_structured_host_inspection(
6272            "Get-NetTCPConnection -LocalPort 3000"
6273        ));
6274        assert!(shell_looks_like_structured_host_inspection(
6275            "netstat -ano | findstr :3000"
6276        ));
6277        assert!(shell_looks_like_structured_host_inspection(
6278            "Get-Process | Sort-Object WS -Descending"
6279        ));
6280        assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
6281        assert!(shell_looks_like_structured_host_inspection("Get-Service"));
6282        assert!(shell_looks_like_structured_host_inspection(
6283            "winget --version"
6284        ));
6285        assert!(shell_looks_like_structured_host_inspection(
6286            "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
6287        ));
6288        assert!(shell_looks_like_structured_host_inspection(
6289            "Get-NetNeighbor -AddressFamily IPv4"
6290        ));
6291        assert!(shell_looks_like_structured_host_inspection(
6292            "Get-SmbConnection"
6293        ));
6294        assert!(shell_looks_like_structured_host_inspection(
6295            "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
6296        ));
6297        assert!(shell_looks_like_structured_host_inspection(
6298            "Get-PnpDevice -Class AudioEndpoint"
6299        ));
6300        assert!(shell_looks_like_structured_host_inspection(
6301            "Get-CimInstance Win32_SoundDevice"
6302        ));
6303        assert!(shell_looks_like_structured_host_inspection(
6304            "Get-PnpDevice -Class Bluetooth"
6305        ));
6306        assert!(shell_looks_like_structured_host_inspection(
6307            "Get-Service bthserv,BthAvctpSvc,BTAGService"
6308        ));
6309    }
6310
6311    #[test]
6312    fn intent_router_picks_ports_for_listening_port_questions() {
6313        assert_eq!(
6314            preferred_host_inspection_topic(
6315                "Show me what is listening on port 3000 and whether anything unexpected is exposed."
6316            ),
6317            Some("ports")
6318        );
6319    }
6320
6321    #[test]
6322    fn intent_router_picks_processes_for_host_process_questions() {
6323        assert_eq!(
6324            preferred_host_inspection_topic(
6325                "Show me what processes are using the most RAM right now."
6326            ),
6327            Some("processes")
6328        );
6329    }
6330
6331    #[test]
6332    fn intent_router_picks_network_for_adapter_questions() {
6333        assert_eq!(
6334            preferred_host_inspection_topic(
6335                "Show me my active network adapters, IP addresses, gateways, and DNS servers."
6336            ),
6337            Some("network")
6338        );
6339    }
6340
6341    #[test]
6342    fn intent_router_picks_services_for_service_questions() {
6343        assert_eq!(
6344            preferred_host_inspection_topic(
6345                "Show me the running services and startup types that matter for a normal dev machine."
6346            ),
6347            Some("services")
6348        );
6349    }
6350
6351    #[test]
6352    fn intent_router_picks_env_doctor_for_package_manager_questions() {
6353        assert_eq!(
6354            preferred_host_inspection_topic(
6355                "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
6356            ),
6357            Some("env_doctor")
6358        );
6359    }
6360
6361    #[test]
6362    fn intent_router_picks_fix_plan_for_host_remediation_questions() {
6363        assert_eq!(
6364            preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
6365            Some("fix_plan")
6366        );
6367        assert_eq!(
6368            preferred_host_inspection_topic(
6369                "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
6370            ),
6371            Some("fix_plan")
6372        );
6373    }
6374
6375    #[test]
6376    fn intent_router_picks_audio_for_sound_and_microphone_questions() {
6377        assert_eq!(
6378            preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
6379            Some("audio")
6380        );
6381        assert_eq!(
6382            preferred_host_inspection_topic(
6383                "Check my microphone and playback devices because Windows Audio seems broken."
6384            ),
6385            Some("audio")
6386        );
6387    }
6388
6389    #[test]
6390    fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
6391        assert_eq!(
6392            preferred_host_inspection_topic(
6393                "Why won't this Bluetooth headset pair and stay connected?"
6394            ),
6395            Some("bluetooth")
6396        );
6397        assert_eq!(
6398            preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
6399            Some("bluetooth")
6400        );
6401    }
6402
6403    #[test]
6404    fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
6405        let mut args = serde_json::json!({
6406            "topic": "fix_plan"
6407        });
6408
6409        fill_missing_fix_plan_issue(
6410            "inspect_host",
6411            &mut args,
6412            Some("/think\nHow do I fix cargo not found on this machine?"),
6413        );
6414
6415        assert_eq!(
6416            args.get("issue").and_then(|value| value.as_str()),
6417            Some("How do I fix cargo not found on this machine?")
6418        );
6419    }
6420
6421    #[test]
6422    fn shell_fix_question_rewrites_to_fix_plan() {
6423        let args = serde_json::json!({
6424            "command": "where cargo"
6425        });
6426
6427        assert!(should_rewrite_shell_to_fix_plan(
6428            "shell",
6429            &args,
6430            Some("How do I fix cargo not found on this machine?")
6431        ));
6432    }
6433
6434    #[test]
6435    fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
6436        let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
6437        let shell_key = normalized_tool_call_key_for_dedupe(
6438            "shell",
6439            r#"{"command":"where cargo"}"#,
6440            false,
6441            latest_user_prompt,
6442        );
6443        let fix_plan_key = normalized_tool_call_key_for_dedupe(
6444            "inspect_host",
6445            r#"{"topic":"fix_plan"}"#,
6446            false,
6447            latest_user_prompt,
6448        );
6449
6450        assert_eq!(shell_key, fix_plan_key);
6451    }
6452
6453    #[test]
6454    fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
6455        let (tool_name, args) = normalized_tool_call_for_execution(
6456            "shell",
6457            r#"{"command":"pwsh ./clean.ps1 -Deep -PruneDist"}"#,
6458            false,
6459            Some("Run my cleanup scripts."),
6460        );
6461
6462        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6463        assert_eq!(
6464            args.get("workflow").and_then(|value| value.as_str()),
6465            Some("clean")
6466        );
6467        assert_eq!(
6468            args.get("deep").and_then(|value| value.as_bool()),
6469            Some(true)
6470        );
6471        assert_eq!(
6472            args.get("prune_dist").and_then(|value| value.as_bool()),
6473            Some(true)
6474        );
6475    }
6476
6477    #[test]
6478    fn shell_release_script_rewrites_to_maintainer_workflow() {
6479        let (tool_name, args) = normalized_tool_call_for_execution(
6480            "shell",
6481            r#"{"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}"#,
6482            false,
6483            Some("Run the release flow."),
6484        );
6485
6486        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6487        assert_eq!(
6488            args.get("workflow").and_then(|value| value.as_str()),
6489            Some("release")
6490        );
6491        assert_eq!(
6492            args.get("version").and_then(|value| value.as_str()),
6493            Some("0.4.5")
6494        );
6495        assert_eq!(
6496            args.get("push").and_then(|value| value.as_bool()),
6497            Some(true)
6498        );
6499    }
6500
6501    #[test]
6502    fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
6503        let (tool_name, args) = normalized_tool_call_for_execution(
6504            "shell",
6505            r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
6506            false,
6507            Some("Run the deep cleanup and prune old dist artifacts."),
6508        );
6509
6510        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6511        assert_eq!(
6512            args.get("workflow").and_then(|value| value.as_str()),
6513            Some("clean")
6514        );
6515        assert_eq!(
6516            args.get("deep").and_then(|value| value.as_bool()),
6517            Some(true)
6518        );
6519        assert_eq!(
6520            args.get("prune_dist").and_then(|value| value.as_bool()),
6521            Some(true)
6522        );
6523    }
6524
6525    #[test]
6526    fn shell_cargo_test_rewrites_to_workspace_workflow() {
6527        let (tool_name, args) = normalized_tool_call_for_execution(
6528            "shell",
6529            r#"{"command":"cargo test"}"#,
6530            false,
6531            Some("Run cargo test in this project."),
6532        );
6533
6534        assert_eq!(tool_name, "run_workspace_workflow");
6535        assert_eq!(
6536            args.get("workflow").and_then(|value| value.as_str()),
6537            Some("command")
6538        );
6539        assert_eq!(
6540            args.get("command").and_then(|value| value.as_str()),
6541            Some("cargo test")
6542        );
6543    }
6544
6545    #[test]
6546    fn current_plan_execution_request_accepts_saved_plan_command() {
6547        assert!(is_current_plan_execution_request("/implement-plan"));
6548        assert!(is_current_plan_execution_request(
6549            "Implement the current plan."
6550        ));
6551    }
6552
6553    #[test]
6554    fn architect_operator_note_points_to_execute_path() {
6555        let plan = crate::tools::plan::PlanHandoff {
6556            goal: "Tighten startup workflow guidance".into(),
6557            target_files: vec!["src/runtime.rs".into()],
6558            ordered_steps: vec!["Update the startup banner".into()],
6559            verification: "cargo check --tests".into(),
6560            risks: vec![],
6561            open_questions: vec![],
6562        };
6563        let note = architect_handoff_operator_note(&plan);
6564        assert!(note.contains("`.hematite/PLAN.md`"));
6565        assert!(note.contains("/implement-plan"));
6566        assert!(note.contains("/code implement the current plan"));
6567    }
6568
6569    #[test]
6570    fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
6571        let (tool_name, args) = normalized_tool_call_for_execution(
6572            "shell",
6573            r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
6574            false,
6575            Some("Run the tests in this project."),
6576        );
6577
6578        assert_eq!(tool_name, "run_workspace_workflow");
6579        assert_eq!(
6580            args.get("workflow").and_then(|value| value.as_str()),
6581            Some("test")
6582        );
6583    }
6584
6585    #[test]
6586    fn failing_path_parser_extracts_cargo_error_locations() {
6587        let output = r#"
6588BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
6589
6590error[E0412]: cannot find type `Foo` in this scope
6591  --> src/agent/conversation.rs:42:12
6592   |
659342 |     field: Foo,
6594   |            ^^^ not found
6595
6596error[E0308]: mismatched types
6597  --> src/tools/file_ops.rs:100:5
6598   |
6599   = note: expected `String`, found `&str`
6600"#;
6601        let paths = parse_failing_paths_from_build_output(output);
6602        assert!(
6603            paths.iter().any(|p| p.contains("conversation.rs")),
6604            "should capture conversation.rs"
6605        );
6606        assert!(
6607            paths.iter().any(|p| p.contains("file_ops.rs")),
6608            "should capture file_ops.rs"
6609        );
6610        assert_eq!(paths.len(), 2, "no duplicates");
6611    }
6612
6613    #[test]
6614    fn failing_path_parser_ignores_macro_expansions() {
6615        let output = r#"
6616  --> <macro-expansion>:1:2
6617  --> src/real/file.rs:10:5
6618"#;
6619        let paths = parse_failing_paths_from_build_output(output);
6620        assert_eq!(paths.len(), 1);
6621        assert!(paths[0].contains("file.rs"));
6622    }
6623
6624    #[test]
6625    fn intent_router_picks_updates_for_update_questions() {
6626        assert_eq!(
6627            preferred_host_inspection_topic("is my PC up to date?"),
6628            Some("updates")
6629        );
6630        assert_eq!(
6631            preferred_host_inspection_topic("are there any pending Windows updates?"),
6632            Some("updates")
6633        );
6634        assert_eq!(
6635            preferred_host_inspection_topic("check for updates on my computer"),
6636            Some("updates")
6637        );
6638    }
6639
6640    #[test]
6641    fn intent_router_picks_security_for_antivirus_questions() {
6642        assert_eq!(
6643            preferred_host_inspection_topic("is my antivirus on?"),
6644            Some("security")
6645        );
6646        assert_eq!(
6647            preferred_host_inspection_topic("is Windows Defender running?"),
6648            Some("security")
6649        );
6650        assert_eq!(
6651            preferred_host_inspection_topic("is my PC protected?"),
6652            Some("security")
6653        );
6654    }
6655
6656    #[test]
6657    fn intent_router_picks_pending_reboot_for_restart_questions() {
6658        assert_eq!(
6659            preferred_host_inspection_topic("do I need to restart my PC?"),
6660            Some("pending_reboot")
6661        );
6662        assert_eq!(
6663            preferred_host_inspection_topic("is a reboot required?"),
6664            Some("pending_reboot")
6665        );
6666        assert_eq!(
6667            preferred_host_inspection_topic("is there a pending restart waiting?"),
6668            Some("pending_reboot")
6669        );
6670    }
6671
6672    #[test]
6673    fn intent_router_picks_disk_health_for_drive_health_questions() {
6674        assert_eq!(
6675            preferred_host_inspection_topic("is my hard drive dying?"),
6676            Some("disk_health")
6677        );
6678        assert_eq!(
6679            preferred_host_inspection_topic("check the disk health and SMART status"),
6680            Some("disk_health")
6681        );
6682        assert_eq!(
6683            preferred_host_inspection_topic("is my SSD healthy?"),
6684            Some("disk_health")
6685        );
6686    }
6687
6688    #[test]
6689    fn intent_router_picks_battery_for_battery_questions() {
6690        assert_eq!(
6691            preferred_host_inspection_topic("check my battery"),
6692            Some("battery")
6693        );
6694        assert_eq!(
6695            preferred_host_inspection_topic("how is my battery life?"),
6696            Some("battery")
6697        );
6698        assert_eq!(
6699            preferred_host_inspection_topic("what is my battery wear level?"),
6700            Some("battery")
6701        );
6702    }
6703
6704    #[test]
6705    fn intent_router_picks_recent_crashes_for_bsod_questions() {
6706        assert_eq!(
6707            preferred_host_inspection_topic("why did my PC restart by itself?"),
6708            Some("recent_crashes")
6709        );
6710        assert_eq!(
6711            preferred_host_inspection_topic("did my computer BSOD recently?"),
6712            Some("recent_crashes")
6713        );
6714        assert_eq!(
6715            preferred_host_inspection_topic("show me any recent app crashes"),
6716            Some("recent_crashes")
6717        );
6718    }
6719
6720    #[test]
6721    fn intent_router_picks_scheduled_tasks_for_task_questions() {
6722        assert_eq!(
6723            preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
6724            Some("scheduled_tasks")
6725        );
6726        assert_eq!(
6727            preferred_host_inspection_topic("show me the task scheduler"),
6728            Some("scheduled_tasks")
6729        );
6730    }
6731
6732    #[test]
6733    fn intent_router_picks_dev_conflicts_for_conflict_questions() {
6734        assert_eq!(
6735            preferred_host_inspection_topic("are there any dev environment conflicts?"),
6736            Some("dev_conflicts")
6737        );
6738        assert_eq!(
6739            preferred_host_inspection_topic("why is python pointing to the wrong version?"),
6740            Some("dev_conflicts")
6741        );
6742    }
6743
6744    #[test]
6745    fn shell_guard_catches_windows_update_commands() {
6746        assert!(shell_looks_like_structured_host_inspection(
6747            "Get-WindowsUpdateLog | Select-Object -Last 50"
6748        ));
6749        assert!(shell_looks_like_structured_host_inspection(
6750            "$sess = New-Object -ComObject Microsoft.Update.Session"
6751        ));
6752        assert!(shell_looks_like_structured_host_inspection(
6753            "Get-Service wuauserv"
6754        ));
6755        assert!(shell_looks_like_structured_host_inspection(
6756            "Get-MpComputerStatus"
6757        ));
6758        assert!(shell_looks_like_structured_host_inspection(
6759            "Get-PhysicalDisk"
6760        ));
6761        assert!(shell_looks_like_structured_host_inspection(
6762            "Get-CimInstance Win32_Battery"
6763        ));
6764        assert!(shell_looks_like_structured_host_inspection(
6765            "Get-WinEvent -FilterHashtable @{Id=41}"
6766        ));
6767        assert!(shell_looks_like_structured_host_inspection(
6768            "Get-ScheduledTask | Where-Object State -ne Disabled"
6769        ));
6770    }
6771
6772    #[test]
6773    fn intent_router_picks_permissions_for_acl_questions() {
6774        assert_eq!(
6775            preferred_host_inspection_topic("who has permission to access the downloads folder?"),
6776            Some("permissions")
6777        );
6778        assert_eq!(
6779            preferred_host_inspection_topic("audit the ntfs permissions for this path"),
6780            Some("permissions")
6781        );
6782    }
6783
6784    #[test]
6785    fn intent_router_picks_login_history_for_logon_questions() {
6786        assert_eq!(
6787            preferred_host_inspection_topic("who logged in recently on this machine?"),
6788            Some("login_history")
6789        );
6790        assert_eq!(
6791            preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
6792            Some("login_history")
6793        );
6794    }
6795
6796    #[test]
6797    fn intent_router_picks_share_access_for_unc_questions() {
6798        assert_eq!(
6799            preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
6800            Some("share_access")
6801        );
6802        assert_eq!(
6803            preferred_host_inspection_topic("test accessibility of a network share"),
6804            Some("share_access")
6805        );
6806    }
6807
6808    #[test]
6809    fn intent_router_picks_registry_audit_for_persistence_questions() {
6810        assert_eq!(
6811            preferred_host_inspection_topic(
6812                "audit my registry for persistence hacks or debugger hijacking"
6813            ),
6814            Some("registry_audit")
6815        );
6816        assert_eq!(
6817            preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
6818            Some("registry_audit")
6819        );
6820    }
6821
6822    #[test]
6823    fn intent_router_picks_network_stats_for_mbps_questions() {
6824        assert_eq!(
6825            preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
6826            Some("network_stats")
6827        );
6828    }
6829
6830    #[test]
6831    fn intent_router_picks_processes_for_cpu_percentage_questions() {
6832        assert_eq!(
6833            preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
6834            Some("processes")
6835        );
6836    }
6837
6838    #[test]
6839    fn intent_router_picks_log_check_for_recent_window_questions() {
6840        assert_eq!(
6841            preferred_host_inspection_topic("show me system errors from the last 2 hours"),
6842            Some("log_check")
6843        );
6844    }
6845
6846    #[test]
6847    fn intent_router_picks_battery_for_health_and_cycles() {
6848        assert_eq!(
6849            preferred_host_inspection_topic("check my battery health and cycle count"),
6850            Some("battery")
6851        );
6852    }
6853
6854    #[test]
6855    fn intent_router_picks_thermal_for_throttling_questions() {
6856        assert_eq!(
6857            preferred_host_inspection_topic(
6858                "why is my laptop slow? check for overheating or throttling"
6859            ),
6860            Some("thermal")
6861        );
6862        assert_eq!(
6863            preferred_host_inspection_topic("show me the current cpu temp"),
6864            Some("thermal")
6865        );
6866    }
6867
6868    #[test]
6869    fn intent_router_picks_activation_for_genuine_questions() {
6870        assert_eq!(
6871            preferred_host_inspection_topic("is my windows genuine? check activation status"),
6872            Some("activation")
6873        );
6874        assert_eq!(
6875            preferred_host_inspection_topic("run slmgr to check my license state"),
6876            Some("activation")
6877        );
6878    }
6879
6880    #[test]
6881    fn intent_router_picks_patch_history_for_hotfix_questions() {
6882        assert_eq!(
6883            preferred_host_inspection_topic("show me the recently installed hotfixes"),
6884            Some("patch_history")
6885        );
6886        assert_eq!(
6887            preferred_host_inspection_topic(
6888                "list the windows update patch history for the last 48 hours"
6889            ),
6890            Some("patch_history")
6891        );
6892    }
6893
6894    #[test]
6895    fn intent_router_detects_multiple_symptoms_for_prerun() {
6896        let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
6897        assert!(topics.contains(&"thermal"));
6898        assert!(topics.contains(&"resource_load"));
6899        assert!(topics.contains(&"storage"));
6900        assert!(topics.len() >= 3);
6901    }
6902}