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;
34use 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#[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 #[serde(default)]
79 last_goal: Option<String>,
80 #[serde(default)]
82 turn_count: u32,
83}
84
85pub 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
94pub 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 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 Chat,
154 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
490fn 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 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
600pub 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 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 words.len() >= 3 {
623 return true;
624 }
625 }
626
627 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 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 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 pub fast_model: Option<String>,
661 pub think_model: Option<String>,
663 pub correction_hints: Vec<String>,
665 pub running_summary: Option<String>,
667 pub gpu_state: Arc<GpuState>,
669 pub vein: crate::memory::vein::Vein,
671 pub transcript: crate::agent::transcript::TranscriptLogger,
673 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
675 pub git_state: Arc<crate::agent::git_monitor::GitState>,
677 pub think_mode: Option<bool>,
680 workflow_mode: WorkflowMode,
681 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 pub soul_personality: String,
687 pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
688 pub reasoning_history: Option<String>,
690 pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
692 action_grounding: Arc<Mutex<ActionGroundingState>>,
694 plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
696 recovery_context: RecoveryContext,
698 pub l1_context: Option<String>,
701 pub repo_map: Option<String>,
703 pub turn_count: u32,
705 pub last_goal: Option<String>,
707 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 let mcp_manager = Arc::new(tokio::sync::Mutex::new(
955 crate::agent::mcp_manager::McpManager::new(),
956 ));
957
958 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 pub fn initialize_vein(&mut self) -> usize {
1019 self.refresh_vein_index()
1020 }
1021
1022 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 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 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 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 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 let topic = match preferred_host_inspection_topic(command) {
1448 Some(t) => t.to_string(),
1449 None => return Ok(()), };
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 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 if topic == "ad_user" || topic == "dns_lookup" {
1489 let cmd_lower = command.to_lowercase();
1490 let mut identity = String::new();
1491
1492 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 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 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 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 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 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 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, ¤t_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 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 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 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 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 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 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 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 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 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 => {} }
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 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 self.cancel_token
2591 .store(false, std::sync::atomic::Ordering::SeqCst);
2592
2593 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 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 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 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 {
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 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 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 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 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 let _result_counts: std::collections::HashMap<String, usize> =
2801 std::collections::HashMap::new();
2802 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 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 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 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 if self
2837 .compact_history_if_needed(&tx, Some(turn_anchor))
2838 .await?
2839 {
2840 turn_anchor = 2;
2843 }
2844
2845 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 let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
2861 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 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 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 if let Some(ref u) = usage {
2998 let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
2999 }
3000
3001 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 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 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 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 self.reasoning_history = Some(thought);
3139 }
3140
3141 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 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 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 let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
3239 .into_iter()
3240 .partition(|c| is_parallel_safe(&c.function.name));
3241
3242 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 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 results.extend(futures::future::join_all(tasks).await);
3260 }
3261
3262 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 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 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 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 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 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 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 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 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 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 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 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;
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 if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
3846 {
3847 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
3848 self.reasoning_history = Some(thought);
3851 }
3852
3853 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 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 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 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(¬e));
3913 self.transcript.log_system(¬e);
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 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 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 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 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 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 fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
4151 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 fn context_window_slice(&self) -> Vec<ChatMessage> {
4198 let mut result = Vec::new();
4199
4200 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 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 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 fn trim_history(&mut self, max_messages: usize) {
4252 if self.history.len() <= max_messages {
4253 return;
4254 }
4255 let excess = self.history.len() - max_messages;
4257 self.history.drain(1..=excess);
4258 }
4259
4260 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 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 async fn run_critic_check(
4305 &self,
4306 path: &str,
4307 content: &str,
4308 tx: &mpsc::Sender<InferenceEvent>,
4309 ) -> Option<String> {
4310 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
4354pub 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 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 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 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 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 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; 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 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 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 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 Err(_) => dispatch_tool(&call.name, &args).await,
5171 }
5172 } else if call.name == "verify_build" {
5173 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
5176 } else if call.name == "shell" {
5177 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 {
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 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
5287struct 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
5334pub 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
5353pub(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 "hklm:",
5429 "hkcu:",
5430 "hklm:\\",
5431 "hkcu:\\",
5432 "currentversion",
5433 "productname",
5434 "displayversion",
5435 "get-itemproperty",
5436 "get-itempropertyvalue",
5437 "get-windowsupdatelog",
5439 "windowsupdatelog",
5440 "microsoft.update.session",
5441 "createupdatesearcher",
5442 "wuauserv",
5443 "usoclient",
5444 "get-hotfix",
5445 "wu_",
5446 "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 "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 "gpresult",
5483 "applied gpo",
5484 "cert:\\",
5485 "cert:",
5486 "component based servicing",
5487 "componentstore",
5488 "get-computerinfo",
5489 "win32_computersystem",
5490 "win32_battery",
5492 "batterystaticdata",
5493 "batteryfullchargedcapacity",
5494 "batterystatus",
5495 "estimatedchargeremaining",
5496 "get-winevent",
5498 "eventid",
5499 "bugcheck",
5500 "kernelpower",
5501 "win32_ntlogevent",
5502 "filterhashtable",
5503 "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 "get-ciminstance win32",
5523 "get-wmiobject win32",
5524 "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 "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 "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 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 "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 "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 "winget list",
5623 "get-package",
5624 "get-itempropert.*uninstall",
5625 "dpkg --get-selections",
5626 "rpm -qa",
5627 "brew list",
5628 "get-localuser",
5630 "get-localgroupmember",
5631 "net user",
5632 "query user",
5633 "net localgroup administrators",
5634 "auditpol /get",
5636 "auditpol",
5637 "get-smbshare",
5639 "get-smbserverconfiguration",
5640 "net share",
5641 "net use",
5642 "get-dnsclientserveraddress",
5644 "get-dnsclientdohserveraddress",
5645 "get-dnsclientglobalsetting",
5646 ]
5647 .iter()
5648 .any(|needle| lower.contains(needle))
5649}
5650
5651fn cap_output(text: &str, max_bytes: usize) -> String {
5654 cap_output_for_tool(text, max_bytes, "output")
5655}
5656
5657fn 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 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
5687fn 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 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 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 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 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 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
5875fn is_quick_tool_request(input: &str) -> bool {
5880 let lower = input.to_lowercase();
5881 if lower.contains("run_code") || lower.contains("run code") {
5883 return true;
5884 }
5885 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 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 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 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 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 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}