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